@opengsd/gsd-core 1.6.0-rc.1 → 1.6.0-rc.2

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 (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-roadmapper.md +6 -0
  3. package/bin/install.js +92 -332
  4. package/commands/gsd/capture.md +5 -1
  5. package/gemini-extension.json +1 -1
  6. package/gsd-core/bin/gsd-tools.cjs +18 -3
  7. package/gsd-core/bin/lib/adr-parser.cjs +21 -6
  8. package/gsd-core/bin/lib/capability-registry.cjs +48 -48
  9. package/gsd-core/bin/lib/commands.cjs +247 -0
  10. package/gsd-core/bin/lib/config-loader.cjs +6 -0
  11. package/gsd-core/bin/lib/config.cjs +6 -0
  12. package/gsd-core/bin/lib/frontmatter.cjs +7 -3
  13. package/gsd-core/bin/lib/phase-id.cjs +25 -11
  14. package/gsd-core/bin/lib/phase.cjs +4 -4
  15. package/gsd-core/bin/lib/probe-core.cjs +7 -0
  16. package/gsd-core/bin/lib/prohibition-enforcement.cjs +59 -26
  17. package/gsd-core/bin/lib/roadmap-command-router.cjs +16 -3
  18. package/gsd-core/bin/lib/roadmap-parser.cjs +29 -8
  19. package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -17
  20. package/gsd-core/bin/lib/roadmap.cjs +5 -2
  21. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +423 -3
  22. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +77 -0
  23. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -28
  24. package/gsd-core/bin/lib/runtime-name-policy.cjs +44 -0
  25. package/gsd-core/bin/lib/shell-command-projection.cjs +55 -1
  26. package/gsd-core/bin/lib/surface.cjs +12 -19
  27. package/gsd-core/bin/lib/validate.cjs +5 -2
  28. package/gsd-core/bin/lib/verify.cjs +11 -2
  29. package/gsd-core/bin/lib/worktree-safety.cjs +202 -0
  30. package/gsd-core/bin/shared/config-defaults.manifest.json +2 -1
  31. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  32. package/gsd-core/references/context-budget.md +8 -8
  33. package/gsd-core/references/execute-phase-context-guard.md +16 -0
  34. package/gsd-core/references/planning-config.md +1 -0
  35. package/gsd-core/references/prohibition-probe.md +15 -9
  36. package/gsd-core/workflows/autonomous.md +33 -33
  37. package/gsd-core/workflows/diagnose-issues.md +6 -1
  38. package/gsd-core/workflows/execute-phase.md +8 -6
  39. package/gsd-core/workflows/help/modes/full.md +10 -0
  40. package/gsd-core/workflows/list-seeds.md +63 -0
  41. package/gsd-core/workflows/manager.md +37 -37
  42. package/gsd-core/workflows/pr-branch.md +156 -0
  43. package/gsd-core/workflows/quick.md +6 -1
  44. package/gsd-core/workflows/review.md +10 -2
  45. package/gsd-core/workflows/spec-phase.md +8 -3
  46. package/gsd-core/workflows/verify-phase.md +2 -2
  47. package/package.json +4 -1
  48. package/scripts/prompt-injection-scan.sh +1 -0
@@ -510,13 +510,49 @@ function normalizeContent(filePath, content, opts = {}) {
510
510
  }
511
511
  return { content: normalized, encoding };
512
512
  }
513
+ // Rename errnos that are transient on Windows: a concurrent reader (or an AV
514
+ // scanner / indexer) holding the target open makes renameSync fail briefly.
515
+ // Same idiom as capability-ledger.cts / capability-consent.cts.
516
+ const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']);
517
+ const RENAME_MAX_ATTEMPTS = 3;
518
+ const RENAME_RETRY_BACKOFF_MS = 50;
519
+ /** Synchronous best-effort backoff sleep (Atomics.wait — same idiom as io.cts). */
520
+ let _renameSleepBuf = null;
521
+ function renameBackoff() {
522
+ if (_renameSleepBuf === null)
523
+ _renameSleepBuf = new Int32Array(new SharedArrayBuffer(4));
524
+ Atomics.wait(_renameSleepBuf, 0, 0, RENAME_RETRY_BACKOFF_MS);
525
+ }
526
+ /**
527
+ * Atomic publish with bounded retry on transient Windows lock errnos.
528
+ * Returns null on success, or the final error if every attempt failed.
529
+ */
530
+ function atomicRenameWithRetry(tmpPath, filePath) {
531
+ let renameErr = null;
532
+ for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt++) {
533
+ try {
534
+ node_fs_1.default.renameSync(tmpPath, filePath);
535
+ return null;
536
+ }
537
+ catch (err) {
538
+ renameErr = err;
539
+ if (attempt < RENAME_MAX_ATTEMPTS && RENAME_RETRY_ERRNOS.has(renameErr.code ?? '')) {
540
+ renameBackoff();
541
+ continue;
542
+ }
543
+ break;
544
+ }
545
+ }
546
+ return renameErr;
547
+ }
513
548
  function platformWriteSync(filePath, content, opts = {}) {
514
549
  const { content: normalized, encoding } = normalizeContent(filePath, content, opts);
515
550
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(filePath), { recursive: true });
516
551
  const tmpPath = filePath + '.tmp.' + process.pid;
