@opengsd/gsd-core 1.8.0 → 1.9.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/.opencode/plugins/gsd-core.js +31 -1
- package/agents/gsd-code-fixer.md +107 -34
- package/agents/gsd-codebase-mapper.md +1 -1
- package/agents/gsd-debug-session-manager.md +36 -0
- package/agents/gsd-executor.md +20 -7
- package/agents/gsd-intel-updater.md +3 -3
- package/agents/gsd-phase-researcher.md +4 -2
- package/agents/gsd-plan-checker.md +20 -0
- package/agents/gsd-planner.md +15 -23
- package/agents/gsd-project-researcher.md +2 -2
- package/agents/gsd-ui-auditor.md +0 -40
- package/bin/install.js +236 -107
- package/commands/gsd/plan-review-convergence.md +5 -1
- package/gsd-core/bin/gsd-tools.cjs +882 -4
- package/gsd-core/bin/lib/api-coverage.cjs +22 -8
- package/gsd-core/bin/lib/audit.cjs +8 -8
- package/gsd-core/bin/lib/capability-consent.cjs +40 -1
- package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
- package/gsd-core/bin/lib/capability-loader.cjs +23 -1
- package/gsd-core/bin/lib/capability-registry.cjs +1353 -132
- package/gsd-core/bin/lib/capability-trust.cjs +468 -33
- package/gsd-core/bin/lib/capability-validator.cjs +882 -6
- package/gsd-core/bin/lib/check-command-router.cjs +12 -2
- package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +102 -12
- package/gsd-core/bin/lib/claude-orchestration.cjs +125 -22
- package/gsd-core/bin/lib/commands.cjs +246 -18
- package/gsd-core/bin/lib/config-loader.cjs +200 -28
- package/gsd-core/bin/lib/config.cjs +90 -5
- package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
- package/gsd-core/bin/lib/frontmatter.cjs +125 -15
- package/gsd-core/bin/lib/host-integration.cjs +215 -8
- package/gsd-core/bin/lib/init.cjs +44 -19
- package/gsd-core/bin/lib/install-engine.cjs +1 -0
- package/gsd-core/bin/lib/milestone.cjs +36 -9
- package/gsd-core/bin/lib/model-catalog.cjs +51 -1
- package/gsd-core/bin/lib/observability/logger.cjs +7 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
- package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
- package/gsd-core/bin/lib/phase-id.cjs +278 -5
- package/gsd-core/bin/lib/phase.cjs +61 -6
- package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
- package/gsd-core/bin/lib/plan-scan.cjs +1 -1
- package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
- package/gsd-core/bin/lib/profile-output.cjs +34 -8
- package/gsd-core/bin/lib/project-root.cjs +48 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
- package/gsd-core/bin/lib/roadmap-parser.cjs +54 -6
- package/gsd-core/bin/lib/roadmap.cjs +10 -4
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +31 -4
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -1
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +140 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
- package/gsd-core/bin/lib/smart-entry.cjs +1 -1
- package/gsd-core/bin/lib/state-document.cjs +164 -20
- package/gsd-core/bin/lib/state-transition.cjs +28 -10
- package/gsd-core/bin/lib/state.cjs +141 -21
- package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
- package/gsd-core/bin/lib/uat.cjs +9 -7
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
- package/gsd-core/bin/lib/unusable-input.cjs +216 -0
- package/gsd-core/bin/lib/validate.cjs +32 -0
- package/gsd-core/bin/lib/verification.cjs +51 -14
- package/gsd-core/bin/lib/verify.cjs +146 -22
- package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -13
- package/gsd-core/bin/shared/model-catalog.json +5 -0
- package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
- package/gsd-core/references/context-budget.md +40 -0
- package/gsd-core/references/gate-prompts.md +6 -3
- package/gsd-core/references/model-profile-resolution.md +64 -13
- package/gsd-core/references/offer-next.md +88 -0
- package/gsd-core/references/planning-config.md +2 -1
- package/gsd-core/references/reviewer-instances.md +28 -21
- package/gsd-core/references/runtime-aware-dispatch.md +42 -0
- package/gsd-core/references/ui-consideration-probe.md +2 -2
- package/gsd-core/references/worktree-branch-check.md +4 -4
- package/gsd-core/templates/summary-minimal.md +4 -0
- package/gsd-core/templates/summary-standard.md +4 -0
- package/gsd-core/templates/summary.md +7 -0
- package/gsd-core/workflows/ai-integration-phase.md +4 -4
- package/gsd-core/workflows/audit-fix.md +4 -0
- package/gsd-core/workflows/audit-milestone.md +8 -0
- package/gsd-core/workflows/autonomous.md +19 -15
- package/gsd-core/workflows/check-todos.md +2 -2
- package/gsd-core/workflows/code-review-fix.md +14 -6
- package/gsd-core/workflows/code-review.md +93 -21
- package/gsd-core/workflows/debug.md +10 -2
- package/gsd-core/workflows/diagnose-issues.md +4 -0
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
- package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
- package/gsd-core/workflows/discuss-phase-assumptions.md +15 -9
- package/gsd-core/workflows/discuss-phase.md +2 -2
- package/gsd-core/workflows/docs-update.md +8 -0
- package/gsd-core/workflows/eval-review.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
- package/gsd-core/workflows/execute-phase.md +85 -115
- package/gsd-core/workflows/execute-plan.md +5 -4
- package/gsd-core/workflows/explore.md +4 -0
- package/gsd-core/workflows/extract-learnings.md +21 -0
- package/gsd-core/workflows/help/modes/full.md +3 -3
- package/gsd-core/workflows/import.md +4 -1
- package/gsd-core/workflows/ingest-docs.md +4 -0
- package/gsd-core/workflows/map-codebase.md +13 -6
- package/gsd-core/workflows/new-milestone.md +10 -2
- package/gsd-core/workflows/new-project.md +11 -4
- package/gsd-core/workflows/next.md +5 -2
- package/gsd-core/workflows/plan-phase.md +42 -46
- package/gsd-core/workflows/plan-review-convergence.md +18 -14
- package/gsd-core/workflows/progress.md +1 -1
- package/gsd-core/workflows/quick.md +14 -3
- package/gsd-core/workflows/review.md +146 -575
- package/gsd-core/workflows/scan.md +9 -1
- package/gsd-core/workflows/secure-phase.md +10 -2
- package/gsd-core/workflows/ship.md +41 -11
- package/gsd-core/workflows/smart-entry.md +1 -1
- package/gsd-core/workflows/ui-phase.md +8 -1
- package/gsd-core/workflows/ui-review.md +8 -1
- package/gsd-core/workflows/update.md +104 -5
- package/gsd-core/workflows/validate-phase.md +10 -2
- package/gsd-core/workflows/verify-work.md +8 -1
- package/hooks/dist/gsd-cursor-session-start.js +6 -2
- package/hooks/dist/gsd-cursor-stop.js +6 -2
- package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
- package/hooks/dist/gsd-graphify-update.sh +9 -0
- package/hooks/dist/gsd-phase-boundary.sh +14 -2
- package/hooks/dist/gsd-prompt-guard.js +101 -2
- package/hooks/dist/gsd-read-guard.js +100 -2
- package/hooks/dist/gsd-read-injection-scanner.js +109 -2
- package/hooks/dist/gsd-statusline.js +9 -6
- package/hooks/dist/gsd-workflow-guard.js +110 -6
- package/hooks/dist/gsd-worktree-path-guard.js +132 -8
- package/hooks/dist/lib/cursor-workspace.js +74 -0
- package/hooks/gsd-cursor-session-start.js +6 -2
- package/hooks/gsd-cursor-stop.js +6 -2
- package/hooks/gsd-cursor-subagent-start.js +6 -2
- package/hooks/gsd-graphify-update.sh +9 -0
- package/hooks/gsd-phase-boundary.sh +14 -2
- package/hooks/gsd-prompt-guard.js +101 -2
- package/hooks/gsd-read-guard.js +100 -2
- package/hooks/gsd-read-injection-scanner.js +109 -2
- package/hooks/gsd-statusline.js +9 -6
- package/hooks/gsd-workflow-guard.js +110 -6
- package/hooks/gsd-worktree-path-guard.js +132 -8
- package/hooks/lib/cursor-workspace.js +74 -0
- package/package.json +7 -7
- package/pi/gsd.cjs +26 -1
- package/scripts/check-coverage-gate.cjs +51 -0
- package/scripts/check-glossary-refs.cjs +24 -0
- package/scripts/ci-test-scope.cjs +67 -17
- package/scripts/gen-adr-index.cjs +6 -4
- package/scripts/gen-capability-matrix.cjs +26 -2
- package/scripts/gen-capability-registry.cjs +132 -34
- package/scripts/gen-emitted-baseline.cjs +145 -0
- package/scripts/gen-registry.cjs +39 -15
- package/scripts/lint-compiled-artifact-sync.cjs +146 -0
- package/scripts/lint-emitted-drift-ack.cjs +149 -0
- package/scripts/lint-fix-has-regression-test.cjs +131 -0
- package/scripts/lint-resolution-provenance.cjs +9 -0
- package/scripts/mutation-matrix.cjs +4 -0
- package/scripts/prompt-injection-scan.sh +6 -0
- package/scripts/registry-schema.cjs +372 -94
- package/scripts/release-notes/conventional-title.cjs +19 -1
- package/scripts/release-notes/format-github-release-notes.cjs +7 -3
- package/scripts/validate-registry.cjs +10 -6
- package/scripts/workflow-size.cjs +16 -8
- package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
- package/vscode/package.json +1 -1
- package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
- package/scripts/update-size-baseline.cjs +0 -68
|
@@ -105,6 +105,109 @@ function isExcludedPath(filePath) {
|
|
|
105
105
|
);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// Kimi CLI delivers the tool vocabulary the matcher was registered with —
|
|
109
|
+
// the scanner's Kimi matcher is 'ReadFile' (runtime-hooks-surface.cts), so
|
|
110
|
+
// tool_name arrives as 'ReadFile' (possibly module-qualified) and tool_input
|
|
111
|
+
// carries `path` (kimi-cli src/kimi_cli/tools/file/read.py Params), not
|
|
112
|
+
// `file_path`. Without normalization the SCANNED_TOOLS check below never
|
|
113
|
+
// matches on Kimi and the scanner is silently dormant (#2304).
|
|
114
|
+
//
|
|
115
|
+
// SCOPE ON KIMI (#2547): normalization makes this scanner's CHECKS run on
|
|
116
|
+
// Kimi. It does NOT make its block effective there. This is a PostToolUse
|
|
117
|
+
// hook, and kimi-cli's dispatch never inspects PostToolUse hook results —
|
|
118
|
+
// src/kimi_cli/soul/toolset.py fires them via asyncio.create_task() and
|
|
119
|
+
// returns the ToolResult without awaiting, whereas PreToolUse results are
|
|
120
|
+
// awaited and honoured. So `security.injection_blocking` cannot take effect
|
|
121
|
+
// on Kimi regardless of the shape emitted below; reshaping the output would
|
|
122
|
+
// not change that. Blocking prompt injection on Kimi needs a PreToolUse
|
|
123
|
+
// mechanism, or an upstream kimi-cli change. Do not describe this hook as
|
|
124
|
+
// "engaged" or "blocking" on Kimi. This block is
|
|
125
|
+
// kept byte-identical with the copies in gsd-prompt-guard.js,
|
|
126
|
+
// gsd-read-guard.js, and gsd-worktree-path-guard.js — a parity test binds
|
|
127
|
+
// them (tests/kimi-guard-normalization-parity.test.cjs). Inlined per guard
|
|
128
|
+
// (not hooks/lib/): hook scripts are staged as standalone files, and a
|
|
129
|
+
// sibling require is a staging dependency that can fail silently.
|
|
130
|
+
// A Map, not an object literal: bare bracket lookup resolves prototype keys
|
|
131
|
+
// ('constructor', '__proto__', 'toString') to truthy functions/objects, so the
|
|
132
|
+
// !mapped fall-through never fires for them; Map.get returns undefined (same
|
|
133
|
+
// shape as canonicalizeRuntimeName in src/runtime-name-policy.cts).
|
|
134
|
+
const KIMI_TOOL_NAMES = new Map([['WriteFile', 'Write'], ['StrReplaceFile', 'Edit'], ['ReadFile', 'Read'], ['Shell', 'Bash']]);
|
|
135
|
+
function normalizeKimiPayload(data) {
|
|
136
|
+
// #2595 (review nit): `JSON.parse('null')` is null, and null/primitive
|
|
137
|
+
// payloads reached the `data.tool_name` read below and threw — falsifying
|
|
138
|
+
// this function's own "total over the inputs JSON can express" claim, which
|
|
139
|
+
// property (e) now tests directly. Harmless in practice (a null payload has
|
|
140
|
+
// nothing to guard, and the throw landed in the same fail-open catch as the
|
|
141
|
+
// exit-0 it now takes deliberately) but the claim should be true as stated.
|
|
142
|
+
if (data === null || typeof data !== 'object') return data;
|
|
143
|
+
const raw = data.tool_name;
|
|
144
|
+
if (typeof raw !== 'string') return data;
|
|
145
|
+
const mapped = KIMI_TOOL_NAMES.get(raw.slice(raw.lastIndexOf(':') + 1));
|
|
146
|
+
if (!mapped) return data;
|
|
147
|
+
data.tool_name = mapped;
|
|
148
|
+
if (data.tool_response === undefined && data.tool_output !== undefined) {
|
|
149
|
+
data.tool_response = data.tool_output;
|
|
150
|
+
}
|
|
151
|
+
const input = data.tool_input;
|
|
152
|
+
if (input && typeof input === 'object') {
|
|
153
|
+
// #2547 (review): Kimi's `path` is AUTHORITATIVE — it must win outright,
|
|
154
|
+
// not merely fill in when `file_path` happens to be absent. kimi-cli's file
|
|
155
|
+
// tools carry no `file_path` field at all (src/kimi_cli/tools/file/write.py,
|
|
156
|
+
// replace.py, @ 4a550ef — the SHA #2547 pins), and soul/toolset.py hands the
|
|
157
|
+
// model's raw json-parsed
|
|
158
|
+
// arguments to PreToolUse verbatim, doing typed validation only later inside
|
|
159
|
+
// tool.call() — after the hook has already decided. So a `file_path` in a
|
|
160
|
+
// Kimi payload is ALWAYS model-supplied, and under the old `=== undefined`
|
|
161
|
+
// condition it SHADOWED the field kimi-cli actually executes on. A payload
|
|
162
|
+
// pairing a cross-root `path` with a spurious `file_path: ""` left every
|
|
163
|
+
// guard reading an empty string and exiting 0, while the identical write
|
|
164
|
+
// without the extra key blocked — a bypass needing no crash at all. The same
|
|
165
|
+
// shadowing also preserved a NON-STRING `file_path` (`[]`), which threw
|
|
166
|
+
// inside gsd-worktree-path-guard's path.isAbsolute() and reached its outer
|
|
167
|
+
// `catch { process.exit(0) }`: the same crash-to-allow this fix closes
|
|
168
|
+
// elsewhere, reached through the guard's own read rather than through
|
|
169
|
+
// normalization. Overwriting can only ever narrow what a guard inspects to
|
|
170
|
+
// the path that will actually be written, so it cannot under-block.
|
|
171
|
+
if (typeof input.path === 'string') {
|
|
172
|
+
input.file_path = input.path;
|
|
173
|
+
}
|
|
174
|
+
const edits = Array.isArray(input.edit) ? input.edit
|
|
175
|
+
: (input.edit && typeof input.edit === 'object') ? [input.edit] : [];
|
|
176
|
+
if (edits.length) {
|
|
177
|
+
// #2547: `e?.old`, not `e.old` — `??` guards the value, not the
|
|
178
|
+
// dereference, so a NULLISH entry (`edit: [null]`) threw a TypeError
|
|
179
|
+
// here. normalizeKimiPayload runs before any tool dispatch, so that throw
|
|
180
|
+
// reached each guard's outer `catch { process.exit(0) }` and silently
|
|
181
|
+
// downgraded a should-BLOCK call into an allow. (A string/number entry
|
|
182
|
+
// never threw — `('x').old` is a legal read yielding undefined.)
|
|
183
|
+
//
|
|
184
|
+
// The String() coercion is guarded for the same reason: `{"toString":
|
|
185
|
+
// null}` is valid JSON that throws "Cannot convert object to primitive
|
|
186
|
+
// value", which is the identical crash-to-allow with a different
|
|
187
|
+
// trigger. Degrading only the non-coercible entry to '' keeps
|
|
188
|
+
// stringification intact for every value that CAN coerce (numbers,
|
|
189
|
+
// arrays, plain objects), so nothing downstream — including
|
|
190
|
+
// gsd-prompt-guard's scan of new_string — loses content it saw before.
|
|
191
|
+
const editText = (v) => { try { return String(v ?? ''); } catch { return ''; } };
|
|
192
|
+
// #2595 (review Major 2): reconstruct UNCONDITIONALLY, mirroring the
|
|
193
|
+
// `path` decision above rather than merely filling in when the field
|
|
194
|
+
// happens to be absent. kimi-cli's StrReplaceFile schema is `path` +
|
|
195
|
+
// `edit` only (src/kimi_cli/tools/file/replace.py @ 4a550ef) — it carries
|
|
196
|
+
// no `old_string`/`new_string` at all, so either field appearing in a
|
|
197
|
+
// Kimi payload is ALWAYS model-supplied, exactly like `file_path`. Under
|
|
198
|
+
// the old `=== undefined` condition a model-supplied `new_string: ""`
|
|
199
|
+
// SHADOWED the reconstruction, leaving gsd-prompt-guard's injection scan
|
|
200
|
+
// reading '' and exiting at its `if (!content)` before it ever saw the
|
|
201
|
+
// real `edit[].new` — a one-key bypass of the very scan this fix's
|
|
202
|
+
// guarded coercion exists to keep fed. A `typeof` test would NOT close
|
|
203
|
+
// it: a benign non-empty string shadows just as effectively as ''.
|
|
204
|
+
input.old_string = edits.map((e) => editText(e?.old)).join('\n');
|
|
205
|
+
input.new_string = edits.map((e) => editText(e?.new)).join('\n');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return data;
|
|
209
|
+
}
|
|
210
|
+
|
|
108
211
|
let inputBuf = '';
|
|
109
212
|
const stdinTimeout = setTimeout(() => process.exit(0), 5000);
|
|
110
213
|
process.stdin.setEncoding('utf8');
|
|
@@ -112,7 +215,7 @@ process.stdin.on('data', chunk => { inputBuf += chunk; });
|
|
|
112
215
|
process.stdin.on('end', () => {
|
|
113
216
|
clearTimeout(stdinTimeout);
|
|
114
217
|
try {
|
|
115
|
-
const data = JSON.parse(inputBuf);
|
|
218
|
+
const data = normalizeKimiPayload(JSON.parse(inputBuf));
|
|
116
219
|
|
|
117
220
|
const toolName = data.tool_name;
|
|
118
221
|
const SCANNED_TOOLS = new Set(['Read', 'WebFetch', 'WebSearch']);
|
|
@@ -123,7 +226,11 @@ process.stdin.on('end', () => {
|
|
|
123
226
|
// Source label + path-exclusion (path-exclusion applies to file reads only)
|
|
124
227
|
let source;
|
|
125
228
|
if (toolName === 'Read') {
|
|
126
|
-
|
|
229
|
+
// #2595 (review Major 3, sibling sweep): typed read — a non-string
|
|
230
|
+
// threw inside isExcludedPath()'s .replace() into the outer catch.
|
|
231
|
+
source = typeof data.tool_input?.file_path === 'string'
|
|
232
|
+
? data.tool_input.file_path
|
|
233
|
+
: '';
|
|
127
234
|
if (!source) process.exit(0);
|
|
128
235
|
if (isExcludedPath(source)) process.exit(0);
|
|
129
236
|
} else if (toolName === 'WebFetch') {
|
package/hooks/gsd-statusline.js
CHANGED
|
@@ -143,12 +143,15 @@ function readGsdState(dir) {
|
|
|
143
143
|
function parseStateMd(content) {
|
|
144
144
|
const state = {};
|
|
145
145
|
|
|
146
|
-
// YAML frontmatter between --- markers (anchored at file start)
|
|
147
|
-
|
|
146
|
+
// YAML frontmatter between --- markers (anchored at file start).
|
|
147
|
+
// #2754: \r?\n (not literal \n) so a CRLF STATE.md (Windows-authored) parses
|
|
148
|
+
// identically to LF — pre-fix the literal-\n fence dropped the ENTIRE block.
|
|
149
|
+
// Mirrors the CRLF-safe extractFrontmatter in src/frontmatter.cts.
|
|
150
|
+
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
148
151
|
if (fmMatch) {
|
|
149
152
|
const fm = fmMatch[1];
|
|
150
153
|
// Top-level scalar key: value
|
|
151
|
-
for (const line of fm.split(
|
|
154
|
+
for (const line of fm.split(/\r?\n/)) {
|
|
152
155
|
const m = line.match(/^(\w+):\s*(.+)/);
|
|
153
156
|
if (!m) continue;
|
|
154
157
|
const [, key, val] = m;
|
|
@@ -169,10 +172,10 @@ function parseStateMd(content) {
|
|
|
169
172
|
const items = npFlowMatch[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
170
173
|
state.nextPhases = items.length > 0 ? items : null;
|
|
171
174
|
} else {
|
|
172
|
-
const npBlockMatch = fm.match(/^next_phases:\s*\n((?:[ \t]*-[ \t]*[^\n]+\n?)*)/m);
|
|
175
|
+
const npBlockMatch = fm.match(/^next_phases:\s*\r?\n((?:[ \t]*-[ \t]*[^\r\n]+\r?\n?)*)/m);
|
|
173
176
|
if (npBlockMatch) {
|
|
174
177
|
const items = npBlockMatch[1]
|
|
175
|
-
.split(
|
|
178
|
+
.split(/\r?\n/)
|
|
176
179
|
.map(line => line.match(/^[ \t]*-[ \t]*(.+)$/))
|
|
177
180
|
.filter(Boolean)
|
|
178
181
|
.map(m => m[1].trim().replace(/^["']|["']$/g, ''))
|
|
@@ -181,7 +184,7 @@ function parseStateMd(content) {
|
|
|
181
184
|
}
|
|
182
185
|
}
|
|
183
186
|
// progress nested block: completed_phases / total_phases / percent (2-space indent)
|
|
184
|
-
const progMatch = fm.match(/^progress:\s*\n((?:[ \t]+\w+:.+\n?)+)/m);
|
|
187
|
+
const progMatch = fm.match(/^progress:\s*\r?\n((?:[ \t]+\w+:.+\r?\n?)+)/m);
|
|
185
188
|
if (progMatch) {
|
|
186
189
|
const cp = progMatch[1].match(/^[ \t]+completed_phases:\s*(\d+)/m);
|
|
187
190
|
const tp = progMatch[1].match(/^[ \t]+total_phases:\s*(\d+)/m);
|
|
@@ -78,6 +78,100 @@ function workflowGuardEnabled(cwd) {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// Kimi CLI delivers the tool vocabulary the matcher was registered with —
|
|
82
|
+
// this guard's Kimi matcher is 'Shell|WriteFile|StrReplaceFile'
|
|
83
|
+
// (runtime-hooks-surface.cts), so tool_name arrives in Kimi vocabulary
|
|
84
|
+
// (possibly module-qualified) and neither the Bash branch nor the
|
|
85
|
+
// Write/Edit/MultiEdit allowlist below ever matched on Kimi (#2304).
|
|
86
|
+
// kimi-cli's Shell.Params names its field `command`
|
|
87
|
+
// (src/kimi_cli/tools/shell/__init__.py), same as Claude's Bash, so the
|
|
88
|
+
// Shell leg needs only the name mapping. This block is kept byte-identical
|
|
89
|
+
// with the copies in gsd-prompt-guard.js, gsd-read-guard.js,
|
|
90
|
+
// gsd-worktree-path-guard.js, and gsd-read-injection-scanner.js — a parity
|
|
91
|
+
// test binds them (tests/kimi-guard-normalization-parity.test.cjs). Inlined
|
|
92
|
+
// per guard (not hooks/lib/): hook scripts are staged as standalone files,
|
|
93
|
+
// and a sibling require is a staging dependency that can fail silently.
|
|
94
|
+
// A Map, not an object literal: bare bracket lookup resolves prototype keys
|
|
95
|
+
// ('constructor', '__proto__', 'toString') to truthy functions/objects, so the
|
|
96
|
+
// !mapped fall-through never fires for them; Map.get returns undefined (same
|
|
97
|
+
// shape as canonicalizeRuntimeName in src/runtime-name-policy.cts).
|
|
98
|
+
const KIMI_TOOL_NAMES = new Map([['WriteFile', 'Write'], ['StrReplaceFile', 'Edit'], ['ReadFile', 'Read'], ['Shell', 'Bash']]);
|
|
99
|
+
function normalizeKimiPayload(data) {
|
|
100
|
+
// #2595 (review nit): `JSON.parse('null')` is null, and null/primitive
|
|
101
|
+
// payloads reached the `data.tool_name` read below and threw — falsifying
|
|
102
|
+
// this function's own "total over the inputs JSON can express" claim, which
|
|
103
|
+
// property (e) now tests directly. Harmless in practice (a null payload has
|
|
104
|
+
// nothing to guard, and the throw landed in the same fail-open catch as the
|
|
105
|
+
// exit-0 it now takes deliberately) but the claim should be true as stated.
|
|
106
|
+
if (data === null || typeof data !== 'object') return data;
|
|
107
|
+
const raw = data.tool_name;
|
|
108
|
+
if (typeof raw !== 'string') return data;
|
|
109
|
+
const mapped = KIMI_TOOL_NAMES.get(raw.slice(raw.lastIndexOf(':') + 1));
|
|
110
|
+
if (!mapped) return data;
|
|
111
|
+
data.tool_name = mapped;
|
|
112
|
+
if (data.tool_response === undefined && data.tool_output !== undefined) {
|
|
113
|
+
data.tool_response = data.tool_output;
|
|
114
|
+
}
|
|
115
|
+
const input = data.tool_input;
|
|
116
|
+
if (input && typeof input === 'object') {
|
|
117
|
+
// #2547 (review): Kimi's `path` is AUTHORITATIVE — it must win outright,
|
|
118
|
+
// not merely fill in when `file_path` happens to be absent. kimi-cli's file
|
|
119
|
+
// tools carry no `file_path` field at all (src/kimi_cli/tools/file/write.py,
|
|
120
|
+
// replace.py, @ 4a550ef — the SHA #2547 pins), and soul/toolset.py hands the
|
|
121
|
+
// model's raw json-parsed
|
|
122
|
+
// arguments to PreToolUse verbatim, doing typed validation only later inside
|
|
123
|
+
// tool.call() — after the hook has already decided. So a `file_path` in a
|
|
124
|
+
// Kimi payload is ALWAYS model-supplied, and under the old `=== undefined`
|
|
125
|
+
// condition it SHADOWED the field kimi-cli actually executes on. A payload
|
|
126
|
+
// pairing a cross-root `path` with a spurious `file_path: ""` left every
|
|
127
|
+
// guard reading an empty string and exiting 0, while the identical write
|
|
128
|
+
// without the extra key blocked — a bypass needing no crash at all. The same
|
|
129
|
+
// shadowing also preserved a NON-STRING `file_path` (`[]`), which threw
|
|
130
|
+
// inside gsd-worktree-path-guard's path.isAbsolute() and reached its outer
|
|
131
|
+
// `catch { process.exit(0) }`: the same crash-to-allow this fix closes
|
|
132
|
+
// elsewhere, reached through the guard's own read rather than through
|
|
133
|
+
// normalization. Overwriting can only ever narrow what a guard inspects to
|
|
134
|
+
// the path that will actually be written, so it cannot under-block.
|
|
135
|
+
if (typeof input.path === 'string') {
|
|
136
|
+
input.file_path = input.path;
|
|
137
|
+
}
|
|
138
|
+
const edits = Array.isArray(input.edit) ? input.edit
|
|
139
|
+
: (input.edit && typeof input.edit === 'object') ? [input.edit] : [];
|
|
140
|
+
if (edits.length) {
|
|
141
|
+
// #2547: `e?.old`, not `e.old` — `??` guards the value, not the
|
|
142
|
+
// dereference, so a NULLISH entry (`edit: [null]`) threw a TypeError
|
|
143
|
+
// here. normalizeKimiPayload runs before any tool dispatch, so that throw
|
|
144
|
+
// reached each guard's outer `catch { process.exit(0) }` and silently
|
|
145
|
+
// downgraded a should-BLOCK call into an allow. (A string/number entry
|
|
146
|
+
// never threw — `('x').old` is a legal read yielding undefined.)
|
|
147
|
+
//
|
|
148
|
+
// The String() coercion is guarded for the same reason: `{"toString":
|
|
149
|
+
// null}` is valid JSON that throws "Cannot convert object to primitive
|
|
150
|
+
// value", which is the identical crash-to-allow with a different
|
|
151
|
+
// trigger. Degrading only the non-coercible entry to '' keeps
|
|
152
|
+
// stringification intact for every value that CAN coerce (numbers,
|
|
153
|
+
// arrays, plain objects), so nothing downstream — including
|
|
154
|
+
// gsd-prompt-guard's scan of new_string — loses content it saw before.
|
|
155
|
+
const editText = (v) => { try { return String(v ?? ''); } catch { return ''; } };
|
|
156
|
+
// #2595 (review Major 2): reconstruct UNCONDITIONALLY, mirroring the
|
|
157
|
+
// `path` decision above rather than merely filling in when the field
|
|
158
|
+
// happens to be absent. kimi-cli's StrReplaceFile schema is `path` +
|
|
159
|
+
// `edit` only (src/kimi_cli/tools/file/replace.py @ 4a550ef) — it carries
|
|
160
|
+
// no `old_string`/`new_string` at all, so either field appearing in a
|
|
161
|
+
// Kimi payload is ALWAYS model-supplied, exactly like `file_path`. Under
|
|
162
|
+
// the old `=== undefined` condition a model-supplied `new_string: ""`
|
|
163
|
+
// SHADOWED the reconstruction, leaving gsd-prompt-guard's injection scan
|
|
164
|
+
// reading '' and exiting at its `if (!content)` before it ever saw the
|
|
165
|
+
// real `edit[].new` — a one-key bypass of the very scan this fix's
|
|
166
|
+
// guarded coercion exists to keep fed. A `typeof` test would NOT close
|
|
167
|
+
// it: a benign non-empty string shadows just as effectively as ''.
|
|
168
|
+
input.old_string = edits.map((e) => editText(e?.old)).join('\n');
|
|
169
|
+
input.new_string = edits.map((e) => editText(e?.new)).join('\n');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return data;
|
|
173
|
+
}
|
|
174
|
+
|
|
81
175
|
let input = '';
|
|
82
176
|
const stdinTimeout = setTimeout(() => process.exit(0), 3000);
|
|
83
177
|
process.stdin.setEncoding('utf8');
|
|
@@ -85,7 +179,7 @@ process.stdin.on('data', chunk => input += chunk);
|
|
|
85
179
|
process.stdin.on('end', () => {
|
|
86
180
|
clearTimeout(stdinTimeout);
|
|
87
181
|
try {
|
|
88
|
-
const data = JSON.parse(input);
|
|
182
|
+
const data = normalizeKimiPayload(JSON.parse(input));
|
|
89
183
|
const toolName = data.tool_name;
|
|
90
184
|
const cwd = data.cwd || process.cwd();
|
|
91
185
|
const isWorkflowGuardEnabled = workflowGuardEnabled(cwd);
|
|
@@ -97,12 +191,15 @@ process.stdin.on('end', () => {
|
|
|
97
191
|
const command = data.tool_input?.command || '';
|
|
98
192
|
for (const gitCwd of forceGitAddCwds(command, cwd)) {
|
|
99
193
|
const branch = currentBranch(gitCwd);
|
|
100
|
-
if (
|
|
101
|
-
|
|
194
|
+
if (/^(worktree-)?agent-/.test(branch)) {
|
|
195
|
+
const output = {
|
|
102
196
|
decision: 'block',
|
|
103
197
|
code: 'WORKTREE_AGENT_FORCE_ADD_FORBIDDEN',
|
|
104
|
-
reason: 'worktree-agent branches must not run git add -f or git add --force. Respect the SDK skipped_gitignored/skipped_commit_docs_false contract and leave gitignored files untracked.',
|
|
105
|
-
}
|
|
198
|
+
reason: 'agent/worktree-agent branches must not run git add -f or git add --force. Respect the SDK skipped_gitignored/skipped_commit_docs_false contract and leave gitignored files untracked.',
|
|
199
|
+
};
|
|
200
|
+
process.stdout.write(JSON.stringify(output));
|
|
201
|
+
// Kimi CLI's exit-2 protocol feeds stderr back to the model (#2304)
|
|
202
|
+
process.stderr.write(output.reason);
|
|
106
203
|
process.exit(2);
|
|
107
204
|
}
|
|
108
205
|
}
|
|
@@ -122,7 +219,14 @@ process.stdin.on('end', () => {
|
|
|
122
219
|
}
|
|
123
220
|
|
|
124
221
|
// Check the file being edited
|
|
125
|
-
|
|
222
|
+
// #2595 (review Major 3, sibling sweep): typed read on BOTH fields. The
|
|
223
|
+
// `&& value` keeps the original truthiness fallback intact — an empty
|
|
224
|
+
// file_path must still fall through to `path`, which a bare typeof test
|
|
225
|
+
// would have broken.
|
|
226
|
+
const filePath =
|
|
227
|
+
(typeof data.tool_input?.file_path === 'string' && data.tool_input.file_path) ||
|
|
228
|
+
(typeof data.tool_input?.path === 'string' && data.tool_input.path) ||
|
|
229
|
+
'';
|
|
126
230
|
|
|
127
231
|
// Allow edits to .planning/ files (GSD state management)
|
|
128
232
|
if (filePath.includes('.planning/') || filePath.includes('.planning\\')) {
|
|
@@ -37,6 +37,100 @@ function nearestExistingDir(start) {
|
|
|
37
37
|
return null;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// #2304: Kimi's native hook bus delivers Kimi's tool vocabulary in the payload
|
|
41
|
+
// (Write → WriteFile, Edit/MultiEdit → StrReplaceFile) while the [[hooks]]
|
|
42
|
+
// matcher is registered pre-translated (runtime-hooks-surface.cts
|
|
43
|
+
// buildKimiHooksTomlBlock) — so without normalizing the payload too, the
|
|
44
|
+
// matcher fires but the tool_name check below exits 0 and the guard is dormant
|
|
45
|
+
// on Kimi. The tool_input field names differ as well (kimi-cli
|
|
46
|
+
// src/kimi_cli/tools/file/{write,replace}.py): WriteFile takes `path`/`content`,
|
|
47
|
+
// StrReplaceFile takes `path` + `edit: Edit | list[Edit]` with `old`/`new` —
|
|
48
|
+
// kimi-cli's hooks/events.py forwards tool_input verbatim, so both layers need
|
|
49
|
+
// mapping. Accepts bare and module-qualified ('kimi_cli.tools.file:WriteFile')
|
|
50
|
+
// names; unknown names fall through untouched. Inlined per guard (not
|
|
51
|
+
// hooks/lib/): hook scripts are staged as standalone files, and a sibling
|
|
52
|
+
// require is a staging dependency that can fail silently.
|
|
53
|
+
// A Map, not an object literal: bare bracket lookup resolves prototype keys
|
|
54
|
+
// ('constructor', '__proto__', 'toString') to truthy functions/objects, so the
|
|
55
|
+
// !mapped fall-through never fires for them; Map.get returns undefined (same
|
|
56
|
+
// shape as canonicalizeRuntimeName in src/runtime-name-policy.cts).
|
|
57
|
+
const KIMI_TOOL_NAMES = new Map([['WriteFile', 'Write'], ['StrReplaceFile', 'Edit'], ['ReadFile', 'Read'], ['Shell', 'Bash']]);
|
|
58
|
+
function normalizeKimiPayload(data) {
|
|
59
|
+
// #2595 (review nit): `JSON.parse('null')` is null, and null/primitive
|
|
60
|
+
// payloads reached the `data.tool_name` read below and threw — falsifying
|
|
61
|
+
// this function's own "total over the inputs JSON can express" claim, which
|
|
62
|
+
// property (e) now tests directly. Harmless in practice (a null payload has
|
|
63
|
+
// nothing to guard, and the throw landed in the same fail-open catch as the
|
|
64
|
+
// exit-0 it now takes deliberately) but the claim should be true as stated.
|
|
65
|
+
if (data === null || typeof data !== 'object') return data;
|
|
66
|
+
const raw = data.tool_name;
|
|
67
|
+
if (typeof raw !== 'string') return data;
|
|
68
|
+
const mapped = KIMI_TOOL_NAMES.get(raw.slice(raw.lastIndexOf(':') + 1));
|
|
69
|
+
if (!mapped) return data;
|
|
70
|
+
data.tool_name = mapped;
|
|
71
|
+
if (data.tool_response === undefined && data.tool_output !== undefined) {
|
|
72
|
+
data.tool_response = data.tool_output;
|
|
73
|
+
}
|
|
74
|
+
const input = data.tool_input;
|
|
75
|
+
if (input && typeof input === 'object') {
|
|
76
|
+
// #2547 (review): Kimi's `path` is AUTHORITATIVE — it must win outright,
|
|
77
|
+
// not merely fill in when `file_path` happens to be absent. kimi-cli's file
|
|
78
|
+
// tools carry no `file_path` field at all (src/kimi_cli/tools/file/write.py,
|
|
79
|
+
// replace.py, @ 4a550ef — the SHA #2547 pins), and soul/toolset.py hands the
|
|
80
|
+
// model's raw json-parsed
|
|
81
|
+
// arguments to PreToolUse verbatim, doing typed validation only later inside
|
|
82
|
+
// tool.call() — after the hook has already decided. So a `file_path` in a
|
|
83
|
+
// Kimi payload is ALWAYS model-supplied, and under the old `=== undefined`
|
|
84
|
+
// condition it SHADOWED the field kimi-cli actually executes on. A payload
|
|
85
|
+
// pairing a cross-root `path` with a spurious `file_path: ""` left every
|
|
86
|
+
// guard reading an empty string and exiting 0, while the identical write
|
|
87
|
+
// without the extra key blocked — a bypass needing no crash at all. The same
|
|
88
|
+
// shadowing also preserved a NON-STRING `file_path` (`[]`), which threw
|
|
89
|
+
// inside gsd-worktree-path-guard's path.isAbsolute() and reached its outer
|
|
90
|
+
// `catch { process.exit(0) }`: the same crash-to-allow this fix closes
|
|
91
|
+
// elsewhere, reached through the guard's own read rather than through
|
|
92
|
+
// normalization. Overwriting can only ever narrow what a guard inspects to
|
|
93
|
+
// the path that will actually be written, so it cannot under-block.
|
|
94
|
+
if (typeof input.path === 'string') {
|
|
95
|
+
input.file_path = input.path;
|
|
96
|
+
}
|
|
97
|
+
const edits = Array.isArray(input.edit) ? input.edit
|
|
98
|
+
: (input.edit && typeof input.edit === 'object') ? [input.edit] : [];
|
|
99
|
+
if (edits.length) {
|
|
100
|
+
// #2547: `e?.old`, not `e.old` — `??` guards the value, not the
|
|
101
|
+
// dereference, so a NULLISH entry (`edit: [null]`) threw a TypeError
|
|
102
|
+
// here. normalizeKimiPayload runs before any tool dispatch, so that throw
|
|
103
|
+
// reached each guard's outer `catch { process.exit(0) }` and silently
|
|
104
|
+
// downgraded a should-BLOCK call into an allow. (A string/number entry
|
|
105
|
+
// never threw — `('x').old` is a legal read yielding undefined.)
|
|
106
|
+
//
|
|
107
|
+
// The String() coercion is guarded for the same reason: `{"toString":
|
|
108
|
+
// null}` is valid JSON that throws "Cannot convert object to primitive
|
|
109
|
+
// value", which is the identical crash-to-allow with a different
|
|
110
|
+
// trigger. Degrading only the non-coercible entry to '' keeps
|
|
111
|
+
// stringification intact for every value that CAN coerce (numbers,
|
|
112
|
+
// arrays, plain objects), so nothing downstream — including
|
|
113
|
+
// gsd-prompt-guard's scan of new_string — loses content it saw before.
|
|
114
|
+
const editText = (v) => { try { return String(v ?? ''); } catch { return ''; } };
|
|
115
|
+
// #2595 (review Major 2): reconstruct UNCONDITIONALLY, mirroring the
|
|
116
|
+
// `path` decision above rather than merely filling in when the field
|
|
117
|
+
// happens to be absent. kimi-cli's StrReplaceFile schema is `path` +
|
|
118
|
+
// `edit` only (src/kimi_cli/tools/file/replace.py @ 4a550ef) — it carries
|
|
119
|
+
// no `old_string`/`new_string` at all, so either field appearing in a
|
|
120
|
+
// Kimi payload is ALWAYS model-supplied, exactly like `file_path`. Under
|
|
121
|
+
// the old `=== undefined` condition a model-supplied `new_string: ""`
|
|
122
|
+
// SHADOWED the reconstruction, leaving gsd-prompt-guard's injection scan
|
|
123
|
+
// reading '' and exiting at its `if (!content)` before it ever saw the
|
|
124
|
+
// real `edit[].new` — a one-key bypass of the very scan this fix's
|
|
125
|
+
// guarded coercion exists to keep fed. A `typeof` test would NOT close
|
|
126
|
+
// it: a benign non-empty string shadows just as effectively as ''.
|
|
127
|
+
input.old_string = edits.map((e) => editText(e?.old)).join('\n');
|
|
128
|
+
input.new_string = edits.map((e) => editText(e?.new)).join('\n');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return data;
|
|
132
|
+
}
|
|
133
|
+
|
|
40
134
|
let input = '';
|
|
41
135
|
const stdinTimeout = setTimeout(() => process.exit(0), 3000);
|
|
42
136
|
process.stdin.setEncoding('utf8');
|
|
@@ -44,7 +138,7 @@ process.stdin.on('data', chunk => input += chunk);
|
|
|
44
138
|
process.stdin.on('end', () => {
|
|
45
139
|
clearTimeout(stdinTimeout);
|
|
46
140
|
try {
|
|
47
|
-
const data = JSON.parse(input);
|
|
141
|
+
const data = normalizeKimiPayload(JSON.parse(input));
|
|
48
142
|
const toolName = data.tool_name;
|
|
49
143
|
|
|
50
144
|
// Only guard Edit, Write, and MultiEdit tool calls
|
|
@@ -72,13 +166,14 @@ process.stdin.on('end', () => {
|
|
|
72
166
|
}
|
|
73
167
|
|
|
74
168
|
// #1342: Only enforce inside a GSD-managed isolated executor worktree. Those
|
|
75
|
-
// are always on
|
|
76
|
-
// by worktree-branch-check.md, #2924). A manually-
|
|
77
|
-
// non-GSD work, e.g. Claude Code plan-mode) is
|
|
78
|
-
// guard must be a no-op there. Detached HEAD
|
|
169
|
+
// are always on an `agent-*` or legacy `worktree-agent-*` branch (the positive
|
|
170
|
+
// allow-list enforced by worktree-branch-check.md, #2924, #1995). A manually-
|
|
171
|
+
// created linked worktree (plain non-GSD work, e.g. Claude Code plan-mode) is
|
|
172
|
+
// on the user's own branch, so the guard must be a no-op there. Detached HEAD
|
|
173
|
+
// / error → not GSD-managed → no-op.
|
|
79
174
|
const branchResult = git(['symbolic-ref', '--short', 'HEAD'], cwd);
|
|
80
175
|
const branch = branchResult.status === 0 && branchResult.stdout ? branchResult.stdout.trim() : '';
|
|
81
|
-
if (!/^worktree-agent-[A-Za-z0-9._/-]+$/.test(branch)) {
|
|
176
|
+
if (!/^(worktree-)?agent-[A-Za-z0-9._/-]+$/.test(branch)) {
|
|
82
177
|
process.exit(0); // not a GSD-managed executor worktree — no-op
|
|
83
178
|
}
|
|
84
179
|
|
|
@@ -91,12 +186,37 @@ process.stdin.on('end', () => {
|
|
|
91
186
|
}
|
|
92
187
|
const wtTopRaw = wtTopResult.stdout.trim();
|
|
93
188
|
|
|
94
|
-
|
|
189
|
+
// #2595 (review Major 3): read the field TYPED. `?.file_path || ''` let a
|
|
190
|
+
// non-string through — `[]` and `{}` are truthy, so they survived the
|
|
191
|
+
// `!rawFilePath` check and threw inside path.isAbsolute() below, landing in
|
|
192
|
+
// this script's outer `catch { process.exit(0) }`. That is the same
|
|
193
|
+
// crash-to-allow #2547 closes elsewhere, reached through the guard's own
|
|
194
|
+
// read rather than through normalization, and it is NOT closed by making
|
|
195
|
+
// `path` authoritative: normalization returns early for native Claude Code
|
|
196
|
+
// payloads (KIMI_TOOL_NAMES has no 'Edit' entry), so `{"tool_name":"Edit",
|
|
197
|
+
// "tool_input":{"file_path":[]}}` reached it untouched — this guard's
|
|
198
|
+
// original #260 surface. Same shape as hooks/gsd-windsurf-pre-write.js:75.
|
|
199
|
+
const rawFilePath = typeof data.tool_input?.file_path === 'string'
|
|
200
|
+
? data.tool_input.file_path
|
|
201
|
+
: '';
|
|
95
202
|
if (!rawFilePath) {
|
|
96
203
|
process.exit(0);
|
|
97
204
|
}
|
|
98
205
|
|
|
99
|
-
// Relative paths
|
|
206
|
+
// Relative paths resolve against the tool's CWD, which is inside the worktree
|
|
207
|
+
// — so under the runtime this guard was written for they cannot leave it.
|
|
208
|
+
//
|
|
209
|
+
// #2595 (review Minor 5) — state the premise rather than leave it implicit,
|
|
210
|
+
// because THIS PR is what widened the guard's reach to Kimi. "Always safe"
|
|
211
|
+
// holds only while every runtime reaching here either rejects relative paths
|
|
212
|
+
// or resolves them against the worktree CWD. Claude Code's Edit/Write require
|
|
213
|
+
// an absolute file_path, so the original #260 surface satisfies it by
|
|
214
|
+
// construction. kimi-cli's StrReplaceFile takes `path` with no documented
|
|
215
|
+
// absoluteness guarantee, and its resolution behaviour is NOT verified here
|
|
216
|
+
// (no source available to this repo at 4a550ef beyond the schema). If it
|
|
217
|
+
// resolves relative paths against anything other than the tool CWD, a
|
|
218
|
+
// `../`-laden path exits 0 at this line and escapes the worktree. Stating a
|
|
219
|
+
// mechanism and an unverified premise — not asserting a live bypass.
|
|
100
220
|
if (!path.isAbsolute(rawFilePath)) {
|
|
101
221
|
process.exit(0);
|
|
102
222
|
}
|
|
@@ -151,6 +271,8 @@ process.stdin.on('end', () => {
|
|
|
151
271
|
`absolute path is not permitted from an isolated executor worktree. Use a relative path.`,
|
|
152
272
|
};
|
|
153
273
|
process.stdout.write(JSON.stringify(output));
|
|
274
|
+
// Kimi feeds stderr (not stdout) back to the model on exit 2.
|
|
275
|
+
process.stderr.write(output.reason);
|
|
154
276
|
process.exit(2);
|
|
155
277
|
}
|
|
156
278
|
// Outside all git repositories — fail open (#1342).
|
|
@@ -177,6 +299,8 @@ process.stdin.on('end', () => {
|
|
|
177
299
|
};
|
|
178
300
|
|
|
179
301
|
process.stdout.write(JSON.stringify(output));
|
|
302
|
+
// Kimi feeds stderr (not stdout) back to the model on exit 2.
|
|
303
|
+
process.stderr.write(output.reason);
|
|
180
304
|
process.exit(2);
|
|
181
305
|
} catch {
|
|
182
306
|
// Silent fail — never block valid tool calls due to hook errors
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// cursor-workspace.js — shared workspace resolution for Cursor lifecycle hooks (#2587).
|
|
2
|
+
//
|
|
3
|
+
// No `gsd-hook-version:` marker, deliberately — matching hooks/lib/git-cmd.js.
|
|
4
|
+
// Neither copy path that stages hooks/lib/*.js substitutes the version
|
|
5
|
+
// placeholder (copyLibDir stamps .sh files only), and the managed-hooks
|
|
6
|
+
// staleness scan covers top-level hooks/ names, not hooks/lib/. A marker here
|
|
7
|
+
// would ship to users as an unsubstituted literal.
|
|
8
|
+
//
|
|
9
|
+
// Cursor invokes hooks with cwd set to the Cursor config dir (~/.cursor) under
|
|
10
|
+
// the cursor-agent CLI, NOT the workspace. Every hook that resolves a project
|
|
11
|
+
// path from process.cwd() therefore missed: sessionStart could only emit its
|
|
12
|
+
// "no .planning/ workflow found" nudge, stop's verify-work reminder never fired,
|
|
13
|
+
// and subagentStart left every subagent without phase context.
|
|
14
|
+
//
|
|
15
|
+
// The hook payload carries the real path in `workspace_roots`. This module is
|
|
16
|
+
// the single place that turns that payload into a project root, so the three
|
|
17
|
+
// hooks cannot drift apart (they previously carried three copies of it).
|
|
18
|
+
//
|
|
19
|
+
// Consumers: gsd-cursor-session-start.js, gsd-cursor-stop.js,
|
|
20
|
+
// gsd-cursor-subagent-start.js. Staged into <config>/hooks/lib/ by
|
|
21
|
+
// writeCursorHooksJson (src/runtime-hooks-surface.cts) alongside the scripts
|
|
22
|
+
// that require it, and registered in the installer's GSD_HOOK_LIB_FILES so
|
|
23
|
+
// uninstall and the manifest manage it.
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the workspace root a Cursor hook should report on.
|
|
32
|
+
*
|
|
33
|
+
* Search order:
|
|
34
|
+
* 1. Each entry of `workspace_roots` that actually carries .planning/STATE.md
|
|
35
|
+
* — so a multi-root workspace whose GSD project is not the first root
|
|
36
|
+
* still resolves.
|
|
37
|
+
* 2. process.cwd(), if IT carries .planning/STATE.md. cwd is a CANDIDATE, not
|
|
38
|
+
* merely the empty-roots fallback: an IDE invocation can supply
|
|
39
|
+
* workspace_roots AND run from the project, and searching roots alone
|
|
40
|
+
* would report "absent" for a project sitting right at cwd — narrower than
|
|
41
|
+
* the pre-fix always-cwd behavior this replaces.
|
|
42
|
+
* 3. The first declared root, else cwd — so the "absent" message still names
|
|
43
|
+
* a sensible directory when there is genuinely no project.
|
|
44
|
+
*
|
|
45
|
+
* Never throws: a malformed payload degrades to cwd rather than wedging the
|
|
46
|
+
* session. (fs.existsSync itself does not throw — it returns false for NUL
|
|
47
|
+
* bytes, over-long components, and non-directory ancestors.)
|
|
48
|
+
*
|
|
49
|
+
* @param {string} rawInput Raw stdin payload as received by the hook.
|
|
50
|
+
* @returns {string} Absolute-or-relative directory to resolve .planning/ against.
|
|
51
|
+
*/
|
|
52
|
+
function resolveWorkspaceRoot(rawInput) {
|
|
53
|
+
let input = {};
|
|
54
|
+
try { input = JSON.parse(rawInput || '{}'); } catch { /* fall back to cwd */ }
|
|
55
|
+
const roots = Array.isArray(input.workspace_roots)
|
|
56
|
+
? input.workspace_roots.filter((r) => typeof r === 'string' && r.length > 0)
|
|
57
|
+
: [];
|
|
58
|
+
for (const root of [...roots, process.cwd()]) {
|
|
59
|
+
if (fs.existsSync(path.join(root, '.planning', 'STATE.md'))) return root;
|
|
60
|
+
}
|
|
61
|
+
return roots[0] || process.cwd();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Convenience: the .planning/STATE.md path for the resolved workspace.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} rawInput Raw stdin payload as received by the hook.
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
function resolveStatePath(rawInput) {
|
|
71
|
+
return path.join(resolveWorkspaceRoot(rawInput), '.planning', 'STATE.md');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { resolveWorkspaceRoot, resolveStatePath };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengsd/gsd-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.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": {
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"eslint-plugin-no-only-tests": "^3.4.0",
|
|
66
66
|
"fast-check": "^4.8.0",
|
|
67
67
|
"globals": "^16.5.0",
|
|
68
|
-
"js-yaml": "^4.2.
|
|
68
|
+
"js-yaml": "^4.2.1",
|
|
69
69
|
"typescript": "^6.0.3",
|
|
70
70
|
"typescript-eslint": "^8.60.0"
|
|
71
71
|
},
|
|
@@ -92,7 +92,8 @@
|
|
|
92
92
|
"gen:plugin-skills": "node scripts/gen-plugin-skills.cjs --write",
|
|
93
93
|
"gen:capability-registry": "node scripts/gen-capability-registry.cjs --write",
|
|
94
94
|
"gen:registry": "node scripts/gen-registry.cjs --write",
|
|
95
|
-
"gen:
|
|
95
|
+
"gen:install-tree": "node scripts/gen-install-tree-fixtures.cjs",
|
|
96
|
+
"regen:derived": "npm run build && npm run gen:registry && node scripts/gen-adr-index.cjs --write && node scripts/gen-capability-matrix.cjs --write && node scripts/gen-inventory-manifest.cjs --write && node scripts/sync-manifest-versions.cjs && npm run gen:install-tree",
|
|
96
97
|
"validate:registry": "node scripts/validate-registry.cjs",
|
|
97
98
|
"prepack": "npm run build:lib",
|
|
98
99
|
"prepare": "npm run build:lib",
|
|
@@ -103,7 +104,7 @@
|
|
|
103
104
|
"lint": "eslint . --cache --cache-location node_modules/.cache/eslint/",
|
|
104
105
|
"lint:fix": "eslint . --fix",
|
|
105
106
|
"lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs",
|
|
106
|
-
"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 && 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/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs",
|
|
107
|
+
"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 && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs",
|
|
107
108
|
"lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs",
|
|
108
109
|
"lint:regression-names": "node scripts/lint-regression-test-names.cjs",
|
|
109
110
|
"lint:descriptions": "node scripts/lint-descriptions.cjs",
|
|
@@ -111,11 +112,10 @@
|
|
|
111
112
|
"lint:test-file-count": "node scripts/lint-test-file-count.cjs",
|
|
112
113
|
"lint:pr-checks": "node scripts/lint-pr-check-project-dir.cjs",
|
|
113
114
|
"lint:changeset": "node scripts/changeset/lint.cjs",
|
|
114
|
-
"lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check && node scripts/check-glossary-refs.cjs --check",
|
|
115
|
+
"lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check && node scripts/check-glossary-refs.cjs --check && node scripts/lint-compiled-artifact-sync.cjs --check",
|
|
115
116
|
"lint:docs": "node scripts/lint-docs-required.cjs",
|
|
116
117
|
"lint:legacy-name": "node scripts/lint-legacy-dir-name.cjs",
|
|
117
118
|
"ci:test-scope": "node scripts/ci-test-scope.cjs",
|
|
118
|
-
"size:baseline": "node scripts/update-size-baseline.cjs",
|
|
119
119
|
"changeset": "node scripts/changeset/new.cjs",
|
|
120
120
|
"changelog:render": "node scripts/changeset/cli.cjs render",
|
|
121
121
|
"test": "node scripts/run-tests.cjs",
|
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
"test:affected": "node scripts/run-affected-tests.cjs",
|
|
128
128
|
"test:coverage": "c8 --check-coverage --lines 70 --branches 60 --reporter text --include 'gsd-core/bin/lib/*.cjs' --exclude 'tests/**' --all node scripts/run-tests.cjs",
|
|
129
129
|
"test:coverage:scripts-floor": "c8 check-coverage --lines 55 --include 'scripts/**/*.cjs' --exclude 'tests/**' --all",
|
|
130
|
-
"test:coverage:unit": "c8 --
|
|
130
|
+
"test:coverage:unit": "c8 --reporter text --reporter json-summary --include 'gsd-core/bin/lib/*.cjs' --exclude 'tests/**' --all node scripts/run-tests.cjs --suite unit && node scripts/check-coverage-gate.cjs",
|
|
131
131
|
"test:coverage:all": "npm run test:coverage",
|
|
132
132
|
"test:mutation": "stryker run",
|
|
133
133
|
"test:mutation:since": "stryker run --incremental --since origin/next"
|