@opengsd/gsd-core 1.19.0 → 1.20.1

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 (131) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.compact.md +5 -5
  4. package/agents/gsd-code-fixer.md +9 -4
  5. package/agents/gsd-code-reviewer.compact.md +49 -8
  6. package/agents/gsd-code-reviewer.md +53 -14
  7. package/agents/gsd-executor.md +8 -4
  8. package/bin/install.js +36 -40
  9. package/commands/gsd/new-milestone.md +2 -2
  10. package/commands/gsd/new-project.md +2 -2
  11. package/commands/gsd/update.md +5 -2
  12. package/gsd-core/bin/gsd-tools.cjs +177 -93
  13. package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
  14. package/gsd-core/bin/lib/capability-registry.cjs +284 -93
  15. package/gsd-core/bin/lib/capability-validator.cjs +15 -0
  16. package/gsd-core/bin/lib/commands.cjs +46 -17
  17. package/gsd-core/bin/lib/config-loader.cjs +3 -3
  18. package/gsd-core/bin/lib/core-utils.cjs +11 -0
  19. package/gsd-core/bin/lib/frontmatter.cjs +21 -3
  20. package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
  21. package/gsd-core/bin/lib/gate-context-drift.cjs +1 -1
  22. package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
  23. package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
  24. package/gsd-core/bin/lib/git-info-exclude.cjs +1 -6
  25. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  26. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
  27. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
  28. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
  29. package/gsd-core/bin/lib/init.cjs +181 -68
  30. package/gsd-core/bin/lib/install-engine.cjs +9 -9
  31. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  32. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  33. package/gsd-core/bin/lib/io.cjs +6 -0
  34. package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -1
  35. package/gsd-core/bin/lib/markdown-table.cjs +23 -0
  36. package/gsd-core/bin/lib/mcp-server.cjs +8 -5
  37. package/gsd-core/bin/lib/milestone.cjs +18 -50
  38. package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
  39. package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
  40. package/gsd-core/bin/lib/phase-id.cjs +627 -36
  41. package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
  42. package/gsd-core/bin/lib/phase-locator.cjs +328 -19
  43. package/gsd-core/bin/lib/phase.cjs +3039 -666
  44. package/gsd-core/bin/lib/planning-document.cjs +26 -5
  45. package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
  46. package/gsd-core/bin/lib/planning-snapshot.cjs +12 -7
  47. package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
  48. package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
  49. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
  50. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
  51. package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
  52. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
  53. package/gsd-core/bin/lib/roadmap-parser.cjs +759 -144
  54. package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
  55. package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
  56. package/gsd-core/bin/lib/roadmap.cjs +358 -106
  57. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -130
  58. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  59. package/gsd-core/bin/lib/shell-command-projection.cjs +36 -9
  60. package/gsd-core/bin/lib/smart-entry.cjs +22 -11
  61. package/gsd-core/bin/lib/state-contract.cjs +11 -7
  62. package/gsd-core/bin/lib/state-document.cjs +332 -23
  63. package/gsd-core/bin/lib/state-transition.cjs +51 -15
  64. package/gsd-core/bin/lib/state.cjs +499 -180
  65. package/gsd-core/bin/lib/template.cjs +2 -2
  66. package/gsd-core/bin/lib/validate.cjs +3 -2
  67. package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
  68. package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
  69. package/gsd-core/bin/lib/verify.cjs +2 -2
  70. package/gsd-core/bin/lib/workstream-inventory.cjs +27 -21
  71. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  72. package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
  73. package/gsd-core/references/cicd-review-patterns.md +45 -0
  74. package/gsd-core/references/necessity-review.md +48 -0
  75. package/gsd-core/workflows/code-review-fix.md +46 -5
  76. package/gsd-core/workflows/code-review.md +3 -0
  77. package/gsd-core/workflows/execute-phase.md +1 -1
  78. package/gsd-core/workflows/execute-plan.md +1 -1
  79. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
  80. package/gsd-core/workflows/plan-phase.md +1 -1
  81. package/gsd-core/workflows/pr-branch.md +2 -0
  82. package/gsd-core/workflows/reapply-patches.md +104 -0
  83. package/gsd-core/workflows/review.md +54 -141
  84. package/gsd-core/workflows/secure-phase.md +1 -1
  85. package/gsd-core/workflows/settings-advanced.md +57 -9
  86. package/gsd-core/workflows/ship.md +12 -0
  87. package/hooks/dist/gsd-agent-isolation-guard.js +1 -1
  88. package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
  89. package/hooks/dist/gsd-graphify-update.sh +6 -0
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
  92. package/hooks/dist/gsd-workflow-guard.js +2 -1
  93. package/hooks/dist/gsd-worktree-path-guard.js +4 -4
  94. package/hooks/dist/lib/git-probe.js +16 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +77 -2
  96. package/hooks/gsd-agent-isolation-guard.js +1 -1
  97. package/hooks/gsd-cursor-subagent-start.js +1 -1
  98. package/hooks/gsd-graphify-update.sh +6 -0
  99. package/hooks/gsd-statusline.js +3 -2
  100. package/hooks/gsd-windsurf-pre-write.js +4 -4
  101. package/hooks/gsd-workflow-guard.js +2 -1
  102. package/hooks/gsd-worktree-path-guard.js +4 -4
  103. package/hooks/lib/git-probe.js +16 -0
  104. package/hooks/lib/isolation-sentinel.js +77 -2
  105. package/package.json +3 -3
  106. package/pi/package-agents/gsd-code-fixer.md +9 -4
  107. package/pi/package-agents/gsd-code-reviewer.md +53 -14
  108. package/pi/package-agents/gsd-executor.md +8 -4
  109. package/pi/package-agents/manifest.json +3 -3
  110. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  111. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  112. package/pi/package-skills/gsd-update/SKILL.md +4 -1
  113. package/scripts/changeset/lint.cjs +28 -3
  114. package/scripts/ci-next-health.cjs +246 -23
  115. package/scripts/docs-guard-registry.cjs +2 -0
  116. package/scripts/gen-features.cjs +14 -2
  117. package/scripts/lib/drift-scan.cjs +27 -0
  118. package/scripts/lib/macos-conformance-tier.generated.cjs +10 -0
  119. package/scripts/lib/platform-conformance-tier.generated.cjs +15 -0
  120. package/scripts/lint-phase-enumeration-drift.cjs +8 -4
  121. package/scripts/lint-roadmap-progress-drift.cjs +421 -0
  122. package/scripts/merge-commit-pr.cjs +186 -0
  123. package/scripts/mutation-matrix.cjs +23 -1
  124. package/scripts/release-ancestry.cjs +86 -0
  125. package/scripts/release-pipeline-pr.cjs +92 -0
  126. package/scripts/require-issue-link-policy.cjs +18 -9
  127. package/scripts/sync-next-version.cjs +48 -7
  128. package/skills/gsd-new-milestone/SKILL.md +1 -1
  129. package/skills/gsd-new-project/SKILL.md +1 -1
  130. package/skills/gsd-update/SKILL.md +5 -2
  131. package/vscode/package.json +1 -1