552
+ // Step 1: write the sibling tmp file. If THIS fails, nothing was published, so a
553
+ // direct fallback write cannot truncate a concurrent reader of an existing file.
517
554
  try {
518
555
  node_fs_1.default.writeFileSync(tmpPath, normalized, encoding);
519
- node_fs_1.default.renameSync(tmpPath, filePath);
520
556
  }
521
557
  catch {
522
558
  try {
@@ -524,7 +560,25 @@ function platformWriteSync(filePath, content, opts = {}) {
524
560
  }
525
561
  catch { /* already gone */ }
526
562
  node_fs_1.default.writeFileSync(filePath, normalized, encoding);
563
+ return;
564
+ }
565
+ // Step 2: atomic publish, retrying transient Windows locks.
566
+ const renameErr = atomicRenameWithRetry(tmpPath, filePath);
567
+ if (renameErr === null)
568
+ return;
569
+ try {
570
+ node_fs_1.default.unlinkSync(tmpPath);
571
+ }
572
+ catch { /* already gone */ }
573
+ if (RENAME_RETRY_ERRNOS.has(renameErr.code ?? '')) {
574
+ // A live reader still holds the target open after every retry. A non-atomic
575
+ // direct write here would truncate that reader (the exact corruption this seam
576
+ // exists to prevent), so surface the error instead of falling back.
577
+ throw renameErr;
527
578
  }
579
+ // Atomic publish is genuinely impossible here (e.g. EXDEV cross-device move):
580
+ // fall back to a direct write to preserve write availability.
581
+ node_fs_1.default.writeFileSync(filePath, normalized, encoding);
528
582
  }
529
583
  function platformReadSync(filePath, opts = {}) {
530
584
  const encoding = opts.encoding ?? 'utf-8';
@@ -33,7 +33,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
33
33
  };
34
34
  const node_fs_1 = __importDefault(require("node:fs"));
35
35
  const node_path_1 = __importDefault(require("node:path"));
36
- const node_os_1 = __importDefault(require("node:os"));
37
36
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
38
37
  // eslint-disable-next-line @typescript-eslint/no-require-imports
39
38
  const installProfiles = require("./install-profiles.cjs");
@@ -41,7 +40,9 @@ const { readActiveProfile, resolveProfile, loadSkillsManifest, } = installProfil
41
40
  const clusters_cjs_1 = require("./clusters.cjs");
42
41
  // eslint-disable-next-line @typescript-eslint/no-require-imports
43
42
  const runtimeArtifactLayout = require("./runtime-artifact-layout.cjs");
44
- const { findInstallSourceRoot, getInstallExports } = runtimeArtifactLayout;
43
+ const { findInstallSourceRoot } = runtimeArtifactLayout;
44
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
45
+ const runtimeArtifactConversion = require("./runtime-artifact-conversion.cjs");
45
46
  const SURFACE_FILE_NAME = '.gsd-surface.json';
