@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-code-fixer.compact.md +5 -5
- package/agents/gsd-code-fixer.md +9 -4
- package/agents/gsd-code-reviewer.compact.md +49 -8
- package/agents/gsd-code-reviewer.md +53 -14
- package/agents/gsd-executor.md +8 -4
- package/bin/install.js +36 -40
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/new-project.md +2 -2
- package/commands/gsd/update.md +5 -2
- package/gsd-core/bin/gsd-tools.cjs +177 -93
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
- package/gsd-core/bin/lib/capability-registry.cjs +284 -93
- package/gsd-core/bin/lib/capability-validator.cjs +15 -0
- package/gsd-core/bin/lib/commands.cjs +46 -17
- package/gsd-core/bin/lib/config-loader.cjs +3 -3
- package/gsd-core/bin/lib/core-utils.cjs +11 -0
- package/gsd-core/bin/lib/frontmatter.cjs +21 -3
- package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
- package/gsd-core/bin/lib/gate-context-drift.cjs +1 -1
- package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
- package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
- package/gsd-core/bin/lib/git-info-exclude.cjs +1 -6
- package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
- package/gsd-core/bin/lib/init.cjs +181 -68
- package/gsd-core/bin/lib/install-engine.cjs +9 -9
- package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
- package/gsd-core/bin/lib/install-profiles.cjs +4 -2
- package/gsd-core/bin/lib/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -1
- package/gsd-core/bin/lib/markdown-table.cjs +23 -0
- package/gsd-core/bin/lib/mcp-server.cjs +8 -5
- package/gsd-core/bin/lib/milestone.cjs +18 -50
- package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
- package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
- package/gsd-core/bin/lib/phase-id.cjs +627 -36
- package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
- package/gsd-core/bin/lib/phase-locator.cjs +328 -19
- package/gsd-core/bin/lib/phase.cjs +3039 -666
- package/gsd-core/bin/lib/planning-document.cjs +26 -5
- package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
- package/gsd-core/bin/lib/planning-snapshot.cjs +12 -7
- package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
- package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
- package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
- package/gsd-core/bin/lib/roadmap-parser.cjs +759 -144
- package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
- package/gsd-core/bin/lib/roadmap.cjs +358 -106
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -130
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +36 -9
- package/gsd-core/bin/lib/smart-entry.cjs +22 -11
- package/gsd-core/bin/lib/state-contract.cjs +11 -7
- package/gsd-core/bin/lib/state-document.cjs +332 -23
- package/gsd-core/bin/lib/state-transition.cjs +51 -15
- package/gsd-core/bin/lib/state.cjs +499 -180
- package/gsd-core/bin/lib/template.cjs +2 -2
- package/gsd-core/bin/lib/validate.cjs +3 -2
- package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
- package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
- package/gsd-core/bin/lib/verify.cjs +2 -2
- package/gsd-core/bin/lib/workstream-inventory.cjs +27 -21
- package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
- package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
- package/gsd-core/references/cicd-review-patterns.md +45 -0
- package/gsd-core/references/necessity-review.md +48 -0
- package/gsd-core/workflows/code-review-fix.md +46 -5
- package/gsd-core/workflows/code-review.md +3 -0
- package/gsd-core/workflows/execute-phase.md +1 -1
- package/gsd-core/workflows/execute-plan.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
- package/gsd-core/workflows/plan-phase.md +1 -1
- package/gsd-core/workflows/pr-branch.md +2 -0
- package/gsd-core/workflows/reapply-patches.md +104 -0
- package/gsd-core/workflows/review.md +54 -141
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/settings-advanced.md +57 -9
- package/gsd-core/workflows/ship.md +12 -0
- package/hooks/dist/gsd-agent-isolation-guard.js +1 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
- package/hooks/dist/gsd-graphify-update.sh +6 -0
- package/hooks/dist/gsd-statusline.js +3 -2
- package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/gsd-worktree-path-guard.js +4 -4
- package/hooks/dist/lib/git-probe.js +16 -0
- package/hooks/dist/lib/isolation-sentinel.js +77 -2
- package/hooks/gsd-agent-isolation-guard.js +1 -1
- package/hooks/gsd-cursor-subagent-start.js +1 -1
- package/hooks/gsd-graphify-update.sh +6 -0
- package/hooks/gsd-statusline.js +3 -2
- package/hooks/gsd-windsurf-pre-write.js +4 -4
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/gsd-worktree-path-guard.js +4 -4
- package/hooks/lib/git-probe.js +16 -0
- package/hooks/lib/isolation-sentinel.js +77 -2
- package/package.json +3 -3
- package/pi/package-agents/gsd-code-fixer.md +9 -4
- package/pi/package-agents/gsd-code-reviewer.md +53 -14
- package/pi/package-agents/gsd-executor.md +8 -4
- package/pi/package-agents/manifest.json +3 -3
- package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
- package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
- package/pi/package-skills/gsd-update/SKILL.md +4 -1
- package/scripts/changeset/lint.cjs +28 -3
- package/scripts/ci-next-health.cjs +246 -23
- package/scripts/docs-guard-registry.cjs +2 -0
- package/scripts/gen-features.cjs +14 -2
- package/scripts/lib/drift-scan.cjs +27 -0
- package/scripts/lib/macos-conformance-tier.generated.cjs +10 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +15 -0
- package/scripts/lint-phase-enumeration-drift.cjs +8 -4
- package/scripts/lint-roadmap-progress-drift.cjs +421 -0
- package/scripts/merge-commit-pr.cjs +186 -0
- package/scripts/mutation-matrix.cjs +23 -1
- package/scripts/release-ancestry.cjs +86 -0
- package/scripts/release-pipeline-pr.cjs +92 -0
- package/scripts/require-issue-link-policy.cjs +18 -9
- package/scripts/sync-next-version.cjs +48 -7
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-new-project/SKILL.md +1 -1
- package/skills/gsd-update/SKILL.md +5 -2
- 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
|
|
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 = (
|
|
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
|
|
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 = (
|
|
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(
|
|
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
|
|
package/hooks/gsd-statusline.js
CHANGED
|
@@ -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
|
|
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 = (
|
|
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
|
|
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 = (
|
|
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.
|
package/hooks/lib/git-probe.js
CHANGED
|
@@ -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(
|
|
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.
|
|
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.
|
|
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">
|