Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions docs/public-readiness-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Setlist is moving from a personal registry toward a local, agent-readable worksp
- Done: core-owned local workspace inspection now enriches agent briefs with lightweight code and non-code source signals without broad indexing or external calls.
- Done: richer source adapter paths are now being developed in parallel for code and non-code workspaces, adding bounded manifest/dependency/runtime signals and document-category/folder-role signals without persisting inspection-derived fields.
- Done: Electron first-run onboarding now guides a user through registering one existing local folder and previewing shallow local inspection signals before registration.
- Done: existing-folder onboarding now lets users choose from live, user-managed project types while keeping broad code/workspace defaults as fallback.

## Slice Proof: Alpha MCP Client Controls Tester Packet

Expand Down Expand Up @@ -57,16 +58,18 @@ What remains:
## Slice Proof: Guided Existing-Folder Registration

- `@setlist/core` now owns `Registry.inspectWorkspaceCandidate(...)` and `Registry.registerExistingWorkspace(...)`, so the app does not decide registration or inspection policy in renderer code.
- The app main process exposes narrow IPC calls for candidate inspection and existing-workspace registration. The renderer only collects name, display name, broad workspace kind, local path, optional structure fields, and shows returned results.
- The app main process exposes narrow IPC calls for candidate inspection and existing-workspace registration. The renderer only collects name, display name, selected project type when available, broad workspace kind fallback, local path, optional structure fields, and shows returned results.
- The empty first-run state now leads with "Register existing folder" and keeps "Create new workspace" as a secondary path. This keeps non-code folders and already-existing workspaces first-class during onboarding.
- Existing-folder registration shows explicit local-first copy: Setlist reads shallow local metadata on the Mac, does not upload contents, does not call an LLM, and does not generate an automatic digest.
- Existing-folder registration now accepts a selected live `project_type_id` from the user-managed `project_types` table. Core validates the selected type and still falls back to broad code/workspace seeded defaults when no specific type is selected.
- Custom non-code project types remain first-class: registration does not require `tech_stack`, does not persist fake code metadata, and brief/enrichment gaps stay workspace-appropriate.
- The preview displays local inspection status, code signals when present, document/folder signals when present, notable files, material hints, and structured gaps such as empty, missing, inaccessible, or entry-limit conditions.
- Registration lands the user on the newly registered project overview, where the existing Agent Brief and Paths sections provide a useful next surface without broad indexing or background digest work.

What remains:

- Add richer folder-picker affordances later if manual path entry proves awkward in the packaged app.
- Decide whether existing-folder registration should eventually support custom project types directly, beyond the current broad code/workspace mapping to seeded defaults.
- Watch real onboarding sessions for confusing project-type labels or missing default types before adding more UI.

## Slice Proof: Workspace Source Inspection

Expand Down Expand Up @@ -229,7 +232,7 @@ Awareness should point to proof, not vibes: before/after agent orientation, Clau
## Next Implementation Slices

1. Run the app-side MCP client controls tester packet/checklist with real alpha users and make only targeted copy refinements where users get stuck.
2. Expand the compatibility matrix only when another client has real detection, planning, install, backup, and recovery behavior in code.
3. Use the launch-awareness drafts only as lightweight feedback copy until real alpha MCP-control feedback and a boringly repeatable install/onboarding path support broader publication.
4. Turn the single-page marketing-site outline into an actual page only after README proof, compatibility docs, and install/onboarding guidance stay in sync.
5. Refine existing-folder onboarding with custom project-type selection only after the broad code/workspace prototype has been used.
2. Watch the custom project-type onboarding path with alpha users and tune labels/copy only where real confusion appears.
3. Expand the compatibility matrix only when another client has real detection, planning, install, backup, and recovery behavior in code.
4. Use the launch-awareness drafts only as lightweight feedback copy until real alpha MCP-control feedback and a boringly repeatable install/onboarding path support broader publication.
5. Turn the single-page marketing-site outline into an actual page only after README proof, compatibility docs, and install/onboarding guidance stay in sync.
13 changes: 11 additions & 2 deletions packages/app/e2e/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,13 +150,19 @@ args = ["-y", "@setlist/mcp"]
mkdirSync(join(workspace, 'Research Notes'), { recursive: true })
writeFileSync(join(workspace, 'client-plan.md'), '# Plan\n')
writeFileSync(join(workspace, 'Research Notes', 'source-notes.txt'), 'Sources\n')
await window.evaluate(() => (window as any).setlist.createProjectType({
name: 'Research workspace',
default_directory: '/tmp',
git_init: false,
color: '#10b981'
}))

