Skip to content
Open
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
19 changes: 17 additions & 2 deletions skills/workflow-manifest/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -192,7 +192,7 @@ node .claude/skills/workflow-manifest/scripts/manifest.cjs set <name>.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`
Expand Down Expand Up @@ -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:
Expand All @@ -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` |
Expand Down
37 changes: 34 additions & 3 deletions skills/workflow-manifest/scripts/manifest.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
241 changes: 241 additions & 0 deletions tests/scripts/test-workflow-manifest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.<topic> 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
# ============================================================================
Expand Down