diff --git a/skills/workflow-manifest/SKILL.md b/skills/workflow-manifest/SKILL.md index d5788a61..4e0738e1 100644 --- a/skills/workflow-manifest/SKILL.md +++ b/skills/workflow-manifest/SKILL.md @@ -100,7 +100,7 @@ $MANIFEST list [--status s] [--work-type t] ### Naming Constraints - **Work unit names must not contain dots** — dots are the path separator -- **Work unit names must not match phase names** (`research`, `discussion`, `investigation`, `specification`, `planning`, `implementation`, `review`) +- **Work unit names must not match phase names** (`inception`, `research`, `discussion`, `investigation`, `specification`, `planning`, `implementation`, `review`) - **Work unit names must not be reserved** — `project` is reserved for project-level manifest access ## Commands @@ -192,7 +192,7 @@ node .claude/skills/workflow-manifest/scripts/manifest.cjs set .planning.a Values are parsed as JSON first (for arrays, objects, numbers, booleans), falling back to string. Validates structural fields: - **work_type**: `epic`, `feature`, `bugfix`, `quick-fix`, `cross-cutting` -- **phase names**: `research`, `discussion`, `investigation`, `scoping`, `specification`, `planning`, `implementation`, `review` +- **phase names**: `inception`, `research`, `discussion`, `investigation`, `scoping`, `specification`, `planning`, `implementation`, `review` - **phase statuses**: per-phase valid values (see Validation section) - **gate modes**: `gated`, `auto` - **work unit status**: `in-progress`, `completed`, `cancelled` @@ -362,6 +362,20 @@ Project-wide settings are stored in the `defaults` section of the project manife The cascade is: **project defaults** (suggestion) → **topic level** (actual value used). There is no phase-level storage. +### Top-Level Work-Unit Fields + +Most work-unit data lives under `phases.{phase}.items.{topic}`. A small number of fields sit at the work-unit root — identity (`name`, `work_type`, `status`, `created`, `description`), the `phases` map, and a top-level `imports` array. + +**`imports[]`** — entries describing seed files copied into `.workflows/{work_unit}/imports/` at work-unit creation. Each entry is a `{path, imported_at}` object. The CLI does not validate entry shape; the importing skill is responsible for forming well-shaped entries. + +```bash +$MANIFEST push {work_unit} imports '{"path":"imports/seed.md","imported_at":"2026-05-09T10:00:00Z"}' +$MANIFEST get {work_unit} imports +$MANIFEST pull {work_unit} imports '{"path":"imports/seed.md","imported_at":"2026-05-09T10:00:00Z"}' +``` + +`imports[]` is the first top-level array field on work-unit manifests — every other array lives under `phases`. Convention for new top-level array fields: use `push`/`pull` for incremental updates (auto-creates the array on first push); use `set` for full replacement. `pull` matches values by deep equality, so the JSON passed to remove an entry must match the stored shape exactly. + ## Validation The CLI validates structural values to prevent invalid state: @@ -370,6 +384,7 @@ The CLI validates structural values to prevent invalid state: |--------------------------------|----------------------------------------------------| | `work_type` | `epic`, `feature`, `bugfix`, `quick-fix`, `cross-cutting` | | `status` (work unit) | `in-progress`, `completed`, `cancelled` | +| Item `status` (inception) | `in-progress` | | Item `status` (research) | `in-progress`, `completed` | | Item `status` (discussion) | `in-progress`, `completed` | | Item `status` (investigation) | `in-progress`, `completed` | diff --git a/skills/workflow-manifest/scripts/manifest.cjs b/skills/workflow-manifest/scripts/manifest.cjs index 8a9ee17f..18bc2077 100644 --- a/skills/workflow-manifest/scripts/manifest.cjs +++ b/skills/workflow-manifest/scripts/manifest.cjs @@ -13,12 +13,13 @@ const WORKFLOWS_DIR = path.resolve(process.cwd(), '.workflows'); const VALID_WORK_TYPES = ['epic', 'feature', 'bugfix', 'cross-cutting', 'quick-fix']; const VALID_PHASES = [ - 'research', 'discussion', 'investigation', 'scoping', + 'inception', 'research', 'discussion', 'investigation', 'scoping', 'specification', 'planning', 'implementation', 'review' ]; const VALID_PHASE_STATUSES = { + inception: ['in-progress'], research: ['in-progress', 'completed', 'cancelled'], discussion: ['in-progress', 'completed', 'cancelled'], investigation: ['in-progress', 'completed', 'cancelled'], @@ -438,6 +439,36 @@ function parseValue(raw) { } } +// Deep equality used by `pull` so object-shaped array entries (e.g. imports[] +// records) can be matched by value, not by reference. Order-independent for +// object keys. +function deepEqual(a, b) { + if (a === b) return true; + if (a === null || b === null) return false; + if (typeof a !== 'object' || typeof b !== 'object') return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + if (Array.isArray(a)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false; + return true; + } + const ak = Object.keys(a); + const bk = Object.keys(b); + if (ak.length !== bk.length) return false; + for (const k of ak) { + if (!Object.prototype.hasOwnProperty.call(b, k)) return false; + if (!deepEqual(a[k], b[k])) return false; + } + return true; +} + +function findDeepIndex(arr, value) { + for (let i = 0; i < arr.length; i++) { + if (deepEqual(arr[i], value)) return i; + } + return -1; +} + function outputValue(value) { if (value !== null && typeof value === 'object') { process.stdout.write(JSON.stringify(value, null, 2) + '\n'); @@ -780,7 +811,7 @@ function cmdPull(args) { const manifest = readProjectManifest(); const current = getByPath(manifest, proj.fieldSegments); if (!Array.isArray(current)) return; // no-op - const idx = current.indexOf(value); + const idx = findDeepIndex(current, value); if (idx === -1) return; // no-op current.splice(idx, 1); writeProjectManifestAtomic(manifest); @@ -802,7 +833,7 @@ function cmdPull(args) { const manifest = readManifest(workUnit); const current = getByPath(manifest, segments); if (!Array.isArray(current)) return; // no-op - const idx = current.indexOf(value); + const idx = findDeepIndex(current, value); if (idx === -1) return; // no-op current.splice(idx, 1); writeManifestAtomic(workUnit, manifest); diff --git a/tests/scripts/test-workflow-manifest.sh b/tests/scripts/test-workflow-manifest.sh index f1fb18ed..08b36027 100755 --- a/tests/scripts/test-workflow-manifest.sh +++ b/tests/scripts/test-workflow-manifest.sh @@ -1540,6 +1540,247 @@ assert_equals "$prev_exists" "false" "previous_status deleted after reactivation echo "" +# ============================================================================ +# INCEPTION PHASE TESTS +# ============================================================================ + +echo -e "${YELLOW}Test: init-phase succeeds for inception${NC}" +setup_fixture +run_cli init incept-init --work-type epic --description "Inception init" >/dev/null 2>&1 +exit_code=$(run_cli_exit_code init-phase incept-init.inception.foo) +output=$(run_cli_stdout get incept-init.inception.foo status) + +assert_equals "$exit_code" "0" "init-phase inception succeeds" +assert_equals "$output" "in-progress" "Inception item status defaults to in-progress" + +echo "" + +# ---------------------------------------------------------------------------- + +echo -e "${YELLOW}Test: inception accepts in-progress status${NC}" +setup_fixture +run_cli init incept-status --work-type epic --description "Inception status" >/dev/null 2>&1 +run_cli init-phase incept-status.inception.foo >/dev/null 2>&1 +run_cli set incept-status.inception.foo status in-progress >/dev/null 2>&1 +output=$(run_cli_stdout get incept-status.inception.foo status) + +assert_equals "$output" "in-progress" "Set status in-progress accepted" + +echo "" + +# ---------------------------------------------------------------------------- + +echo -e "${YELLOW}Test: inception rejects cancelled status (hard-delete model)${NC}" +setup_fixture +run_cli init incept-cancel --work-type epic --description "Inception cancel" >/dev/null 2>&1 +run_cli init-phase incept-cancel.inception.foo >/dev/null 2>&1 +exit_code=$(run_cli_exit_code set incept-cancel.inception.foo status cancelled) +output=$(run_cli set incept-cancel.inception.foo status cancelled 2>&1 || true) + +assert_equals "$exit_code" "1" "Set status cancelled exits non-zero" +assert_contains "$output" "Invalid status" "Error mentions invalid status" + +echo "" + +# ---------------------------------------------------------------------------- + +echo -e "${YELLOW}Test: inception rejects completed status (hard-delete model)${NC}" +setup_fixture +run_cli init incept-comp --work-type epic --description "Inception completed" >/dev/null 2>&1 +run_cli init-phase incept-comp.inception.foo >/dev/null 2>&1 +exit_code=$(run_cli_exit_code set incept-comp.inception.foo status completed) + +assert_equals "$exit_code" "1" "Set status completed exits non-zero" + +echo "" + +# ---------------------------------------------------------------------------- + +echo -e "${YELLOW}Test: inception accepts free-form routing field${NC}" +setup_fixture +run_cli init incept-route --work-type epic --description "Inception routing" >/dev/null 2>&1 +run_cli init-phase incept-route.inception.foo >/dev/null 2>&1 +run_cli set incept-route.inception.foo routing research >/dev/null 2>&1 +output=$(run_cli_stdout get incept-route.inception.foo routing) + +assert_equals "$output" "research" "Routing field stored unchanged" + +# Discussion routing also accepted (no validation enforced) +run_cli set incept-route.inception.foo routing discussion >/dev/null 2>&1 +output=$(run_cli_stdout get incept-route.inception.foo routing) +assert_equals "$output" "discussion" "Routing can be updated to discussion" + +echo "" + +# ---------------------------------------------------------------------------- + +echo -e "${YELLOW}Test: inception accepts summary field${NC}" +setup_fixture +run_cli init incept-sum --work-type epic --description "Inception summary" >/dev/null 2>&1 +run_cli init-phase incept-sum.inception.foo >/dev/null 2>&1 +run_cli set incept-sum.inception.foo summary "A short summary of the topic" >/dev/null 2>&1 +output=$(run_cli_stdout get incept-sum.inception.foo summary) + +assert_equals "$output" "A short summary of the topic" "Summary stored unchanged" + +echo "" + +# ---------------------------------------------------------------------------- + +echo -e "${YELLOW}Test: get returns full inception item${NC}" +setup_fixture +run_cli init incept-get --work-type epic --description "Inception get" >/dev/null 2>&1 +run_cli init-phase incept-get.inception.foo >/dev/null 2>&1 +run_cli set incept-get.inception.foo summary "A topic" >/dev/null 2>&1 +run_cli set incept-get.inception.foo routing research >/dev/null 2>&1 +output=$(run_cli_stdout get incept-get.inception.foo) + +assert_contains "$output" '"status": "in-progress"' "Item JSON includes status" +assert_contains "$output" '"summary": "A topic"' "Item JSON includes summary" +assert_contains "$output" '"routing": "research"' "Item JSON includes routing" + +echo "" + +# ---------------------------------------------------------------------------- + +echo -e "${YELLOW}Test: delete hard-removes inception item${NC}" +setup_fixture +run_cli init incept-del --work-type epic --description "Inception delete" >/dev/null 2>&1 +run_cli init-phase incept-del.inception.foo >/dev/null 2>&1 +run_cli set incept-del.inception.foo summary "to be deleted" >/dev/null 2>&1 +# Hard-delete the entire item via the items. field path (existing convention) +run_cli delete incept-del.inception items.foo >/dev/null 2>&1 +exists_after=$(run_cli_stdout exists incept-del.inception.foo) + +assert_equals "$exists_after" "false" "Inception item gone after hard-delete" + +echo "" + +# ---------------------------------------------------------------------------- + +echo -e "${YELLOW}Test: wildcard get on inception phase${NC}" +setup_fixture +run_cli init incept-wild --work-type epic --description "Inception wildcard" >/dev/null 2>&1 +run_cli init-phase incept-wild.inception.alpha >/dev/null 2>&1 +run_cli init-phase incept-wild.inception.beta >/dev/null 2>&1 +output=$(run_cli_stdout get 'incept-wild.inception.*' status) + +assert_contains "$output" '"topic": "alpha"' "Wildcard returns alpha topic" +assert_contains "$output" '"topic": "beta"' "Wildcard returns beta topic" +assert_contains "$output" '"value": "in-progress"' "Wildcard returns in-progress status" + +echo "" + +# ============================================================================ +# IMPORTS[] FIELD TESTS +# ============================================================================ + +echo -e "${YELLOW}Test: push creates imports[] array on first call${NC}" +setup_fixture +run_cli init imp-create --work-type epic --description "Imports create" >/dev/null 2>&1 +run_cli push imp-create imports '{"path":"imports/seed.md","imported_at":"2026-05-09T10:00:00Z"}' >/dev/null 2>&1 +output=$(run_cli_stdout get imp-create imports) + +assert_contains "$output" '"path": "imports/seed.md"' "First push creates array with entry" +assert_contains "$output" '"imported_at": "2026-05-09T10:00:00Z"' "Entry preserves imported_at" + +echo "" + +# ---------------------------------------------------------------------------- + +echo -e "${YELLOW}Test: push appends subsequent entries to imports[]${NC}" +setup_fixture +run_cli init imp-append --work-type epic --description "Imports append" >/dev/null 2>&1 +run_cli push imp-append imports '{"path":"imports/seed.md","imported_at":"2026-05-09T10:00:00Z"}' >/dev/null 2>&1 +run_cli push imp-append imports '{"path":"imports/notes.md","imported_at":"2026-05-09T11:00:00Z"}' >/dev/null 2>&1 +output=$(run_cli_stdout get imp-append imports) + +assert_contains "$output" "imports/seed.md" "First entry preserved after append" +assert_contains "$output" "imports/notes.md" "Second entry appended" + +# Verify it's a proper array of length 2 +length=$(node -e "const m=JSON.parse(require('fs').readFileSync('$TEST_DIR/.workflows/imp-append/manifest.json','utf8')); console.log(m.imports.length)") +assert_equals "$length" "2" "imports[] has two entries after two pushes" + +echo "" + +# ---------------------------------------------------------------------------- + +echo -e "${YELLOW}Test: pull removes a single imports[] entry by deep equality${NC}" +setup_fixture +run_cli init imp-pull --work-type epic --description "Imports pull" >/dev/null 2>&1 +run_cli push imp-pull imports '{"path":"imports/seed.md","imported_at":"2026-05-09T10:00:00Z"}' >/dev/null 2>&1 +run_cli push imp-pull imports '{"path":"imports/notes.md","imported_at":"2026-05-09T11:00:00Z"}' >/dev/null 2>&1 +run_cli pull imp-pull imports '{"path":"imports/seed.md","imported_at":"2026-05-09T10:00:00Z"}' >/dev/null 2>&1 +output=$(run_cli_stdout get imp-pull imports) + +assert_not_contains "$output" "imports/seed.md" "Pull removed seed.md entry" +assert_contains "$output" "imports/notes.md" "Pull kept notes.md entry" + +length=$(node -e "const m=JSON.parse(require('fs').readFileSync('$TEST_DIR/.workflows/imp-pull/manifest.json','utf8')); console.log(m.imports.length)") +assert_equals "$length" "1" "imports[] has one entry after pull" + +echo "" + +# ---------------------------------------------------------------------------- + +echo -e "${YELLOW}Test: pull is no-op when entry shape does not match${NC}" +setup_fixture +run_cli init imp-pull-miss --work-type epic --description "Imports pull miss" >/dev/null 2>&1 +run_cli push imp-pull-miss imports '{"path":"imports/seed.md","imported_at":"2026-05-09T10:00:00Z"}' >/dev/null 2>&1 +run_cli pull imp-pull-miss imports '{"path":"imports/seed.md","imported_at":"2026-05-09T99:99:99Z"}' >/dev/null 2>&1 +output=$(run_cli_stdout get imp-pull-miss imports) + +assert_contains "$output" "imports/seed.md" "Non-matching pull preserved entry" + +echo "" + +# ---------------------------------------------------------------------------- + +echo -e "${YELLOW}Test: push onto non-array imports field fails${NC}" +setup_fixture +run_cli init imp-bad --work-type epic --description "Imports bad" >/dev/null 2>&1 +# Set imports to a non-array value first +run_cli set imp-bad imports '"not-an-array"' >/dev/null 2>&1 +# Single invocation: capture both output and exit code (the failing call holds +# the work-unit lock past die() since process.exit skips finally blocks). +combined=$(cd "$TEST_DIR" && node "$MANIFEST_JS" push imp-bad imports '{"path":"imports/seed.md","imported_at":"2026-05-09T10:00:00Z"}' 2>&1; echo "__EXIT__=$?") +exit_code=$(echo "$combined" | grep -o '__EXIT__=[0-9]*' | tail -1 | cut -d= -f2) +output=$(echo "$combined" | grep -v '__EXIT__=') + +assert_equals "$exit_code" "1" "Push onto non-array exits non-zero" +assert_contains "$output" "not an array" "Error mentions not an array" + +echo "" + +# ---------------------------------------------------------------------------- + +echo -e "${YELLOW}Test: imports[] round-trip preserves entry shape${NC}" +setup_fixture +run_cli init imp-roundtrip --work-type epic --description "Imports round-trip" >/dev/null 2>&1 +run_cli push imp-roundtrip imports '{"path":"imports/seed.md","imported_at":"2026-05-09T10:00:00Z"}' >/dev/null 2>&1 +output=$(run_cli_stdout get imp-roundtrip imports) + +# Parse the JSON and confirm shape +parsed=$(echo "$output" | node -e " +let s=''; +process.stdin.on('data',c=>s+=c); +process.stdin.on('end',()=>{ + const arr = JSON.parse(s); + if (!Array.isArray(arr)) { console.log('not-array'); return; } + if (arr.length !== 1) { console.log('wrong-length'); return; } + const e = arr[0]; + if (e.path === 'imports/seed.md' && e.imported_at === '2026-05-09T10:00:00Z') { + console.log('shape-ok'); + } else { + console.log('shape-wrong'); + } +}); +") +assert_equals "$parsed" "shape-ok" "Round-tripped entry has expected fields" + +echo "" + # ============================================================================ # RESOLVE COMMAND TESTS # ============================================================================