diff --git a/changes/2026-10-23-issue-36-show-missing-bundled-workflows/acceptance.md b/changes/2026-10-23-issue-36-show-missing-bundled-workflows/acceptance.md new file mode 100644 index 00000000..5f1fef41 --- /dev/null +++ b/changes/2026-10-23-issue-36-show-missing-bundled-workflows/acceptance.md @@ -0,0 +1,10 @@ +# Acceptance + +> **Issue #36: show missing bundled workflows and adopt matching files** · `2026-10-23-issue-36-show-missing-bundled-workflows` + +- A project with only `patch.yaml` and no `.sdd/.generated.json` makes `sdd workflows` print `patch` and `Not copied yet:` including `feature`, plus `sdd init --here --force`. +- `sdd new "probe" -w feature -y --no-agent` exits non-zero. The message contains `Workflow "feature" is not available. Available: patch` and `This CLI ships feature. Run sdd init --here --force to copy new defaults.` +- `sdd init --here --force --no-agent` creates `feature.yaml`, leaves a matching `patch.yaml` byte-identical, and stores a hash for `patch.yaml`. It does not report that file as `not generated by sdd`. +- After that, `sdd new "probe" -w feature -y --no-agent` exits 0. +- An unmarked `patch.yaml` that differs from the bundle stays byte-identical, is reported as not generated, and receives no hash. +- A matching unmarked file, once recorded, is refreshed on a later `--force` when the bundled bytes change. An unmarked file that differs is not refreshed. diff --git a/changes/2026-10-23-issue-36-show-missing-bundled-workflows/intent.md b/changes/2026-10-23-issue-36-show-missing-bundled-workflows/intent.md new file mode 100644 index 00000000..0872387b --- /dev/null +++ b/changes/2026-10-23-issue-36-show-missing-bundled-workflows/intent.md @@ -0,0 +1,22 @@ +# Intent + +> **Issue #36: show missing bundled workflows and adopt matching files** · `2026-10-23-issue-36-show-missing-bundled-workflows` · 2026-10-23 + +## Problem + +An upgraded `sdd` ships workflows the project does not have. `sdd workflows` lists only the files under `.sdd/workflows/`. `sdd new -w feature` then says the workflow is not available and does not say that this CLI ships it, or that `sdd init --here --force` copies it. + +`sdd init --force` does copy a missing file. It does not record a file that is already there when `.sdd/.generated.json` has no hash for it, even when the bytes match the bundled file. A later `--force` can never refresh that file. + +## Decision + +1. `sdd workflows` keeps listing the project workflows. When the CLI ships names that are not in the project, it adds one line: `Not copied yet: . Run sdd init --here --force to copy new defaults.` +2. `sdd new -w ` keeps refusing a name that is not in the project. When that name is bundled, the error still starts with `Workflow "" is not available. Available: .` and then says `This CLI ships . Run sdd init --here --force to copy new defaults.` +3. On init, a workflow or template with no manifest hash whose bytes match the bundled file (line endings aside) is recorded as generated. The file is not rewritten when the bytes are already the same. A later `--force` can refresh it. A file that differs stays kept and gets no hash. +4. Non-interactive `sdd init` without `--force` still does not copy workflows or rewrite config. + +## Out of scope + +- Loading a bundled workflow that was never copied into the project. +- Rewriting an unmarked file that differs from the current bundle. +- Changing prices, search config, or memory files. diff --git a/changes/2026-10-23-issue-36-show-missing-bundled-workflows/local-test-plan.md b/changes/2026-10-23-issue-36-show-missing-bundled-workflows/local-test-plan.md new file mode 100644 index 00000000..717321d9 --- /dev/null +++ b/changes/2026-10-23-issue-36-show-missing-bundled-workflows/local-test-plan.md @@ -0,0 +1,12 @@ +# Local test plan + +> **Issue #36: show missing bundled workflows and adopt matching files** · `2026-10-23-issue-36-show-missing-bundled-workflows` + +Node on this machine is v20.19.3. The `EBADENGINE` warning for `engines.node >= 24` is expected. + +| # | Check | Expected | +|---|--------|----------| +| 1 | `pnpm --filter @structured-vibe-coding/core exec vitest run src/__tests__/init-force.test.ts src/__tests__/generated-defaults.test.ts` | Pass. Matching unmarked files are recorded. Differing unmarked files stay kept. A later source change refreshes the recorded file. | +| 2 | `pnpm --filter @structured-vibe-coding/cli test -- src/__tests__/cli.integration.test.ts` | Pass, including the #36 command repro. | +| 3 | `pnpm exec biome check` on the touched TypeScript | Pass. | +| 4 | `pnpm --filter @structured-vibe-coding/core typecheck` and `pnpm --filter @structured-vibe-coding/cli typecheck` | Pass. | diff --git a/changes/2026-10-23-issue-36-show-missing-bundled-workflows/local-test-results.md b/changes/2026-10-23-issue-36-show-missing-bundled-workflows/local-test-results.md new file mode 100644 index 00000000..aea5fc10 --- /dev/null +++ b/changes/2026-10-23-issue-36-show-missing-bundled-workflows/local-test-results.md @@ -0,0 +1,16 @@ +# Local test results + +> **Issue #36: show missing bundled workflows and adopt matching files** · `2026-10-23-issue-36-show-missing-bundled-workflows` + +Node v20.19.3. `EBADENGINE` warnings for `engines.node >= 24` are expected. No network. + +| # | Result | +|---|--------| +| 1 | PASS. Full core vitest, 276 tests, exit 0. A matching unmarked file is recorded and a later bundled change refreshes it. A differing unmarked file stays kept. | +| 2 | PASS. CLI integration: 29 tests, exit 0, including `names missing bundled workflows and adopts a matching unmarked file (#36)`. The other CLI files (node support, role, search, story) also passed, 16 tests. | +| 3 | PASS. `biome check` on the touched TypeScript, exit 0. | +| 4 | PASS. `tsc --noEmit` for core and cli, exit 0. CLI typecheck used the rebuilt core declarations. | + +The patch workflow's `verify.commands` is empty, so `sdd verify` would report NOT RUN. The checks above are the verification. + +--- diff --git a/changes/2026-10-23-issue-36-show-missing-bundled-workflows/meta.yaml b/changes/2026-10-23-issue-36-show-missing-bundled-workflows/meta.yaml new file mode 100644 index 00000000..708e95f4 --- /dev/null +++ b/changes/2026-10-23-issue-36-show-missing-bundled-workflows/meta.yaml @@ -0,0 +1,29 @@ +id: 2026-10-23-issue-36-show-missing-bundled-workflows +title: "Issue #36: show missing bundled workflows and adopt matching files" +workflow: patch +created: 2026-10-23T00:00:00.000Z +updated: 2026-10-23T00:00:00.000Z +status: completed +stage: local_verify +flags: {} +overrides: + skip_stages: [] + gates: {} + extra_stages: [] +skipped: [] +gates: {} +verify_results: + local_verify: + ok: true + status: pass + at: 2026-10-23T14:09:00.000Z + results: + - name: Core vitest for init, generated defaults, engine, and dynamic bugs + exitCode: 0 + - name: CLI integration tests + exitCode: 0 + - name: biome check on touched TypeScript + exitCode: 0 + - name: tsc --noEmit for core and cli + exitCode: 0 +completed_at: 2026-10-23T14:09:00.000Z diff --git a/docs/reference/agents.md b/docs/reference/agents.md index 34898314..5e201504 100644 --- a/docs/reference/agents.md +++ b/docs/reference/agents.md @@ -66,7 +66,7 @@ This protection covers **`AGENTS.md`, host agent files and folders, `.sdd/protoc - If you already have an `AGENTS.md`, sdd leaves it alone and prints a short section you can paste into it. - Files from sdd versions older than this marker count as unmarked. To regenerate one, delete it and re-run. -**Project state under `.sdd/`:** If `.sdd/config.yaml` already exists, non-interactive `sdd init` without `--force` appends missing ignore lines and does not rewrite config, memory, or workflows. On a terminal, confirming the re-init prompt continues and behaves like `--force`. `--force` merges current defaults into that config. It refreshes a workflow, template, protocol, or README only while the file is still an unchanged sdd-generated file. Edited files, and files sdd did not generate, stay as they are even on a first init. `memory/` is never replaced. Host install rewrites `.sdd/agents.json` when the file is missing or when that install runs with force (the `--force` flag, or a confirmed terminal re-init). `sdd init --no-agent` does not write `.sdd/agents.json`. A fresh init, and a confirmed or `--force` re-init, refreshes `.sdd/active-context.md`. A fresh init writes `.sdd/.gitignore` for `handoff.md` and `active-context.md`, and `/.gitignore` for `.active`. `--force` appends any of those lines that are missing and leaves the rest of the file in place. +**Project state under `.sdd/`:** If `.sdd/config.yaml` already exists, non-interactive `sdd init` without `--force` appends missing ignore lines and does not rewrite config, memory, or workflows. On a terminal, confirming the re-init prompt continues and behaves like `--force`. `--force` merges current defaults into that config. It refreshes a workflow, template, protocol, or README only while the file is still an unchanged sdd-generated file. Edited files, and files sdd did not generate, stay as they are even on a first init. A file with no manifest hash whose bytes match the bundled file is recorded as generated. One that differs stays kept. `memory/` is never replaced. Host install rewrites `.sdd/agents.json` when the file is missing or when that install runs with force (the `--force` flag, or a confirmed terminal re-init). `sdd init --no-agent` does not write `.sdd/agents.json`. A fresh init, and a confirmed or `--force` re-init, refreshes `.sdd/active-context.md`. A fresh init writes `.sdd/.gitignore` for `handoff.md` and `active-context.md`, and `/.gitignore` for `.active`. `--force` appends any of those lines that are missing and leaves the rest of the file in place. ## Agent roles (stubs) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 558bd28a..2840275f 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -10,7 +10,7 @@ Install: **[@structured-vibe-coding/cli on npm](https://www.npmjs.com/package/@s Learn by doing: [Start in 10 minutes](../tutorials/first-change). One command’s flags: `sdd --help`. -Commit `.sdd/config.yaml`, workflows, templates, `protocol.md`, `memory/`, and change packs. Ignore `handoff.md`, `active-context.md`, `index.db`, `index.db-journal`, `index.db-wal`, `index.db-shm`, `models/`, `role-runs/`, and `/.active`. `sdd init` writes those gitignore rules. On a project that already has `.sdd/config.yaml`, `sdd init` without `--force` appends any missing ignore line and does not rewrite config, memory, or workflows. `sdd init --force` still refreshes unchanged generated files. `sdd status` prints the change path relative to the repo. +Commit `.sdd/config.yaml`, workflows, templates, `protocol.md`, `memory/`, and change packs. Ignore `handoff.md`, `active-context.md`, `index.db`, `index.db-journal`, `index.db-wal`, `index.db-shm`, `models/`, `role-runs/`, and `/.active`. `sdd init` writes those gitignore rules. On a project that already has `.sdd/config.yaml`, `sdd init` without `--force` appends any missing ignore line and does not rewrite config, memory, or workflows. `sdd init --force` still refreshes unchanged generated files. A file with no manifest hash is recorded as generated when it matches the bundled file, and left in place when it differs. `sdd workflows` names bundled workflows that are not copied yet. `sdd status` prints the change path relative to the repo. --- @@ -59,7 +59,7 @@ Commit `.sdd/config.yaml`, workflows, templates, `protocol.md`, `memory/`, and c | Command | What it does | When | |---------|--------------|------| | `sdd init` | Create sdd files + one AI setup | Once per app | -| `sdd init --force` | Merge current defaults into supported config; refresh only unchanged SDD-generated workflows/templates and agent files | Upgrades | +| `sdd init --force` | Merge current defaults into supported config; refresh only unchanged SDD-generated workflows/templates and agent files. A file with no manifest hash is recorded as generated when it matches the bundled file | Upgrades | | `sdd new "title"` | Start a change pack from a free-form title | Everyday work | | `sdd new … -w ` | Choose path (hotfix, feature, …) | You know the path | | `sdd new … -y` | Skip confirm prompts | Scripts / CI | @@ -94,7 +94,7 @@ Commit `.sdd/config.yaml`, workflows, templates, `protocol.md`, `memory/`, and c | `sdd branch ` | Create that story branch from `main` | Start one story | | `sdd pr` | Push the change branch and open a pull request | Story is ready | | `sdd checkout ` | Switch active change | Multi-PR | -| `sdd workflows` | List paths | Discover | +| `sdd workflows` | List project workflows, and name bundled workflows that are not copied yet | Discover | | `sdd help` | Short overview | First run | | `sdd doctor` | Health check | Stuck / setup | diff --git a/docs/reference/layout.md b/docs/reference/layout.md index 9118ee92..cfe6ebad 100644 --- a/docs/reference/layout.md +++ b/docs/reference/layout.md @@ -34,7 +34,7 @@ AGENTS.md ← tiny pointer for AIs Commit the shared process: `.sdd/config.yaml`, `.sdd/workflows/`, `.sdd/templates/`, `.sdd/protocol.md`, `memory/`, and each change pack under the configured changes directory. -Ignore per-machine files. Init writes `.sdd/.gitignore` for `handoff.md` and `active-context.md`, and `/.gitignore` for `.active` (by default `changes/.active`). `.active` is the live change pointer. `handoff.md` and `active-context.md` are a repo-relative handoff and task brief. Commands that move or launch work rewrite them; they do not change on every command. If git already tracks one of them, init warns and leaves the index alone. A second non-interactive `sdd init` without `--force` appends missing ignore lines, including `role-runs/`, and does not rewrite config, memory, or workflows. Confirming the re-init prompt in a terminal still behaves like `--force`. `sdd init --force` appends any missing ignore lines and refreshes unchanged generated files. `.sdd/prices.yaml` is committed project config, written by init when missing, and not regenerated over a user edit. +Ignore per-machine files. Init writes `.sdd/.gitignore` for `handoff.md` and `active-context.md`, and `/.gitignore` for `.active` (by default `changes/.active`). `.active` is the live change pointer. `handoff.md` and `active-context.md` are a repo-relative handoff and task brief. Commands that move or launch work rewrite them; they do not change on every command. If git already tracks one of them, init warns and leaves the index alone. A second non-interactive `sdd init` without `--force` appends missing ignore lines, including `role-runs/`, and does not rewrite config, memory, or workflows. Confirming the re-init prompt in a terminal still behaves like `--force`. `sdd init --force` appends any missing ignore lines and refreshes unchanged generated files. A workflow or template with no manifest hash is recorded as generated when its bytes match the bundled file, so a later `--force` can refresh it. A file that differs stays kept. `sdd workflows` names bundled workflows that are not in the project yet. `.sdd/prices.yaml` is committed project config, written by init when missing, and not regenerated over a user edit. ## Keep project data together diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index d9db6ce9..8d8b304e 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -11,6 +11,8 @@ Shipped under `.sdd/workflows/` after `sdd init` (from package defaults). List a sdd workflows ``` +A project initialized by an older `sdd` may be missing packs this CLI ships. `sdd workflows` names those under `Not copied yet`. Copy them with `sdd init --here --force`. That command leaves a file alone when its bytes differ from the bundled file. A file that still matches is recorded, so a later upgrade can refresh it. + Pick a pack at change start: ```bash diff --git a/packages/cli/src/__tests__/cli.integration.test.ts b/packages/cli/src/__tests__/cli.integration.test.ts index c8c07e31..d8c4b091 100644 --- a/packages/cli/src/__tests__/cli.integration.test.ts +++ b/packages/cli/src/__tests__/cli.integration.test.ts @@ -699,6 +699,61 @@ stages: expect(await readFile(ignorePath, "utf8")).toBe(ignore); }); + it("names missing bundled workflows and adopts a matching unmarked file (#36)", async () => { + const root = await mkdtemp(join(tmpdir(), "sdd-cli-missing-workflows-")); + temps.push(root); + expect(runSdd(root, ["init", "--here", "--no-agent"]).status).toBe(0); + + const wfDir = join(root, ".sdd/workflows"); + for (const name of await readdir(wfDir)) { + if (name !== "patch.yaml") await rm(join(wfDir, name)); + } + await rm(join(root, ".sdd/.generated.json")); + const patchPath = join(wfDir, "patch.yaml"); + const patchBefore = await readFile(patchPath, "utf8"); + + const listed = runSdd(root, ["workflows"]); + expect(listed.status, listed.stderr + listed.stdout).toBe(0); + expect(listed.stdout).toContain("patch"); + expect(listed.stdout).toContain("Not copied yet:"); + expect(listed.stdout).toContain("feature"); + expect(listed.stdout).toContain("sdd init --here --force"); + + const blocked = runSdd(root, ["new", "probe", "-w", "feature", "-y", "--no-agent"]); + const blockedOut = blocked.stderr + blocked.stdout; + expect(blocked.status, blockedOut).not.toBe(0); + expect(blockedOut).toContain('Workflow "feature" is not available. Available: patch'); + expect(blockedOut).toContain("This CLI ships feature"); + expect(blockedOut).toContain("sdd init --here --force"); + + const forced = runSdd(root, ["init", "--here", "--force", "--no-agent"]); + expect(forced.status, forced.stderr + forced.stdout).toBe(0); + expect(await readFile(patchPath, "utf8")).toBe(patchBefore); + expect(await exists(join(wfDir, "feature.yaml"))).toBe(true); + expect(forced.stdout + forced.stderr).not.toContain( + ".sdd/workflows/patch.yaml (not generated by sdd — left untouched)", + ); + const manifest = JSON.parse(await readFile(join(root, ".sdd/.generated.json"), "utf8")) as { + files: Record; + }; + expect(manifest.files[".sdd/workflows/patch.yaml"]).toBeTruthy(); + + const custom = `${patchBefore}\n# team edit\n`; + await writeFile(patchPath, custom); + manifest.files = Object.fromEntries( + Object.entries(manifest.files).filter(([path]) => path !== ".sdd/workflows/patch.yaml"), + ); + await writeFile(join(root, ".sdd/.generated.json"), `${JSON.stringify(manifest, null, 2)}\n`); + const again = runSdd(root, ["init", "--here", "--force", "--no-agent"]); + const againOut = again.stderr + again.stdout; + expect(again.status, againOut).toBe(0); + expect(await readFile(patchPath, "utf8")).toBe(custom); + expect(againOut).toContain(".sdd/workflows/patch.yaml (not generated by sdd — left untouched)"); + + const started = runSdd(root, ["new", "probe", "-w", "feature", "-y", "--no-agent"]); + expect(started.status, started.stderr + started.stdout).toBe(0); + }); + it("init --force preserves custom config paths and lists edited workflows/templates (#15)", async () => { const root = await mkdtemp(join(tmpdir(), "sdd-cli-force-preserve-")); temps.push(root); diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 245333b1..8c8271d9 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -21,6 +21,7 @@ import { loadConfig, loadInstalledAgent, loadWorkflow, + missingBundledWorkflows, parseStoryDraft, readChangeMetrics, recommendWorkflow, @@ -807,6 +808,11 @@ const workflows = defineCommand({ `${pc.cyan(name)} ${pc.dim(wf.description ?? "")} ${pc.dim(`(${wf.stages.length} stages)`)}`, ); } + const missing = await missingBundledWorkflows(root); + if (missing.length > 0) { + const copy = "Run `sdd init --here --force` to copy new defaults."; + consola.log(`${pc.yellow("Not copied yet:")} ${missing.join(", ")}. ${copy}`); + } }); }, }); diff --git a/packages/core/src/__tests__/generated-defaults.test.ts b/packages/core/src/__tests__/generated-defaults.test.ts index 35e7ed0c..1cd8ff8d 100644 --- a/packages/core/src/__tests__/generated-defaults.test.ts +++ b/packages/core/src/__tests__/generated-defaults.test.ts @@ -29,8 +29,10 @@ describe("generated defaults manifest", () => { const destTemplates = join(root, ".sdd/templates"); await put(join(sourceWorkflows, "patch.yaml"), "name: patch-v1\n"); await put(join(sourceWorkflows, "team.yaml"), "name: packaged-team-default\n"); + await put(join(sourceWorkflows, "same.yaml"), "name: same\n"); await put(join(sourceTemplates, "intent.md"), "# Intent v1\n"); await put(join(destWorkflows, "team.yaml"), "name: user-team-workflow\n"); + await put(join(destWorkflows, "same.yaml"), "name: same\n"); const manifest = await loadGeneratedManifest(manifestPath); const firstWorkflows = await reconcileGeneratedTree( @@ -54,8 +56,11 @@ describe("generated defaults manifest", () => { path: ".sdd/workflows/team.yaml", reason: "not-generated", }); + expect(manifest.files[".sdd/workflows/same.yaml"]).toBeTruthy(); + expect(firstWorkflows.kept.some((k) => k.path.endsWith("same.yaml"))).toBe(false); await put(join(sourceWorkflows, "patch.yaml"), "name: patch-v2\n"); + await put(join(sourceWorkflows, "same.yaml"), "name: same-v2\n"); await put(join(sourceTemplates, "intent.md"), "# Intent v2\n"); await put(join(destTemplates, "intent.md"), "# Team edited intent\n"); const workflows = await reconcileGeneratedTree( @@ -76,7 +81,9 @@ describe("generated defaults manifest", () => { ); expect(await readFile(join(destWorkflows, "patch.yaml"), "utf8")).toBe("name: patch-v2\n"); + expect(await readFile(join(destWorkflows, "same.yaml"), "utf8")).toBe("name: same-v2\n"); expect(workflows.refreshed).toContain(".sdd/workflows/patch.yaml"); + expect(workflows.refreshed).toContain(".sdd/workflows/same.yaml"); expect(await readFile(join(destWorkflows, "team.yaml"), "utf8")).toBe( "name: user-team-workflow\n", ); diff --git a/packages/core/src/__tests__/init-force.test.ts b/packages/core/src/__tests__/init-force.test.ts index b81513d7..8316e637 100644 --- a/packages/core/src/__tests__/init-force.test.ts +++ b/packages/core/src/__tests__/init-force.test.ts @@ -4,7 +4,13 @@ import { tmpdir } from "node:os"; import { join } from "pathe"; import { afterEach, describe, expect, it } from "vitest"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; -import { contentHash, initProject, loadConfig } from "../index.js"; +import { + contentHash, + initProject, + loadConfig, + missingBundledWorkflows, + recommendWorkflow, +} from "../index.js"; const temps: string[] = []; @@ -165,21 +171,55 @@ describe("sdd init --force preserves project customization", () => { expect(manifest.files[".sdd/workflows/patch.yaml"]).toBe(contentHash(originalWorkflow)); }); - it("treats pre-manifest workflow files as user-owned on legacy upgrades", async () => { + it("records a matching unmarked workflow and keeps one that differs (#36)", async () => { const root = await freshProject(); - const workflowPath = join(root, ".sdd/workflows/patch.yaml"); - const before = await readFile(workflowPath, "utf8"); + const patchPath = join(root, ".sdd/workflows/patch.yaml"); + const featurePath = join(root, ".sdd/workflows/feature.yaml"); + const patchBefore = await readFile(patchPath, "utf8"); + const featureBefore = await readFile(featurePath, "utf8"); + await rm(featurePath); await rm(join(root, ".sdd/.generated.json")); - const result = await initProject({ projectRoot: root, agents: false, force: true }); - const manifest = JSON.parse(await readFile(join(root, ".sdd/.generated.json"), "utf8")); + const config = await loadConfig(root); + const missing = await missingBundledWorkflows(root); + expect(missing).toContain("feature"); + expect(missing).not.toContain("patch"); + await expect( + recommendWorkflow(root, "probe", config, { preferred: "feature" }), + ).rejects.toThrow( + /This CLI ships feature\. Run `sdd init --here --force` to copy new defaults/, + ); - expect(await readFile(workflowPath, "utf8")).toBe(before); - expect(result.kept).toContainEqual({ + const adopted = await initProject({ projectRoot: root, agents: false, force: true }); + const manifest = JSON.parse(await readFile(join(root, ".sdd/.generated.json"), "utf8")) as { + files: Record; + }; + expect(await readFile(patchPath, "utf8")).toBe(patchBefore); + expect(manifest.files[".sdd/workflows/patch.yaml"]).toBe(contentHash(patchBefore)); + expect(adopted.kept.some((k) => k.path === ".sdd/workflows/patch.yaml")).toBe(false); + expect(await readFile(featurePath, "utf8")).toBe(featureBefore); + expect(manifest.files[".sdd/workflows/feature.yaml"]).toBe(contentHash(featureBefore)); + await expect( + recommendWorkflow(root, "probe", config, { preferred: "feature" }), + ).resolves.toMatchObject({ name: "feature" }); + + const custom = `${patchBefore}\n# team edit\n`; + await writeFile(patchPath, custom); + manifest.files = Object.fromEntries( + Object.entries(manifest.files).filter(([path]) => path !== ".sdd/workflows/patch.yaml"), + ); + await writeFile(join(root, ".sdd/.generated.json"), `${JSON.stringify(manifest, null, 2)}\n`); + + const kept = await initProject({ projectRoot: root, agents: false, force: true }); + const after = JSON.parse(await readFile(join(root, ".sdd/.generated.json"), "utf8")) as { + files: Record; + }; + expect(await readFile(patchPath, "utf8")).toBe(custom); + expect(kept.kept).toContainEqual({ path: ".sdd/workflows/patch.yaml", reason: "not-generated", }); - expect(manifest.files[".sdd/workflows/patch.yaml"]).toBeUndefined(); + expect(after.files[".sdd/workflows/patch.yaml"]).toBeUndefined(); }); it("fails on a malformed manifest before changing config or workflow files", async () => { diff --git a/packages/core/src/generated.ts b/packages/core/src/generated.ts index b3817fef..fe1235c5 100644 --- a/packages/core/src/generated.ts +++ b/packages/core/src/generated.ts @@ -186,12 +186,20 @@ export async function reconcileGeneratedTree( continue; } - const previousHash = manifest.files[projectRelative]; + const currentHash = contentHash(current); + let previousHash = manifest.files[projectRelative]; if (!previousHash) { - kept.push({ path: projectRelative, reason: "not-generated" }); - continue; + // No recorded hash. A byte-for-byte match (line endings aside) is the + // bundled file, so record it. A later --force can then refresh it. + // Anything else is a user's file and stays kept. + if (currentHash !== generatedHash) { + kept.push({ path: projectRelative, reason: "not-generated" }); + continue; + } + manifest.files[projectRelative] = generatedHash; + previousHash = generatedHash; } - if (contentHash(current) !== previousHash) { + if (currentHash !== previousHash) { kept.push({ path: projectRelative, reason: "edited" }); continue; } diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 99efa5d1..3abd15a2 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -1,4 +1,5 @@ import { join } from "pathe"; +import { defaultWorkflowsDir } from "./defaults.js"; import { listFiles, pathExists, readYaml } from "./fs.js"; import { workflowsDir } from "./paths.js"; import { @@ -10,15 +11,40 @@ import { WorkflowSchema, } from "./schemas.js"; -export async function listWorkflowNames(projectRoot: string): Promise { - const dir = workflowsDir(projectRoot); - const files = await listFiles(dir); +function workflowNamesIn(files: string[]): string[] { return files .filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")) .map((f) => f.replace(/\.ya?ml$/, "")) .sort(); } +export async function listWorkflowNames(projectRoot: string): Promise { + const dir = workflowsDir(projectRoot); + return workflowNamesIn(await listFiles(dir)); +} + +/** Workflow packs shipped inside this CLI build. */ +export async function listBundledWorkflowNames(): Promise { + return workflowNamesIn(await listFiles(defaultWorkflowsDir())); +} + +/** Bundled workflow names that this project has not copied into `.sdd/workflows/`. */ +export async function missingBundledWorkflows(projectRoot: string): Promise { + const present = new Set(await listWorkflowNames(projectRoot)); + return (await listBundledWorkflowNames()).filter((name) => !present.has(name)); +} + +export function workflowUnavailableMessage( + name: string, + available: string[], + bundled: string[], +): string { + const listed = available.join(", ") || "(none)"; + const base = `Workflow "${name}" is not available. Available: ${listed}`; + if (!bundled.includes(name)) return base; + return `${base}. This CLI ships ${name}. Run \`sdd init --here --force\` to copy new defaults.`; +} + export async function loadWorkflow(projectRoot: string, name: string): Promise { const dir = workflowsDir(projectRoot); const candidates = [`${name}.yaml`, `${name}.yml`]; @@ -33,9 +59,14 @@ export async function loadWorkflow(projectRoot: string, name: string): Promise