@@ -31,7 +31,7 @@ const fs = require('fs');
31
31
  const path = require('path');
32
32
  const { spawnSync } = require('child_process');
33
33
  const { allow, deny } = require('./lib/hook-exit.js');
34
- const { reportIfUndetermined, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib/git-probe.js');
34
+ const { reportIfUndetermined, gitEnv, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib/git-probe.js');
35
35
 
36
36
  // #3911 (ADR-3889 Phase 7): the exit(2) call site (block(), below) is
37
37
  // migrated to hook-exit.js's deny(undefined, reason) — see
@@ -64,10 +64,10 @@ const SPAWNOPT = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeou
64
64
  // Parity: tests/git-optional-locks-parity.test.cjs.
65
65
  // A raw plugin-marketplace or git-clone install never ran `npm run build:lib`
66
66
  // (#3582), so the compiled owner can be absent. This guard only runs
67
- // `git rev-parse`, which never opens the index, so the unmodified environment
67
+ // `git rev-parse`, which never opens the index, so the scrubbed gitEnv() base
68
68
  // loses nothing; requiring the artifact unguarded would throw at load and the
69
69
  // containment guard would silently stop running.
70
- let readOnlyGitEnv = () => process.env;
70
+ let readOnlyGitEnv = (base = process.env) => base;
71
71
  try {
72
72
  const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');
73
73
  ensureRuntimeBuild();
@@ -77,7 +77,7 @@ try {
77
77
  }
78
78
 
79
79
  function git(args, cwd) {
80
- return spawnSync('git', args, { ...SPAWNOPT, cwd, env: readOnlyGitEnv() });
80
+ return spawnSync('git', args, { ...SPAWNOPT, cwd, env: readOnlyGitEnv(gitEnv()) });
81
81
  }
82
82
 
83
83
  // Walk up from `start` to find the nearest existing DIRECTORY (not merely an
@@ -22,7 +22,7 @@ const path = require('path');
22
22
  const { spawnSync } = require('child_process');
23
23
  const { tokenize, skipToSubcommand } = require('./lib/git-cmd.js');
24
24
  const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js');
25
- const { reportIfUndetermined, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib/git-probe.js');
25
+ const { reportIfUndetermined, gitEnv, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib/git-probe.js');
26
26
 
27
27
  // This guard is almost entirely advisory (fail open — a broken advisory must
28
28
  // never wedge every tool call), with ONE hard block (#3504 force-add-on-
@@ -83,6 +83,7 @@ function forceGitAddCwds(command, defaultCwd) {
83
83
  function currentBranch(cwd) {
84
84
  const result = spawnSync('git', ['branch', '--show-current'], { // allow-index-refresh: reads HEAD only, never opens the index
85
85
  cwd,
86
+ env: gitEnv(),
86
87
  encoding: 'utf8',
87
88
  stdio: ['ignore', 'pipe', 'ignore'],
88
89
  windowsHide: true,
@@ -18,7 +18,7 @@ const fs = require('fs');
18
18
  const path = require('path');
19
19
  const { spawnSync } = require('child_process');
20
20
  const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js');
21
- const { reportIfUndetermined, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib/git-probe.js');
21
+ const { reportIfUndetermined, gitEnv, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib/git-probe.js');
22
22
 
23
23
  // This guard's outer catch has always exited 0 (fail open): a path guard that
24
24
  // cannot resolve the worktree must not block the user's edit — its whole job
@@ -49,10 +49,10 @@ const SPAWNOPT = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeou
49
49
  // Parity: tests/git-optional-locks-parity.test.cjs.
50
50
  // A raw plugin-marketplace or git-clone install never ran `npm run build:lib`
51
51
  // (#3582), so the compiled owner can be absent. This guard only runs
52
- // `git rev-parse`, which never opens the index, so the unmodified environment
52
+ // `git rev-parse`, which never opens the index, so the scrubbed gitEnv() base
53
53
  // loses nothing; requiring the artifact unguarded would throw at load and the
54
54
  // containment guard would silently stop running.
55
- let readOnlyGitEnv = () => process.env;
55
+ let readOnlyGitEnv = (base = process.env) => base;
56
56
  try {
57
57
  const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');
58
58
  ensureRuntimeBuild();
@@ -62,7 +62,7 @@ try {
62
62
  }
63
63
 
64
64
  function git(args, cwd) {
65
- return spawnSync('git', args, { ...SPAWNOPT, cwd, env: readOnlyGitEnv() });
65
+ return spawnSync('git', args, { ...SPAWNOPT, cwd, env: readOnlyGitEnv(gitEnv()) });
66
66
  }
67
67
 
68
68
  // Walk up from `start` to find the nearest existing directory.
@@ -92,7 +92,23 @@ function reportIfUndetermined(hookName, probeLabel, result) {
92
92
  const BLOCKING_GUARD_PROBE_TIMEOUT_MS = 5000;
93
93
  const BLOCKING_GUARD_MAX_SEQUENTIAL_PROBES = 3;
94
94
 
95
+ // #5548: repo-location vars git sets in hooks; inherited, they override the
96
+ // probe's cwd/-C. Parity-tested copy of GIT_LOCATION_ENV_VARS in
97
+ // src/shell-command-projection.cts (hooks ship standalone and cannot import it).
98
+ const GIT_LOCATION_ENV_VARS = [
99
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_OBJECT_DIRECTORY', 'GIT_DIR', 'GIT_WORK_TREE',
100
+ 'GIT_IMPLICIT_WORK_TREE', 'GIT_GRAFT_FILE', 'GIT_INDEX_FILE', 'GIT_NO_REPLACE_OBJECTS',
101
+ 'GIT_REPLACE_REF_BASE', 'GIT_PREFIX', 'GIT_SHALLOW_FILE', 'GIT_COMMON_DIR',
102
+ ];
103
+
104
+ /** `env` (default process.env) minus the repo-location vars (case-insensitive, for Windows). */
105
+ function gitEnv(env = process.env) {
106
+ return Object.fromEntries(Object.entries(env).filter(([k]) => !GIT_LOCATION_ENV_VARS.includes(k.toUpperCase())));
107
+ }
108
+
95
109
  module.exports = {
110
+ GIT_LOCATION_ENV_VARS,
111
+ gitEnv,
96
112
  classifyGitProbe,
97
113
  reportIfUndetermined,
98
114
  BLOCKING_GUARD_PROBE_TIMEOUT_MS,
@@ -29,6 +29,12 @@
29
29
  // of the same repo are separate directories on disk, so each gets its own
30
30
  // sentinel with no cross-worktree collision, no lock file needed.
31
31
  //
32
+ // #5682: the single slot above is last-writer-wins, so two orchestrators sharing one checkout
33
+ // overwrote each other's decision and the guard discarded the foreign record. The writer now ALSO
34
+ // records each plan-scoped decision at `<cwd>/.gsd/dispatch-isolation/<phase>--<plan>.json`
35
+ // (same payload, same cwd-derived location and forgery posture), and `readSentinel` consults the
36
+ // record keyed by the dispatch's own phase/plan first, falling back to the single slot.
37
+ //
32
38
  // #3045 SECURITY F3 (LOW, accepted risk — documented, not redesigned for):
33
39
  // the sentinel lives at a fixed, predictable, gitignored path inside the
34
40
  // PRIMARY checkout. An agent already running inside a legitimately isolated
@@ -64,6 +70,31 @@ const VALID_ISOLATION = new Set(['harness-worktree', 'orchestrator-worktree', 'n
64
70
 
65
71
  const SENTINEL_RELATIVE_PATH = path.join('.gsd', 'dispatch-isolation-sentinel.json');
66
72
 
73
+ // #5682: per-dispatch records, one file per `<phase>--<plan>` under this directory.
74
+ const KEYED_SENTINEL_DIR = path.join('.gsd', 'dispatch-isolation');
75
+ const SAFE_SENTINEL_KEY_RE = /^[A-Za-z0-9._-]{1,100}$/;
76
+
77
+ /** #5682: strict allow-list for a phase/plan string that becomes a path segment. */
78
+ function isSafeSentinelKey(s) {
79
+ return typeof s === 'string' && SAFE_SENTINEL_KEY_RE.test(s) && s !== '.' && s !== '..';
80
+ }
81
+
82
+ /** #5682: keyed record path for a dispatch, or null unless BOTH phase and plan are safe keys. */
83
+ function keyedSentinelPath(root, phase, plan) {
84
+ if (!isSafeSentinelKey(phase) || !isSafeSentinelKey(plan)) return null;
85
+ return path.join(root, KEYED_SENTINEL_DIR, `${phase}--${plan}.json`);
86
+ }
87
+
88
+ /**
89
+ * #5682: phase-level keyed record path (`<phase>.json`, written when a decision carries no plan),
90
+ * or null unless the phase is a safe key. Two orchestrators recording plan-less decisions for
91
+ * different phases each keep their own file.
92
+ */
93
+ function phaseSentinelPath(root, phase) {
94
+ if (!isSafeSentinelKey(phase)) return null;
95
+ return path.join(root, KEYED_SENTINEL_DIR, `${phase}.json`);
96
+ }
97
+
67
98
  // #3045 SECURITY F2 fix: how long a written sentinel is trusted as "this
68
99
  // dispatch's decision" before a reader falls back to the conservative
69
100
  // registry+config check.
@@ -174,11 +205,51 @@ function resolveSentinelRoot(cwd) {
174
205
  * per the repo's clock-seam convention, so staleness is testable without
175
206
  * asserting on wall-clock time.
176
207
  */
177
- function readSentinel(cwd, { clock = Date } = {}) {
208
+ function readSentinel(cwd, { clock = Date, dispatchIds = null } = {}) {
178
209
  const root = resolveSentinelRoot(cwd);
210
+
211
+ // #5682: candidates are the record keyed by this dispatch's own phase/plan, the phase-level
212
+ // record for its phase, and the single legacy slot. A keyed candidate counts only when it is
213
+ // well-formed and its contents agree with its own filename key; the legacy slot only when it
214
+ // applies to this dispatch. Among FRESH candidates the latest `writtenAt` wins, so a newer
215
+ // phase-level decision is not shadowed by an older plan-scoped one (and vice versa).
216
+ const keyedCandidates = [];
217
+ if (dispatchIds) {
218
+ const planFile = keyedSentinelPath(root, dispatchIds.phase, dispatchIds.plan);
219
+ if (planFile) {
220
+ const c = parseSentinelFile(planFile, clock);
221
+ if (c.present && !c.malformed && c.phase === dispatchIds.phase && c.plan === dispatchIds.plan) keyedCandidates.push(c);
222
+ }
223
+ const phaseFile = phaseSentinelPath(root, dispatchIds.phase);
224
+ if (phaseFile) {
225
+ const c = parseSentinelFile(phaseFile, clock);
226
+ if (c.present && !c.malformed && c.phase === dispatchIds.phase && c.plan === null) keyedCandidates.push(c);
227
+ }
228
+ }
229
+
230
+ const legacy = parseSentinelFile(sentinelPath(root), clock);
231
+ const legacyApplies = legacy.present && !legacy.malformed && sentinelAppliesToDispatch(legacy, dispatchIds);
232
+
233
+ const fresh = keyedCandidates.filter((c) => !c.stale);
234
+ if (legacyApplies && !legacy.stale) fresh.push(legacy);
235
+ if (fresh.length > 0) return latestWritten(fresh);
236
+
237
+ // Nothing fresh: a stale keyed record still feeds the aged-none path; otherwise the legacy
238
+ // slot is returned exactly as before.
239
+ if (keyedCandidates.length > 0) return latestWritten(keyedCandidates);
240
+ return legacy;
241
+ }
242
+
243
+ /** The candidate with the greatest `writtenAt` (ties keep the earlier-listed, i.e. most specific). */
244
+ function latestWritten(candidates) {
245
+ return candidates.reduce((best, c) => (c.writtenAt > best.writtenAt ? c : best));
246
+ }
247
+
248
+ /** Parse and validate one sentinel file. Never throws. Shape: see `readSentinel`. */
249
+ function parseSentinelFile(file, clock) {
179
250
  let raw;
180
251
  try {
181
- raw = fs.readFileSync(sentinelPath(root), 'utf-8');
252
+ raw = fs.readFileSync(file, 'utf-8');
182
253
  } catch {
183
254
  return { present: false };
184
255
  }
@@ -348,6 +419,10 @@ function buildSentinelDiscard(sentinel, dispatchIds) {
348
419
  module.exports = {
349
420
  VALID_ISOLATION,
350
421
  SENTINEL_RELATIVE_PATH,
422
+ KEYED_SENTINEL_DIR,
423
+ isSafeSentinelKey,
424
+ keyedSentinelPath,
425
+ phaseSentinelPath,
351
426
  SENTINEL_STALE_MS,
352
427
  AGED_NONE_MAX_MS,
353
428
  sentinelPath,
@@ -422,7 +422,7 @@ function resolveIsolationState(cwd, { clock = Date, dispatchIds = null } = {}) {
422
422
  return { gsdProject: false, isolation: null, harnessFlag: null, error: null, sentinelDiscarded: null };
423
423
  }
424
424
 
425
- const sentinel = readSentinel(cwd, { clock });
425
+ const sentinel = readSentinel(cwd, { clock, dispatchIds });
426
426
  // Hoisted so the "sentinel was present/fresh but did not apply" case below
427
427
  // (#4594 row 15 — Postel's-Law finding) can distinguish itself from
428
428
  // "absent"/"stale" without re-deriving applicability.
@@ -512,7 +512,7 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds =
512
512
  // Hoisted (readSentinel never throws) so the "present, fresh, but did not
513
513
  // apply" case (#4594 row 15) can be reported on every deny path below
514
514
  // instead of silently discarded.
515
- const sentinel = readSentinel(root, { clock });
515
+ const sentinel = readSentinel(root, { clock, dispatchIds });
516
516
  const applies = sentinelAppliesToDispatch(sentinel, dispatchIds);
517
517
  const sentinelDiscarded = buildSentinelDiscard(sentinel, dispatchIds);
518
518
 
@@ -81,6 +81,12 @@ case "$COMMAND" in
81
81
  *) exit 0 ;;
82
82
  esac
83
83
 
84
+ # #5548: inherited repo-location vars (GIT_DIR, GIT_INDEX_FILE, ...) would override
85
+ # cwd for every git call below. Drop git's own list minus the config-injection vars.
86
+ for v in $(git rev-parse --local-env-vars 2>/dev/null); do
87
+ case "$v" in GIT_CONFIG|GIT_CONFIG_PARAMETERS|GIT_CONFIG_COUNT) ;; *) unset "$v" ;; esac
88
+ done
89
+
84
90
  # Gate 3 — inside git repo
85
91
  git rev-parse --git-dir >/dev/null 2>&1 || exit 0
86
92
 
@@ -18,6 +18,7 @@ const os = require('os');
18
18
  // monkeypatching childProcess.execFileSync.
19
19
  const childProcess = require('child_process');
20
20
  const { HOOK_ON_CRASH, allow, crash } = require('./lib/hook-exit.js');
21
+ const { gitEnv } = require('./lib/git-probe.js');
21
22
 
22
23
  // This hook's build-seam outer catch (require.main guard just below) has
23
24
  // always exited 0 (fail open — the statusline renders on EVERY prompt, so a
@@ -934,7 +935,7 @@ function readGitStatus(dir) {
934
935
  // or untracked files; overflow still degrades safely to segment-absent via
935
936
  // the catch below.
936
937
  return childProcess.execFileSync('git', ['-C', dir, 'status', '--porcelain=v2', '--branch'],
937
- { encoding: 'utf8', timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, env: readOnlyGitEnv() });
938
+ { encoding: 'utf8', timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, env: readOnlyGitEnv(gitEnv()) });
938
939
  } catch (e) {
939
940
  return null;
940
941
  }
@@ -1029,7 +1030,7 @@ function readStateHeadCommits(root, stamp) {
1029
1030
  try {
1030
1031
  return childProcess.execFileSync('git', // allow-index-refresh: rev-list walks the commit graph and never opens the index
1031
1032
  ['-C', root, 'rev-list', '--left-right', '--count', `${stamp}...HEAD`],
1032
- { encoding: 'utf8', timeout: STATE_FRESHNESS_GIT_TIMEOUT_MS,
1033
+ { env: gitEnv(), encoding: 'utf8', timeout: STATE_FRESHNESS_GIT_TIMEOUT_MS,
1033
1034
  stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
1034
1035
  } catch (e) {
1035
1036
  return null;
@@ -31,7 +31,7 @@ const fs = require('fs');
31
31
  const path = require('path');
32
32
  const { spawnSync } = require('child_process');
33
33
  const { allow, deny } = require('./lib/hook-exit.js');
34
- const { reportIfUndetermined, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib/git-probe.js');
34
+ const { reportIfUndetermined, gitEnv, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib/git-probe.js');
35
35
 
36
36
  // #3911 (ADR-3889 Phase 7): the exit(2) call site (block(), below) is
37
37
  // migrated to hook-exit.js's deny(undefined, reason) — see
@@ -64,10 +64,10 @@ const SPAWNOPT = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeou
64
64
  // Parity: tests/git-optional-locks-parity.test.cjs.
65
65
  // A raw plugin-marketplace or git-clone install never ran `npm run build:lib`
66
66
  // (#3582), so the compiled owner can be absent. This guard only runs
67
- // `git rev-parse`, which never opens the index, so the unmodified environment
67
+ // `git rev-parse`, which never opens the index, so the scrubbed gitEnv() base
68
68
  // loses nothing; requiring the artifact unguarded would throw at load and the
69
69
  // containment guard would silently stop running.
70
- let readOnlyGitEnv = () => process.env;
70
+ let readOnlyGitEnv = (base = process.env) => base;
71
71
  try {
72
72
  const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');
73
73
  ensureRuntimeBuild();
@@ -77,7 +77,7 @@ try {
77
77
  }
78
78
 
79
79
  function git(args, cwd) {
80
- return spawnSync('git', args, { ...SPAWNOPT, cwd, env: readOnlyGitEnv() });
80
+ return spawnSync('git', args, { ...SPAWNOPT, cwd, env: readOnlyGitEnv(gitEnv()) });
81
81
  }
82
82
 
83
83
  // Walk up from `start` to find the nearest existing DIRECTORY (not merely an
@@ -22,7 +22,7 @@ const path = require('path');
22
22
  const { spawnSync } = require('child_process');
23
23
  const { tokenize, skipToSubcommand } = require('./lib/git-cmd.js');
24
24
  const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js');
25
- const { reportIfUndetermined, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib/git-probe.js');
25
+ const { reportIfUndetermined, gitEnv, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib/git-probe.js');
26
26
 
27
27
  // This guard is almost entirely advisory (fail open — a broken advisory must
28
28
  // never wedge every tool call), with ONE hard block (#3504 force-add-on-
@@ -83,6 +83,7 @@ function forceGitAddCwds(command, defaultCwd) {
83
83
  function currentBranch(cwd) {
84
84
  const result = spawnSync('git', ['branch', '--show-current'], { // allow-index-refresh: reads HEAD only, never opens the index
85
85
  cwd,
86
+ env: gitEnv(),
86
87
  encoding: 'utf8',
87
88
  stdio: ['ignore', 'pipe', 'ignore'],
88
89
  windowsHide: true,
@@ -18,7 +18,7 @@ const fs = require('fs');
18
18
  const path = require('path');
19
19
  const { spawnSync } = require('child_process');
20
20
  const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js');
21
- const { reportIfUndetermined, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib/git-probe.js');
21
+ const { reportIfUndetermined, gitEnv, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib/git-probe.js');
22
22
 
23
23
  // This guard's outer catch has always exited 0 (fail open): a path guard that
24
24
  // cannot resolve the worktree must not block the user's edit — its whole job
@@ -49,10 +49,10 @@ const SPAWNOPT = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeou
49
49
  // Parity: tests/git-optional-locks-parity.test.cjs.
50
50
  // A raw plugin-marketplace or git-clone install never ran `npm run build:lib`
51
51
  // (#3582), so the compiled owner can be absent. This guard only runs
52
- // `git rev-parse`, which never opens the index, so the unmodified environment
52
+ // `git rev-parse`, which never opens the index, so the scrubbed gitEnv() base
53
53
  // loses nothing; requiring the artifact unguarded would throw at load and the
54
54
  // containment guard would silently stop running.
55
- let readOnlyGitEnv = () => process.env;
55
+ let readOnlyGitEnv = (base = process.env) => base;
56
56
  try {
57
57
  const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');
58
58
  ensureRuntimeBuild();
@@ -62,7 +62,7 @@ try {
62
62
  }
63
63
 
64
64
  function git(args, cwd) {
65
- return spawnSync('git', args, { ...SPAWNOPT, cwd, env: readOnlyGitEnv() });
65
+ return spawnSync('git', args, { ...SPAWNOPT, cwd, env: readOnlyGitEnv(gitEnv()) });
66
66
  }
67
67
 
68
68
  // Walk up from `start` to find the nearest existing directory.
@@ -92,7 +92,23 @@ function reportIfUndetermined(hookName, probeLabel, result) {
92
92
  const BLOCKING_GUARD_PROBE_TIMEOUT_MS = 5000;
93
93
  const BLOCKING_GUARD_MAX_SEQUENTIAL_PROBES = 3;
94
94
 
95
+ // #5548: repo-location vars git sets in hooks; inherited, they override the
96
+ // probe's cwd/-C. Parity-tested copy of GIT_LOCATION_ENV_VARS in
97
+ // src/shell-command-projection.cts (hooks ship standalone and cannot import it).
98
+ const GIT_LOCATION_ENV_VARS = [
99
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_OBJECT_DIRECTORY', 'GIT_DIR', 'GIT_WORK_TREE',
100
+ 'GIT_IMPLICIT_WORK_TREE', 'GIT_GRAFT_FILE', 'GIT_INDEX_FILE', 'GIT_NO_REPLACE_OBJECTS',
101
+ 'GIT_REPLACE_REF_BASE', 'GIT_PREFIX', 'GIT_SHALLOW_FILE', 'GIT_COMMON_DIR',
102
+ ];
103
+
104
+ /** `env` (default process.env) minus the repo-location vars (case-insensitive, for Windows). */
105
+ function gitEnv(env = process.env) {
106
+ return Object.fromEntries(Object.entries(env).filter(([k]) => !GIT_LOCATION_ENV_VARS.includes(k.toUpperCase())));
107
+ }
108
+
95
109
  module.exports = {
110
+ GIT_LOCATION_ENV_VARS,
111
+ gitEnv,
96
112
  classifyGitProbe,
97
113
  reportIfUndetermined,
98
114
  BLOCKING_GUARD_PROBE_TIMEOUT_MS,
@@ -29,6 +29,12 @@
29
29
  // of the same repo are separate directories on disk, so each gets its own
30
30
  // sentinel with no cross-worktree collision, no lock file needed.
31
31
  //
32
+ // #5682: the single slot above is last-writer-wins, so two orchestrators sharing one checkout
33
+ // overwrote each other's decision and the guard discarded the foreign record. The writer now ALSO
34
+ // records each plan-scoped decision at `<cwd>/.gsd/dispatch-isolation/<phase>--<plan>.json`
35
+ // (same payload, same cwd-derived location and forgery posture), and `readSentinel` consults the
36
+ // record keyed by the dispatch's own phase/plan first, falling back to the single slot.
37
+ //
32
38
  // #3045 SECURITY F3 (LOW, accepted risk — documented, not redesigned for):
33
39
  // the sentinel lives at a fixed, predictable, gitignored path inside the
34
40
  // PRIMARY checkout. An agent already running inside a legitimately isolated
@@ -64,6 +70,31 @@ const VALID_ISOLATION = new Set(['harness-worktree', 'orchestrator-worktree', 'n
64
70
 
65
71
  const SENTINEL_RELATIVE_PATH = path.join('.gsd', 'dispatch-isolation-sentinel.json');
66
72
 
73
+ // #5682: per-dispatch records, one file per `<phase>--<plan>` under this directory.
74
+ const KEYED_SENTINEL_DIR = path.join('.gsd', 'dispatch-isolation');
75
+ const SAFE_SENTINEL_KEY_RE = /^[A-Za-z0-9._-]{1,100}$/;
76
+
77
+ /** #5682: strict allow-list for a phase/plan string that becomes a path segment. */
78
+ function isSafeSentinelKey(s) {
79
+ return typeof s === 'string' && SAFE_SENTINEL_KEY_RE.test(s) && s !== '.' && s !== '..';
80
+ }
81
+
82
+ /** #5682: keyed record path for a dispatch, or null unless BOTH phase and plan are safe keys. */
83
+ function keyedSentinelPath(root, phase, plan) {
84
+ if (!isSafeSentinelKey(phase) || !isSafeSentinelKey(plan)) return null;
85
+ return path.join(root, KEYED_SENTINEL_DIR, `${phase}--${plan}.json`);
86
+ }
87
+
88
+ /**
89
+ * #5682: phase-level keyed record path (`<phase>.json`, written when a decision carries no plan),
90
+ * or null unless the phase is a safe key. Two orchestrators recording plan-less decisions for
91
+ * different phases each keep their own file.
92
+ */
93
+ function phaseSentinelPath(root, phase) {
94
+ if (!isSafeSentinelKey(phase)) return null;
95
+ return path.join(root, KEYED_SENTINEL_DIR, `${phase}.json`);
96
+ }
97
+
67
98
  // #3045 SECURITY F2 fix: how long a written sentinel is trusted as "this
68
99
  // dispatch's decision" before a reader falls back to the conservative
69
100
  // registry+config check.
@@ -174,11 +205,51 @@ function resolveSentinelRoot(cwd) {
174
205
  * per the repo's clock-seam convention, so staleness is testable without
175
206
  * asserting on wall-clock time.
176
207
  */
177
- function readSentinel(cwd, { clock = Date } = {}) {
208
+ function readSentinel(cwd, { clock = Date, dispatchIds = null } = {}) {
178
209
  const root = resolveSentinelRoot(cwd);
210
+
211
+ // #5682: candidates are the record keyed by this dispatch's own phase/plan, the phase-level
212
+ // record for its phase, and the single legacy slot. A keyed candidate counts only when it is
213
+ // well-formed and its contents agree with its own filename key; the legacy slot only when it
214
+ // applies to this dispatch. Among FRESH candidates the latest `writtenAt` wins, so a newer
215
+ // phase-level decision is not shadowed by an older plan-scoped one (and vice versa).
216
+ const keyedCandidates = [];
217
+ if (dispatchIds) {
218
+ const planFile = keyedSentinelPath(root, dispatchIds.phase, dispatchIds.plan);
219
+ if (planFile) {
220
+ const c = parseSentinelFile(planFile, clock);
221
+ if (c.present && !c.malformed && c.phase === dispatchIds.phase && c.plan === dispatchIds.plan) keyedCandidates.push(c);
222
+ }
223
+ const phaseFile = phaseSentinelPath(root, dispatchIds.phase);
224
+ if (phaseFile) {
225
+ const c = parseSentinelFile(phaseFile, clock);
226
+ if (c.present && !c.malformed && c.phase === dispatchIds.phase && c.plan === null) keyedCandidates.push(c);
227
+ }
228
+ }
229
+
230
+ const legacy = parseSentinelFile(sentinelPath(root), clock);
231
+ const legacyApplies = legacy.present && !legacy.malformed && sentinelAppliesToDispatch(legacy, dispatchIds);
232
+
233
+ const fresh = keyedCandidates.filter((c) => !c.stale);
234
+ if (legacyApplies && !legacy.stale) fresh.push(legacy);
235
+ if (fresh.length > 0) return latestWritten(fresh);
236
+
237
+ // Nothing fresh: a stale keyed record still feeds the aged-none path; otherwise the legacy
238
+ // slot is returned exactly as before.
239
+ if (keyedCandidates.length > 0) return latestWritten(keyedCandidates);
240
+ return legacy;
241
+ }
242
+
243
+ /** The candidate with the greatest `writtenAt` (ties keep the earlier-listed, i.e. most specific). */
244
+ function latestWritten(candidates) {
245
+ return candidates.reduce((best, c) => (c.writtenAt > best.writtenAt ? c : best));
246
+ }
247
+
248
+ /** Parse and validate one sentinel file. Never throws. Shape: see `readSentinel`. */
249
+ function parseSentinelFile(file, clock) {
179
250
  let raw;
180
251
  try {
181
- raw = fs.readFileSync(sentinelPath(root), 'utf-8');
252
+ raw = fs.readFileSync(file, 'utf-8');
182
253
  } catch {
183
254
  return { present: false };
184
255
  }
@@ -348,6 +419,10 @@ function buildSentinelDiscard(sentinel, dispatchIds) {
348
419
  module.exports = {
349
420
  VALID_ISOLATION,
350
421
  SENTINEL_RELATIVE_PATH,
422
+ KEYED_SENTINEL_DIR,
423
+ isSafeSentinelKey,
424
+ keyedSentinelPath,
425
+ phaseSentinelPath,
351
426
  SENTINEL_STALE_MS,
352
427
  AGED_NONE_MAX_MS,
353
428
  sentinelPath,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengsd/gsd-core",
3
- "version": "1.19.0",
3
+ "version": "1.20.1",
4
4
  "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
5
5
  "main": ".opencode/plugins/gsd-core.js",
6
6
  "bin": {
@@ -91,7 +91,7 @@
91
91
  "gpt-tokenizer": "4.0.0",
92
92
  "js-yaml": "^4.3.2",
93
93
  "mutation-testing-metrics": "^3.7.3",
94
- "re2js": "^2.8.6",
94
+ "re2js": "^2.8.7",
95
95
  "saxes": "6.0.0",
96
96
  "tap-parser": "18.3.4",
97
97
  "typescript": "^6.0.3",
@@ -146,7 +146,7 @@
146
146
  "lint:gate-positive-control": "node scripts/lint-gate-positive-control.cjs",
147
147
  "lint:frontmatter-fence-drift": "node scripts/lint-frontmatter-fence-drift.cjs",
148
148
  "lint:response-language": "node scripts/lint-response-language-coverage.cjs",
149
- "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && npm run lint:retired-runtime-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-portable-timeout.cjs && node scripts/lint-portable-grep.cjs && node scripts/lint-allowed-tools-parity.cjs && node scripts/lint-phase-arg-assignment.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-planning-document-positive-control.cjs && node scripts/lint-pr-branch-pattern-drift.cjs && node scripts/lint-fix-has-regression-tests.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-unreachable-guard-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-slug-derivation-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-state-write-path-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-health-diagnostic-rule-table.cjs && node scripts/lint-planning-artifact-writer-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs && node scripts/lint-no-adhoc-regex-escape.cjs && node scripts/lint-vendored-deps.cjs && node scripts/lint-docs-guard-registration.cjs && node scripts/lint-source-test-name-collision.cjs && npm run lint:hooks-runtime-build-seam && node scripts/check-contract-drift.cjs && node scripts/lint-mutation-test-derivation-drift.cjs && node scripts/lint-seam-enforcement.cjs && node scripts/lint-workflow-shellcheck.cjs && npm run lint:response-language && node scripts/lint-verify-lifecycle-writes.cjs && node scripts/lint-frontmatter-fence-drift.cjs && node scripts/lint-evaluation-scope-drift.cjs && node scripts/lint-gate-evidence-drift.cjs && node scripts/lint-gate-positive-control.cjs",
149
+ "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && npm run lint:retired-runtime-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-portable-timeout.cjs && node scripts/lint-portable-grep.cjs && node scripts/lint-allowed-tools-parity.cjs && node scripts/lint-phase-arg-assignment.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-planning-document-positive-control.cjs && node scripts/lint-pr-branch-pattern-drift.cjs && node scripts/lint-fix-has-regression-tests.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-unreachable-guard-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-slug-derivation-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-state-write-path-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-health-diagnostic-rule-table.cjs && node scripts/lint-planning-artifact-writer-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs && node scripts/lint-no-adhoc-regex-escape.cjs && node scripts/lint-vendored-deps.cjs && node scripts/lint-docs-guard-registration.cjs && node scripts/lint-source-test-name-collision.cjs && npm run lint:hooks-runtime-build-seam && node scripts/check-contract-drift.cjs && node scripts/lint-mutation-test-derivation-drift.cjs && node scripts/lint-seam-enforcement.cjs && node scripts/lint-workflow-shellcheck.cjs && npm run lint:response-language && node scripts/lint-verify-lifecycle-writes.cjs && node scripts/lint-frontmatter-fence-drift.cjs && node scripts/lint-evaluation-scope-drift.cjs && node scripts/lint-gate-evidence-drift.cjs && node scripts/lint-gate-positive-control.cjs && node scripts/lint-roadmap-progress-drift.cjs",
150
150
  "lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs",
151
151
  "lint:regression-names": "node scripts/lint-regression-test-names.cjs",
152
152
  "lint:descriptions": "node scripts/lint-descriptions.cjs",
@@ -157,6 +157,8 @@ Each finding starts with:
157
157
 
158
158
  Where ID matches: `CR-\d+` or `BL-\d+` (Critical-tier-equivalent), `WR-\d+` (Warning), or `IN-\d+` (Info)
159
159
 
160
+ A finding whose body carries a `**Category:** necessity` line is a **necessity finding** (#4100): code the reviewer judged unnecessary, not broken. Its prefix still gives its severity.
161
+
160
162
  **Required Fields:**
161
163
 
162
164
  - **File:** line contains primary file path
@@ -438,7 +440,7 @@ This cleanup is unconditional when a worktree was created — register it mental
438
440
  - `phase_dir`: Path to phase directory (e.g., `.planning/phases/02-code-review-command`)
439
441
  - `padded_phase`: Zero-padded phase number (e.g., "02")
440
442
  - `review_path`: Full path to REVIEW.md (e.g., `.planning/phases/02-code-review-command/02-REVIEW.md`)
441
- - `fix_scope`: "critical_warning" (default) or "all" (includes Info findings)
443
+ - `fix_scope`: "critical_warning" (default) or "all" (includes Info and necessity findings)
442
444
  - `fix_report_path`: Full path for REVIEW-FIX.md output (e.g., `.planning/phases/02-code-review-command/02-REVIEW-FIX.md`)
443
445
 
444
446
  **3. Read REVIEW.md:**
@@ -464,6 +466,7 @@ Read `./CLAUDE.md` and check for `.claude/skills/` or `.agents/skills/` (as desc
464
466
  For each finding, extract:
465
467
  - `id`: Finding identifier (e.g., CR-01, WR-03, IN-12)
466
468
  - `severity`: Critical (CR-* or BL-*), Warning (WR-*), Info (IN-*)
469
+ - `category`: `necessity` when the finding carries a `**Category:** necessity` line, otherwise none
467
470
  - `title`: Issue title from `### ` heading
468
471
  - `file`: Primary file path from **File:** line
469
472
  - `files`: ALL file paths referenced in finding (including in Fix section) — for multi-file fixes
@@ -472,15 +475,17 @@ For each finding, extract:
472
475
  - `fix`: Full fix content from **Fix:** section (may be multi-line, may contain code fences)
473
476
 
474
477
  **2. Filter by fix_scope:**
475
- - If `fix_scope == "critical_warning"`: include only CR-*, BL-*, and WR-* findings
476
- - If `fix_scope == "all"`: include CR-*, BL-*, WR-*, and IN-* findings
478
+ - If `fix_scope == "critical_warning"`: include only CR-*, BL-*, and WR-* findings; `critical_warning` excludes necessity findings (removing code is a scope decision for the human, #4100). The marker overrides the prefix: a WR-* finding carrying `**Category:** necessity` is OUT of scope here, and is skipped without applying any edit
479
+ - If `fix_scope == "all"`: include CR-*, BL-*, WR-*, and IN-* findings; `all` includes necessity findings, ordered last. A necessity fix removes code, so record it as `fixed: requires human verification`
477
480
 
478
481
  **3. Sort findings by severity:**
479
- - Critical (CR-* and BL-*) first, then Warning, then Info
482
+ - Critical (CR-* and BL-*) first, then Warning, then Info, then necessity findings
480
483
  - Within same severity, maintain document order
481
484
 
482
485
  **4. Count findings in scope:**
483
486
  Record `findings_in_scope` for REVIEW-FIX.md frontmatter.
487
+
488
+ If `findings_in_scope` is 0, apply nothing: write REVIEW-FIX.md with `fixed: 0`, `skipped: 0`, `status: all_fixed`, and a body stating that no finding was in scope and the out-of-scope findings remain in REVIEW.md.
484
489
  </step>
485
490
 
486
491
  <step name="apply_fixes">