chore(#1671): widen the when= grammar and key the section manifest per workflow — Phase 6.1 (#3013)

* chore(#2992): widen the when= grammar and key the section manifest per workflow

Epic #1671 Phase 6.1. Two blockers stopped the fragment model reaching any
file beyond execute-phase.md: the when= vocabulary was frozen at 4 atoms
(3 execute-phase-specific), and the section manifest was single-workflow by
construction with 'execute-phase' hardcoded into buildSectionManifestField.

- widen WHEN_VOCABULARY 4 -> 14 via a coordinated ADR-1671 amendment; the
  grammar stays CLOSED (one atom, no operators, negation or nesting) and
  WHEN_PREDICATES stays a hand-written literal map, never deriving a
  predicate from its atom string
- InvocationFacts gains flags: ReadonlySet<string> plus three computed state
  booleans; add the missing reverse vocabulary/predicate parity guard
- key the manifest artifact per workflow; a stale flat {sections:[...]}
  artifact now fails shape validation instead of being misattributed
- wire the field into six init entry points and parse the flags each needs

An atom ships only with both a real consuming section and a fact the init
seam actually computes. Six surveyed atoms are withheld because their
workflows have no dedicated init entry point; an atom without a computed
fact evaluates false forever and silently disables its own section.

Fixes a defect found while wiring: parseNamedArgs always materializes a
boolean flag key, so folding its false into the absent sentinel is required
or every flag reads as present and gating is silently always-on.

Also resolves ADR-1671:194 by measurement: --mvp stays unmarkable, because
its interleaved sites are always-run flag resolution and a ~340 byte block
that already delegates lazily.

Refs #2992

* fix(#2992): treat any falsy option value as an absent flag and reject unsafe manifest read paths

Findings from two orthogonal reviews (Claude /code-review + an isolated
adversarial pass); both independently reproduced the first one.

- MAJOR: the flags-builder treated only `undefined` as absent, but
  parseNamedArgs yields `null` for an absent value-flag and `false` for an
  absent boolean-flag, so `--granularity` read as present on every
  plan-phase invocation. Fixed at the root: a flag is present iff its
  option value is truthy. The six per-handler `|| undefined` folds are now
  redundant and removed, which also closes the duplicate-translation and
  missed-onboard-handler findings.
- MAJOR: state:needs-codebase-map had zero coverage. Added unit, property
  and real-CLI integration tests.
- MINOR: reject absolute, UNC/drive and `..`-traversing `read` paths in the
  manifest, degrading the whole load to null like every other shape
  violation. Verified: `/etc/passwd` previously reached section_manifest.read.
- MINOR: corrected a stale "4 to 20" doc comment; the vocabulary is 14.

Refs #2992

* test(#2992): update the generator suite for the per-workflow manifest shape

The remote matrix went red with 5 unique failures, identical on
linux-node22 and linux-node24, all in tests/gen-section-manifest.test.cjs.
Re-keying the artifact to {workflows:{...}} left this suite asserting the
old flat {sections:[...]} shape; nothing else in the tree still does.

- three tests read manifest.sections.length, now undefined; retargeted at
  workflows.<name> with their original intent preserved (a fenced or
  loop-host marker still asserts NO section is produced, not merely a
  changed count)
- the stale-manifest test wrote its fixture in the OLD shape, so it tripped
  shape validation and stopped exercising staleness at all. Its fixture is
  now valid-but-mismatched so FAIL_STALE is genuinely reached again.
- added the coverage that exposed: a pre-6.1 flat artifact must report
  FAIL_MANIFEST_MALFORMED_SHAPE. That is the real upgrade path for an
  installed tree and nothing covered it.

Refs #2992

* chore(#2992): backfill changeset pr number to 3013

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-02 22:36:45 -04:00
committed by GitHub
parent 1259e4b619
commit f1af47766a
17 changed files with 1272 additions and 132 deletions

View File

@@ -3275,20 +3275,366 @@ describe('init section manifest', () => {
const body = JSON.parse(result.stdout);
assert.equal(body.section_manifest, null);
});
// ── C4 (#2992): stale FLAT pre-6.1 artifact must degrade to null and
// never be mis-attributed to any workflow — the upgrade-path row.
test('staleFlatPreWideningArtifactDegradesToNull (row C4)', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-c4-');
const flatPath = path.join(dir, 'flat-section-manifest.json');
// The pre-#2932-Phase-6.1 shape: a single top-level `sections` array,
// no `workflows` key at all. It even NAMES an execute-phase-shaped
// section, so a mis-attribution bug would silently "work".
fs.writeFileSync(
flatPath,
JSON.stringify({
sections: [{ id: 'partial-wave', when: 'flag:--wave', read: 'gsd-core/workflows/execute-phase/steps/partial-wave.md' }],
}),
);
const result = runExecutePhase(['1', '--wave', '1'], dir, { GSD_SECTION_MANIFEST: flatPath });
assert.equal(result.status, 0, `expected exit 0 despite stale flat artifact, got ${result.status} (stderr: ${result.stderr})`);
assertNoStackTrace(result.stderr, 'stale-flat-artifact');
const body = JSON.parse(result.stdout);
assert.equal(body.section_manifest, null, 'a pre-6.1 flat artifact must never be mis-parsed as some workflow\'s sections');
});
// ── C8 (#2992): valid JSON that is not an object — one row each.
test('nonObjectJsonArtifactDegradesToNull (row C8)', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-c8-');
for (const hostileJson of ['0', '"str"', '[]', 'null', 'true']) {
const hostilePath = path.join(dir, `hostile-${Buffer.from(hostileJson).toString('hex')}-section-manifest.json`);
fs.writeFileSync(hostilePath, hostileJson);
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: hostilePath });
assert.equal(result.status, 0, `hostile JSON ${hostileJson}: expected exit 0 (stderr: ${result.stderr})`);
assertNoStackTrace(result.stderr, `hostile-json:${hostileJson}`);
const body = JSON.parse(result.stdout);
assert.equal(body.section_manifest, null, `hostile JSON ${hostileJson} must degrade to null`);
}
});
// ── C9 (#2992): file present but 0 bytes.
test('emptyZeroByteArtifactFileDegradesToNull (row C9)', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-c9-');
const emptyPath = path.join(dir, 'empty-section-manifest.json');
fs.writeFileSync(emptyPath, '');
assert.equal(fs.statSync(emptyPath).size, 0, 'sanity: fixture file must be 0 bytes');
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: emptyPath });
assert.equal(result.status, 0, `expected exit 0 despite empty artifact file, got ${result.status} (stderr: ${result.stderr})`);
assertNoStackTrace(result.stderr, 'empty-artifact-file');
const body = JSON.parse(result.stdout);
assert.equal(body.section_manifest, null);
});
// ── #2992 review finding: an unsafe `read` path degrades the WHOLE
// load to null, exactly like every other shape violation above — the
// field is documented as a POSIX-normalized, repo-root-RELATIVE path,
// so an absolute path, a Windows drive/UNC prefix, or a `..` traversal
// segment must never be trusted through to a later `fs.readFileSync`.
test('degradesToNullWhenReadPathIsAbsolute', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-unsafe-abs-');
const manifestPath = path.join(dir, 'unsafe-abs-section-manifest.json');
fs.writeFileSync(manifestPath, JSON.stringify({
workflows: { 'execute-phase': [{ id: 'x', when: 'always', read: '/etc/passwd' }] },
}));
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath });
assert.equal(result.status, 0, `expected exit 0 despite an absolute read path, got ${result.status} (stderr: ${result.stderr})`);
assertNoStackTrace(result.stderr, 'unsafe-read-absolute');
const body = JSON.parse(result.stdout);
assert.equal(body.section_manifest, null, 'an absolute `read` path must degrade the whole load to null');
});
test('degradesToNullWhenReadPathContainsDotDotTraversal', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-unsafe-dotdot-');
const manifestPath = path.join(dir, 'unsafe-dotdot-section-manifest.json');
fs.writeFileSync(manifestPath, JSON.stringify({
workflows: { 'execute-phase': [{ id: 'x', when: 'always', read: '../../etc/passwd' }] },
}));
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath });
assert.equal(result.status, 0, `expected exit 0 despite a ../ traversal read path, got ${result.status} (stderr: ${result.stderr})`);
assertNoStackTrace(result.stderr, 'unsafe-read-dotdot');
const body = JSON.parse(result.stdout);
assert.equal(body.section_manifest, null, 'a `..` traversal segment in `read` must degrade the whole load to null');
});
test('degradesToNullWhenReadPathIsWindowsDriveAbsolute', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-unsafe-windrive-');
const manifestPath = path.join(dir, 'unsafe-windrive-section-manifest.json');
fs.writeFileSync(manifestPath, JSON.stringify({
workflows: { 'execute-phase': [{ id: 'x', when: 'always', read: 'C:\\Windows\\System32\\config' }] },
}));
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath });
assert.equal(result.status, 0, `expected exit 0 despite a Windows drive-absolute read path, got ${result.status} (stderr: ${result.stderr})`);
assertNoStackTrace(result.stderr, 'unsafe-read-windrive');
const body = JSON.parse(result.stdout);
assert.equal(body.section_manifest, null, 'a Windows drive-absolute `read` path must degrade the whole load to null');
});
});
// ── C1/C12/C16/C17 (#2992): shape guarantees on the field itself ────────
describe('init execute-phase: section_manifest field-shape guarantees (#2992)', () => {
test('emptySectionsArrayIsComputedNotDegraded (row C12 — present + empty is not null)', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-c12-');
const emptyWorkflowPath = path.join(dir, 'empty-workflow-section-manifest.json');
fs.writeFileSync(emptyWorkflowPath, JSON.stringify({ workflows: { 'execute-phase': [] } }));
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: emptyWorkflowPath });
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
const body = JSON.parse(result.stdout);
assert.deepStrictEqual(
body.section_manifest,
{ workflow: 'execute-phase', included: [], excluded: [], read: [] },
'a workflow key present with sections:[] must compute to an empty-but-present selection, never null',
);
});
test('workflowAbsentFromArtifactDegradesToNullNotEmptySections (row C16 — absence is not empty)', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-c16-');
const otherWorkflowPath = path.join(dir, 'other-workflow-section-manifest.json');
fs.writeFileSync(otherWorkflowPath, JSON.stringify({ workflows: { 'plan-phase': [{ id: 'x', when: 'always', read: 'x.md' }] } }));
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: otherWorkflowPath });
assert.equal(result.status, 0);
const body = JSON.parse(result.stdout);
assert.equal(body.section_manifest, null, 'execute-phase has no key in this artifact — must degrade to null, never {included:[]}');
});
test('executePhaseSectionManifestFieldIsByteIdenticalToThePreChangeShape (row C1 — Hyrum gate)', (t) => {
// Locks the EXACT shape execute-phase's real, shipped manifest produces
// for a plain (no --wave, no gap-closure, no prior phases) invocation —
// #2992 widened the vocabulary and generalized the artifact to
// per-workflow keying, but execute-phase's own 3 real sections (all
// pre-existing atoms) must select IDENTICALLY to before this change.
const dir = seedSinglePhaseProject(t, 'gsd-c1-');
const body = parseOkJson(runExecutePhase(['1'], dir), 'c1-baseline');
assert.deepStrictEqual(body.section_manifest, {
workflow: 'execute-phase',
included: [],
excluded: ['partial-wave', 'gap-closure-artifacts', 'regression-gate'],
read: [],
});
});
test('restOfTheInitBundleIsUnaffectedByEverySectionManifestDegradedCondition (row C17 — Hyrum gate)', (t) => {
// The `section_manifest` field is additive; every OTHER field of the
// init-bundle (22 direct dependents per the design doc's blast-radius
// table) must be byte-identical regardless of whether the manifest
// artifact resolves, is missing, or is malformed.
const dir = seedSinglePhaseProject(t, 'gsd-c17-');
const real = parseOkJson(runExecutePhase(['1'], dir), 'c17-real');
const missingPath = path.join(dir, 'does-not-exist-c17.json');
const missing = parseOkJson(runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: missingPath }), 'c17-missing');
const badPath = path.join(dir, 'bad-c17.json');
fs.writeFileSync(badPath, '{ not valid json');
const malformed = parseOkJson(runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: badPath }), 'c17-malformed');
delete real.section_manifest;
delete missing.section_manifest;
delete malformed.section_manifest;
assert.deepStrictEqual(missing, real, 'every other init-bundle field must be unaffected by a missing manifest artifact');
assert.deepStrictEqual(malformed, real, 'every other init-bundle field must be unaffected by a malformed manifest artifact');
});
});
// ── D4 (#2992, prod-shape): an absent CLI flag is absent from `flags` ────
//
// #2992 review finding (fixed in this change, src/init-command-router.cts):
// `parseNamedArgs`'s booleanFlags ALWAYS populate the option key (`true`
// when the token was seen, `false` otherwise — never `undefined`).
// `buildSectionManifestField`'s flags-Set builder (src/init.cts) treats
// ANY non-`undefined` option value as present (matrix D2/D3, for VALUE
// flags whose absence is `null`). Passed through unmodified, a
// booleanFlag's own `false` ("--wave" never typed) would still have been
// added to `flags`, making `flag:--wave` permanently true regardless of
// the actual invocation — verified live pre-fix: `partial-wave` was
// INCLUDED with no `--wave` on the command line at all, silently
// defeating the gating feature this whole seam exists for. The router now
// folds a booleanFlag's own `false` into `undefined` before it reaches
// `buildSectionManifestField`, so this test is the regression lock for
// that fix — it duplicates the assertion `emitsSectionManifestWithoutWaveFlag`
// already makes, under an explicit D4 name for matrix traceability.
//
// D2 ("option present, value false" as a generically-present option) and
// D3 ("option present, string value") and D6 (a hostile literal option
// key) are NOT independently prod-shape-testable through the real CLI as
// currently wired: `execute-phase`/`plan-phase` are the only two handlers
// that feed router-derived options into `buildSectionManifestField`, their
// option-key lists are FIXED literals (`validate`/`tdd`/`wave`/
// `granularity`) never derived from user input (so a hostile key name can
// never reach it for real), none of the 14 shipped vocabulary atoms is
// backed by a value-taking CLI flag (so a real string-valued option can
// never reach a `flag:` atom yet), and this fix means a booleanFlag's own
// `false` never reaches the builder at all anymore. Synthesizing an
// options object to force these paths would violate the matrix's own
// "(prod-shape) rows must drive the options path" constraint, so they are
// left unaddressed here rather than faked — surfaced for the orchestrator.
describe('init execute-phase: undefined CLI flag is absent from flags (#2992 row D4)', () => {
test('waveOptionAbsentWithoutTheFlagNeverActivatesPartialWave', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-d4-');
const body = parseOkJson(runExecutePhase(['1'], dir), 'd4-no-wave');
assert.ok(!body.section_manifest.included.includes('partial-wave'), 'an unset --wave must never leak into flags as "present"');
assert.deepStrictEqual(body.section_manifest.excluded, ['partial-wave', 'gap-closure-artifacts', 'regression-gate']);
});
});
// ── D9/D10/D11 (#2992, prod-shape): state:* detector degradation ─────────
// Drives the REAL cmdInitExecutePhase -> buildSectionManifestField ->
// readConfigJsonBoolean/detectPhaseMvpMode seam through the real CLI with a
// fixture manifest naming the two config-backed atoms, never a hand-built
// InvocationFacts (matrix note: "(prod-shape)" rows must drive the options
// path). `seedSinglePhaseProject` writes no `.planning/config.json` and no
// `.planning/ROADMAP.md` at all, so D9 (absent config) and D11 (absent
// ROADMAP) are the fixture's natural, un-monkeypatched state.
describe('init execute-phase: state:* detector degradation (#2992 rows D9-D11)', () => {
function writeDetectorManifest(dir) {
const manifestPath = path.join(dir, 'detector-section-manifest.json');
fs.writeFileSync(manifestPath, JSON.stringify({
workflows: {
'execute-phase': [
{ id: 'worktrees-section', when: 'state:worktrees-enabled', read: 'x.md' },
{ id: 'mvp-section', when: 'state:phase-mvp-mode', read: 'y.md' },
],
},
}));
return manifestPath;
}
test('absentConfigAndAbsentRoadmapDegradeBothStateAtomsToFalse (rows D9/D11)', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-d9-');
assert.equal(fs.existsSync(path.join(dir, '.planning', 'config.json')), false, 'sanity: no config.json');
assert.equal(fs.existsSync(path.join(dir, '.planning', 'ROADMAP.md')), false, 'sanity: no ROADMAP.md');
const manifestPath = writeDetectorManifest(dir);
const body = parseOkJson(runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath }), 'd9-d11');
assert.deepStrictEqual(body.section_manifest.excluded, ['worktrees-section', 'mvp-section']);
assert.deepStrictEqual(body.section_manifest.included, []);
});
test('nonBooleanConfigValueDegradesToFalse (row D10 — strict boolean, string "true" is not true)', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-d10-');
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify({ workflow: { use_worktrees: 'true' } }));
const manifestPath = writeDetectorManifest(dir);
const body = parseOkJson(runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath }), 'd10');
assert.ok(
body.section_manifest.excluded.includes('worktrees-section'),
'a string "true" config value must never coerce to boolean true',
);
});
test('realBooleanTrueConfigValueIncludesTheSection (independence: the strict check still accepts a real boolean)', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-d10b-');
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify({ workflow: { use_worktrees: true } }));
const manifestPath = writeDetectorManifest(dir);
const body = parseOkJson(runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath }), 'd10b');
assert.ok(body.section_manifest.included.includes('worktrees-section'));
});
});
// ── #2992 review finding: state:needs-codebase-map wiring (real CLI) ─────
//
// No test anywhere drove `state:needs-codebase-map` (src/section-manifest.cts)
// or its `cmdInitNewProject` override wiring (src/init.cts, `overrides.needsCodebaseMap`)
// through the real CLI. Drives `init new-project` with a `mkdtempSync`
// fixture manifest (via `GSD_SECTION_MANIFEST`) naming a single section
// gated on the atom, proving inclusion/exclusion tracks the SAME
// isBrownfield/hasCodebaseMap computation `needs_codebase_map` itself uses
// — never mutating the shipped gsd-core/workflows/section-manifest.json.
describe('init new-project: state:needs-codebase-map wiring (#2992 review finding)', () => {
function writeNeedsCodebaseMapManifest(dir) {
const manifestPath = path.join(dir, 'needs-map-section-manifest.json');
fs.writeFileSync(manifestPath, JSON.stringify({
workflows: {
'new-project': [
{ id: 'needs-map-section', when: 'state:needs-codebase-map', read: 'z.md' },
],
},
}));
return manifestPath;
}
test('brownfieldWithoutCodebaseMapIncludesTheGatedSection', () => {
const dir = createTempProject('gsd-needsmap-true-');
try {
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"test"}');
const manifestPath = writeNeedsCodebaseMapManifest(dir);
const result = runGsdTools('init new-project', dir, { GSD_SECTION_MANIFEST: manifestPath });
assert.ok(result.success, `init new-project failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.needs_codebase_map, true, 'sanity: fixture must be brownfield without a codebase map');
assert.deepStrictEqual(output.section_manifest.included, ['needs-map-section']);
assert.deepStrictEqual(output.section_manifest.excluded, []);
} finally {
cleanup(dir);
}
});
test('greenfieldExcludesTheGatedSection', () => {
const dir = createTempProject('gsd-needsmap-false-');
try {
const manifestPath = writeNeedsCodebaseMapManifest(dir);
const result = runGsdTools('init new-project', dir, { GSD_SECTION_MANIFEST: manifestPath });
assert.ok(result.success, `init new-project failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.needs_codebase_map, false, 'sanity: fixture must be greenfield');
assert.deepStrictEqual(output.section_manifest.included, []);
assert.deepStrictEqual(output.section_manifest.excluded, ['needs-map-section']);
} finally {
cleanup(dir);
}
});
test('brownfieldWithExistingCodebaseMapExcludesTheGatedSection', () => {
const dir = createTempProject('gsd-needsmap-hascomap-');
try {
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"test"}');
fs.mkdirSync(path.join(dir, '.planning', 'codebase'), { recursive: true });
for (const name of ['STACK', 'ARCHITECTURE', 'STRUCTURE', 'CONVENTIONS', 'TESTING', 'INTEGRATIONS', 'CONCERNS']) {
fs.writeFileSync(path.join(dir, '.planning', 'codebase', `${name}.md`), `# ${name}\n`);
}
const manifestPath = writeNeedsCodebaseMapManifest(dir);
const result = runGsdTools('init new-project', dir, { GSD_SECTION_MANIFEST: manifestPath });
assert.ok(result.success, `init new-project failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.needs_codebase_map, false, 'sanity: fixture must already have a complete codebase map');
assert.deepStrictEqual(output.section_manifest.included, []);
assert.deepStrictEqual(output.section_manifest.excluded, ['needs-map-section']);
} finally {
cleanup(dir);
}
});
});
// ── E59: independence — other init subcommands unaffected ───────────────
//
// #2992 (epic #1671 Phase 6.1) generalized the manifest seam from
// execute-phase-only to per-workflow (`buildSectionManifestField` now wires
// into `cmdInitPlanPhase`/`cmdInitNewProject`/`cmdInitNewMilestone`/
// `cmdInitQuick`/`cmdInitProgress` too — src/init.cts:740/787/839/885/1810).
// The OLD assertion here ("section_manifest must be execute-phase-only")
// is stale: plan-phase's field is now PRESENT, but `null` — the shipped
// gsd-core/workflows/section-manifest.json still keys only `execute-phase`
// (design row C3: a workflow absent from the artifact's `workflows` map
// degrades to `null`, never an absent field). `resume` has no dedicated
// `cmdInit*` manifest wiring at all (design's withheld-atom survey), so its
// field truly remains absent — that half of the guard is unchanged.
describe('init dispatch: other subcommands unaffected (#2932 row 59, CRITICAL radius guard)', () => {
test('leavesOtherInitSubcommandsUnchanged', (t) => {
describe('init dispatch: other subcommands unaffected (#2932/#2992 row 59, CRITICAL radius guard)', () => {
test('planPhaseEmitsANullManifestNotAnAbsentField (row C3/C16 — absence is not empty)', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-e59-');
const planPhase = parseOkJson(runSectionManifestCli(['init.plan-phase', '1'], dir), 'plan-phase');
assert.equal(planPhase.phase_found, true);
assert.ok(!('section_manifest' in planPhase), 'section_manifest must be execute-phase-only, never leak into plan-phase');
assert.ok('section_manifest' in planPhase, 'section_manifest field must be present (computed, degraded to null) for plan-phase');
assert.equal(planPhase.section_manifest, null, 'plan-phase has no key in the shipped artifact, so it must degrade to null, never {included:[]}');
});
test('resumeNeverEmitsASectionManifestField', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-e59-resume-');
const resume = parseOkJson(runSectionManifestCli(['init.resume'], dir), 'resume');
assert.ok(!('section_manifest' in resume), 'section_manifest must never leak into resume');
assert.ok(!('section_manifest' in resume), 'section_manifest must never leak into resume — resume has no cmdInit* manifest wiring at all');
});
});