@friedbotstudio/create-baseline 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +14 -10
  2. package/bin/cli.js +16 -12
  3. package/obj/template/.claude/commands/init-project-doctor.md +74 -0
  4. package/obj/template/.claude/hooks/lib/resume_writer.py +14 -1
  5. package/obj/template/.claude/hooks/track_guard.sh +11 -1
  6. package/obj/template/.claude/manifest.json +29 -97
  7. package/obj/template/.claude/schemas/workflow-track.v1.json +64 -0
  8. package/obj/template/.claude/skills/audit-baseline/audit.sh +2 -2
  9. package/obj/template/.claude/skills/chore/SKILL.md +2 -2
  10. package/obj/template/.claude/skills/harness/SKILL.md +15 -6
  11. package/obj/template/.claude/skills/intake/SKILL.md +1 -1
  12. package/obj/template/.claude/skills/swarm-plan/SKILL.md +2 -0
  13. package/obj/template/.claude/skills/tdd/SKILL.md +2 -2
  14. package/obj/template/.claude/skills/triage/SKILL.md +29 -6
  15. package/obj/template/.claude/skills/triage/seed-tasklist.mjs +107 -0
  16. package/obj/template/.claude/workflows.jsonl +6 -0
  17. package/obj/template/CLAUDE.md +8 -14
  18. package/obj/template/docs/init/seed.md +148 -3
  19. package/package.json +1 -1
  20. package/src/.claude/workflows.template.jsonl +6 -0
  21. package/src/CLAUDE.template.md +8 -14
  22. package/src/cli/install.js +5 -1
  23. package/src/cli/merge.js +28 -1
  24. package/src/cli/track-tasklist-materializer.js +223 -0
  25. package/src/cli/tui/upgrade.js +14 -8
  26. package/src/cli/upgrade-tiers.js +22 -0
  27. package/src/cli/workflow-migrator.js +40 -0
  28. package/src/cli/workflows-validator-invariants.js +417 -0
  29. package/src/cli/workflows-validator-predicates.js +19 -0
  30. package/src/cli/workflows-validator.js +156 -0
  31. package/src/seed.template.md +148 -3
  32. package/obj/template/.claude/skills/google-analytics/SKILL.md +0 -129
  33. package/obj/template/.claude/skills/google-analytics/references/audiences.md +0 -389
  34. package/obj/template/.claude/skills/google-analytics/references/bigquery.md +0 -470
  35. package/obj/template/.claude/skills/google-analytics/references/custom-dimensions.md +0 -355
  36. package/obj/template/.claude/skills/google-analytics/references/custom-events.md +0 -383
  37. package/obj/template/.claude/skills/google-analytics/references/data-management.md +0 -416
  38. package/obj/template/.claude/skills/google-analytics/references/debugview.md +0 -364
  39. package/obj/template/.claude/skills/google-analytics/references/events-fundamentals.md +0 -398
  40. package/obj/template/.claude/skills/google-analytics/references/gtag.md +0 -502
  41. package/obj/template/.claude/skills/google-analytics/references/gtm-integration.md +0 -483
  42. package/obj/template/.claude/skills/google-analytics/references/measurement-protocol.md +0 -519
  43. package/obj/template/.claude/skills/google-analytics/references/privacy.md +0 -441
  44. package/obj/template/.claude/skills/google-analytics/references/recommended-events.md +0 -464
  45. package/obj/template/.claude/skills/google-analytics/references/reporting.md +0 -397
  46. package/obj/template/.claude/skills/google-analytics/references/setup.md +0 -344
  47. package/obj/template/.claude/skills/google-analytics/references/user-tracking.md +0 -417
  48. package/obj/template/.claude/skills/optimize-seo/SKILL.md +0 -313
  49. package/obj/template/.claude/skills/optimize-seo/scripts/pagespeed.mjs +0 -197
  50. package/obj/template/.claude/skills/pagespeed-insights/LICENSE.md +0 -37
  51. package/obj/template/.claude/skills/pagespeed-insights/SKILL.md +0 -446
  52. package/obj/template/.claude/skills/pagespeed-insights/reference.md +0 -50
