@opengsd/gsd-core 1.13.0 → 1.14.0
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-advisor-researcher.compact.md +85 -0
- package/agents/gsd-ai-researcher.compact.md +96 -0
- package/agents/gsd-assumptions-analyzer.compact.md +81 -0
- package/agents/gsd-code-fixer.compact.md +458 -0
- package/agents/gsd-code-fixer.md +5 -5
- package/agents/gsd-code-reviewer.compact.md +269 -0
- package/agents/gsd-code-reviewer.md +15 -3
- package/agents/gsd-codebase-mapper.compact.md +760 -0
- package/agents/gsd-debug-session-manager.compact.md +345 -0
- package/agents/gsd-doc-classifier.compact.md +192 -0
- package/agents/gsd-doc-synthesizer.compact.md +200 -0
- package/agents/gsd-doc-verifier.compact.md +143 -0
- package/agents/gsd-doc-writer.compact.md +440 -0
- package/agents/gsd-dom-verifier.compact.md +138 -0
- package/agents/gsd-domain-researcher.compact.md +141 -0
- package/agents/gsd-eval-auditor.compact.md +160 -0
- package/agents/gsd-eval-planner.compact.md +137 -0
- package/agents/gsd-framework-selector.compact.md +82 -0
- package/agents/gsd-integration-checker.compact.md +245 -0
- package/agents/gsd-intel-updater.compact.md +226 -0
- package/agents/gsd-mempalace-curator.compact.md +45 -0
- package/agents/gsd-nyquist-auditor.compact.md +179 -0
- package/agents/gsd-pattern-mapper.compact.md +275 -0
- package/agents/gsd-project-researcher.compact.md +587 -0
- package/agents/gsd-research-synthesizer.compact.md +212 -0
- package/agents/gsd-roadmapper.compact.md +454 -0
- package/agents/gsd-roadmapper.md +13 -0
- package/agents/gsd-security-auditor.compact.md +162 -0
- package/agents/gsd-ui-auditor.compact.md +404 -0
- package/agents/gsd-ui-checker.compact.md +277 -0
- package/agents/gsd-ui-researcher.compact.md +282 -0
- package/agents/gsd-user-profiler.compact.md +108 -0
- package/bin/install.js +206 -68
- package/commands/gsd/cleanup.md +1 -0
- package/commands/gsd/code-review.md +2 -1
- package/commands/gsd/complete-milestone.md +1 -0
- package/commands/gsd/config.md +1 -0
- package/commands/gsd/debug.md +1 -0
- package/commands/gsd/graphify.md +1 -0
- package/commands/gsd/health.md +1 -0
- package/commands/gsd/mempalace-capture.md +1 -0
- package/commands/gsd/mempalace-recall.md +1 -0
- package/commands/gsd/new-milestone.md +1 -0
- package/commands/gsd/new-project.md +1 -0
- package/commands/gsd/next.md +1 -0
- package/commands/gsd/pause-work.md +1 -0
- package/commands/gsd/phase.md +1 -0
- package/commands/gsd/pr-branch.md +1 -0
- package/commands/gsd/resume-work.md +1 -0
- package/commands/gsd/review-backlog.md +1 -0
- package/commands/gsd/settings.md +2 -1
- package/commands/gsd/stats.md +1 -0
- package/commands/gsd/thread.md +1 -0
- package/commands/gsd/workspace.md +1 -0
- package/commands/gsd/workstreams.md +1 -0
- package/gsd-core/bin/check-latest-version.cjs +8 -3
- package/gsd-core/bin/gsd-tools.cjs +338 -125
- package/gsd-core/bin/lib/adr-parser.cjs +1 -1
- package/gsd-core/bin/lib/artifacts.cjs +2 -1
- package/gsd-core/bin/lib/audit.cjs +39 -22
- package/gsd-core/bin/lib/broken-windows.cjs +168 -49
- package/gsd-core/bin/lib/capability-lifecycle.cjs +10 -6
- package/gsd-core/bin/lib/capability-loader.cjs +135 -1
- package/gsd-core/bin/lib/capability-registry.cjs +79 -67
- package/gsd-core/bin/lib/capability-source.cjs +19 -2
- package/gsd-core/bin/lib/capability-validator.cjs +14 -1
- package/gsd-core/bin/lib/check-command-router.cjs +113 -36
- package/gsd-core/bin/lib/code-review-depth.cjs +2 -2
- package/gsd-core/bin/lib/commands.cjs +650 -72
- package/gsd-core/bin/lib/config-loader.cjs +1 -0
- package/gsd-core/bin/lib/config.cjs +153 -38
- package/gsd-core/bin/lib/coverage.cjs +1 -1
- package/gsd-core/bin/lib/decisions.cjs +137 -34
- package/gsd-core/bin/lib/external-descriptor-trust.cjs +29 -14
- package/gsd-core/bin/lib/gsd2-import.cjs +1 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +12 -1
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +1 -1
- package/gsd-core/bin/lib/init.cjs +409 -47
- package/gsd-core/bin/lib/install-engine.cjs +16 -3
- package/gsd-core/bin/lib/install-profiles.cjs +14 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +33 -4
- package/gsd-core/bin/lib/loop-resolver.cjs +50 -31
- package/gsd-core/bin/lib/mcp-catalog.cjs +2 -2
- package/gsd-core/bin/lib/milestone.cjs +19 -8
- package/gsd-core/bin/lib/model-resolver.cjs +101 -10
- package/gsd-core/bin/lib/phase-command-router.cjs +7 -1
- package/gsd-core/bin/lib/phase-id.cjs +161 -22
- package/gsd-core/bin/lib/phase-lifecycle.cjs +61 -0
- package/gsd-core/bin/lib/phase.cjs +167 -63
- package/gsd-core/bin/lib/planning-inspect.cjs +34 -18
- package/gsd-core/bin/lib/planning-snapshot.cjs +61 -12
- package/gsd-core/bin/lib/planning-workspace.cjs +50 -1
- package/gsd-core/bin/lib/pristine-baseline.cjs +182 -0
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +91 -4
- package/gsd-core/bin/lib/quick-batch.cjs +1 -1
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +61 -2
- package/gsd-core/bin/lib/research-store.cjs +11 -12
- package/gsd-core/bin/lib/review-lane-invocation.cjs +23 -0
- package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +337 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +56 -15
- package/gsd-core/bin/lib/roadmap.cjs +108 -14
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +27 -10
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +12 -3
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +13 -5
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +193 -4
- package/gsd-core/bin/lib/security.cjs +126 -7
- package/gsd-core/bin/lib/state-document.cjs +130 -28
- package/gsd-core/bin/lib/state-md-schema.cjs +21 -14
- package/gsd-core/bin/lib/state-transition.cjs +142 -28
- package/gsd-core/bin/lib/state.cjs +223 -27
- package/gsd-core/bin/lib/surface.cjs +60 -2
- package/gsd-core/bin/lib/task-command-router.cjs +12 -6
- package/gsd-core/bin/lib/uat.cjs +1 -1
- package/gsd-core/bin/lib/update-context.cjs +30 -24
- package/gsd-core/bin/lib/vendor/js-yaml.cjs +11 -3
- package/gsd-core/bin/lib/verification.cjs +47 -15
- package/gsd-core/bin/lib/verify-command-grounding.cjs +1 -1
- package/gsd-core/bin/lib/verify.cjs +188 -23
- package/gsd-core/bin/lib/workstream-inventory.cjs +1 -0
- package/gsd-core/bin/lib/worktree-safety.cjs +13 -7
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
- package/gsd-core/bin/verify-reapply-patches.cjs +439 -80
- package/gsd-core/references/compact-content-gate.md +66 -0
- package/gsd-core/references/loop-hook-dispatch.md +18 -0
- package/gsd-core/references/model-profiles.md +12 -3
- package/gsd-core/references/planning-config.md +3 -0
- package/gsd-core/references/tdd.md +5 -2
- package/gsd-core/references/thinking-models-planning.md +18 -2
- package/gsd-core/references/verification-patterns.md +17 -4
- package/gsd-core/references/worktree-path-safety.md +112 -2
- package/gsd-core/templates/README.md +7 -1
- package/gsd-core/templates/state.md +6 -3
- package/gsd-core/templates/summary.compact.md +212 -0
- package/gsd-core/templates/user-setup.compact.md +199 -0
- package/gsd-core/templates/user-setup.md +0 -9
- package/gsd-core/workflows/add-todo.md +3 -2
- package/gsd-core/workflows/autonomous.md +13 -10
- package/gsd-core/workflows/check-todos.md +4 -2
- package/gsd-core/workflows/cleanup.md +3 -1
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +7 -0
- package/gsd-core/workflows/code-review-fix.md +3 -3
- package/gsd-core/workflows/code-review.md +156 -30
- package/gsd-core/workflows/complete-milestone/detail/elaboration.md +274 -0
- package/gsd-core/workflows/complete-milestone.md +39 -262
- package/gsd-core/workflows/docs-update/detail/elaboration.md +179 -0
- package/gsd-core/workflows/docs-update.md +14 -155
- package/gsd-core/workflows/execute-phase/detail/elaboration.md +124 -0
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +18 -3
- package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +56 -0
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +7 -2
- package/gsd-core/workflows/execute-phase/steps/executor-progress-policy.md +43 -0
- package/gsd-core/workflows/execute-phase/steps/sequential-root-pin.md +35 -0
- package/gsd-core/workflows/execute-phase.md +53 -152
- package/gsd-core/workflows/execute-plan.md +20 -7
- package/gsd-core/workflows/help/modes/full.compact.md +398 -0
- package/gsd-core/workflows/help.md +1 -1
- package/gsd-core/workflows/map-codebase.md +50 -3
- package/gsd-core/workflows/new-milestone.md +54 -12
- package/gsd-core/workflows/new-project/detail/elaboration.md +216 -0
- package/gsd-core/workflows/new-project.md +32 -202
- package/gsd-core/workflows/plan-phase/detail/elaboration.md +209 -0
- package/gsd-core/workflows/plan-phase.md +22 -181
- package/gsd-core/workflows/pr-branch.md +19 -7
- package/gsd-core/workflows/quick.md +8 -1
- package/gsd-core/workflows/reapply-patches.md +77 -3
- package/gsd-core/workflows/settings.md +18 -5
- package/gsd-core/workflows/update.md +7 -5
- package/gsd-core/workflows/verify-work/detail/elaboration.md +230 -0
- package/gsd-core/workflows/verify-work.md +20 -180
- package/hooks/dist/gsd-agent-isolation-guard.js +42 -16
- package/hooks/dist/gsd-context-monitor.js +88 -15
- package/hooks/dist/gsd-cursor-subagent-start.js +34 -14
- package/hooks/dist/gsd-secret-read-guard.js +44 -18
- package/hooks/dist/gsd-statusline.js +11 -7
- package/hooks/dist/gsd-validate-commit.sh +34 -4
- package/hooks/dist/gsd-worktree-path-guard.js +25 -14
- package/hooks/dist/gsd-write-guard.js +46 -1
- package/hooks/dist/lib/dispatch-identity.js +187 -0
- package/hooks/dist/lib/filename-classification.js +64 -0
- package/hooks/dist/lib/isolation-deny-reason.js +53 -1
- package/hooks/dist/lib/isolation-sentinel.js +58 -19
- package/hooks/gsd-agent-isolation-guard.js +42 -16
- package/hooks/gsd-context-monitor.js +88 -15
- package/hooks/gsd-cursor-subagent-start.js +34 -14
- package/hooks/gsd-secret-read-guard.js +44 -18
- package/hooks/gsd-statusline.js +11 -7
- package/hooks/gsd-validate-commit.sh +34 -4
- package/hooks/gsd-worktree-path-guard.js +25 -14
- package/hooks/gsd-write-guard.js +46 -1
- package/hooks/lib/dispatch-identity.js +187 -0
- package/hooks/lib/filename-classification.js +64 -0
- package/hooks/lib/isolation-deny-reason.js +53 -1
- package/hooks/lib/isolation-sentinel.js +58 -19
- package/package.json +10 -6
- package/scripts/benchmark-compact-content-variants.cjs +298 -0
- package/scripts/benchmark-compact-content.cjs +368 -0
- package/scripts/check-contract-drift.cjs +4 -1
- package/scripts/check-env.cjs +36 -8
- package/scripts/check-glossary-refs.cjs +25 -21
- package/scripts/ci-next-health.cjs +271 -0
- package/scripts/ci-prepare-test-scope.cjs +7 -7
- package/scripts/ci-test-scope.cjs +126 -20
- package/scripts/ci-timeout-report.cjs +1 -1
- package/scripts/diff-touches-shipped-paths.cjs +1 -1
- package/scripts/docs-guard-registry.cjs +7 -2
- package/scripts/gen-adr-index.cjs +8 -2
- package/scripts/gen-inventory-manifest.cjs +12 -0
- package/scripts/gen-platform-conformance-tier.cjs +557 -0
- package/scripts/lib/drift-scan.cjs +1 -1
- package/scripts/lib/macos-conformance-tier.generated.cjs +210 -0
- package/scripts/lib/npm-version-check-diagnosis.cjs +59 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +276 -0
- package/scripts/lib/suite-detection.cjs +32 -0
- package/scripts/lint-allowed-tools-parity.cjs +221 -0
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +19 -2
- package/scripts/lint-phase-id-drift.cjs +338 -13
- package/scripts/lint-response-language-coverage.cjs +9 -3
- package/scripts/lint-source-test-name-collision.cjs +1 -1
- package/scripts/lint-test-file-count.allowlist.json +1 -0
- package/scripts/lint-vendored-deps.cjs +128 -17
- package/scripts/lint-workflow-shellcheck-baseline.json +85 -0
- package/scripts/prompt-injection-scan.sh +14 -0
- package/scripts/workflow-size.cjs +139 -0
- package/skills/gsd-cleanup/SKILL.md +1 -0
- package/skills/gsd-code-review/SKILL.md +2 -1
- package/skills/gsd-complete-milestone/SKILL.md +1 -0
- package/skills/gsd-config/SKILL.md +1 -0
- package/skills/gsd-debug/SKILL.md +1 -0
- package/skills/gsd-graphify/SKILL.md +1 -0
- package/skills/gsd-health/SKILL.md +1 -0
- package/skills/gsd-mempalace-capture/SKILL.md +1 -0
- package/skills/gsd-mempalace-recall/SKILL.md +1 -0
- package/skills/gsd-new-milestone/SKILL.md +1 -0
- package/skills/gsd-new-project/SKILL.md +1 -0
- package/skills/gsd-next/SKILL.md +1 -0
- package/skills/gsd-pause-work/SKILL.md +1 -0
- package/skills/gsd-phase/SKILL.md +1 -0
- package/skills/gsd-pr-branch/SKILL.md +1 -0
- package/skills/gsd-resume-work/SKILL.md +1 -0
- package/skills/gsd-review-backlog/SKILL.md +1 -0
- package/skills/gsd-settings/SKILL.md +2 -1
- package/skills/gsd-stats/SKILL.md +1 -0
- package/skills/gsd-thread/SKILL.md +1 -0
- package/skills/gsd-workspace/SKILL.md +1 -0
- package/skills/gsd-workstreams/SKILL.md +1 -0
- package/vscode/package.json +1 -1
- package/gsd-core/templates/claude-md.md +0 -145
- package/gsd-core/templates/codebase/concerns.md +0 -310
- package/gsd-core/templates/codebase/conventions.md +0 -307
- package/gsd-core/templates/codebase/integrations.md +0 -280
- package/gsd-core/templates/codebase/structure.md +0 -285
- package/gsd-core/templates/codebase/testing.md +0 -480
- package/gsd-core/templates/debug-subagent-prompt.md +0 -91
- package/gsd-core/templates/discovery.md +0 -146
|
@@ -26,12 +26,24 @@
|
|
|
26
26
|
// .env, .secrets, and .env.<suffix> — EXCEPT .env.example / .env.sample /
|
|
27
27
|
// .env.template / .env.dist, which are the non-secret templates GSD's own
|
|
28
28
|
// phase prompt tells executors to read.
|
|
29
|
-
// Stated cost:
|
|
30
|
-
//
|
|
29
|
+
// Stated cost (#4580): the exemption matches the token's FINAL EXTENSION,
|
|
30
|
+
// not the whole name, so the trusted set is `.env.<anything>.example` /
|
|
31
|
+
// `.sample` / `.template` / `.dist` — an unbounded family, not four fixed
|
|
32
|
+
// names. A real secret named `.env.prod-real-secrets.example` is NOT
|
|
33
|
+
// protected, and renaming any secret to end in one of those four
|
|
34
|
+
// extensions bypasses the guard across Read, Grep and Bash alike. This is
|
|
35
|
+
// the deliberate cost of #4580, which fixed the prior whole-name
|
|
36
|
+
// comparison wrongly refusing committed, secret-free templates like
|
|
37
|
+
// `.env.local.example`.
|
|
31
38
|
// A token containing `:` is also tested on the part after its LAST `:`,
|
|
32
39
|
// so `git show HEAD:.env`, `origin/main:config/.env` and `C:\proj\.env`
|
|
33
|
-
// are caught without git-specific parsing.
|
|
34
|
-
// commit message `fix: .env parsing` yields
|
|
40
|
+
// are caught without git-specific parsing. Leading/interior whitespace is
|
|
41
|
+
// still NOT trimmed: the commit message `fix: .env parsing` yields
|
|
42
|
+
// ` .env parsing`, which is prose, not a name. TRAILING dots and spaces ARE
|
|
43
|
+
// stripped from the basename before classification (`.env.`, `.env..`,
|
|
44
|
+
// `.env `, `.env. ` all normalize to `.env`), because Win32 strips trailing
|
|
45
|
+
// dots and spaces from each path component, so these are aliases for the
|
|
46
|
+
// same on-disk file, not distinct names.
|
|
35
47
|
//
|
|
36
48
|
// Bash analysis is a two-pass token scan, not a shell:
|
|
37
49
|
// pass 1 tokenizes with quote state, comments, redirect operators (with fd
|
|
@@ -86,6 +98,7 @@
|
|
|
86
98
|
'use strict';
|
|
87
99
|
|
|
88
100
|
const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js');
|
|
101
|
+
const { finalExtension, normalizeWindowsBasename, lastSegment } = require('./lib/filename-classification.js');
|
|
89
102
|
|
|
90
103
|
// Fail open on a hook-internal error (see header). Declared ONCE so the
|
|
91
104
|
// outer catch states its policy explicitly (#3911).
|
|
@@ -148,21 +161,18 @@ const GLOB_PROBES = [
|
|
|
148
161
|
// ---------------------------------------------------------------------------
|
|
149
162
|
|
|
150
163
|
function isSecretBasename(name) {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
164
|
+
// Win32 strips trailing dots/spaces per path component, so `.env.`,
|
|
165
|
+
// `.env ` etc. resolve to the real `.env` on Windows — normalize FIRST so
|
|
166
|
+
// those aliases can't bypass classification.
|
|
167
|
+
const n = normalizeWindowsBasename(name);
|
|
168
|
+
if (n === '.env' || n === '.secrets') return true;
|
|
169
|
+
if (n.startsWith('.env.')) {
|
|
170
|
+
const suffix = n.slice('.env.'.length);
|
|
171
|
+
return suffix !== '' && !NON_SECRET_ENV_SUFFIXES.has(finalExtension(suffix).toLowerCase());
|
|
155
172
|
}
|
|
156
173
|
return false;
|
|
157
174
|
}
|
|
158
175
|
|
|
159
|
-
// Last `/`- or `\`-separated segment, ignoring trailing separators.
|
|
160
|
-
function lastSegment(tok) {
|
|
161
|
-
const s = tok.replace(/[\\/]+$/, '');
|
|
162
|
-
const i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'));
|
|
163
|
-
return i === -1 ? s : s.slice(i + 1);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
176
|
// True when the token's basename — or the basename of the part after its
|
|
167
177
|
// last `:` (git `<ref>:<path>`, Windows drive) — is a secret name. Folded to
|
|
168
178
|
// lower case once at the top so `.ENV` / `.Secrets` match on the
|
|
@@ -247,7 +257,19 @@ function globAltSelectsSecret(alt) {
|
|
|
247
257
|
if (/^[*?]+$/.test(alt)) return false; // pure wildcard: equivalent to no glob
|
|
248
258
|
const wild = alt.search(/[*?[]/);
|
|
249
259
|
const lit = wild === -1 ? alt : alt.slice(0, wild);
|
|
250
|
-
|
|
260
|
+
// #4580: when there is no wildcard, `alt` (== `lit`) is a WHOLE literal
|
|
261
|
+
// filename, so classify it exactly the same way Read/Bash do (by its
|
|
262
|
+
// FINAL extension, via isSecretBasename) rather than by a `.env.`-prefix
|
|
263
|
+
// heuristic — that heuristic mis-blocked multi-dot templates like
|
|
264
|
+
// `.env.local.example`. When a wildcard IS present, `lit` is only a
|
|
265
|
+
// PARTIAL literal prefix (`.env.local.exam*` can still select
|
|
266
|
+
// `.env.local`), which cannot be classified exactly, so the original
|
|
267
|
+
// conservative prefix rule stays.
|
|
268
|
+
if (wild === -1) {
|
|
269
|
+
if (isSecretBasename(lit)) return true;
|
|
270
|
+
} else if (lit.startsWith('.env.')) {
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
251
273
|
if (lit !== '' && ('.env.'.startsWith(lit) || '.secrets'.startsWith(lit))) return true;
|
|
252
274
|
let re;
|
|
253
275
|
try {
|
|
@@ -260,10 +282,14 @@ function globAltSelectsSecret(alt) {
|
|
|
260
282
|
|
|
261
283
|
// Returns null (allowed), 'secret-read', or 'glob-too-complex'.
|
|
262
284
|
function classifyGrepGlob(glob) {
|
|
263
|
-
|
|
285
|
+
// Segment via the SAME `lastSegment` helper Read/Bash use (namesSecret),
|
|
286
|
+
// rather than a hand-rolled forward-slash-only split — the two used to
|
|
287
|
+
// diverge on a backslash-bearing glob (`config\.env`), which `lastSegment`
|
|
288
|
+
// reduces to `.env` but a `/`-only split left untouched, letting it escape
|
|
289
|
+
// this arm's predicate while Read/Bash still blocked it.
|
|
264
290
|
// Case-fold the last segment (GLOB_PROBES are lower case) so `.ENV*` and
|
|
265
291
|
// `*.ENV` select the secret namespace on case-insensitive filesystems.
|
|
266
|
-
const segment = (
|
|
292
|
+
const segment = lastSegment(glob).toLowerCase();
|
|
267
293
|
const alts = expandBraces(segment);
|
|
268
294
|
if (alts === null) return 'glob-too-complex';
|
|
269
295
|
return alts.some(globAltSelectsSecret) ? 'secret-read' : null;
|
|
@@ -449,13 +449,17 @@ function contextTokenSuffix(currentUsage) {
|
|
|
449
449
|
// --- Compact state format (opt-in) ---------------------------------------------
|
|
450
450
|
|
|
451
451
|
/**
|
|
452
|
-
* Collapse GSD's
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
452
|
+
* Collapse GSD's status value to a single keyword, built on the canonical
|
|
453
|
+
* normalizer (#2162 approval condition): normalizeStateStatus() in
|
|
454
|
+
* state-document.cjs owns the status vocabulary (discussing / planning /
|
|
455
|
+
* executing / verifying / completed / paused) so the two can't drift.
|
|
456
|
+
* #4186: the normalizer recognizes the DECLARED vocabulary by anchored
|
|
457
|
+
* whole-field match — vocabulary values (the state writer persists tokens)
|
|
458
|
+
* collapse to their keyword; free-text narratives are no longer
|
|
459
|
+
* keyword-guessed from substrings (a `.planning/` mention in non-English
|
|
460
|
+
* prose used to render `planning`), and pass through unrecognized to the
|
|
461
|
+
* first-word fallback below. "paused" — the canonical stuck state — is
|
|
462
|
+
* uppercased to PAUSED, the one state worth shouting about. The fallback is
|
|
459
463
|
* capped at 16 chars so a rogue STATE.md can't blow up the line.
|
|
460
464
|
* Returns null for empty input.
|
|
461
465
|
*/
|
|
@@ -69,7 +69,11 @@ if [ -f .planning/config.json ]; then
|
|
|
69
69
|
echo "gsd-validate-commit.sh: could not read .planning/config.json (opt-in check) — validator disabled for this call. $(cat "$ENABLED_ERR")" >&2
|
|
70
70
|
exit 0
|
|
71
71
|
fi
|
|
72
|
-
|
|
72
|
+
# Pure parameter expansion, not `printf ... | head -1`: same SIGPIPE race
|
|
73
|
+
# class as the SUBJECT extraction below (`echo "$MSG" | head -1`) — CONFIG_OUT
|
|
74
|
+
# is multi-line whenever extra commit types are configured, and `head -1`
|
|
75
|
+
# closing early can SIGPIPE `printf` under `set -euo pipefail`.
|
|
76
|
+
ENABLED="${CONFIG_OUT%%$'\n'*}"
|
|
73
77
|
if [ "$ENABLED" != "1" ]; then exit 0; fi
|
|
74
78
|
# Remaining lines (if any) are the sanitized, deduped configured commit
|
|
75
79
|
# types beyond the 10 built-ins (#3811). Read into a bash-3.2-safe array —
|
|
@@ -214,7 +218,24 @@ if [ "$CLASSIFY_STATUS" = "0" ]; then
|
|
|
214
218
|
# the message — the window a guard must use when the token it scans for
|
|
215
219
|
# is also legal English inside a commit message, but may legally appear
|
|
216
220
|
# on EITHER side of the message on the command line.
|
|
217
|
-
|
|
221
|
+
# Indexed, not searched (#4492). `${CMD#*"$MSG_MATCH"}` is quadratic in
|
|
222
|
+
# the message: bash walks every prefix length and compares the whole
|
|
223
|
+
# matched literal at each one, and MSG_MATCH is BASH_REMATCH[0] — the
|
|
224
|
+
# entire `-m "..."` — so the cost grows with the thing being scanned.
|
|
225
|
+
# Measured on the path EVERY commit takes (conforming and non-conforming
|
|
226
|
+
# cost the same): 10.0 s at a 64 KB message, 22.0 s at 96 KB, 30.2 s at
|
|
227
|
+
# 112 KB. Sizes stop there deliberately — a single argument above Linux's
|
|
228
|
+
# MAX_ARG_STRLEN (131072 on a 4 KB-page kernel) never reaches this code
|
|
229
|
+
# at all, because execve fails and the hook fails open, so a larger
|
|
230
|
+
# "measurement" would be timing the wrong thing.
|
|
231
|
+
#
|
|
232
|
+
# MSG_PREFIX above has already located the match, so the suffix is
|
|
233
|
+
# arithmetic rather than a search: skip the prefix and the match. This
|
|
234
|
+
# removes the quadratic SEARCH; the expansion still counts characters and
|
|
235
|
+
# materialises a substring, so it is linear in the command, not O(1).
|
|
236
|
+
# Same first-occurrence assumption both expansions here always made —
|
|
237
|
+
# MSG_MATCH is a literal substring of CMD by construction.
|
|
238
|
+
MSG_SUFFIX="${CMD:$(( ${#MSG_PREFIX} + ${#MSG_MATCH} ))}"
|
|
218
239
|
# LINE CONTINUATIONS ARE NOT SEPARATORS (review of #3816, rounds 8 and 9).
|
|
219
240
|
# `git commit \` newline ` -m "$(cat <<'EOF' …` is an ordinary way to
|
|
220
241
|
# spread an invocation over lines, and every guard below reads a newline in
|
|
@@ -521,9 +542,18 @@ if [ "$CLASSIFY_STATUS" = "0" ]; then
|
|
|
521
542
|
SUBJECT=$(GIT_CMD_LIB="$HOOK_DIR/lib/git-cmd.js" MSG="$MSG" node -e "
|
|
522
543
|
const {resolveCommitSubject}=require(process.env.GIT_CMD_LIB);
|
|
523
544
|
process.stdout.write(resolveCommitSubject(process.env.MSG));
|
|
524
|
-
" 2>/dev/null) || SUBJECT
|
|
545
|
+
" 2>/dev/null) || SUBJECT="${MSG%%$'\n'*}"
|
|
525
546
|
else
|
|
526
|
-
|
|
547
|
+
# Pure parameter expansion, not `echo "$MSG" | head -1`: that pipeline
|
|
548
|
+
# raced a SIGPIPE under `set -euo pipefail` whenever $MSG had a body
|
|
549
|
+
# (the common case) — `head -1` can close its read end as soon as it
|
|
550
|
+
# has the first line, and if `echo`'s write lands after that close,
|
|
551
|
+
# `echo` dies with signal 13 (exit 141), which is NOT suppressed by
|
|
552
|
+
# `set -e` and aborted the whole hook intermittently (observed in
|
|
553
|
+
# tests/hooks-opt-in.test.cjs's --fixup=HEAD "round 7" case). Zero
|
|
554
|
+
# subprocesses here means zero pipe/race surface. Equivalent to
|
|
555
|
+
# `head -1` for single-line, multi-line, and trailing-newline input.
|
|
556
|
+
SUBJECT="${MSG%%$'\n'*}"
|
|
527
557
|
fi
|
|
528
558
|
# Single source of truth for the accepted commit-type list (#3811): the
|
|
529
559
|
# 10 built-ins plus whatever passed the safe-token filter above. Both the
|
|
@@ -163,17 +163,37 @@ process.stdin.on('end', () => {
|
|
|
163
163
|
// returns a path containing .git/worktrees/ as a component.
|
|
164
164
|
// In the main repo or a submodule it returns .git (or a path without /worktrees/).
|
|
165
165
|
// This approach works even when cwd is a subdirectory of the worktree.
|
|
166
|
-
|
|
166
|
+
// Combined into one spawn — git rev-parse accepts multiple query flags in
|
|
167
|
+
// one invocation and prints one line of output per flag, in the exact
|
|
168
|
+
// order given, reducing this guard's worst-case subprocess count under
|
|
169
|
+
// CI/load contention (three spawns collapse into one). `--abbrev-ref HEAD`
|
|
170
|
+
// is used instead of `symbolic-ref --short HEAD` because it is combinable
|
|
171
|
+
// (a single `rev-parse` call) and behaviorally equivalent for this guard's
|
|
172
|
+
// branch-acceptance check, including on detached HEAD: `--abbrev-ref`
|
|
173
|
+
// returns the literal string `HEAD` there (exit 0), which the acceptance
|
|
174
|
+
// regex below also rejects — the same guard outcome as symbolic-ref's
|
|
175
|
+
// exit-128/empty-stdout failure. Do not change any timeout value as part
|
|
176
|
+
// of this change, only the spawn count.
|
|
177
|
+
const combinedResult = git(['rev-parse', '--git-dir', '--abbrev-ref', 'HEAD', '--show-toplevel'], cwd);
|
|
167
178
|
// #3911: a timeout/spawn-failure result is indistinguishable from a clean
|
|
168
179
|
// "not a git repo" answer by status/stdout alone — reportIfUndetermined
|
|
169
180
|
// is a no-op on a genuine negative and only fires the diagnostic when the
|
|
170
181
|
// probe itself could not run. The allow() below is UNCHANGED either way.
|
|
171
|
-
reportIfUndetermined(
|
|
172
|
-
|
|
182
|
+
reportIfUndetermined(
|
|
183
|
+
'gsd-worktree-path-guard',
|
|
184
|
+
'git rev-parse --git-dir --abbrev-ref HEAD --show-toplevel',
|
|
185
|
+
combinedResult
|
|
186
|
+
);
|
|
187
|
+
if (combinedResult.status !== 0 || !combinedResult.stdout) {
|
|
173
188
|
allow(undefined); // not a git repo — pass through
|
|
174
189
|
}
|
|
175
190
|
|
|
176
|
-
const
|
|
191
|
+
const combinedLines = combinedResult.stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
|
|
192
|
+
if (combinedLines.length < 3) {
|
|
193
|
+
allow(undefined); // malformed/short output — can't determine root, fail open
|
|
194
|
+
}
|
|
195
|
+
const [gitDir, branch, wtTopRaw] = combinedLines;
|
|
196
|
+
|
|
177
197
|
// A linked worktree's --git-dir contains .git/worktrees/ as a path component
|
|
178
198
|
const isLinkedWorktree = /[/\\]\.git[/\\]worktrees[/\\]/.test(gitDir);
|
|
179
199
|
if (!isLinkedWorktree) {
|
|
@@ -186,23 +206,14 @@ process.stdin.on('end', () => {
|
|
|
186
206
|
// created linked worktree (plain non-GSD work, e.g. Claude Code plan-mode) is
|
|
187
207
|
// on the user's own branch, so the guard must be a no-op there. Detached HEAD
|
|
188
208
|
// / error → not GSD-managed → no-op.
|
|
189
|
-
const branchResult = git(['symbolic-ref', '--short', 'HEAD'], cwd);
|
|
190
|
-
reportIfUndetermined('gsd-worktree-path-guard', 'git symbolic-ref --short HEAD', branchResult);
|
|
191
|
-
const branch = branchResult.status === 0 && branchResult.stdout ? branchResult.stdout.trim() : '';
|
|
192
209
|
// #3021: accept worktree-wf_<runid>-<n> branches (Workflow backend's naming).
|
|
193
210
|
if (!/^((worktree-)?agent-|worktree-wf_)[A-Za-z0-9._/-]+$/.test(branch)) {
|
|
194
211
|
allow(undefined); // not a GSD-managed executor worktree — no-op
|
|
195
212
|
}
|
|
196
213
|
|
|
197
|
-
//
|
|
214
|
+
// wtTopRaw: the raw --show-toplevel output for the worktree (cwd).
|
|
198
215
|
// We keep it raw (not path.resolve'd) to compare directly with the
|
|
199
216
|
// file's toplevel — same git binary, same format, no normalization needed.
|
|
200
|
-
const wtTopResult = git(['rev-parse', '--show-toplevel'], cwd);
|
|
201
|
-
reportIfUndetermined('gsd-worktree-path-guard', 'git rev-parse --show-toplevel (worktree cwd)', wtTopResult);
|
|
202
|
-
if (wtTopResult.status !== 0 || !wtTopResult.stdout) {
|
|
203
|
-
allow(undefined); // can't determine root — fail open
|
|
204
|
-
}
|
|
205
|
-
const wtTopRaw = wtTopResult.stdout.trim();
|
|
206
217
|
|
|
207
218
|
// #2595 (review Major 3): read the field TYPED. `?.file_path || ''` let a
|
|
208
219
|
// non-string through — `[]` and `{}` are truthy, so they survived the
|
|
@@ -105,10 +105,37 @@ const FLOOR_LINES = 40;
|
|
|
105
105
|
// default to, a differently-cased path is the SAME real file — a Write to
|
|
106
106
|
// '.planning/roadmap.md' clobbers ROADMAP.md while a case-sensitive match
|
|
107
107
|
// waves it through.
|
|
108
|
+
// #4455: workstream-scoped (and optionally project-scoped) variants —
|
|
109
|
+
// planningDir(cwd) (src/planning-workspace.cts) resolves to
|
|
110
|
+
// `.planning/[<project>/]workstreams/<ws>/...` whenever GSD_WORKSTREAM is
|
|
111
|
+
// set. Before this, none of these three root-only patterns matched a
|
|
112
|
+
// workstream-scoped target at all, so the ENTIRE guard (not just the
|
|
113
|
+
// sentinel step — the shrink-ratio check too) silently never engaged for a
|
|
114
|
+
// workstream-scoped ROADMAP.md/STATE.md/milestone-archive Write: exactly
|
|
115
|
+
// the catastrophic-shrink scenario this file exists to stop, unguarded
|
|
116
|
+
// under an active workstream. consumeSentinelFor's own `.planning`
|
|
117
|
+
// derivation below is unaffected by this addition — it locates the single
|
|
118
|
+
// outer `.planning` segment regardless of what's nested inside it, which is
|
|
119
|
+
// also where the workflow's sentinel `printf` already writes, so no change
|
|
120
|
+
// was needed there.
|
|
121
|
+
//
|
|
122
|
+
// Same gap exists one level up: planningDir(cwd) ALSO resolves to
|
|
123
|
+
// `.planning/<project>/...` when GSD_PROJECT is set with NO GSD_WORKSTREAM
|
|
124
|
+
// (project-only mode — the two env vars are independent; see planningDir's
|
|
125
|
+
// own body). None of the patterns above cover that shape either. Found
|
|
126
|
+
// during #4455's own review pass (same root cause, one more path variant)
|
|
127
|
+
// — fixed in the same change rather than deferred, since it is the
|
|
128
|
+
// identical defect class this PR already exists to close.
|
|
108
129
|
const CURATED_PATTERNS = [
|
|
109
130
|
/(?:^|\/)\.planning\/ROADMAP\.md$/i,
|
|
110
131
|
/(?:^|\/)\.planning\/STATE\.md$/i,
|
|
111
132
|
/(?:^|\/)\.planning\/milestones\/[^/]+-ROADMAP\.md$/i,
|
|
133
|
+
/(?:^|\/)\.planning\/(?:[^/]+\/)?workstreams\/[^/]+\/ROADMAP\.md$/i,
|
|
134
|
+
/(?:^|\/)\.planning\/(?:[^/]+\/)?workstreams\/[^/]+\/STATE\.md$/i,
|
|
135
|
+
/(?:^|\/)\.planning\/(?:[^/]+\/)?workstreams\/[^/]+\/milestones\/[^/]+-ROADMAP\.md$/i,
|
|
136
|
+
/(?:^|\/)\.planning\/[^/]+\/ROADMAP\.md$/i,
|
|
137
|
+
/(?:^|\/)\.planning\/[^/]+\/STATE\.md$/i,
|
|
138
|
+
/(?:^|\/)\.planning\/[^/]+\/milestones\/[^/]+-ROADMAP\.md$/i,
|
|
112
139
|
];
|
|
113
140
|
|
|
114
141
|
// Count logical lines, ignoring a single trailing newline so that
|
|
@@ -156,7 +183,25 @@ function consumeSentinelFor(filePath, normalized) {
|
|
|
156
183
|
// Path-bound: the token names exactly one file, resolved against the
|
|
157
184
|
// .planning/ dir's parent (repo root) — same case-insensitive stance as
|
|
158
185
|
// the curated match itself.
|
|
159
|
-
|
|
186
|
+
let namedPath = path.resolve(path.join(planningDir, '..'), token);
|
|
187
|
+
// Symmetry with the caller's own resolution (#4455 CI finding, macOS
|
|
188
|
+
// full-test shard): `filePath`/`normalized` were already realpath-resolved
|
|
189
|
+
// before this function was called (round 9 Minor 1's symlink-before-match
|
|
190
|
+
// fix), but `token` — typically an already-absolute path composed by the
|
|
191
|
+
// workflow's own init.* fields — was compared WITHOUT that same
|
|
192
|
+
// resolution. Wherever cwd sits under a symlink (macOS's /var ->
|
|
193
|
+
// /private/var is the common case, since that's exactly what os.tmpdir()
|
|
194
|
+
// resolves through, but any symlinked project/worktree checkout hits the
|
|
195
|
+
// same asymmetry), the token names the lexical path while `normalized`
|
|
196
|
+
// names the realpath — a validly-armed sentinel then never matches, and a
|
|
197
|
+
// legitimate milestone-reset Write stays incorrectly blocked. The named
|
|
198
|
+
// file is already known to exist (the caller only reaches this function
|
|
199
|
+
// after successfully reading it), so realpath is expected to succeed;
|
|
200
|
+
// keep the lexical path on failure, matching the caller's own fallback.
|
|
201
|
+
try {
|
|
202
|
+
namedPath = fs.realpathSync(namedPath);
|
|
203
|
+
} catch { /* keep the lexical path */ }
|
|
204
|
+
const namedNorm = namedPath.replace(/\\/g, '/').toLowerCase();
|
|
160
205
|
if (namedNorm !== normalized.toLowerCase()) {
|
|
161
206
|
return false; // armed for a different file — leave it for that write
|
|
162
207
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// hooks/lib/dispatch-identity.js — the ONE canonical owner of the
|
|
3
|
+
// `[gsd:dispatch phase="…" plan="…"]` marker format and its prose fallback
|
|
4
|
+
// (#4594, epic #4630 Phase 1). See
|
|
5
|
+
// `.gsd/phase/fix-4594-dispatch-identity-seam/40-design.md` for the full
|
|
6
|
+
// rationale (behavior table, negative space, rejected alternatives).
|
|
7
|
+
//
|
|
8
|
+
// COLD-LOAD CONSTRAINT (load-bearing, not a style choice): this module MUST
|
|
9
|
+
// require NOTHING — no `fs`, no `path`, and above all nothing under
|
|
10
|
+
// `gsd-core/bin/lib/` or `ensure-runtime-build`. The guard hooks that consume
|
|
11
|
+
// this module must load on a raw plugin-marketplace install where the
|
|
12
|
+
// compiled lib is absent and the self-healing build seam has not run — a
|
|
13
|
+
// hook that dies at module load is worse than one carrying a mirror. See
|
|
14
|
+
// `tests/dispatch-identity.test.cjs`'s "cold tree load" test, which asserts
|
|
15
|
+
// this by monkeypatching `Module._load` to throw on exactly those paths.
|
|
16
|
+
|
|
17
|
+
// DISPATCH_PHASE_TOKEN_SOURCE is a DELIBERATE MIRROR of
|
|
18
|
+
// `CASE_FLEXIBLE_PHASE_NUMBER_TOKEN_SOURCE` in `src/phase-id.cts`
|
|
19
|
+
// (ADR-2121 owns the phase-token grammar; `gsd-core/bin/lib/phase-id.cjs` is
|
|
20
|
+
// its compiled form). It cannot be an `require()`-based import: importing the
|
|
21
|
+
// compiled lib here would violate the cold-load constraint above (either a
|
|
22
|
+
// direct dependency on `gsd-core/bin/lib/` or a forced self-heal via
|
|
23
|
+
// `ensure-runtime-build`), which is exactly the failure mode this module
|
|
24
|
+
// exists to avoid for the guard hooks that consume it.
|
|
25
|
+
//
|
|
26
|
+
// Because this is a hand-copied mirror and not a shared reference, a
|
|
27
|
+
// hand-edited grammar change on one side that is not mirrored on the other
|
|
28
|
+
// does NOT fail loudly — both sides remain independently valid regex
|
|
29
|
+
// sources, so the failure mode is a silent, invisible non-match (a dispatch
|
|
30
|
+
// whose phase token the two owners now parse differently), never a thrown
|
|
31
|
+
// error. `tests/dispatch-identity.test.cjs`'s "templates: prose token source
|
|
32
|
+
// matches the case-flexible phase-id grammar" test pins this string equal to
|
|
33
|
+
// the compiled source at test time specifically to turn that silent drift
|
|
34
|
+
// into a loud, in-CI failure.
|
|
35
|
+
const DISPATCH_PHASE_TOKEN_SOURCE = '\\d+[A-Za-z]?(?:\\.\\d+)*';
|
|
36
|
+
|
|
37
|
+
// Bounded marker grammar: `[gsd:dispatch key="value" key2="value2"]`.
|
|
38
|
+
// - Key names bounded to {1,31} — no key we emit or expect is anywhere near
|
|
39
|
+
// that long; this is purely a backstop against pathological input.
|
|
40
|
+
// - Values bounded to {0,200} and forbidden from containing `"`, `]`, or any
|
|
41
|
+
// control character that could either close the marker early or forge a
|
|
42
|
+
// sibling key — enforced structurally by the negated character class, not
|
|
43
|
+
// by a separate validation pass.
|
|
44
|
+
// Global flag so `findMarker` can scan forward through a large prompt.
|
|
45
|
+
const MARKER_RE = /\[gsd:dispatch((?:\s+[A-Za-z][A-Za-z0-9_-]{0,31}="[^"\]\r\n]{0,200}")*)\s*\]/g;
|
|
46
|
+
const MARKER_KV_RE = /([A-Za-z][A-Za-z0-9_-]{0,31})="([^"\]\r\n]{0,200})"/g;
|
|
47
|
+
|
|
48
|
+
// Prose fallback frame: `execute plan <token> of phase <PHASE TOKEN>`.
|
|
49
|
+
// - Case-insensitive, `\s+` throughout so CRLF (and any run of whitespace)
|
|
50
|
+
// parses identically to LF.
|
|
51
|
+
// - The plan token is bounded (`\S{1,80}`) but its VALUE is deliberately
|
|
52
|
+
// discarded by the caller (see `parseDispatchIdentity` below) — captured
|
|
53
|
+
// only so the phase token can be anchored correctly after it.
|
|
54
|
+
// - The phase token is bounded by `DISPATCH_PHASE_TOKEN_SOURCE`, replacing
|
|
55
|
+
// the old greedy `(\S+)`, so a directory-name suffix (`-auth`) or a
|
|
56
|
+
// sentence-terminating period is never swept into the token.
|
|
57
|
+
const PROSE_RE = new RegExp(
|
|
58
|
+
`execute\\s+plan\\s+(\\S{1,80})\\s+of\\s+phase\\s+(${DISPATCH_PHASE_TOKEN_SOURCE})`,
|
|
59
|
+
'i',
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Render the `[gsd:dispatch phase="…" plan="…"]` marker for a producer to
|
|
64
|
+
* embed verbatim in a dispatch description/prompt. Never throws.
|
|
65
|
+
*
|
|
66
|
+
* Emits only keys whose value is a non-empty string not containing `"`, `]`,
|
|
67
|
+
* `\r`, or `\n` — such a value is UNUSABLE and that key is omitted entirely,
|
|
68
|
+
* because embedding it could close the marker early and forge a second,
|
|
69
|
+
* attacker-controlled field. Key order is always `phase` then `plan`.
|
|
70
|
+
* Returns `''` when neither key is usable (nothing worth emitting).
|
|
71
|
+
*/
|
|
72
|
+
function renderDispatchIdentityMarker(input) {
|
|
73
|
+
const value = input && typeof input === 'object' ? input : {};
|
|
74
|
+
const parts = [];
|
|
75
|
+
for (const key of ['phase', 'plan']) {
|
|
76
|
+
const v = value[key];
|
|
77
|
+
if (typeof v === 'string' && v.length > 0 && !/["\]\r\n]/.test(v)) {
|
|
78
|
+
parts.push(`${key}="${v}"`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (parts.length === 0) return '';
|
|
82
|
+
return `[gsd:dispatch ${parts.join(' ')}]`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function emptyResult() {
|
|
86
|
+
return { phase: null, plan: null, source: null };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Scan `texts` in order for the first well-formed marker that yields at
|
|
91
|
+
* least one recognized key (`phase` and/or `plan`). Returns `{ phase, plan }`
|
|
92
|
+
* (one of the two possibly null, never both) or `null` if no QUALIFYING
|
|
93
|
+
* marker was found in any text. Unrecognized keys inside a marker are
|
|
94
|
+
* ignored (forward compatibility — a later `run=`/`wave=` key must not break
|
|
95
|
+
* a deployed parser).
|
|
96
|
+
*
|
|
97
|
+
* #4594 F1 fix: a marker that matches `MARKER_RE` but carries neither
|
|
98
|
+
* `phase=` nor `plan=` (e.g. only unrecognized keys, or an empty kv block)
|
|
99
|
+
* is NOT treated as "found" — it is skipped and scanning continues (later
|
|
100
|
+
* markers in the same text, then subsequent texts), falling through to the
|
|
101
|
+
* prose fallback if nothing qualifying turns up. Without this, prompt text
|
|
102
|
+
* that merely CONTAINS the literal marker syntax with no usable identifiers
|
|
103
|
+
* silently suppressed the prose fallback entirely, since the old
|
|
104
|
+
* implementation returned on the first syntactic match regardless of
|
|
105
|
+
* content.
|
|
106
|
+
*/
|
|
107
|
+
function findMarker(texts) {
|
|
108
|
+
for (const text of texts) {
|
|
109
|
+
if (typeof text !== 'string' || text.length === 0) continue;
|
|
110
|
+
MARKER_RE.lastIndex = 0;
|
|
111
|
+
let match;
|
|
112
|
+
while ((match = MARKER_RE.exec(text)) !== null) {
|
|
113
|
+
const kvBlock = match[1] || '';
|
|
114
|
+
let phase = null;
|
|
115
|
+
let plan = null;
|
|
116
|
+
MARKER_KV_RE.lastIndex = 0;
|
|
117
|
+
let kv;
|
|
118
|
+
while ((kv = MARKER_KV_RE.exec(kvBlock)) !== null) {
|
|
119
|
+
const [, key, val] = kv;
|
|
120
|
+
if (key === 'phase' && phase === null) phase = val;
|
|
121
|
+
else if (key === 'plan' && plan === null) plan = val;
|
|
122
|
+
}
|
|
123
|
+
if (phase === null && plan === null) continue;
|
|
124
|
+
return { phase, plan };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Scan `texts` in order for the first `execute plan <token> of phase
|
|
132
|
+
* <PHASE TOKEN>` frame. Returns `{ phase }` or `null`.
|
|
133
|
+
*
|
|
134
|
+
* The prose fallback returns `plan: null` DELIBERATELY (enforced by the
|
|
135
|
+
* caller, not here): the prose plan token (e.g. `02`) is a bare in-phase
|
|
136
|
+
* plan number, a different namespace from the sentinel's `plan_id` (e.g.
|
|
137
|
+
* `03-02-hardening`, which is phase-prefixed AND slugged). Reporting the
|
|
138
|
+
* prose plan token as the parsed `plan` is exactly the false-mismatch bug
|
|
139
|
+
* this module exists to fix — an absent value is honestly "cannot compare",
|
|
140
|
+
* while a wrong value silently forges a mismatch on every dispatch. Do not
|
|
141
|
+
* "fix" this by threading the plan token through — that was the bug.
|
|
142
|
+
*/
|
|
143
|
+
function findProse(texts) {
|
|
144
|
+
for (const text of texts) {
|
|
145
|
+
if (typeof text !== 'string' || text.length === 0) continue;
|
|
146
|
+
const match = PROSE_RE.exec(text);
|
|
147
|
+
if (match) {
|
|
148
|
+
return { phase: match[2] };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Parse dispatch identity out of one or more texts (a short description, a
|
|
156
|
+
* full prompt body, etc). Never throws for any input type; non-string
|
|
157
|
+
* entries are skipped.
|
|
158
|
+
*
|
|
159
|
+
* Pass 1: scan all texts in order for a well-formed marker; the first one
|
|
160
|
+
* wins outright (marker beats prose even if prose appears earlier in the
|
|
161
|
+
* same text — see the design doc's row 8).
|
|
162
|
+
* Pass 2 (only if no marker was found anywhere): scan all texts in order for
|
|
163
|
+
* the prose frame; the first match wins. `plan` is always `null` from this
|
|
164
|
+
* path (see `findProse`'s doc comment).
|
|
165
|
+
*
|
|
166
|
+
* Returns `{ phase, plan, source }` where `source` is `'marker' | 'prose' |
|
|
167
|
+
* null`.
|
|
168
|
+
*/
|
|
169
|
+
function parseDispatchIdentity(...texts) {
|
|
170
|
+
const marker = findMarker(texts);
|
|
171
|
+
if (marker) {
|
|
172
|
+
return { phase: marker.phase, plan: marker.plan, source: 'marker' };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const prose = findProse(texts);
|
|
176
|
+
if (prose) {
|
|
177
|
+
return { phase: prose.phase, plan: null, source: 'prose' };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return emptyResult();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = {
|
|
184
|
+
DISPATCH_PHASE_TOKEN_SOURCE,
|
|
185
|
+
renderDispatchIdentityMarker,
|
|
186
|
+
parseDispatchIdentity,
|
|
187
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// hooks/lib/filename-classification.js — hand-written, NOT generated. One
|
|
3
|
+
// tiny, deliberately-named filename slicer.
|
|
4
|
+
//
|
|
5
|
+
// WHY this exists: issue #4580 was caused by comparing "everything after the
|
|
6
|
+
// `.env.` prefix" — a multi-segment token like `local.example` — against a
|
|
7
|
+
// set whose members are FINAL EXTENSIONS (`example`). `.env.local.example`
|
|
8
|
+
// was classified by its full `local.example` tail, which is not in a set
|
|
9
|
+
// built from bare extensions, so the comparison silently failed. This module
|
|
10
|
+
// deliberately exposes ONLY the final-extension answer so the wrong token
|
|
11
|
+
// can't be picked by accident at a call site. The "everything after the
|
|
12
|
+
// first dot" form is intentionally NOT exported: no caller needs it, and an
|
|
13
|
+
// unused export is dead code.
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The segment after the LAST dot in `name`. A string with no dot IS its own
|
|
17
|
+
* final extension. Inert on non-strings/empty input: never throws, returns
|
|
18
|
+
* ''.
|
|
19
|
+
*
|
|
20
|
+
* @param {*} name
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
function finalExtension(name) {
|
|
24
|
+
if (typeof name !== 'string' || name === '') return '';
|
|
25
|
+
const i = name.lastIndexOf('.');
|
|
26
|
+
return i === -1 ? name : name.slice(i + 1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Strips ALL trailing dots and spaces from `name`, repeatedly, from the end
|
|
31
|
+
* of the basename.
|
|
32
|
+
*
|
|
33
|
+
* WHY: Win32 strips trailing dots and trailing spaces from each path
|
|
34
|
+
* component when resolving a filesystem path — `.env.`, `.env..`, `.env `,
|
|
35
|
+
* and `.env. ` all resolve to the same on-disk file as `.env` on Windows.
|
|
36
|
+
* These are therefore ALIASES for the protected name, not distinct names,
|
|
37
|
+
* and a guard that classifies the literal string without normalizing first
|
|
38
|
+
* can be bypassed by any of them. This is not cosmetic tidying — it closes
|
|
39
|
+
* that Windows path-alias bypass.
|
|
40
|
+
*
|
|
41
|
+
* This runs UNCONDITIONALLY on every host platform (macOS, Linux, Windows),
|
|
42
|
+
* not only when actually running on Windows: the guard must behave
|
|
43
|
+
* identically everywhere, and a name is judged by what Win32 would resolve
|
|
44
|
+
* it to, regardless of what OS the hook happens to run on.
|
|
45
|
+
*
|
|
46
|
+
* @param {*} name
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
function normalizeWindowsBasename(name) {
|
|
50
|
+
if (typeof name !== 'string' || name === '') return '';
|
|
51
|
+
let end = name.length;
|
|
52
|
+
while (end > 0 && (name[end - 1] === '.' || name[end - 1] === ' ')) end--;
|
|
53
|
+
return name.slice(0, end);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Last `/`- or `\`-separated segment, ignoring trailing separators. A string
|
|
57
|
+
// with no separator IS its own last segment.
|
|
58
|
+
function lastSegment(tok) {
|
|
59
|
+
const s = tok.replace(/[\\/]+$/, '');
|
|
60
|
+
const i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'));
|
|
61
|
+
return i === -1 ? s : s.slice(i + 1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { finalExtension, normalizeWindowsBasename, lastSegment };
|
|
@@ -36,4 +36,56 @@ const REASON_CODE = Object.freeze({
|
|
|
36
36
|
NOT_ISOLATED_WORKTREE: 'not_isolated_worktree',
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
// #4594 row 15 / F2: values interpolated into a deny reason (sentinel/dispatch
|
|
40
|
+
// phase and plan) come from a sentinel file on disk and, transitively, from
|
|
41
|
+
// model-authored prompt text, neither of which is trusted — bound length and
|
|
42
|
+
// strip control characters/newlines so a crafted value cannot forge extra
|
|
43
|
+
// lines or otherwise inject content into the guard's stdout/stderr message
|
|
44
|
+
// (same discipline as escaping an untrusted token before embedding it in a
|
|
45
|
+
// message, e.g. phase-plan-index's `depends_on` warning).
|
|
46
|
+
//
|
|
47
|
+
// Originally duplicated byte-for-byte in hooks/gsd-agent-isolation-guard.js
|
|
48
|
+
// and hooks/gsd-cursor-subagent-start.js (#4594 F2 review finding) — both
|
|
49
|
+
// hooks already require this dependency-free module, so there is no
|
|
50
|
+
// cold-load justification for the duplication the way there is for
|
|
51
|
+
// hooks/lib/dispatch-identity.js's grammar mirror. Moved here as the single
|
|
52
|
+
// owner; both hooks now import it.
|
|
53
|
+
const REASON_INTERPOLATION_MAX_LEN = 64;
|
|
54
|
+
|
|
55
|
+
// F6: also strip Unicode line/paragraph separators (U+2028/U+2029) and the
|
|
56
|
+
// bidi-override/isolate control characters (U+202A-U+202E, U+2066-U+2069) —
|
|
57
|
+
// none of these are in `[\x00-\x1f\x7f]`, so a crafted sentinel or dispatch
|
|
58
|
+
// value carrying them could still visually reflow the deny message onto a
|
|
59
|
+
// new line or reverse/hide part of it despite the ASCII control-char strip.
|
|
60
|
+
const REASON_UNSAFE_CHARS_RE = new RegExp(
|
|
61
|
+
// eslint-disable-next-line no-control-regex -- deliberately stripping control chars/newlines
|
|
62
|
+
'[\\x00-\\x1f\\x7f\\u2028\\u2029\\u202a-\\u202e\\u2066-\\u2069]',
|
|
63
|
+
'g',
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
function sanitizeForReason(value) {
|
|
67
|
+
if (typeof value !== 'string' || value.length === 0) return '(none)';
|
|
68
|
+
const stripped = value.replace(REASON_UNSAFE_CHARS_RE, '');
|
|
69
|
+
return stripped.length > REASON_INTERPOLATION_MAX_LEN
|
|
70
|
+
? `${stripped.slice(0, REASON_INTERPOLATION_MAX_LEN)}…`
|
|
71
|
+
: stripped;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function describeSentinelDiscard(sentinelDiscarded) {
|
|
75
|
+
const sentinelPhase = sanitizeForReason(sentinelDiscarded.sentinel.phase);
|
|
76
|
+
const sentinelPlan = sanitizeForReason(sentinelDiscarded.sentinel.plan);
|
|
77
|
+
const dispatchPhase = sanitizeForReason(sentinelDiscarded.dispatch.phase);
|
|
78
|
+
const dispatchPlan = sanitizeForReason(sentinelDiscarded.dispatch.plan);
|
|
79
|
+
return (
|
|
80
|
+
` A fresh dispatch-isolation sentinel was present but did not apply to this dispatch ` +
|
|
81
|
+
`(sentinel phase="${sentinelPhase}" plan="${sentinelPlan}"; dispatch phase="${dispatchPhase}" ` +
|
|
82
|
+
`plan="${dispatchPlan}"), so it was not consulted.`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = {
|
|
87
|
+
REASON_CODE,
|
|
88
|
+
REASON_INTERPOLATION_MAX_LEN,
|
|
89
|
+
sanitizeForReason,
|
|
90
|
+
describeSentinelDiscard,
|
|
91
|
+
};
|