await window.getByRole('button', { name: 'Register existing folder' }).first().click()
await expect(window.getByRole('heading', { name: 'Register existing folder' })).toBeVisible()
await expect(window.getByText(/shallow local metadata/)).toBeVisible()
const typePicker = window.getByLabel('Type')
await typePicker.selectOption('non_code_project')
await expect(typePicker).toHaveValue('non_code_project')
await expect(typePicker.locator('option', { hasText: 'Research workspace' })).toHaveCount(1)
await typePicker.selectOption({ label: 'Research workspace' })
await window.getByLabel('Name (slug)').fill('client-research')
await window.getByLabel('Display name (optional)').fill('Client Research')
await window.getByLabel('Local folder path').fill(workspace)
Expand All @@ -167,6 +173,9 @@ args = ["-y", "@setlist/mcp"]
await window.getByRole('button', { name: 'Register' }).click()
await expect(window.getByRole('heading', { name: 'Client Research' })).toBeVisible()
await expect(window.getByText(workspace)).toBeVisible()
const project = await window.evaluate(() => (window as any).setlist.getProject('client-research', 'standard'))
expect(project.project_type).toBe('Research workspace')
expect(project.project_type_id).toBeTruthy()
} finally {
await app.close()
rmSync(home, { recursive: true, force: true })
Expand Down
32 changes: 28 additions & 4 deletions packages/app/out/main/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2655,6 +2655,17 @@ class Registry {
throw new InvalidInputError(`Workspace path is not a readable directory: ${inspectedPath?.error ?? normalizedPath}`);
}
const displayName = opts.display_name?.trim() || void 0;
const typeDb = this.open();
let projectTypeId;
try {
projectTypeId = this.resolveExistingWorkspaceProjectTypeId(typeDb, {
project_type_id: opts.project_type_id,
workspace_kind: opts.workspace_kind,
inspection
});
} finally {
typeDb.close();
}
this.register({
name: projectName,
type: "project",
Expand All @@ -2671,10 +2682,8 @@ class Registry {
});
const db = this.open();
try {
const typeName = opts.workspace_kind === "code" ? "Code project" : "Non-code project";
const typeRow = db.prepare("SELECT id FROM project_types WHERE name = ?").get(typeName);
if (typeRow) {
db.prepare("UPDATE projects SET project_type_id = ?, updated_at = datetime('now') WHERE name = ?").run(typeRow.id, projectName);
if (projectTypeId != null) {
db.prepare("UPDATE projects SET project_type_id = ?, updated_at = datetime('now') WHERE name = ?").run(projectTypeId, projectName);
}
} finally {
db.close();
Expand All @@ -2684,6 +2693,21 @@ class Registry {
inspection
};
}
resolveExistingWorkspaceProjectTypeId(db, opts) {
if (opts.project_type_id != null) {
if (!Number.isInteger(opts.project_type_id) || opts.project_type_id <= 0) {
throw new InvalidProjectTypeError(opts.project_type_id);
}
const row2 = db.prepare("SELECT id FROM project_types WHERE id = ?").get(opts.project_type_id);
if (!row2)
throw new InvalidProjectTypeError(opts.project_type_id);
return row2.id;
}
const fallbackKind = opts.workspace_kind ?? (opts.inspection.summary.kind === "code" ? "code" : "workspace");
const typeName = fallbackKind === "code" ? "Code project" : "Non-code project";
const row = db.prepare("SELECT id FROM project_types WHERE name = ?").get(typeName);
return row?.id ?? null;
}
listProjects(opts) {
const depth = opts?.depth ?? "summary";
const db = this.open();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22170,7 +22170,7 @@ function SettingsView({ onBack }) {
/* @__PURE__ */ jsxRuntimeExports.jsx(UpdatesSection, {})
] });
}
const PROJECT_TYPES = [
const BROAD_PROJECT_TYPES = [
{ value: "code_project", label: "Code repository or app", workspaceKind: "code" },
{ value: "non_code_project", label: "General workspace or folder", workspaceKind: "workspace" }
];
Expand All @@ -22185,10 +22185,18 @@ function slugFromText(value) {
function folderName(path) {
return path.split("/").filter(Boolean).pop() ?? "";
}
function defaultProjectTypeFor(types, fallback) {
const wantsCode = fallback === "code_project";
const seededName = wantsCode ? "Code project" : "Non-code project";
return types.find((type) => type.name === seededName) ?? types.find((type) => type.git_init === wantsCode);
}
function RegisterProjectDialog({ open, onOpenChange, initialMode = "create", onSuccess }) {
const [name, setName] = reactExports.useState("");
const [displayName, setDisplayName] = reactExports.useState("");
const [type, setType] = reactExports.useState("code_project");
const [fallbackType, setFallbackType] = reactExports.useState("code_project");
const [projectTypes, setProjectTypes] = reactExports.useState([]);
const [projectTypesLoading, setProjectTypesLoading] = reactExports.useState(false);
const [selectedProjectTypeId, setSelectedProjectTypeId] = reactExports.useState(null);
const [description, setDescription] = reactExports.useState("");
const [area, setArea] = reactExports.useState(UNASSIGNED);
const [parentProject, setParentProject] = reactExports.useState("");
Expand All @@ -22207,15 +22215,22 @@ function RegisterProjectDialog({ open, onOpenChange, initialMode = "create", onS
const [inspecting, setInspecting] = reactExports.useState(false);
reactExports.useEffect(() => {
if (open) {
const defaultFallback = initialMode === "existing" ? "non_code_project" : "code_project";
setCreateFolder(initialMode !== "existing");
if (initialMode === "existing") setType("non_code_project");
setFallbackType(defaultFallback);
setSelectedProjectTypeId(null);
api.getBootstrapConfig().then(setConfig).catch(() => setConfig(null));
setProjectTypesLoading(true);
api.listProjectTypes().then((types) => {
setProjectTypes(types);
setSelectedProjectTypeId(defaultProjectTypeFor(types, defaultFallback)?.id ?? null);
}).catch(() => setProjectTypes([])).finally(() => setProjectTypesLoading(false));
api.listProjects({ depth: "summary" }).then((projects) => setParentOptions(projects)).catch(() => setParentOptions([]));
}
}, [open, initialMode]);
reactExports.useEffect(() => {
setInspection(null);
}, [existingFolder, type]);
}, [existingFolder, fallbackType, selectedProjectTypeId]);
reactExports.useEffect(() => {
const trimmed = name.trim();
setNameWarning(null);
Expand All @@ -22232,12 +22247,26 @@ function RegisterProjectDialog({ open, onOpenChange, initialMode = "create", onS
}, 300);
return () => clearTimeout(checkTimer.current);
}, [name]);
const pathKey = TYPE_TO_PATH_KEY[type];
const pathRoot = config?.path_roots?.[pathKey];
const hasBootstrapConfig = config && Object.keys(config.path_roots).length > 0;
const selectedProjectType = selectedProjectTypeId == null ? null : projectTypes.find((type) => type.id === selectedProjectTypeId) ?? null;
const selectedTypeValue = selectedProjectType ? `project-type:${selectedProjectType.id}` : fallbackType;
const selectedBroadType = selectedProjectType ? selectedProjectType.git_init ? "code_project" : "non_code_project" : fallbackType;
const pathKey = TYPE_TO_PATH_KEY[selectedBroadType];
const pathRoot = selectedProjectType?.default_directory ?? config?.path_roots?.[pathKey];
const hasBootstrapConfig = Boolean(selectedProjectType) || Boolean(config && Object.keys(config.path_roots).length > 0);
const resolvedPath = pathRoot && name.trim() ? `${pathRoot}/${name.trim()}` : null;
const workspaceKind = PROJECT_TYPES.find((candidate) => candidate.value === type)?.workspaceKind;
const workspaceKind = selectedProjectType ? selectedProjectType.git_init ? "code" : "workspace" : BROAD_PROJECT_TYPES.find((candidate) => candidate.value === fallbackType)?.workspaceKind;
const selectedFolder = existingFolder?.trim() ?? "";
const handleTypeChange = (value) => {
if (value.startsWith("project-type:")) {
const id = Number(value.slice("project-type:".length));
const projectType = projectTypes.find((type) => type.id === id);
setSelectedProjectTypeId(projectType?.id ?? null);
if (projectType) setFallbackType(projectType.git_init ? "code_project" : "non_code_project");
return;
}
setSelectedProjectTypeId(null);
setFallbackType(value);
};
const setExistingFolderValue = (value) => {
setExistingFolder(value);
if (!name.trim()) {
Expand Down Expand Up @@ -22286,11 +22315,12 @@ function RegisterProjectDialog({ open, onOpenChange, initialMode = "create", onS
if (createFolder) {
await api.bootstrapProject({
name: name.trim(),
type: type === "code_project" ? "project" : "non_code_project",
type: selectedBroadType === "code_project" ? "project" : "non_code_project",
project_type_id: selectedProjectType?.id,
status: "active",
description: description.trim() || void 0,
display_name: displayName.trim() || void 0,
skip_git: skipGit || type !== "code_project",
skip_git: skipGit || selectedBroadType !== "code_project",
area: resolvedArea,
parent_project: resolvedParent,
email_account: resolvedEmail
Expand All @@ -22304,6 +22334,7 @@ function RegisterProjectDialog({ open, onOpenChange, initialMode = "create", onS
name: name.trim(),
path: selectedFolder,
workspace_kind: workspaceKind,
project_type_id: selectedProjectType?.id,
status: "active",
description: description.trim() || void 0,
display_name: displayName.trim() || void 0,
Expand All @@ -22316,7 +22347,8 @@ function RegisterProjectDialog({ open, onOpenChange, initialMode = "create", onS
const registeredName = name.trim();
setName("");
setDisplayName("");
setType("code_project");
setFallbackType("code_project");
setSelectedProjectTypeId(null);
setDescription("");
setArea(UNASSIGNED);
setParentProject("");
Expand Down Expand Up @@ -22364,15 +22396,18 @@ function RegisterProjectDialog({ open, onOpenChange, initialMode = "create", onS
className: "input-field"
}
) }),
/* @__PURE__ */ jsxRuntimeExports.jsx(Field, { label: "Type", children: /* @__PURE__ */ jsxRuntimeExports.jsx(
"select",
{
value: type,
onChange: (e) => setType(e.target.value),
className: "input-field",
children: PROJECT_TYPES.map((t) => /* @__PURE__ */ jsxRuntimeExports.jsx("option", { value: t.value, children: t.label }, t.value))
}
) }),
/* @__PURE__ */ jsxRuntimeExports.jsxs(Field, { label: "Type", children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(
"select",
{
value: selectedTypeValue,
onChange: (e) => handleTypeChange(e.target.value),
className: "input-field",
children: projectTypes.length > 0 ? projectTypes.map((t) => /* @__PURE__ */ jsxRuntimeExports.jsx("option", { value: `project-type:${t.id}`, children: t.name }, t.id)) : BROAD_PROJECT_TYPES.map((t) => /* @__PURE__ */ jsxRuntimeExports.jsx("option", { value: t.value, children: t.label }, t.value))
}
),
projectTypesLoading && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "mt-1 text-xs text-[var(--color-text-tertiary)]", children: "Loading saved project types..." })
] }),
/* @__PURE__ */ jsxRuntimeExports.jsx(Field, { label: "Description (optional)", children: /* @__PURE__ */ jsxRuntimeExports.jsx(
"textarea",
{
Expand Down Expand Up @@ -22448,7 +22483,7 @@ function RegisterProjectDialog({ open, onOpenChange, initialMode = "create", onS
"Templates from ",
config.template_dir
] }),
type === "code_project" && /* @__PURE__ */ jsxRuntimeExports.jsxs("label", { className: "flex items-center gap-2 cursor-pointer", children: [
selectedBroadType === "code_project" && /* @__PURE__ */ jsxRuntimeExports.jsxs("label", { className: "flex items-center gap-2 cursor-pointer", children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(
"input",
{
Expand Down
2 changes: 1 addition & 1 deletion packages/app/out/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Setlist</title>
<script type="module" crossorigin src="./assets/index-Siu6r3F2.js"></script>
<script type="module" crossorigin src="./assets/index-9KfjZFFz.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-pkWOQE-N.css">
</head>
<body>
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ const api = {
registerExistingWorkspace: (opts: {
name: string;
path: string;
workspace_kind: 'code' | 'workspace';
workspace_kind?: 'code' | 'workspace';
project_type_id?: number | null;
status?: string;
description?: string;
goals?: string;
Expand Down
Loading
Loading