@@ -0,0 +1,6 @@
1
+ {"$schema":"./schemas/workflow-track.v1.json","track_id":"intake-full","name":"Intake-entry full pipeline","description":"Canonical 11-phase pipeline for new features that need full design upfront (intake → scout → research → spec → approval → tdd/swarm → simplify → security → integrate → document → archive → memory-flush → grant-commit → changelog → commit).","selectable":true,"selector_hints":["build a new feature with full design upfront","add new functionality requiring a written spec","constitutional change or architectural refactor"],"preconditions":[],"invariants":["commits","requires_spec"],"nodes":[{"id":"intake","type":"task","skill":"intake","depends_on":[],"blocks":["scout"],"can_parallel":false,"needs_user":false,"activeForm":"Running intake","metadata":{"phase":"intake"}},{"id":"scout","type":"task","skill":"scout","depends_on":["intake"],"blocks":["research"],"can_parallel":false,"needs_user":false,"activeForm":"Running scout","metadata":{"phase":"scout"}},{"id":"research","type":"task","skill":"research","depends_on":["scout"],"blocks":["spec"],"can_parallel":false,"needs_user":false,"activeForm":"Running research","metadata":{"phase":"research"}},{"id":"spec","type":"task","skill":"spec","depends_on":["research"],"blocks":["approve-spec"],"can_parallel":false,"needs_user":false,"activeForm":"Running spec","metadata":{"phase":"spec"}},{"id":"approve-spec","type":"task","skill":"approve-spec","depends_on":["spec"],"blocks":["implementation"],"can_parallel":false,"needs_user":true,"activeForm":"Awaiting spec approval","metadata":{"phase":"approve-spec","needs_user":true}},{"id":"implementation","type":"selector","alternates":[{"sub_track":"swarm-implementation","preconditions":[{"name":"requires_git"},{"name":"requires_min_components","argument":"3"}],"description":"Swarm path for git projects with 3+ independent components"},{"sub_track":"tdd-worker-chain","preconditions":[],"description":"Solo TDD fallback (default)"}],"depends_on":["approve-spec"],"blocks":["simplify"],"can_parallel":false,"needs_user":false},{"id":"simplify","type":"task","skill":"simplify","depends_on":["implementation"],"blocks":["security"],"can_parallel":false,"needs_user":false,"activeForm":"Running simplify","metadata":{"phase":"simplify"}},{"id":"security","type":"task","skill":"security","depends_on":["simplify"],"blocks":["integrate"],"can_parallel":false,"needs_user":false,"activeForm":"Running security","metadata":{"phase":"security"}},{"id":"integrate","type":"task","skill":"integrate","depends_on":["security"],"blocks":["document"],"can_parallel":false,"needs_user":false,"activeForm":"Running integrate","metadata":{"phase":"integrate"}},{"id":"document","type":"task","skill":"document","depends_on":["integrate"],"blocks":["archive"],"can_parallel":false,"needs_user":false,"activeForm":"Running document","metadata":{"phase":"document"}},{"id":"archive","type":"task","skill":"archive","depends_on":["document"],"blocks":["memory-flush"],"can_parallel":false,"needs_user":false,"activeForm":"Running archive","metadata":{"phase":"archive"}},{"id":"memory-flush","type":"task","skill":"memory-flush","depends_on":["archive"],"blocks":["grant-commit"],"can_parallel":false,"needs_user":false,"activeForm":"Running memory-flush","metadata":{"phase":"memory-flush"}},{"id":"grant-commit","type":"task","skill":"grant-commit","depends_on":["memory-flush"],"blocks":["changelog"],"can_parallel":false,"needs_user":true,"activeForm":"Awaiting commit consent","metadata":{"phase":"grant-commit","needs_user":true}},{"id":"changelog","type":"task","skill":"changelog","depends_on":["grant-commit"],"blocks":["commit"],"can_parallel":false,"needs_user":false,"activeForm":"Running changelog","metadata":{"phase":"changelog"}},{"id":"commit","type":"task","skill":"commit","depends_on":["changelog"],"blocks":[],"can_parallel":false,"needs_user":false,"activeForm":"Running commit","metadata":{"phase":"commit"}}]}
2
+ {"$schema":"./schemas/workflow-track.v1.json","track_id":"spec-entry","name":"Spec-entry bugfix pipeline","description":"Bugfix workflow that starts at /spec; skips intake/scout/research. For known-behavior bugs needing a contract-level fix.","selectable":true,"selector_hints":["bug needs a spec","contract-level bugfix","behaviour change requires written design"],"preconditions":[],"invariants":["commits","requires_spec"],"nodes":[{"id":"spec","type":"task","skill":"spec","depends_on":[],"blocks":["approve-spec"],"can_parallel":false,"needs_user":false,"activeForm":"Running spec","metadata":{"phase":"spec"}},{"id":"approve-spec","type":"task","skill":"approve-spec","depends_on":["spec"],"blocks":["tdd"],"can_parallel":false,"needs_user":true,"activeForm":"Awaiting spec approval","metadata":{"phase":"approve-spec","needs_user":true}},{"id":"tdd","type":"task","skill":"tdd","depends_on":["approve-spec"],"blocks":["simplify"],"can_parallel":false,"needs_user":false,"activeForm":"Running TDD","metadata":{"phase":"tdd"}},{"id":"simplify","type":"task","skill":"simplify","depends_on":["tdd"],"blocks":["security"],"can_parallel":false,"needs_user":false,"activeForm":"Running simplify","metadata":{"phase":"simplify"}},{"id":"security","type":"task","skill":"security","depends_on":["simplify"],"blocks":["integrate"],"can_parallel":false,"needs_user":false,"activeForm":"Running security","metadata":{"phase":"security"}},{"id":"integrate","type":"task","skill":"integrate","depends_on":["security"],"blocks":["document"],"can_parallel":false,"needs_user":false,"activeForm":"Running integrate","metadata":{"phase":"integrate"}},{"id":"document","type":"task","skill":"document","depends_on":["integrate"],"blocks":["archive"],"can_parallel":false,"needs_user":false,"activeForm":"Running document","metadata":{"phase":"document"}},{"id":"archive","type":"task","skill":"archive","depends_on":["document"],"blocks":["memory-flush"],"can_parallel":false,"needs_user":false,"activeForm":"Running archive","metadata":{"phase":"archive"}},{"id":"memory-flush","type":"task","skill":"memory-flush","depends_on":["archive"],"blocks":["grant-commit"],"can_parallel":false,"needs_user":false,"activeForm":"Running memory-flush","metadata":{"phase":"memory-flush"}},{"id":"grant-commit","type":"task","skill":"grant-commit","depends_on":["memory-flush"],"blocks":["changelog"],"can_parallel":false,"needs_user":true,"activeForm":"Awaiting commit consent","metadata":{"phase":"grant-commit","needs_user":true}},{"id":"changelog","type":"task","skill":"changelog","depends_on":["grant-commit"],"blocks":["commit"],"can_parallel":false,"needs_user":false,"activeForm":"Running changelog","metadata":{"phase":"changelog"}},{"id":"commit","type":"task","skill":"commit","depends_on":["changelog"],"blocks":[],"can_parallel":false,"needs_user":false,"activeForm":"Running commit","metadata":{"phase":"commit"}}]}
3
+ {"$schema":"./schemas/workflow-track.v1.json","track_id":"tdd-quickfix","name":"TDD-entry quickfix pipeline","description":"Quickfix workflow that starts directly at /tdd. For localized misbehaviour with a known failing case; no spec needed.","selectable":true,"selector_hints":["quickfix with known failing test","localized bug; no spec needed","small bundled patch with a clear failing case"],"preconditions":[],"invariants":["commits"],"nodes":[{"id":"tdd","type":"task","skill":"tdd","depends_on":[],"blocks":["simplify"],"can_parallel":false,"needs_user":false,"activeForm":"Running TDD","metadata":{"phase":"tdd"}},{"id":"simplify","type":"task","skill":"simplify","depends_on":["tdd"],"blocks":["security"],"can_parallel":false,"needs_user":false,"activeForm":"Running simplify","metadata":{"phase":"simplify"}},{"id":"security","type":"task","skill":"security","depends_on":["simplify"],"blocks":["integrate"],"can_parallel":false,"needs_user":false,"activeForm":"Running security","metadata":{"phase":"security"}},{"id":"integrate","type":"task","skill":"integrate","depends_on":["security"],"blocks":["document"],"can_parallel":false,"needs_user":false,"activeForm":"Running integrate","metadata":{"phase":"integrate"}},{"id":"document","type":"task","skill":"document","depends_on":["integrate"],"blocks":["archive"],"can_parallel":false,"needs_user":false,"activeForm":"Running document","metadata":{"phase":"document"}},{"id":"archive","type":"task","skill":"archive","depends_on":["document"],"blocks":["memory-flush"],"can_parallel":false,"needs_user":false,"activeForm":"Running archive","metadata":{"phase":"archive"}},{"id":"memory-flush","type":"task","skill":"memory-flush","depends_on":["archive"],"blocks":["grant-commit"],"can_parallel":false,"needs_user":false,"activeForm":"Running memory-flush","metadata":{"phase":"memory-flush"}},{"id":"grant-commit","type":"task","skill":"grant-commit","depends_on":["memory-flush"],"blocks":["changelog"],"can_parallel":false,"needs_user":true,"activeForm":"Awaiting commit consent","metadata":{"phase":"grant-commit","needs_user":true}},{"id":"changelog","type":"task","skill":"changelog","depends_on":["grant-commit"],"blocks":["commit"],"can_parallel":false,"needs_user":false,"activeForm":"Running changelog","metadata":{"phase":"changelog"}},{"id":"commit","type":"task","skill":"commit","depends_on":["changelog"],"blocks":[],"can_parallel":false,"needs_user":false,"activeForm":"Running commit","metadata":{"phase":"commit"}}]}
4
+ {"$schema":"./schemas/workflow-track.v1.json","track_id":"chore","name":"Chore track","description":"Stripped-down pipeline for changes that need no failing-test-driven code change: documentation edits, configuration tweaks, formatting, dependency bumps with no project code change.","selectable":true,"selector_hints":["documentation edit","governance count refresh","configuration tweak","formatting or typo fix","skill consolidation move"],"preconditions":[],"invariants":["commits","chore"],"nodes":[{"id":"chore","type":"task","skill":"chore","depends_on":[],"blocks":["memory-flush"],"can_parallel":false,"needs_user":false,"activeForm":"Running chore","metadata":{"phase":"chore"}},{"id":"memory-flush","type":"task","skill":"memory-flush","depends_on":["chore"],"blocks":["grant-commit"],"can_parallel":false,"needs_user":false,"activeForm":"Running memory-flush","metadata":{"phase":"memory-flush"}},{"id":"grant-commit","type":"task","skill":"grant-commit","depends_on":["memory-flush"],"blocks":["changelog"],"can_parallel":false,"needs_user":true,"activeForm":"Awaiting commit consent","metadata":{"phase":"grant-commit","needs_user":true}},{"id":"changelog","type":"task","skill":"changelog","depends_on":["grant-commit"],"blocks":["commit"],"can_parallel":false,"needs_user":false,"activeForm":"Running changelog","metadata":{"phase":"changelog"}},{"id":"commit","type":"task","skill":"commit","depends_on":["changelog"],"blocks":[],"can_parallel":false,"needs_user":false,"activeForm":"Running commit","metadata":{"phase":"commit"}}]}
5
+ {"$schema":"./schemas/workflow-track.v1.json","track_id":"swarm-implementation","name":"Swarm implementation sub-track","description":"Parallel implementation via swarm-plan + approve-swarm + swarm-dispatch. Selected by intake-full's selector node when requires_git and requires_min_components:3 preconditions pass.","selectable":false,"selector_hints":[],"preconditions":[{"name":"requires_git"}],"invariants":["requires_swarm","git_only"],"nodes":[{"id":"swarm-plan","type":"task","skill":"swarm-plan","depends_on":[],"blocks":["approve-swarm"],"can_parallel":false,"needs_user":false,"activeForm":"Running swarm-plan","metadata":{"phase":"swarm-plan"}},{"id":"approve-swarm","type":"task","skill":"approve-swarm","depends_on":["swarm-plan"],"blocks":["swarm-dispatch"],"can_parallel":false,"needs_user":true,"activeForm":"Awaiting swarm approval","metadata":{"phase":"approve-swarm","needs_user":true}},{"id":"swarm-dispatch","type":"task","skill":"swarm-dispatch","depends_on":["approve-swarm"],"blocks":[],"can_parallel":false,"needs_user":false,"activeForm":"Running swarm-dispatch","metadata":{"phase":"swarm-dispatch"}}]}
6
+ {"$schema":"./schemas/workflow-track.v1.json","track_id":"tdd-worker-chain","name":"TDD worker-chain sub-track","description":"Solo TDD path. Fallback alternate for intake-full when swarm preconditions fail or the user picks solo explicitly.","selectable":false,"selector_hints":[],"preconditions":[],"invariants":[],"nodes":[{"id":"tdd","type":"task","skill":"tdd","depends_on":[],"blocks":[],"can_parallel":false,"needs_user":false,"activeForm":"Running TDD","metadata":{"phase":"tdd"}}]}
@@ -91,25 +91,19 @@ The 11-phase workflow is the only sanctioned path from request to commit. Phase
91
91
 