46
47
  /**
47
48
  * Read the surface state from a runtime config directory.
@@ -265,26 +266,18 @@ function applySurface(runtimeConfigDir, layout, manifest, clusterMap, registry)
265
266
  const resolved = resolveSurface(layout.configDir, skillManifest, clusterMap, registry);
266
267
  // Mirror installRuntimeArtifacts: skills kinds get per-runtime path rewrites
267
268
  // so SKILL.md bodies reference the install target (pathPrefix), not the
268
- // converter's default ~/.claude paths (#813). Computed lazily so command-only
269
- // runtimes do not trigger the install.js require.
270
- let pathPrefix = null;
269
+ // converter's default ~/.claude paths (#813). Delegated to the conversion
270
+ // module's deep seam (ADR-1508 / #1511 Phase 2) — no attribution resolver
271
+ // needed here (proven: Co-Authored-By never appears in staged content; see
272
+ // brief PROVEN KEY FACT). No getInstallExports() call required.
271
273
  for (const kind of layout.kinds) {
272
274
  const staged = kind.stage(resolved);
273
275
  if (kind.kind === 'skills') {
274
- const installExports = getInstallExports();
275
- if (pathPrefix === null) {
276
- const scope = layout.scope ?? 'global';
277
- const resolvedTarget = node_path_1.default.resolve(layout.configDir).replace(/\\/g, '/');
278
- const homeDir = node_os_1.default.homedir().replace(/\\/g, '/');
279
- pathPrefix = installExports.computePathPrefix({
280
- isGlobal: scope === 'global',
281
- isOpencode: layout.runtime === 'opencode',
282
- isWindowsHost: process.platform === 'win32',
283
- resolvedTarget,
284
- homeDir,
285
- });
286
- }
287
- installExports.applyRuntimeContentRewritesInPlace(staged, layout.runtime, pathPrefix);
276
+ runtimeArtifactConversion.rewriteStagedSkillBodies(staged, {
277
+ runtime: layout.runtime,
278
+ configDir: layout.configDir,
279
+ scope: layout.scope ?? 'global',
280
+ });
288
281
  }
289
282
  const dest = node_path_1.default.join(layout.configDir, kind.destSubpath);
290
283
  _syncGsdDir(staged, dest, kind, skillManifest);
@@ -37,14 +37,17 @@ exports.canonicalPlanStem = canonicalPlanStem;
37
37
  exports.phaseVariants = phaseVariants;
38
38
  exports.buildRoadmapPhaseVariants = buildRoadmapPhaseVariants;
39
39
  exports.buildNotStartedPhaseVariants = buildNotStartedPhaseVariants;
40
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
41
+ const phaseIdMod = require("./phase-id.cjs");
42
+ const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE } = phaseIdMod;
40
43
  // ── Issue #26: regex constants (W005, W006-archived) ────────────────────────
41
44
  // Matches legacy numeric dirs (01-setup), milestone-prefixed dirs (02-01-setup),
42
45
  // deep dirs (02-04-01-deep), and project-code-prefixed variants (GSD-02-01-setup).
43
- exports.phaseDirNameRe = /^(?:[A-Z]{1,6}-)?\d{2,}(?:-\d+)*(?:\.\d+)*-[\w-]+$/i;
46
+ exports.phaseDirNameRe = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}\\d{2,}(?:-\\d+)*(?:\\.\\d+)*-[\\w-]+$`, 'i');
44
47
  // Extracts the full phase token from a directory name, including milestone-prefixed
45
48
  // multi-segment tokens like "02-01" from "02-01-setup" or "GSD-02-01-setup".
46
49
  // Greedily captures all leading all-digit segments before the first letter-start segment.
47
- exports.PHASE_TOKEN_FROM_DIR_RE = /^(?:[A-Z]{1,6}-)?(\d+(?:-\d+)*[A-Z]?(?:\.\d+)*)(?:-[a-z]|$)/i;
50
+ exports.PHASE_TOKEN_FROM_DIR_RE = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+(?:-\\d+)*[A-Z]?(?:\\.\\d+)*)(?:-[a-z]|$)`, 'i');
48
51
  exports.MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i;
49
52
  // ── Issue #26: I001 canonicalization ────────────────────────────────────────
50
53
  function canonicalPlanStem(stem) {
@@ -1887,8 +1887,17 @@ function cmdVerifyCodebaseDrift(cwd, raw) {
1887
1887
  else if (status === 'D')
1888
1888
  deleted.push(file);
1889
1889
  }
1890
- const config = loadConfig(cwd);
1891
- const wf = config?.workflow;
1890
+ // loadConfig() returns a flattened object — there is no nested `workflow`
1891
+ // key. Read the raw config.json directly to access workflow-scoped keys,
1892
+ // matching the pattern used in check-command-router.cts:readWorkflowConfig.
1893
+ let wf;
1894
+ try {
1895
+ const rawCfg = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(planningDir(cwd), 'config.json'), 'utf-8'));
1896
+ wf = rawCfg['workflow'];
1897
+ }
1898
+ catch {
1899
+ wf = undefined;
1900
+ }
1892
1901
  const threshold = Number.isInteger(wf?.drift_threshold) && wf?.drift_threshold >= 1
1893
1902
  ? wf?.drift_threshold
1894
1903
  : 3;
@@ -693,6 +693,206 @@ function cmdWorktreeCleanupWave(cwd, args = []) {
693
693
  process.exitCode = 1;
694
694
  }
695
695
  }
696
+ /**
697
+ * Pure planner for the per-agent wave-manifest append.
698
+ *
699
+ * Validates the candidate entry at write time using the SAME rules the
700
+ * cleanup-wave reader enforces (via `normalizeCleanupManifestEntry`), so an
701
+ * entry that `record-agent` accepts is guaranteed to survive
702
+ * `normalizeCleanupManifest` on read — a field that would be silently dropped
703
+ * at cleanup time fails loudly here instead.
704
+ *
705
+ * `agent_id` is treated write-strict (required) even though the reader is
706
+ * lenient (nullable): the whole point of this verb is to catch an
707
+ * under-populated entry at write time, and an entry whose author cannot be
708
+ * identified defeats that. A duplicate `(worktree_path, branch)` is also
709
+ * rejected loudly — the reader dedups on that key, so a re-record would be
710
+ * silently dropped (the failure mode this verb exists to eliminate). The
711
+ * on-disk shape stays the existing 4-field entry (`agent_id`, `worktree_path`,
712
+ * `branch`, `expected_base`) — no schema change; the reader re-derives
713
+ * `allowed_bases`.
714
+ */
715
+ function planWorktreeRecordAgent(manifestRaw, fields) {
716
+ // 1. Write-strict required-field check (loud, with which flag is missing).
717
+ // Trim first so a whitespace-only value (" ") is rejected here rather
718
+ // than deferred to a guaranteed `git worktree remove` failure at cleanup.
719
+ const agentId = (fields.agentId || '').trim();
720
+ const worktreePath = (fields.worktreePath || '').trim();
721
+ const branch = (fields.branch || '').trim();
722
+ const base = (fields.base || '').trim();
723
+ const missing = [];
724
+ if (!agentId)
725
+ missing.push('--agent-id');
726
+ if (!worktreePath)
727
+ missing.push('--path');
728
+ if (!branch)
729
+ missing.push('--branch');
730
+ if (!base)
731
+ missing.push('--base');
732
+ if (missing.length > 0) {
733
+ return {
734
+ ok: false,
735
+ reason: 'missing_field',
736
+ hint: `record-agent requires ${missing.join(', ')}. Re-run with all of --agent-id, --path, --branch, --base set to non-empty (non-whitespace) values.`,
737
+ entry: null,
738
+ manifest: null,
739
+ };
740
+ }
741
+ // 2. Shared validation: run the candidate through the reader's normalizer.
742
+ // If it returns null the reader would drop this entry on read — reject now.
743
+ const candidate = {
744
+ agent_id: agentId,
745
+ worktree_path: worktreePath,
746
+ branch,
747
+ expected_base: base,
748
+ };
749
+ const entry = normalizeCleanupManifestEntry(candidate);
750
+ if (!entry) {
751
+ return {
752
+ ok: false,
753
+ reason: 'invalid_entry',
754
+ hint: `Entry failed cleanup-manifest validation: --path/--branch/--base must be non-empty and --branch must match ^worktree-agent-[A-Za-z0-9._/-]+$ (got branch="${branch}"). Fix the field and re-run.`,
755
+ entry: null,
756
+ manifest: null,
757
+ };
758
+ }
759
+ // 3. Parse the existing manifest. The init shell ({orchestrator_root, worktrees: []})
760
+ // is written inline by the orchestrator before any agent spawns; a missing or
761
+ // malformed manifest is a loud failure here, not a silent under-populated write.
762
+ let parsed;
763
+ try {
764
+ parsed = JSON.parse(manifestRaw);
765
+ }
766
+ catch {
767
+ return {
768
+ ok: false,
769
+ reason: 'invalid_manifest_json',
770
+ hint: 'Manifest is not valid JSON. The orchestrator must initialize it as {"orchestrator_root": "...", "worktrees": []} before recording agents.',
771
+ entry: null,
772
+ manifest: null,
773
+ };
774
+ }
775
+ // Accept the canonical {worktrees: []} shell or a bare top-level array (both
776
+ // are read by normalizeCleanupManifest); preserve any other top-level keys.
777
+ let worktrees;
778
+ let writeBack;
779
+ if (Array.isArray(parsed)) {
780
+ worktrees = parsed;
781
+ writeBack = worktrees;
782
+ }
783
+ else if (parsed && typeof parsed === 'object') {
784
+ const container = parsed;
785
+ if (container.worktrees === undefined)
786
+ container.worktrees = [];
787
+ if (!Array.isArray(container.worktrees)) {
788
+ return {
789
+ ok: false,
790
+ reason: 'manifest_shape_invalid',
791
+ hint: 'Manifest "worktrees" must be an array. Re-initialize as {"orchestrator_root": "...", "worktrees": []}.',
792
+ entry: null,
793
+ manifest: null,
794
+ };
795
+ }
796
+ worktrees = container.worktrees;
797
+ writeBack = container;
798
+ }
799
+ else {
800
+ return {
801
+ ok: false,
802
+ reason: 'manifest_shape_invalid',
803
+ hint: 'Manifest must be a JSON object {"worktrees": []} or a top-level array.',
804
+ entry: null,
805
+ manifest: null,
806
+ };
807
+ }
808
+ // 4. Reject a duplicate (worktree_path, branch). The reader dedups on this
809
+ // exact key, but only over entries that NORMALIZE successfully — so an
810
+ // existing malformed same-key entry (which the reader would drop) must NOT
811
+ // block recording a valid one. Run each existing entry through the reader's
812
+ // own normalizer and compare only the entries the reader would keep; this
813
+ // matches its dedup behavior exactly. A real duplicate signals an upstream
814
+ // double-spawn — surface it loudly instead of silently dropping it.
815
+ const dupKey = `${entry.worktree_path}\0${entry.branch}`;
816
+ const isDuplicate = worktrees.some((existing) => {
817
+ const normalized = normalizeCleanupManifestEntry(existing);
818
+ return normalized !== null && `${normalized.worktree_path}\0${normalized.branch}` === dupKey;
819
+ });
820
+ if (isDuplicate) {
821
+ return {
822
+ ok: false,
823
+ reason: 'duplicate_entry',
824
+ hint: `The manifest already records worktree_path="${entry.worktree_path}" branch="${entry.branch}". The cleanup reader dedups on (worktree_path, branch), so re-recording would be silently dropped — this usually signals an upstream double-spawn. Investigate rather than re-record.`,
825
+ entry: null,
826
+ manifest: null,
827
+ };
828
+ }
829
+ // 5. Append the minimal 4-field entry, matching the existing on-disk format.
830
+ const recorded = {
831
+ agent_id: entry.agent_id,
832
+ worktree_path: entry.worktree_path,
833
+ branch: entry.branch,
834
+ expected_base: entry.expected_base,
835
+ };
836
+ worktrees.push(recorded);
837
+ return {
838
+ ok: true,
839
+ reason: 'ok',
840
+ entry: recorded,
841
+ manifest: `${JSON.stringify(writeBack, null, 2)}\n`,
842
+ };
843
+ }
844
+ /**
845
+ * CLI command: append a validated per-agent entry to a wave cleanup manifest.
846
+ *
847
+ * Usage: worktree record-agent --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>
848
+ *
849
+ * Fails loudly (non-zero exit + recovery hint on stderr) when a field is
850
+ * missing/garbled or the manifest is absent/malformed, rather than appending an
851
+ * under-populated entry that the cleanup reader would silently drop.
852
+ */
853
+ function cmdWorktreeRecordAgent(cwd, args = [], deps = {}) {
854
+ const flag = (name) => {
855
+ const i = args.indexOf(name);
856
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : '';
857
+ };
858
+ const write = deps.write || ((s) => process.stdout.write(s));
859
+ const writeErr = deps.writeErr || ((s) => process.stderr.write(s));
860
+ const manifestPath = flag('--manifest');
861
+ if (!manifestPath) {
862
+ writeErr('Usage: worktree record-agent --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>\n');
863
+ process.exitCode = 2;
864
+ return { ok: false, reason: 'usage', entry: null };
865
+ }
866
+ const resolved = node_path_1.default.resolve(cwd, manifestPath);
867
+ const readFile = deps.readFile || ((p) => node_fs_1.default.readFileSync(p, 'utf8'));
868
+ let manifestRaw;
869
+ try {
870
+ manifestRaw = readFile(resolved);
871
+ }
872
+ catch (err) {
873
+ const hint = `Manifest not found or unreadable at ${manifestPath}. The orchestrator must initialize it ({"orchestrator_root": "...", "worktrees": []}) before recording agents.`;
874
+ writeErr(`[gsd] worktree.record-agent: manifest_read_failed — ${hint}\n`);
875
+ write(`${JSON.stringify({ ok: false, reason: 'manifest_read_failed', hint, error: err.message }, null, 2)}\n`);
876
+ process.exitCode = 1;
877
+ return { ok: false, reason: 'manifest_read_failed', hint, entry: null };
878
+ }
879
+ const plan = planWorktreeRecordAgent(manifestRaw, {
880
+ agentId: flag('--agent-id'),
881
+ worktreePath: flag('--path'),
882
+ branch: flag('--branch'),
883
+ base: flag('--base'),
884
+ });
885
+ if (!plan.ok || plan.manifest === null) {
886
+ writeErr(`[gsd] worktree.record-agent: ${plan.reason} — ${plan.hint || ''}\n`);
887
+ write(`${JSON.stringify({ ok: false, reason: plan.reason, hint: plan.hint }, null, 2)}\n`);
888
+ process.exitCode = 1;
889
+ return { ok: false, reason: plan.reason, hint: plan.hint, entry: null };
890
+ }
891
+ const writeFile = deps.writeFile || ((p, content) => node_fs_1.default.writeFileSync(p, content, 'utf8'));
892
+ writeFile(resolved, plan.manifest);
893
+ write(`${JSON.stringify({ ok: true, reason: 'ok', entry: plan.entry, manifest_path: resolved }, null, 2)}\n`);
894
+ return { ok: true, reason: 'ok', entry: plan.entry, manifest_path: resolved };
895
+ }
696
896
  /**
697
897
  * Reap orphaned linked worktrees whose lock owner process is dead, whose
698
898
  * branch tip is fully merged into the default branch, and whose lock file
@@ -978,6 +1178,8 @@ module.exports = {
978
1178
  planWorktreeWaveCleanup,
979
1179
  executeWorktreeWaveCleanupPlan,
980
1180
  cmdWorktreeCleanupWave,
1181
+ planWorktreeRecordAgent,
1182
+ cmdWorktreeRecordAgent,
981
1183
  reapOrphanWorktrees,
982
1184
  cmdWorktreeReapOrphans,
983
1185
  resolveWorktreeRoot,
@@ -53,7 +53,8 @@
53
53
  "post_planning_gaps": true,
54
54
  "security_enforcement": true,
55
55
  "security_asvs_level": 1,
56
- "security_block_on": "high"
56
+ "security_block_on": "high",
57
+ "context_guard_mode": "warn"
57
58
  },
58
59
  "planning": {
59
60
  "commit_docs": true,
@@ -56,6 +56,7 @@
56
56
  "workflow.test_command",
57
57
  "workflow.build_command",
58
58
  "workflow.mvp_mode",
59
+ "workflow.context_guard_mode",
59
60
  "executor.stall_detect_interval_minutes",
60
61
  "executor.stall_threshold_minutes",
61
62
  "workflow.inline_plan_threshold",
@@ -29,14 +29,14 @@ Every workflow that spawns agents or reads significant content must follow these
29
29
 
30
30
  ## Context Degradation Tiers
31
31
 
32
- Monitor context usage and adjust behavior accordingly:
33
-
34
- | Tier | Usage | Behavior |
35
- |------|-------|----------|
36
- | PEAK | 0-30% | Full operations. Read bodies, spawn multiple agents, inline results. |
37
- | GOOD | 30-50% | Normal operations. Prefer frontmatter reads, delegate aggressively. |
38
- | DEGRADING | 50-70% | Economize. Frontmatter-only reads, minimal inlining, warn user about budget. |
39
- | POOR | 70%+ | Emergency mode. Checkpoint progress immediately. No new reads unless critical. |
32
+ Monitor context usage and adjust behavior accordingly. The `workflow.context_guard_mode` config key (values: `auto`, `warn`, `off`; default `warn`) controls how `execute-phase.md` responds when the guard fires at a wave boundary.
33
+
34
+ | Tier | Usage | Behavior | Trigger Action (execute-phase) |
35
+ |------|-------|----------|-------------------------------|
36
+ | PEAK | 0-30% | Full operations. Read bodies, spawn multiple agents, inline results. | None |
37
+ | GOOD | 30-50% | Normal operations. Prefer frontmatter reads, delegate aggressively. | None |
38
+ | DEGRADING | 50-70% | Economize. Frontmatter-only reads, minimal inlining, warn user about budget. | Emit warning, continue |
39
+ | POOR | 70%+ | Emergency mode. Checkpoint progress immediately. No new reads unless critical. | `warn`: emit warning + recommend `/gsd:pause-work`. `auto`: invoke pause-work before next wave. `off`: proceed anyway. |
40
40
 
41
41
  ## Context Degradation Warning Signs
42
42
 
@@ -0,0 +1,16 @@
1
+ 0. **Context exhaustion guard — `context_guard` (BEFORE spawning, #1452):**
2
+
3
+ Before spawning any agents for this wave, self-assess context pressure using the
4
+ degradation signals in `references/context-budget.md`. Signs of POOR tier (70%+):
5
+ increasing vagueness, skipped steps, silent partial completion.
6
+
7
+ Read `workflow.context_guard_mode` from `.planning/config.json` (default `warn`).
8
+
9
+ | Tier | `warn` (default) | `auto` | `off` |
10
+ |------|-----------------|--------|-------|
11
+ | PEAK / GOOD | No output | No output | No output |
12
+ | DEGRADING (50-70%) | Emit: "⚠ Context pressure DEGRADING — switching to frontmatter-only reads for remaining waves." Continue. | Same as warn | Skip |
13
+ | POOR (70%+) | Emit: "🛑 Context pressure POOR — risk of context exhaustion. Run `/gsd:pause-work` to checkpoint before this wave, then resume in a fresh session." Continue (user decides). | Invoke `/gsd:pause-work` immediately and halt. Do NOT spawn wave agents. | Skip |
14
+
15
+ The guard is heuristic — no programmatic context-percentage API exists. Use your
16
+ assessment of degradation signals, not a fixed token count.
@@ -267,6 +267,7 @@ Set via `workflow.*` namespace in config.json (e.g., `"workflow": { "research":
267
267
  | `workflow.test_command` | string\|null | `null` | Any shell command | Regression/test gate command run by verify-phase, execute-phase, audit-fix, and post-merge-gate. Unset → GSD auto-detects (Makefile / package.json / Cargo.toml / go.mod / pyproject.toml). |
268
268
  | `workflow.build_command` | string\|null | `null` | Any shell command | Build gate command run by the post-merge gate. Unset → build step auto-detected/skipped. |
269
269
  | `workflow.mvp_mode` | boolean | `false` | `true`, `false` | Persist the MVP-mode flag in config so every phase defaults to MVP framing without requiring `--mvp` on the CLI. Resolved via the chain: `--mvp` CLI flag → ROADMAP.md `**Mode:** mvp` field → this config value → `false`. When `true`, the planner, executor, verifier, and discovery surfaces (progress, stats, graphify) all treat the phase as an MVP vertical slice (UI → API → DB) of one user-visible capability. |
270
+ | `workflow.context_guard_mode` | string | `"warn"` | `"auto"`, `"warn"`, `"off"` | Context exhaustion guard mode for `execute-phase`. Before each wave, the orchestrator self-assesses context pressure using degradation signals from `context-budget.md`. `"warn"` (default): emit a warning and recommend `/gsd:pause-work` when POOR tier is detected. `"auto"`: automatically invoke `/gsd:pause-work` before the next wave when POOR tier is detected. `"off"`: disable the guard. The guard is heuristic — no programmatic context-% API exists. |
270
271
  | `workflow.plan_chunked` | boolean | `false` | `true`, `false` | Enable chunked planning mode. When `true`, the plan-phase orchestrator splits the single long-lived planner Task into a short outline Task followed by N short per-plan Tasks (~3–5 min each). Each plan is committed individually for crash resilience. Particularly useful on Windows where long-lived Tasks may hang on stdio. Also activated by the `--chunked` flag. |
271
272
  | `workflow.code_review_command` | string\|null | `null` | Any shell command | External code-review command integrated into `/gsd:ship`. The diff is piped to the command via stdin; the command must output JSON with a `verdict` field (`"APPROVED"` or `"REVISE"`). Non-zero exit or `"REVISE"` verdict blocks the ship workflow. When unset, the built-in review flow runs. Example: `my-review-tool --review`. |
272
273
  | `workflow.inline_plan_threshold` | number | `2` | `0`–`10` | Plans with ≤N tasks execute inline instead of spawning a subagent |
@@ -157,7 +157,7 @@ A `resolved`/`test`-tier prohibition MAY carry an **optional `check` descriptor*
157
157
  the wired mechanical check, so verify-phase locates it deterministically instead of inventing
158
158
  `{kind, target, rule}` each run. The descriptor is captured at spec-phase (soft / optional —
159
159
  the author wires it when the negative test or lint rule already exists) and is represented as
160
- **four flat scalar keys** on the `must_haves.prohibitions` item — never a nested `check: {}`
160
+ **five flat scalar keys** on the `must_haves.prohibitions` item — never a nested `check: {}`
161
161
  object:
162
162
 
163
163
  - `check_kind` — `node-test` | `lint-rule` (which producer mechanism runs the check).
@@ -165,16 +165,19 @@ object:
165
165
  - `check_rule` — the `ruleId` to filter on, **lint-rule only** (absent for `node-test`).
166
166
  - `check_violation_fixture` — path to a KNOWN-BAD subject the #1279 prover runs the check against to
167
167
  machine-prove fail-first (rides BOTH kinds; for `node-test` it is injected via `GSD_PROHIB_SUBJECT`).
168
+ - `check_clean_fixture` — **optional** path to a KNOWN-CLEAN control subject (#1346). When present the
169
+ node-test prover also runs the check against it and requires GREEN, proving the violation's RED is
170
+ caused by the subject's *content* (not merely by `GSD_PROHIB_SUBJECT` being set). Absent → no control.
168
171
 
169
172
  The flat-scalar shape is load-bearing: the shared `parseMustHavesBlock` is a flat parser and a
170
173
  nested object would flatten/mangle the round-trip (ADR-550 2026-06-15 addendum; #644 "no parser
171
174
  rewrite" precedent). `projectProhibitions` emits these keys **only for a well-formed descriptor**
172
175
  (valid `check_kind` + non-empty `check_target`; `check_rule` only on the lint-rule path;
173
- `check_violation_fixture` only when non-empty), and verify-phase reads them back via
174
- `descriptorFromProjection` into the `CheckDescriptor` handed to `check prohibition-enforcement`. This
175
- closes **both** the locate (#1278) and the machine-proof-fixture (#1346) halves with **zero manual
176
- descriptor authoring**: a prohibition authored with all four scalars greens end-to-end through the
177
- projection alone.
176
+ `check_violation_fixture` and `check_clean_fixture` only when non-empty), and verify-phase reads them
177
+ back via `descriptorFromProjection` into the `CheckDescriptor` handed to `check prohibition-enforcement`.
178
+ This closes the locate (#1278), the machine-proof-fixture (#1279), and the causation-control (#1346)
179
+ halves with **zero manual descriptor authoring**: a prohibition authored with the scalars greens
180
+ end-to-end through the projection alone.
178
181
 
179
182
  **Fail-closed + backward-compat.** A partial descriptor (`lint-rule` missing `check_rule`), an
180
183
  unknown `check_kind`, an **absent** descriptor, OR a descriptor with **no `check_violation_fixture`**
@@ -182,8 +185,11 @@ falls through to the producer's fail-closed paths (`located: false`, or located-
182
185
  never a silent green. A prohibition with no descriptor parses and disposes byte-identically to today.
183
186
  `failFirst` is **not** sourced from the descriptor and is **demoted** (machine-proven fail-first
184
187
  DELIVERED in #1279 — no path greens on attestation alone, FF-08); the `dispositionForProhibition`
185
- policy is unchanged. Residual (tracked **#1346**): the node-test proof confirms the fixture exists and
186
- the check goes RED, but cannot generically prove the red was *caused by* the subject's content.
188
+ policy is unchanged. Causation (**#1346**): the node-test proof confirms the fixture exists and the
189
+ check goes RED; supplying `check_clean_fixture` adds an opt-in control that *also* requires GREEN on a
190
+ known-clean subject, proving the red is content-caused. With no clean fixture the control cannot run,
191
+ so that one residual case (a deceptive test reding merely because the env var is set) stays a
192
+ documented constraint — an author opts into the stronger proof by wiring a clean control subject.
187
193
 
188
194
  ## Output schema
189
195
 
@@ -191,7 +197,7 @@ The probe emits, per kept prohibition, an item of the form:
191
197
 
192
198
  ```
193
199
  { requirement_id, category, status, verification, resolution, reason, statement,
194
- check_kind?, check_target?, check_rule? }
200
+ check_kind?, check_target?, check_rule?, check_violation_fixture?, check_clean_fixture? }
195
201
  ```
196
202
 
197
203
  where `statement` is the must-NOT sentence and `category` is the values/safety/ethics class