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
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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: <names>. Run sdd init --here --force to copy new defaults.`
2. `sdd new -w <name>` keeps refusing a name that is not in the project. When that name is bundled, the error still starts with `Workflow "<name>" is not available. Available: <project names>.` and then says `This CLI ships <name>. 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.
Original file line number Diff line number Diff line change
@@ -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. |
Original file line number Diff line number Diff line change
@@ -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.

---
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion docs/reference/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<changes_path>/.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 `<changes_path>/.gitignore` for `.active`. `--force` appends any of those lines that are missing and leaves the rest of the file in place.

## Agent roles (stubs)

Expand Down
6 changes: 3 additions & 3 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command> --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 `<changes_path>/.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 `<changes_path>/.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.

---

Expand Down Expand Up @@ -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 <pack>` | Choose path (hotfix, feature, …) | You know the path |
| `sdd new … -y` | Skip confirm prompts | Scripts / CI |
Expand Down Expand Up @@ -94,7 +94,7 @@ Commit `.sdd/config.yaml`, workflows, templates, `protocol.md`, `memory/`, and c
| `sdd branch <name>` | 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 <id>` | 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 |

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<changes_path>/.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 `<changes_path>/.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

Expand Down
2 changes: 2 additions & 0 deletions docs/reference/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions packages/cli/src/__tests__/cli.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
};
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);
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
loadConfig,
loadInstalledAgent,
loadWorkflow,
missingBundledWorkflows,
parseStoryDraft,
readChangeMetrics,
recommendWorkflow,
Expand Down Expand Up @@ -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}`);
}
});
},
});
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/__tests__/generated-defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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",
);
Expand Down
Loading
Loading