92
92
  **Swarm vs solo at Phase 6.** When the approved spec has fewer than `project.json → swarm.min_tasks_worth_swarming` (default 3) independent components **OR** the project is not a git repository, run `/tdd` solo. Otherwise route through `/swarm-plan` → `/approve-swarm` → `/swarm-dispatch`. In non-git projects the swarm phases are excepted at triage time (see the "Phase 6c and Phase 11 are git-conditional" bullet above), so this decision always resolves to solo — the rule's first clause never fires on a non-git tree, and a user "use swarm" override SHALL be refused with the reason `swarm requires git`.
93
93
 
94
+ **Post-§18 amendment (2026-05-21).** Workflow track definitions live in `.claude/workflows.jsonl` per `docs/init/seed.md §18`. The phase-ordering rules and entry-point classifications above remain binding; every Track declared in `workflows.jsonl` SHALL satisfy them plus the additional invariants in seed.md §18.3 (I1..I11). `/triage` reads `workflows.jsonl`, validates each Track against §18, classifies the user's request via LLM reasoning over `name + description + selector_hints`, confirms via `AskUserQuestion`, and materializes the chosen Track's DAG into the TaskList (via `src/cli/track-tasklist-materializer.js`). The 4 canonical tracks shipped in the pristine template are byte-equivalent to this Article's hardcoded templates per spec AC-016. The harness migrates pre-§18 `workflow.json` files (carrying `entry_phase` + no `track_id`) one-shot at preflight via `src/cli/workflow-migrator.js`. `/init-project doctor` (sub-command) detects schema / invariant / mirror drift and offers interactive fixes.
95
+
94
96
  ## Article V — Harness orchestration (MANDATORY SOP)
95
97
 
96
98
  `/harness` is invokable by both the user (via the slash command) and the model (via `Skill(harness)`). A single `Skill(harness)` invocation **loops internally through every non-gated phase boundary** until the loop hits one of four exit conditions: consent gate, phase-skill failure, integrate-failure-needs-spec-change, or workflow done. The user invokes `/harness` to start a fresh workflow or to resume after a yield. You SHALL suggest `/harness` when a concrete engineering ask crystallizes in conversation; the user decides when to invoke it.
97
99
 
98
- When `/harness` is invoked, you SHALL:
99
-
100
- 1. **Preflight (once per invocation).** Read `.claude/state/workflow.json` and `.claude/state/harness_state` (if present); read `.claude/state/spec_approvals/`, `swarm_approvals/`, and `commit_consent` to reconcile state.
101
- 2. **Arm the safety net.** Marker FIRST: `echo "<slug>" > .claude/state/.harness_active`. Then write `harness_state` with `{state: "continue", slug, reason: "loop armed; preflight passed"}`. This pair stays in place for the entire loop.
102
- 3. **Enter the loop body.** Each iteration: pick the lowest-id `pending` task whose `blockedBy` list is empty. If no task remains → **EXIT LOOP with DONE**. If the task carries `metadata.needs_user: true` → **EXIT LOOP with YIELD** (surface the gate; do NOT self-approve, simulate approval, or write approval tokens directly). Otherwise invoke the matching phase skill via the Skill tool, mark `completed` on success, append to `workflow.json → completed`, refresh marker+state, and **continue to the next iteration**.
103
- 4. **Exit the loop** on yield/failure/done. Write the matching `harness_state` (`yielded` or `done`) with marker-first ordering, then emit a single terminal message naming what just happened.
104
- 5. **Log every transition** to `.claude/state/harness/<slug>.log`.
105
-
106
- **Internal loop atomicity.** Inside the loop, every iteration is one Skill(`<phase>`) invocation plus one marker refresh plus one `harness_state` refresh — all happening within the same `Skill(harness)` call, **without emitting an intermediate terminal message**. Intermediate terminal messages would invite the model to stop and trigger the safety net unnecessarily. The marker op is FIRST (`echo "<slug>" > .claude/state/.harness_active` on `continue`-refresh, `rm -f .claude/state/.harness_active` on exit with `yielded`/`done`), then `harness_state` is written, then (only on loop exit) the terminal message is emitted.
107
-
108
- **The safety net.** The `harness_continuation` Stop hook (Article VIII) re-fires `Skill(harness)` only when the loop exited mid-flow — i.e., on-disk state is `{state: "continue"}` with the marker present, and `stop_hook_active` is absent on the Stop payload. In normal operation (loop runs to gate/failure/done), the hook sees `state != continue` or marker absent and stays silent. The hook is a defense-in-depth signal, not the primary driver: a healthy `Skill(harness)` invocation never depends on it. The hook is also bounded to one block per turn by Claude Code's `stop_hook_active` semantics, so it cannot itself drive multi-phase chaining.
109
-
110
- **Resume after yield (auto).** After yielding at a consent gate, the harness skill writes `state: yielded` and removes `.harness_active`. The user runs the consent slash command in their next prompt; `consent_gate_grant` writes the gate marker (outside Claude's tool boundary), the command body writes the consent token, and the `harness_continuation` Stop hook detects fresh consent (rung 4: `workflow.json` present + `state=yielded` + a consent-token mtime newer than `harness_state`). The hook emits `{decision:"block"}`, and `Skill(harness)` is re-invoked in the same turn. The next invocation re-enters preflight, finds the gate token on disk, marks the gate task `completed`, and re-enters the loop. The user does not type `/harness` to resume.
100
+ **Operational SOP lives in `.claude/skills/harness/SKILL.md`** — preflight, marker-first state writes, loop body iteration, safety-net interaction with `harness_continuation`, resume-after-yield mechanics, and task discipline. This Article declares the constitutional invariants the SOP must satisfy:
111
101
 
112
- **Task discipline (autonomous progression).** `/triage` seeds a `TaskCreate`-backed checklist covering every non-excepted phase plus consent-gate placeholders (the placeholders carry `metadata.needs_user: true`). Inside the loop you SHALL: (a) call `TaskList` first; if empty, re-seed from `workflow.json → completed + exceptions + entry_phase` using the canonical templates in `triage`'s SKILL.md; (b) `TaskUpdate` the next pending non-blocked task to `in_progress` before invoking its phase skill; (c) `TaskUpdate` to `completed` only after the phase skill returns success; (d) when the next pending task carries `needs_user: true`, EXIT LOOP with YIELD — never invoke a skill for that task. The TaskList is session-bound; `workflow.json → completed` is the durable truth, and re-seeding always reconciles to it.
102
+ - The loop SHALL exit on one of four conditions: consent gate (yield), phase-skill failure (yield), integrate-failure-needs-spec-change (yield), or workflow done.
103
+ - You SHALL NOT self-approve at any consent gate. You SHALL NOT simulate approval. You SHALL NOT write approval tokens directly.
104
+ - Every successful phase invocation SHALL `TaskUpdate` to `completed`, append the phase name to `workflow.json → completed`, and refresh marker + `harness_state` (marker FIRST) before continuing.
105
+ - `workflow.json → completed` is the durable truth across sessions; the TaskList is session-bound. When they disagree, trust `workflow.json` and re-seed.
106
+ - The `harness_continuation` Stop hook is a safety net, not the primary driver. A healthy `Skill(harness)` invocation runs to a clean exit on its own; the hook re-fires only when the loop was interrupted mid-flow with `state: "continue"` + marker present.
113
107
 
114
108
  **Integrate-failure decision tree.** When `/integrate` fails inside the loop, you SHALL classify:
115
109
 
@@ -10,7 +10,11 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
10
10
  const PACKAGE_ROOT = resolve(__dirname, '../..');
11
11
  const NPMRC_TEMPLATE_PATH = join(PACKAGE_ROOT, 'src/.npmrc.template');
12
12
 
13
- export const NEVER_TOUCH = Object.freeze(['.claude/project.json']);
13
+ export const NEVER_TOUCH = Object.freeze([
14
+ '.claude/project.json',
15
+ '.claude/workflows.jsonl',
16
+ '.claude/schemas/workflow-track.v1.json',
17
+ ]);
14
18
  export const SPECIAL_MERGE = Object.freeze(['.mcp.json']);
15
19
  // The shipped manifest now lives at `.claude/manifest.json` (inside the
16
20
  // template's .claude/ subtree), so the recursive cp drops it at the correct
package/src/cli/merge.js CHANGED
@@ -4,7 +4,7 @@ import { hashFile, saveManifest } from './manifest.js';
4
4
  import { deepMergeMcpServers } from './mcp.js';
5
5
  import { NEVER_TOUCH, SPECIAL_MERGE } from './install.js';
6
6
  import { pathExists } from './util.js';
7
- import { dispatchByTier, NoBaseError } from './upgrade-tiers.js';
7
+ import { dispatchByTier, NoBaseError, canRecoverBase } from './upgrade-tiers.js';
8
8
 
9
9
  export const ACTION_KINDS = Object.freeze({
10
10
  ADD: 'ADD',
@@ -21,6 +21,26 @@ export const ACTION_KINDS = Object.freeze({
21
21
  SEMANTIC_MERGE_STAGED: 'SEMANTIC_MERGE_STAGED',
22
22
  });
23
23
 
24
+ // User-facing labels for each ACTION_KIND. Surfaced in the per-file upgrade
25
+ // report (TTY via `tui/upgrade.js`, non-TTY via `bin/cli.js dispatchUpgrade`).
26
+ // Kept centralized so both paths render identically.
27
+ export const ACTION_LABELS = Object.freeze({
28
+ ADD: 'add',
29
+ OVERWRITE: 'update',
30
+ NOOP: 'unchanged',
31
+ SKIP_CUSTOMIZED: 'kept yours',
32
+ PRUNE: 'removed (upstream)',
33
+ PRUNE_SKIPPED_CUSTOMIZED: 'kept yours (upstream removed)',
34
+ NEVER_TOUCH_PRESERVE: 'kept yours (never-touch)',
35
+ NEVER_TOUCH_ADD: 'add (never-touch)',
36
+ SPECIAL_MERGE: 'merged (.mcp.json deep-merge)',
37
+ MECHANICAL_MERGE_CLEAN: 'merged cleanly',
38
+ MECHANICAL_MERGE_CONFLICTED: 'merged with conflicts — resolve manually',
39
+ SEMANTIC_MERGE_STAGED: 'staged for /upgrade-project',
40
+ });
41
+
42
+ export const ACTION_LABEL_WIDTH = Math.max(...Object.values(ACTION_LABELS).map((s) => s.length));
43
+
24
44
  async function copyFile(src, dst) {
25
45
  await mkdir(dirname(dst), { recursive: true });
26
46
  await cp(src, dst, { force: true });
@@ -143,6 +163,13 @@ async function dispatchCustomized({ rel, newEntry, tierCtx, dryRun, onSkipCustom
143
163
  const tier = readTierFromEntry(newEntry);
144
164
  if (tier === 'MECHANICAL' || tier === 'SEMANTIC') {
145
165
  if (dryRun) {
166
+ // When BASE recovery would fail (legacy manifest with no cache hit, no
167
+ // npm fallback), the real run will fall through to the binary prompt.
168
+ // Surface this file as SKIP_CUSTOMIZED at dry-run time so the TUI
169
+ // collects a user choice up front instead of silently keep-mine'ing it.
170
+ if (!canRecoverBase(rel, tierCtx.baseline_version, tierCtx.target)) {
171
+ return { kind: ACTION_KINDS.SKIP_CUSTOMIZED, path: rel, reason: 'BASE unrecoverable; will prompt user' };
172
+ }
146
173
  return { kind: tier === 'MECHANICAL' ? ACTION_KINDS.MECHANICAL_MERGE_CLEAN : ACTION_KINDS.SEMANTIC_MERGE_STAGED, path: rel, reason: 'dry-run: tier dispatch deferred' };
147
174
  }
148
175
  try {
@@ -0,0 +1,223 @@
1
+ // Foundation — translate a workflows.jsonl Track record into a canonical
2
+ // TaskList shape. Used by triage at workflow-seed time AND by the
3
+ // byte-equivalent-migration tests (compared to golden fixtures).
4
+ //
5
+ // The returned shape is ordinal-positioned (1-indexed `ord`); `blockedBy`
6
+ // references predecessor ordinals (NOT session task_ids). The runtime
7
+ // caller (triage skill body, harness re-seed) translates ordinals to
8
+ // TaskCreate-assigned task_ids at materialization time.
9
+ //
10
+ // Selector nodes resolve to their default alternate (the first alternate
11
+ // with empty preconditions). Sub-track refs read the originating track's
12
+ // `_allTracks` Map (attached by the validator) to find the target track.
13
+
14
+ export function materializeTaskList(track, { slug, ctx } = {}) {
15
+ if (!slug) {
16
+ throw new Error('materializeTaskList requires a slug option (used for <slug> substitution in subjects/activeForms).');
17
+ }
18
+ const emitter = createEmitter(slug, track._allTracks ?? new Map(), ctx);
19
+ emitNodes(track.nodes, emitter);
20
+ return finalize(emitter);
21
+ }
22
+
23
+ function createEmitter(slug, allTracks, ctx) {
24
+ return {
25
+ slug,
26
+ allTracks,
27
+ ctx: ctx ?? null,
28
+ tasks: [],
29
+ idToOrd: new Map(),
30
+ };
31
+ }
32
+
33
+ function emitNodes(nodes, emitter) {
34
+ for (const node of nodes) {
35
+ emitNode(node, emitter);
36
+ }
37
+ }
38
+
39
+ function emitNode(node, emitter) {
40
+ if (node.type === 'selector') {
41
+ const chosen = evaluateAlternates(node, emitter.ctx);
42
+ if (!chosen) {
43
+ throw new Error(
44
+ `Selector node '${node.id}' has no alternate whose preconditions pass against the provided context. ` +
45
+ `Either provide a ctx that satisfies one alternate's preconditions, or declare an alternate with empty preconditions (unconditional default).`
46
+ );
47
+ }
48
+ emitAlternate(chosen, node, emitter);
49
+ return;
50
+ }
51
+ if (node.sub_track) {
52
+ expandSubTrack(node.sub_track, node, emitter);
53
+ return;
54
+ }
55
+ recordTask(node, emitter, []);
56
+ }
57
+
58
+ function emitAlternate(alternate, parentNode, emitter) {
59
+ if (alternate.sub_track) {
60
+ expandSubTrack(alternate.sub_track, parentNode, emitter);
61
+ return;
62
+ }
63
+ if (alternate.skill) {
64
+ const synthetic = {
65
+ id: parentNode.id,
66
+ type: 'task',
67
+ skill: alternate.skill,
68
+ depends_on: parentNode.depends_on || [],
69
+ blocks: parentNode.blocks || [],
70
+ can_parallel: false,
71
+ needs_user: false,
72
+ };
73
+ recordTask(synthetic, emitter, []);
74
+ return;
75
+ }
76
+ throw new Error(`Alternate on selector '${parentNode.id}' has neither skill nor sub_track.`);
77
+ }
78
+
79
+ function expandSubTrack(subTrackId, parentNode, emitter) {
80
+ const sub = emitter.allTracks.get(subTrackId);
81
+ if (!sub) {
82
+ throw new Error(`sub_track '${subTrackId}' referenced by node '${parentNode.id}' not found in _allTracks.`);
83
+ }
84
+ const parentDepends = parentNode.depends_on || [];
85
+ const beforeOrd = emitter.tasks.length;
86
+ for (const subNode of sub.nodes) {
87
+ const isEntry = !subNode.depends_on || subNode.depends_on.length === 0;
88
+ const effectiveDepends = isEntry ? parentDepends : subNode.depends_on;
89
+ recordTask(subNode, emitter, effectiveDepends);
90
+ if (emitter.tasks.length === beforeOrd + 1) {
91
+ emitter.idToOrd.set(parentNode.id, emitter.tasks[beforeOrd].ord);
92
+ }
93
+ }
94
+ }
95
+
96
+ function recordTask(node, emitter, effectiveDepends) {
97
+ const ord = emitter.tasks.length + 1;
98
+ emitter.idToOrd.set(node.id, ord);
99
+ emitter.tasks.push({
100
+ ord,
101
+ subject: deriveSubject(node, emitter.slug),
102
+ activeForm: deriveActiveForm(node),
103
+ metadata: deriveMetadata(node),
104
+ needs_user: !!node.needs_user,
105
+ can_parallel: !!node.can_parallel,
106
+ _dependsOnIds: effectiveDepends.length > 0 ? effectiveDepends : (node.depends_on || []),
107
+ });
108
+ }
109
+
110
+ function finalize(emitter) {
111
+ const out = [];
112
+ for (const task of emitter.tasks) {
113
+ const blockedBy = task._dependsOnIds
114
+ .map((id) => emitter.idToOrd.get(id))
115
+ .filter((ord) => typeof ord === 'number');
116
+ out.push({
117
+ ord: task.ord,
118
+ subject: task.subject,
119
+ activeForm: task.activeForm,
120
+ metadata: task.metadata,
121
+ needs_user: task.needs_user,
122
+ can_parallel: task.can_parallel,
123
+ blockedBy,
124
+ });
125
+ }
126
+ return out;
127
+ }
128
+
129
+ // evaluateAlternates walks the selector node's alternates in declaration order
130
+ // and returns the first one whose preconditions all pass against ctx. When ctx
131
+ // is null/undefined, only alternates with empty preconditions are eligible
132
+ // (preserves the materialize-time-only default-fallback behavior used by tests
133
+ // that don't pass a ctx — e.g., the byte-equivalent fixture comparison). When
134
+ // ctx is provided, predicates evaluate against its fields:
135
+ // { isGit: bool, componentCount: int, userOverride: string|null,
136
+ // completed: string[], knownSkills: Set<string> }
137
+ // Any predicate whose required field is absent in ctx evaluates false.
138
+ function evaluateAlternates(selectorNode, ctx) {
139
+ const alts = selectorNode.alternates || [];
140
+ for (const alt of alts) {
141
+ const preconditions = Array.isArray(alt.preconditions) ? alt.preconditions : [];
142
+ if (preconditions.every((p) => evaluatePredicate(p, ctx))) {
143
+ return alt;
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+
149
+ function evaluatePredicate(pred, ctx) {
150
+ if (!ctx) return false;
151
+ switch (pred.name) {
152
+ case 'requires_git':
153
+ return ctx.isGit === true;
154
+ case 'requires_user_override':
155
+ return typeof ctx.userOverride === 'string' && ctx.userOverride === pred.argument;
156
+ case 'requires_min_components': {
157
+ const n = parseInt(pred.argument, 10);
158
+ return Number.isFinite(n) && typeof ctx.componentCount === 'number' && ctx.componentCount >= n;
159
+ }
160
+ case 'requires_phase_completed':
161
+ return Array.isArray(ctx.completed) && ctx.completed.includes(pred.argument);
162
+ case 'requires_skill_present':
163
+ return ctx.knownSkills instanceof Set && ctx.knownSkills.has(pred.argument);
164
+ default:
165
+ return false;
166
+ }
167
+ }
168
+
169
+ const ACTIVE_FORM_OVERRIDES = Object.freeze({
170
+ tdd: 'Running TDD',
171
+ intake: 'Running intake',
172
+ scout: 'Running scout',
173
+ research: 'Running research',
174
+ spec: 'Running spec',
175
+ simplify: 'Running simplify',
176
+ security: 'Running security',
177
+ integrate: 'Running integrate',
178
+ document: 'Running document',
179
+ archive: 'Running archive',
180
+ 'memory-flush': 'Running memory-flush',
181
+ changelog: 'Running changelog',
182
+ commit: 'Running commit',
183
+ chore: 'Running chore',
184
+ 'swarm-plan': 'Running swarm-plan',
185
+ 'swarm-dispatch': 'Running swarm-dispatch',
186
+ });
187
+
188
+ const CONSENT_GATE_SUBJECTS = Object.freeze({
189
+ 'approve-spec': 'Wait for /approve-spec <path>',
190
+ 'grant-commit': 'Wait for /grant-commit',
191
+ 'approve-swarm': 'Wait for /approve-swarm <slug>',
192
+ });
193
+
194
+ const CONSENT_GATE_ACTIVE_FORMS = Object.freeze({
195
+ 'approve-spec': 'Awaiting spec approval',
196
+ 'grant-commit': 'Awaiting commit consent',
197
+ 'approve-swarm': 'Awaiting swarm approval',
198
+ });
199
+
200
+ function deriveSubject(node, slug) {
201
+ if (node.needs_user) {
202
+ return CONSENT_GATE_SUBJECTS[node.id] ?? `Wait for /${node.id}`;
203
+ }
204
+ const skill = node.skill || node.id;
205
+ return `Run /${skill} for ${slug}`;
206
+ }
207
+
208
+ function deriveActiveForm(node) {
209
+ if (node.activeForm) return node.activeForm;
210
+ if (node.needs_user) {
211
+ return CONSENT_GATE_ACTIVE_FORMS[node.id] ?? `Awaiting /${node.id}`;
212
+ }
213
+ const skill = node.skill || node.id;
214
+ return ACTIVE_FORM_OVERRIDES[skill] ?? `Running ${skill}`;
215
+ }
216
+
217
+ function deriveMetadata(node) {
218
+ const phase = node.metadata?.phase ?? node.skill ?? node.id;
219
+ if (node.needs_user) {
220
+ return { phase, needs_user: true };
221
+ }
222
+ return { phase };
223
+ }
@@ -12,10 +12,10 @@ import * as clackModule from '@clack/prompts';
12
12
  import { existsSync } from 'node:fs';
13
13
  import { readdir, readFile } from 'node:fs/promises';
14
14
  import { join, relative, sep } from 'node:path';
15
- import { threeWayMerge, ACTION_KINDS } from '../merge.js';
15
+ import { threeWayMerge, ACTION_KINDS, ACTION_LABELS, ACTION_LABEL_WIDTH } from '../merge.js';
16
16
  import { loadManifest, buildManifestFromDir } from '../manifest.js';
17
17
  import { COPY_EXCLUDE } from '../install.js';
18
- import { findPendingStage } from '../upgrade-tiers.js';
18
+ import { findPendingStage, formatStageTimestamp } from '../upgrade-tiers.js';
19
19
  import { renderUnifiedDiff } from '../diff-render.js';
20
20
  import { renderBrandStrip } from './splash.js';
21
21
 
@@ -58,7 +58,7 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
58
58
 
59
59
  const { oldManifest, newManifest } = await loadManifests(opts.templateDir, manifestPath);
60
60
  if (isLegacyManifest(oldManifest)) {
61
- prompts.log.warn('legacy manifest_version: 1 detected; BASE-content recovery unavailable. Tier-2 / tier-3 files will fall back to the binary prompt.');
61
+ prompts.log.warn("Your previous install predates version-tracked manifests, so this upgrade can't perform automatic three-way merges on customized files. You'll be prompted to keep your version or take the new baseline for each customized file. To enable three-way merges next time, re-install with the latest baseline.");
62
62
  }
63
63
 
64
64
  const dryReport = await threeWayMerge(opts.templateDir, target, oldManifest, newManifest, { dryRun: true });
@@ -73,7 +73,8 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
73
73
 
74
74
  if (opts.dryRun) {
75
75
  for (const action of dryReport.actions) {
76
- prompts.log.info(`${action.kind.padEnd(28)} ${action.path}`);
76
+ const label = ACTION_LABELS[action.kind] ?? action.kind;
77
+ prompts.log.info(`${label.padEnd(ACTION_LABEL_WIDTH)} ${action.path}`);
77
78
  }
78
79
  prompts.outro('Dry run complete; no changes written.');
79
80
  return SUCCESS;
@@ -84,7 +85,8 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
84
85
 
85
86
  for (const action of finalReport.actions) {
86
87
  if (isReportableAction(action.kind)) {
87
- prompts.log.info(`${action.kind.padEnd(28)} ${action.path}`);
88
+ const label = ACTION_LABELS[action.kind] ?? action.kind;
89
+ prompts.log.info(`${label.padEnd(ACTION_LABEL_WIDTH)} ${action.path}`);
88
90
  }
89
91
  if (action.kind === ACTION_KINDS.MECHANICAL_MERGE_CONFLICTED) {
90
92
  prompts.log.warn(`Merged with conflicts — resolve in ${action.path}`);
@@ -98,14 +100,18 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
98
100
 
99
101
  const applied = finalReport.actions.filter((a) => isApplied(a.kind)).length;
100
102
  const skipped = finalReport.actions.filter((a) => a.kind === ACTION_KINDS.SKIP_CUSTOMIZED).length;
101
- prompts.outro(`Applied ${applied}; ${skipped} skipped.`);
103
+ prompts.outro(
104
+ skipped === 0
105
+ ? `Applied ${applied} update(s).`
106
+ : `Applied ${applied} update(s); kept your version on ${skipped} customized file(s). Re-run \`create-baseline upgrade\` if you want to revisit those choices.`,
107
+ );
102
108
  return mapExitCode(finalReport.exitCode);
103
109
  }
104
110
 
105
111
  function reportPendingStage(prompts, pending) {
106
112
  const fileLines = pending.files.map((f) => ` - ${f}`).join('\n');
107
- prompts.log.warn(`Pending semantic-merge stage at ${pending.stage_ts}.\n${pending.files.length} file(s) awaiting reconciliation:\n${fileLines}\nOpen Claude Code and run /upgrade-project to reconcile.`);
108
- prompts.outro('No new work; existing stage pending.');
113
+ prompts.log.warn(`A previous upgrade staged ${pending.files.length} file(s) for Claude Code review (staged ${formatStageTimestamp(pending.stage_ts)}):\n${fileLines}\nOpen Claude Code and run /upgrade-project to reconcile.`);
114
+ prompts.outro('No new work; previous staged files still need reconciliation.');
109
115
  return ERR_SEMANTIC_STAGED;
110
116
  }
111
117
 
@@ -41,6 +41,28 @@ export async function resolveBase(rel, baseline_version, target, opts = {}) {
41
41
  return fetched;
42
42
  }
43
43
 
44
+ // Returns true when resolveBase would succeed for this file without throwing
45
+ // NoBaseError. Used by merge.js:dispatchCustomized in dry-run mode so the TUI
46
+ // surfaces files whose BASE is unrecoverable as conflicts (and prompts the
47
+ // user) instead of optimistically classifying them as tier-2/3 merge candidates
48
+ // that will silently fall back to keep-mine at real-run time.
49
+ export function canRecoverBase(rel, baseline_version, target) {
50
+ const cachePath = join(target, '.claude/.baseline-prior', rel);
51
+ if (existsSync(cachePath)) return true;
52
+ return Boolean(baseline_version);
53
+ }
54
+
55
+ // Render a stage_ts (the `stageTimestamp` format: ISO 8601 with every `:` and
56
+ // `.` replaced by `-`, e.g. "2026-05-20T14-49-00-000Z") as a human-readable
57
+ // "YYYY-MM-DD HH:MM UTC". Returns the input unchanged when the pattern doesn't
58
+ // match so we never silently corrupt an unexpected value.
59
+ export function formatStageTimestamp(ts) {
60
+ if (typeof ts !== 'string') return String(ts);
61
+ const m = ts.match(/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-\d{2}-\d{3}Z$/);
62
+ if (!m) return ts;
63
+ return `${m[1]} ${m[2]}:${m[3]} UTC`;
64
+ }
65
+
44
66
  export async function findPendingStage(target) {
45
67
  const stageRoot = join(target, '.claude/state/upgrade');
46
68
  if (!existsSync(stageRoot)) return null;
@@ -0,0 +1,40 @@
1
+ // Foundation — one-shot migrator that rewrites a pre-§18 `workflow.json`
2
+ // (entry_phase set, no track_id) into the post-§18 shape (track_id, plus
3
+ // skipped_alternates[]) in place. Idempotent on already-post-§18 input.
4
+ // Throws a named error when entry_phase is not in the canonical map.
5
+
6
+ import { readFile, writeFile } from 'node:fs/promises';
7
+
8
+ export const ENTRY_PHASE_TO_TRACK_ID = Object.freeze({
9
+ intake: 'intake-full',
10
+ spec: 'spec-entry',
11
+ tdd: 'tdd-quickfix',
12
+ chore: 'chore',
13
+ });
14
+
15
+ export async function migrateWorkflowJsonInPlace(filePath) {
16
+ const text = await readFile(filePath, 'utf8');
17
+ const data = JSON.parse(text);
18
+ if ('track_id' in data && !('entry_phase' in data)) {
19
+ return { migrated: false, reason: 'already post-§18' };
20
+ }
21
+ if (!('entry_phase' in data)) {
22
+ return { migrated: false, reason: 'no entry_phase and no track_id; cannot determine shape' };
23
+ }
24
+ const entryPhase = data.entry_phase;
25
+ const trackId = ENTRY_PHASE_TO_TRACK_ID[entryPhase];
26
+ if (!trackId) {
27
+ throw new Error(
28
+ `Pre-§18 workflow.json has unmapped entry_phase='${entryPhase}'. ` +
29
+ `Canonical map covers ${Object.keys(ENTRY_PHASE_TO_TRACK_ID).join(', ')}. ` +
30
+ `Cannot migrate; run /triage to restart this workflow.`
31
+ );
32
+ }
33
+ const migrated = { ...data };
34
+ migrated.track_id = trackId;
35
+ migrated.skipped_alternates = Array.isArray(data.skipped_alternates) ? data.skipped_alternates : [];
36
+ migrated.updated_at = Math.floor(Date.now() / 1000);
37
+ delete migrated.entry_phase;
38
+ await writeFile(filePath, JSON.stringify(migrated, null, 2) + '\n');
39
+ return { migrated: true, track_id: trackId };
40
+ }