@opengsd/gsd-core 1.16.0 → 1.17.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.
Files changed (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -2
  3. package/.opencode/plugins/gsd-core.js +251 -1
  4. package/.opencode/plugins/gsd-statusline/tui.tsx +55 -0
  5. package/GEMINI.md +1 -1
  6. package/agents/gsd-debug-session-manager.compact.md +6 -0
  7. package/agents/gsd-debug-session-manager.md +6 -0
  8. package/bin/gsd-mcp-server.js +3 -1
  9. package/bin/install.js +361 -94
  10. package/gsd-core/bin/gsd-tools.cjs +17 -2
  11. package/gsd-core/bin/lib/adr-parser.cjs +37 -5
  12. package/gsd-core/bin/lib/capability-registry.cjs +132 -80
  13. package/gsd-core/bin/lib/capability-validator.cjs +2 -1
  14. package/gsd-core/bin/lib/capability-writer.cjs +2 -1
  15. package/gsd-core/bin/lib/commands.cjs +28 -8
  16. package/gsd-core/bin/lib/gate-context-drift.cjs +5 -4
  17. package/gsd-core/bin/lib/gate-ui-safety.cjs +2 -1
  18. package/gsd-core/bin/lib/graphify.cjs +3 -2
  19. package/gsd-core/bin/lib/init.cjs +32 -12
  20. package/gsd-core/bin/lib/install-engine.cjs +11 -0
  21. package/gsd-core/bin/lib/install-shadow-report.cjs +12 -0
  22. package/gsd-core/bin/lib/installer-migrations/011-opencode-mcp-no-prompts.cjs +99 -0
  23. package/gsd-core/bin/lib/installer-migrations.cjs +54 -1
  24. package/gsd-core/bin/lib/jsonc-edit.cjs +418 -0
  25. package/gsd-core/bin/lib/loop-host-contract.cjs +2 -1
  26. package/gsd-core/bin/lib/mcp-server.cjs +5 -1
  27. package/gsd-core/bin/lib/opencode-mcp-entry.cjs +39 -0
  28. package/gsd-core/bin/lib/opencode-version.cjs +486 -0
  29. package/gsd-core/bin/lib/phase.cjs +21 -0
  30. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +2 -1
  31. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +95 -4
  32. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +58 -3
  33. package/gsd-core/bin/lib/runtime-slash.cjs +13 -0
  34. package/gsd-core/bin/lib/smart-entry.cjs +92 -53
  35. package/gsd-core/bin/lib/state-transition.cjs +67 -9
  36. package/gsd-core/bin/lib/state.cjs +454 -66
  37. package/gsd-core/bin/lib/update-context.cjs +18 -1
  38. package/gsd-core/bin/lib/verification.cjs +107 -17
  39. package/gsd-core/workflows/code-review.md +16 -1
  40. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +1 -1
  41. package/gsd-core/workflows/execute-phase.md +3 -1
  42. package/gsd-core/workflows/resume-project.md +14 -10
  43. package/hooks/dist/gsd-check-update.js +26 -20
  44. package/hooks/dist/gsd-context-monitor.js +14 -5
  45. package/hooks/dist/gsd-secret-read-guard.js +117 -1
  46. package/hooks/dist/gsd-statusline.js +321 -22
  47. package/hooks/gsd-check-update.js +26 -20
  48. package/hooks/gsd-context-monitor.js +14 -5
  49. package/hooks/gsd-secret-read-guard.js +117 -1
  50. package/hooks/gsd-statusline.js +321 -22
  51. package/package.json +1 -1
  52. package/scripts/gen-capability-registry.cjs +7 -1
  53. package/scripts/gen-loop-host-contract.cjs +4 -1
  54. package/scripts/lib/macos-conformance-tier.generated.cjs +4 -0
  55. package/scripts/lib/platform-conformance-tier.generated.cjs +10 -0
  56. package/scripts/mutation-matrix.cjs +3 -1
  57. package/scripts/release-tarball-smoke.cjs +14 -7
  58. package/vscode/package.json +1 -1
@@ -258,6 +258,11 @@ function reportStatusOf(fm, filePath) {
258
258
  * too): it re-runs the verifier, which regenerates VERIFICATION.md and its
259
259
  * digest (#4682, #4887).
260
260
  */
261
+ /**
262
+ * The one remedy for a stale report (#4682 / #5118), shared by the route table and by the more specific
263
+ * diagnostic below so the two cannot drift: only re-running the verifier regenerates VERIFICATION.md.
264
+ */
265
+ const STALE_REMEDY = 'Re-run execute-phase for this phase: it resumes at the verification gates and re-runs the verifier, regenerating VERIFICATION.md and its digest. verify-work alone cannot refresh a stale report.';
261
266
  const VERIFICATION_ROUTES = Object.freeze({
262
267
  passed: Object.freeze({
263
268
  next_action: 'Verification passed — continue.',
@@ -279,7 +284,7 @@ const VERIFICATION_ROUTES = Object.freeze({
279
284
  stale: Object.freeze({
280
285
  // #4682 / #5118: the only remedy for a stale report is re-running the
281
286
  // verifier; /gsd-verify-work on its own never rewrites VERIFICATION.md.
282
- next_action: 'Verification is stale — covered source files changed after the verifier last ran. Re-run execute-phase for this phase: it resumes at the verification gates and re-runs the verifier, regenerating VERIFICATION.md and its digest. verify-work alone cannot refresh a stale report.',
287
+ next_action: `Verification is stale — covered source files changed after the verifier last ran. ${STALE_REMEDY}`,
283
288
  command: 'execute-phase',
284
289
  tail: '',
285
290
  }),
@@ -540,7 +545,7 @@ function phaseArtifactPaths(phaseDir, projectRoot) {
540
545
  const scan = scanPhasePlans(phaseDir);
541
546
  // Fail closed on a non-COMPLETE scan (unreadable phase dir, or an
542
547
  // unreadable nested plans/ dir) — a partial scan's invisible contents can
543
- // never be proven covered (mirrors allCurrentArtifactsCovered's own
548
+ // never be proven covered (mirrors uncoveredCurrentArtifacts's own
544
549
  // fail-closed contract).
545
550
  if (scan.scope !== SCOPE.COMPLETE) {
546
551
  return { ok: false, reason: `phase directory scan is incomplete (scope: ${scan.scope}): ${phaseDir}` };
@@ -849,9 +854,9 @@ function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_V
849
854
  * each is represented in `coveredFiles` — matched by suffix (mirrors
850
855
  * `matchRequestedFile`'s convention) since `coveredFiles` holds
851
856
  * project-root-relative paths while the scan returns phase-relative
852
- * filenames. Returns `true` if every current plan/summary is covered,
853
- * `false` otherwise — callers only ever branch on this pass/fail, so no
854
- * caller needs which artifact was uncovered.
857
+ * filenames. Returns the current plans/summaries the report never declared
858
+ * (empty when every one is covered), so the stale diagnostic can name them (#4817); `null` when the
859
+ * scan itself was incomplete, which callers treat as stale.
855
860
  *
856
861
  * Fails CLOSED on an incomplete scan: `scanPhasePlans` never throws on a
857
862
  * readdir failure — it reports it via `scope` (`SCOPE.UNREADABLE` for the
@@ -860,18 +865,18 @@ function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_V
860
865
  * contract (`planning-scope.cts`): zero items under a non-`COMPLETE` scope
861
866
  * is a NON-answer, never "this phase has no plans." Branching on `scope`
862
867
  * here (rather than a try/catch, which this scan never triggers) is what
863
- * makes an unreadable `plans/` dir report `false` instead of silently
868
+ * makes an unreadable `plans/` dir report `null` (stale) instead of silently
864
869
  * treating its invisible contents as vacuously covered — the same
865
870
  * fail-open regression #3057 B3 fixed for the legacy path.
866
871
  */
867
- function allCurrentArtifactsCovered(phaseDir, coveredFiles) {
872
+ function uncoveredCurrentArtifacts(phaseDir, coveredFiles) {
868
873
  const scan = scanPhasePlans(phaseDir);
869
874
  if (scan.scope !== SCOPE.COMPLETE)
870
- return false;
875
+ return null;
871
876
  const coveredPosix = canonicalizeCoveredFiles(coveredFiles);
872
- return [...scan.allPlanFiles, ...scan.summaryFiles].every((artifact) => {
877
+ return [...scan.allPlanFiles, ...scan.summaryFiles].filter((artifact) => {
873
878
  const artifactPosix = toPosix(artifact);
874
- return coveredPosix.some((c) => c === artifactPosix || c.endsWith(`/${artifactPosix}`));
879
+ return !coveredPosix.some((c) => c === artifactPosix || c.endsWith(`/${artifactPosix}`));
875
880
  });
876
881
  }
877
882
  /**
@@ -943,6 +948,73 @@ function defaultPhaseCleanCommitTimesMs(phaseDir, files, execGitFn = shell_comma
943
948
  }
944
949
  return commitTimes;
945
950
  }
951
+ /**
952
+ * #4857: which declared covered files changed since the report was written.
953
+ * `covered_digest` is one aggregate hash, so it can say THAT something moved
954
+ * but not what; this names the files by time instead — each covered file
955
+ * (report paths and, for v2+, shared planning documents excluded, exactly as
956
+ * the digest excludes them) whose clean commit time, or mtime when it is
957
+ * uncommitted or edited since, is newer than the report's own. A file that
958
+ * can no longer be statted counts as moved. Advisory only: it never decides
959
+ * staleness (the digest does), and an unreadable clock yields fewer names,
960
+ * never a wrong status. Timed through the same injectable resolver the
961
+ * legacy mtime check uses, rooted at the project so the covered paths
962
+ * (project-root-relative) resolve.
963
+ */
964
+ function coveredFilesMovedSinceReport(projectRoot, phaseDir, reportPath, coveredFiles, version, fsImpl, resolver) {
965
+ const sharedRoots = version >= 2 ? sharedRootsFor(projectRoot, phaseDir, null) : [];
966
+ const files = canonicalizeCoveredFiles(coveredFiles, { version })
967
+ .filter((f) => !isVerificationReportPath(f) && !isSharedPlanningDoc(f, sharedRoots));
968
+ if (files.length === 0)
969
+ return [];
970
+ const reportRel = toPosix(node_path_1.default.relative(projectRoot, reportPath));
971
+ let commitTimes;
972
+ try {
973
+ commitTimes = resolver(projectRoot, [reportRel, ...files]);
974
+ }
975
+ catch {
976
+ commitTimes = new Map();
977
+ }
978
+ const timeOf = (rel) => {
979
+ const committed = commitTimes.get(rel);
980
+ if (committed !== undefined)
981
+ return committed;
982
+ try {
983
+ return fsImpl.statSync(node_path_1.default.join(projectRoot, rel)).mtimeMs;
984
+ }
985
+ catch {
986
+ return null;
987
+ }
988
+ };
989
+ const reportTime = timeOf(reportRel);
990
+ if (reportTime === null)
991
+ return [];
992
+ return files.filter((rel) => {
993
+ const t = timeOf(rel);
994
+ return t === null || t > reportTime;
995
+ });
996
+ }
997
+ /** At most this many uncovered artifact names are spelled out in `next_action`; the rest are counted. */
998
+ const STALE_NAMED_ARTIFACT_LIMIT = 5;
999
+ function staleNextAction(detail) {
1000
+ const name = (items) => {
1001
+ const shown = items.slice(0, STALE_NAMED_ARTIFACT_LIMIT).map((a) => io.formatDiagnosticToken(a));
1002
+ const more = items.length - shown.length;
1003
+ return `${shown.join(', ')}${more > 0 ? ` and ${more} more` : ''}`;
1004
+ };
1005
+ const reasons = [];
1006
+ if (detail.moved.length > 0) {
1007
+ reasons.push(`covered source files changed after the verifier last ran: ${name(detail.moved)}`);
1008
+ }
1009
+ else if (detail.digestMoved) {
1010
+ reasons.push('covered source files changed after the verifier last ran');
1011
+ }
1012
+ if (detail.uncovered.length > 0) {
1013
+ reasons.push(`the report never covered ${name(detail.uncovered)} (a current phase plan or summary it did not declare in covered_files)`);
1014
+ }
1015
+ const tail = detail.digestMoved ? '' : '; no covered file changed';
1016
+ return `Verification is stale — ${reasons.join(', and ')}${tail}. ${STALE_REMEDY}`;
1017
+ }
946
1018
  /**
947
1019
  * The one result builder (#5118): every `VerificationStatusResult` is
948
1020
  * projected from `VERIFICATION_ROUTES[status]` here — `route` (the bare
@@ -951,12 +1023,18 @@ function defaultPhaseCleanCommitTimesMs(phaseDir, files, execGitFn = shell_comma
951
1023
  */
952
1024
  function routeResult(status, ctx) {
953
1025
  const entry = VERIFICATION_ROUTES[status];
1026
+ const detail = status === VERIFICATION_STATUS.STALE && ctx.staleDetail
1027
+ && (ctx.staleDetail.uncovered.length > 0 || ctx.staleDetail.moved.length > 0)
1028
+ ? ctx.staleDetail
1029
+ : undefined;
954
1030
  return {
955
1031
  status,
956
- next_action: entry.next_action,
1032
+ next_action: detail ? staleNextAction(detail) : entry.next_action,
957
1033
  next_command: projectNextCommand(entry.command, ctx.runtime, `${ctx.phaseArg}${entry.tail}`),
958
1034
  route: entry.command,
959
1035
  ...(ctx.message !== undefined ? { message: ctx.message } : {}),
1036
+ ...(detail && detail.uncovered.length > 0 ? { uncovered_artifacts: detail.uncovered } : {}),
1037
+ ...(detail && detail.moved.length > 0 ? { moved_files: detail.moved } : {}),
960
1038
  ...(ctx.staleCheckIndeterminate ? { staleCheckIndeterminate: true } : {}),
961
1039
  };
962
1040
  }
@@ -1396,12 +1474,13 @@ function readVerificationStatus(phaseDir, opts = {}) {
1396
1474
  typeof coveredDigestVal === 'string' &&
1397
1475
  coveredDigestVal.trim().length > 0;
1398
1476
  let staleCheckIndeterminate = false;
1477
+ let staleDetail;
1399
1478
  let isStale;
1400
1479
  if (declaresFingerprint) {
1401
1480
  // Stated directly rather than relying on `null !== coveredDigestVal`
1402
1481
  // being true whenever the pair is malformed: `!hasWellFormedFingerprint`
1403
1482
  // fails closed explicitly, and its `||` short-circuit means
1404
- // computeCoveredDigest/allCurrentArtifactsCovered never run on a
1483
+ // computeCoveredDigest/uncoveredCurrentArtifacts never run on a
1405
1484
  // malformed (wrong-shaped) `coveredFilesVal`. The two `||`s after it
1406
1485
  // short-circuit in turn: the live-directory re-scan (for a plan/summary
1407
1486
  // added AFTER verification and never declared in covered_files) only
@@ -1423,17 +1502,28 @@ function readVerificationStatus(phaseDir, opts = {}) {
1423
1502
  // #5095 (R1): v3's digest is computed over the declared set UNIONED
1424
1503
  // with the phase's own live artifacts — a plan/summary added after
1425
1504
  // fingerprinting already moves the digest itself, so the separate
1426
- // live-directory re-scan (`allCurrentArtifactsCovered`) is redundant
1505
+ // live-directory re-scan (`uncoveredCurrentArtifacts`) is redundant
1427
1506
  // for v3 and is not run.
1428
1507
  isStale =
1429
1508
  computeCoveredDigest(projectRoot, coveredFilesVal, storedVersion, { phaseDir }) !== coveredDigestVal;
1509
+ if (isStale) {
1510
+ const moved = coveredFilesMovedSinceReport(projectRoot, phaseDir, located.filePath, coveredFilesVal, storedVersion, fsImpl, phaseCleanCommitTimesMs);
1511
+ if (moved.length > 0)
1512
+ staleDetail = { uncovered: [], moved, digestMoved: true };
1513
+ }
1430
1514
  }
1431
1515
  else {
1432
1516
  // v1/v2: unchanged — the digest alone cannot see a plan/summary that
1433
1517
  // was never declared, so the live-directory re-scan still runs.
1434
- isStale =
1435
- computeCoveredDigest(projectRoot, coveredFilesVal, storedVersion, { phaseDir }) !== coveredDigestVal ||
1436
- !allCurrentArtifactsCovered(phaseDir, coveredFilesVal);
1518
+ const digestMoved = computeCoveredDigest(projectRoot, coveredFilesVal, storedVersion, { phaseDir }) !== coveredDigestVal;
1519
+ const uncovered = uncoveredCurrentArtifacts(phaseDir, coveredFilesVal);
1520
+ isStale = digestMoved || uncovered === null || uncovered.length > 0;
1521
+ const moved = digestMoved
1522
+ ? coveredFilesMovedSinceReport(projectRoot, phaseDir, located.filePath, coveredFilesVal, storedVersion, fsImpl, phaseCleanCommitTimesMs)
1523
+ : [];
1524
+ if ((uncovered !== null && uncovered.length > 0) || moved.length > 0) {
1525
+ staleDetail = { uncovered: uncovered ? [...uncovered].sort() : [], moved, digestMoved };
1526
+ }
1437
1527
  }
1438
1528
  }
1439
1529
  else {
@@ -1449,7 +1539,7 @@ function readVerificationStatus(phaseDir, opts = {}) {
1449
1539
  if (isStale) {
1450
1540
  // #4682 / #5118: the one stale route — execute-phase's verify_phase_goal
1451
1541
  // step re-runs the verifier, regenerating VERIFICATION.md and its digest.
1452
- return route(VERIFICATION_STATUS.STALE);
1542
+ return route(VERIFICATION_STATUS.STALE, staleDetail ? { staleDetail } : {});
1453
1543
  }
1454
1544
  // 3. Route the writer-set member through the one table.
1455
1545
  return route(reportStatus, { staleCheckIndeterminate });
@@ -750,6 +750,19 @@ if [ -n "$FALLOW_JSON_PATH" ] && [ -f "$FALLOW_JSON_PATH" ]; then
750
750
  fi
751
751
  ```
752
752
 
753
+ Gather the capability review rules addressed to this reviewer (#4790): active `contribution` hooks with `into: code-reviewer`,
754
+ read from the loop point the review step is registered at (`workflow.code_review_point`, default `execute:post`). This is
755
+ advisory by construction: any failure leaves the prompt without them and never blocks the review. The fragments are handed over once,
756
+ below, beside — never instead of — `${AGENT_SKILLS_REVIEWER}`:
757
+ ```bash
758
+ REVIEWER_POINT=$(gsd_run query config-get workflow.code_review_point --raw 2>/dev/null) || REVIEWER_POINT="execute:post"
759
+ case "$REVIEWER_POINT" in execute:post|execute:wave:post) ;; *) REVIEWER_POINT="execute:post" ;; esac
760
+ REVIEWER_HOOKS_JSON=$(gsd_run loop render-hooks "${REVIEWER_POINT}" --raw 2>/dev/null) || REVIEWER_HOOKS_JSON=""
761
+ if [[ "$REVIEWER_HOOKS_JSON" == @file:* ]]; then REVIEWER_HOOKS_JSON=$(cat "${REVIEWER_HOOKS_JSON#@file:}" 2>/dev/null || true); fi
762
+ # A literal closing tag inside a fragment is escaped so third-party text cannot end the block early.
763
+ REVIEWER_CONTRIBUTIONS=$(printf '%s' "$REVIEWER_HOOKS_JSON" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const h=(JSON.parse(s).activeHooks||[]).filter(x=>x&&x.kind==="contribution"&&x.into==="code-reviewer"&&x.fragment&&typeof x.fragment.inline==="string"&&x.fragment.inline!=="").map(x=>x.fragment.inline.split("</capability_review_rules>").join("<\\/capability_review_rules>"));if(h.length)process.stdout.write("<capability_review_rules>\n"+h.join("\n\n")+"\n</capability_review_rules>")}catch(e){}})' 2>/dev/null || true)
764
+ ```
765
+
753
766
  Spawn the gsd-code-reviewer agent:
754
767
 
755
768
  Print: `◆ Spawning code reviewer... (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)`
@@ -783,7 +796,9 @@ ${CONFIG_FILES}
783
796
 
784
797
  Review the listed source files at ${REVIEW_DEPTH} depth. Write findings to ${REVIEW_PATH}.
785
798
  Do NOT commit the output — the orchestrator handles that.
786
- ${AGENT_SKILLS_REVIEWER}")
799
+ ${AGENT_SKILLS_REVIEWER}
800
+
801
+ ${REVIEWER_CONTRIBUTIONS}")
787
802
  ```
788
803
 
789
804
  > **ORCHESTRATOR RULE — CODEX RUNTIME**: After calling Agent() above, stop working on this task immediately. Do not read more files, edit code, or run tests related to this task while the subagent is active. Wait for the subagent to return its result. This prevents duplicate work, conflicting edits, and wasted context. Only resume when the subagent result is available.
@@ -340,7 +340,7 @@ fi
340
340
 
341
341
  `worktree create` records the entry in `$WAVE_WORKTREE_MANIFEST` itself, so **do not** call `worktree.record-agent` for these plans — that verb is the harness-path counterpart, used because the harness creates the worktree behind GSD's back. Double-recording is deduped by path+branch, but the create verb is the single writer here.
342
342
 
343
- Spawn `EXEC_JSON`'s `command` + `args` as a background process with its working directory set to `EXEC_JSON.cwd`, capturing the PID (`WORKER_PID=$!`) and redirecting stdout+stderr into a per-worker log placed BESIDE the worktree — `${WT_PATH}.worker.log`, not inside it: cleanup removes the worktree, and the log plus the lifecycle record must survive it. The `cwd` is returned for **every** host, including those whose descriptor has no cwd flag (`cwdFlag: null`) and therefore bind through the process's own working directory — always set it, never assume the flag did the job.
343
+ Spawn `EXEC_JSON`'s `command` + `args` as a background process with its working directory set to `EXEC_JSON.cwd`, capturing the PID (`WORKER_PID=$!`) and redirecting stdout+stderr into a per-worker log placed BESIDE the worktree — `${WT_PATH}.worker.log`, not inside it: cleanup removes the worktree, and the log plus the lifecycle record must survive it. The `cwd` is returned for **every** host, including those whose descriptor has no cwd flag (`cwdFlag: null`) and therefore bind through the process's own working directory — always set it, never assume the flag did the job. Launch from a shell that has `cd`'d into it (`cd "$cwd" && command args &`), not from a process API that only sets the child's working directory: OpenCode 2.x resolves its directory from `$PWD` and ignores a bare cwd change, so a stale inherited `$PWD` would bind the worker to the main checkout.
344
344
 
345
345
  **Record the launch before any wait (#4624).** A bare background `wait` left the wave with no durable lifecycle: when the orchestrator's turn ended first, a finished worker sat undiscovered and a blocked one sat unreported, and a resumed session had nothing to recover from. Immediately after the spawn — before waiting on anything — persist the launch identity and result location:
346
346
 
@@ -1,7 +1,7 @@
1
1
  <!-- gsd:loop-host
2
2
  step: execute
3
3
  points: execute:pre, execute:wave:pre, execute:wave:post, execute:post
4
- agent-roles: executor, verifier
4
+ agent-roles: executor, verifier, code-reviewer
5
5
  produces: SUMMARY.md
6
6
  consumes: PLAN.md
7
7
  -->
@@ -187,6 +187,8 @@ EXECUTE_POST_HOOKS_JSON=$(gsd_run loop render-hooks execute:post --raw)
187
187
  TDD_MODE=$(gsd_run loop render-hooks execute:post --active-cap tdd)
188
188
  ```
189
189
 
190
+ A `contribution` hook at this point with `into: code-reviewer` (#4790) is not dispatched from this envelope: `code-review.md` reads it itself and hands it to `gsd-code-reviewer` exactly once.
191
+
190
192
  <step name="safe_resume_gate">
191
193
  Before trusting `STATE.md` or dispatching any executor, derive `CURRENT_PLAN_ID`
192
194
  from the active incomplete plan in `INIT`, then search recent history:
@@ -75,21 +75,25 @@ shopt -s nullglob 2>/dev/null; setopt NULL_GLOB 2>/dev/null
75
75
  # Check for structured handoff (preferred — machine-readable)
76
76
  cat .planning/HANDOFF.json 2>/dev/null || true
77
77
 
78
- # Check for continue-here files (phase + non-phase + legacy fallback).
79
- # Use `find` rather than a chained `ls` of bare globs: under zsh's default
80
- # NOMATCH option (macOS default shell), a single non-matching glob aborts
81
- # the entire command during word-expansion — silently dropping every
82
- # pattern after the first miss, including `.planning/.continue-here*.md`.
83
- # `find` does not use shell glob expansion and tolerates absent
84
- # directories on both bash and zsh.
85
- find .planning -maxdepth 3 -name '.continue-here*.md' -print 2>/dev/null || true
86
- find . -maxdepth 1 -name '.continue-here*.md' -print 2>/dev/null || true
78
+ # Check for continue-here files (phase + non-phase + legacy fallback), three levels below .planning.
79
+ # Guarded glob loops, not `find` and not a chained `ls` of bare globs (#4805, #3689):
80
+ # - a chained `ls` of bare globs aborts under zsh's default NOMATCH on the first miss, silently
81
+ # dropping every pattern after it, including `.planning/.continue-here*.md` (#3689);
82
+ # - `find` is an external binary that allowlisted agent shells (an fd-only policy, for one) refuse, and a
83
+ # per-line `2>/dev/null || true` then hid the refusal, so a checkpoint on disk looked absent (#4805).
84
+ # The shell alone does this: nullglob (set above) drops an unmatched glob, `[ -e ]` guards the shells that
85
+ # keep it, and an absent directory simply matches nothing. Output is `.planning/...` and `./...` as before.
86
+ for f in .planning/.continue-here*.md .planning/*/.continue-here*.md .planning/*/*/.continue-here*.md ./.continue-here*.md; do
87
+ if [ -e "$f" ]; then echo "$f"; fi
88
+ done 2>/dev/null || true
87
89
 
88
90
  # Outstanding async external jobs (legal external_job_waiting half-state).
89
91
  # A PLAN without SUMMARY that has a matching async-job manifest is NOT incomplete
90
92
  # work to redo — it is an external job awaiting reconciliation (handled by the
91
93
  # async-job branch in determine_next_action, not the incomplete-plan branch).
92
- find .planning/async-jobs -maxdepth 1 -name '*.json' -print 2>/dev/null || true
94
+ for f in .planning/async-jobs/*.json; do
95
+ if [ -e "$f" ]; then echo "$f"; fi
96
+ done 2>/dev/null || true
93
97
 
94
98
  # Check for plans without summaries (incomplete execution)
95
99
  for plan in .planning/phases/*/*-PLAN.md; do
@@ -67,26 +67,32 @@ const cacheFile = path.join(cacheDir, updateCacheFileName);
67
67
  const projectVersionFile = path.join(projectConfigDir, 'gsd-core', 'VERSION');
68
68
  const globalVersionFile = path.join(globalConfigDir, 'gsd-core', 'VERSION');
69
69
 
70
- // Ensure cache directory exists
71
- if (!fs.existsSync(cacheDir)) {
70
+ // This background check is advisory. Cache or worker failures must not make
71
+ // the SessionStart hook fail. Recursive mkdir is safe if another session
72
+ // creates the directory concurrently.
73
+ try {
72
74
  fs.mkdirSync(cacheDir, { recursive: true });
73
- }
74
75
 
75
- // Run check in background via a dedicated worker script.
76
- // Spawning a file (rather than node -e '<inline code>') keeps the worker logic
77
- // in plain JS with no template-literal regex-escaping concerns, and makes the
78
- // worker independently testable.
79
- const workerPath = path.join(__dirname, 'gsd-check-update-worker.js');
80
- const child = spawn(process.execPath, [workerPath], {
81
- stdio: 'ignore',
82
- windowsHide: true,
83
- detached: true, // Required on Windows for proper process detachment
84
- env: {
85
- ...process.env,
86
- GSD_CACHE_FILE: cacheFile,
87
- GSD_PROJECT_VERSION_FILE: projectVersionFile,
88
- GSD_GLOBAL_VERSION_FILE: globalVersionFile,
89
- },
90
- });
76
+ // Run check in background via a dedicated worker script.
77
+ // Spawning a file (rather than node -e '<inline code>') keeps the worker logic
78
+ // in plain JS with no template-literal regex-escaping concerns, and makes the
79
+ // worker independently testable.
80
+ const workerPath = path.join(__dirname, 'gsd-check-update-worker.js');
81
+ const child = spawn(process.execPath, [workerPath], {
82
+ stdio: 'ignore',
83
+ windowsHide: true,
84
+ detached: true, // Required on Windows for proper process detachment
85
+ env: {
86
+ ...process.env,
87
+ GSD_CACHE_FILE: cacheFile,
88
+ GSD_PROJECT_VERSION_FILE: projectVersionFile,
89
+ GSD_GLOBAL_VERSION_FILE: globalVersionFile,
90
+ },
91
+ });
91
92
 
92
- child.unref();
93
+ // A spawn failure can also arrive asynchronously on the child process.
94
+ child.on('error', () => {});
95
+ child.unref();
96
+ } catch {
97
+ // Update hints are best effort; a failed check must not interrupt startup.
98
+ }
@@ -14,6 +14,8 @@
14
14
  // Thresholds:
15
15
  // WARNING (remaining <= 35%): Agent should wrap up current task
16
16
  // CRITICAL (remaining <= 25%): Agent should stop immediately and save state
17
+ // "remaining" is of the auto-compact threshold — the statusline puts the
18
+ // bridge on that scale (#4985), so both fire before Claude Code compacts.
17
19
  // Both fire-points are overridable per project via .planning/config.json
18
20
  // (hooks.context_warning_threshold / hooks.context_critical_threshold, #4285);
19
21
  // the values above are the defaults used when the keys are absent or unusable.
@@ -500,23 +502,30 @@ const handleStdinEnd = () => {
500
502
  }
501
503
 
502
504
  // Build advisory warning message (never use imperative commands that
503
- // override user preferences — see #884)
505
+ // override user preferences — see #884). The percentages are of the
506
+ // auto-compact threshold (#4985); the token counts, when the bridge has
507
+ // them, are the ones /context shows (#2451).
508
+ const kTokens = n => `${Math.round(n / 1000)}k`;
509
+ const tokenDetail = Number.isFinite(metrics.used_tokens) && Number.isFinite(metrics.threshold_tokens)
510
+ ? ` (${kTokens(metrics.used_tokens)} of ${kTokens(metrics.threshold_tokens)} usable tokens)`
511
+ : '';
512
+ const usage = `Usage at ${usedPct}%${tokenDetail}. Remaining: ${remaining}%. `;
504
513
  let message;
505
514
  if (isCritical) {
506
515
  message = isGsdActive
507
- ? `CONTEXT CRITICAL: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
516
+ ? `CONTEXT CRITICAL: ${usage}` +
508
517
  'Context is nearly exhausted. Do NOT start new complex work or write handoff files — ' +
509
518
  'GSD state is already tracked in STATE.md. Inform the user so they can run ' +
510
519
  '/gsd:pause-work at the next natural stopping point.'
511
- : `CONTEXT CRITICAL: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
520
+ : `CONTEXT CRITICAL: ${usage}` +
512
521
  'Context is nearly exhausted. Inform the user that context is low and ask how they ' +
513
522
  'want to proceed. Do NOT autonomously save state or write handoff files unless the user asks.';
514
523
  } else {
515
524
  message = isGsdActive
516
- ? `CONTEXT WARNING: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
525
+ ? `CONTEXT WARNING: ${usage}` +
517
526
  'Context is getting limited. Avoid starting new complex work. If not between ' +
518
527
  'defined plan steps, inform the user so they can prepare to pause.'
519
- : `CONTEXT WARNING: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
528
+ : `CONTEXT WARNING: ${usage}` +
520
529
  'Be aware that context is getting limited. Avoid unnecessary exploration or ' +
521
530
  'starting new complex work.';
522
531
  }
@@ -103,9 +103,23 @@
103
103
  // edit of `.env` lands in the link target — a write redirection, the
104
104
  // out-of-scope write class, not a read.)
105
105
  //
106
+ // A second, unrelated contract is enforced on the Bash leg because this is the
107
+ // one hook that already parses EVERY Bash command (#5214): a Bash command that
108
+ // launches the debugger as a CLI process — `claude … --agent gsd-debugger` — is
109
+ // denied (`debugger-cli-spawn`). The debug session manager is required to spawn
110
+ // gsd-debugger with `Agent(run_in_background=false)` so it can read the
111
+ // debugger's structured return; on a captured 1.15.0 run it routed around that
112
+ // literal and shelled out with the Bash parameter `run_in_background: true`,
113
+ // so no return ever came back and the checkpoint never advanced. Prose in the
114
+ // agent file cannot stop an improvised tool call; a PreToolUse denial can, and
115
+ // Claude Code runs plugin hooks for a subagent's tool calls too. A person
116
+ // running `claude --agent gsd-debugger` in their own terminal never reaches
117
+ // this hook. Only the debugger is named: it is the agent whose blocking return
118
+ // the managed debug loop depends on.
119
+ //
106
120
  // Triggers on: Read, Grep, Bash tool calls (Kimi: ReadFile, Grep, Shell)
107
121
  // Action: BLOCK (decision: 'block', exit 2) — codes secret-read |
108
- // glob-too-complex | command-too-large
122
+ // glob-too-complex | command-too-large | debugger-cli-spawn
109
123
  // No-op: other tools, non-secret targets, hook errors (fail open — a parser
110
124
  // bug in a hook that runs on EVERY Bash call must never brick a
111
125
  // session; the crash policy is declared once below).
@@ -1004,6 +1018,100 @@ function findSecretRead(command, depth) {
1004
1018
  return null;
1005
1019
  }
1006
1020
 
1021
+ // ---------------------------------------------------------------------------
1022
+ // gsd-debugger CLI spawn scan (#5214)
1023
+ // ---------------------------------------------------------------------------
1024
+
1025
+ const DEBUGGER_AGENT_NAME = 'gsd-debugger';
1026
+ const CLAUDE_BINARY_RE = /^claude(\.exe)?$/;
1027
+
1028
+ // `gsd-debugger`, or a plugin-namespaced `<plugin>:gsd-debugger`.
1029
+ function isDebuggerAgent(value) {
1030
+ return value.slice(value.lastIndexOf(':') + 1) === DEBUGGER_AGENT_NAME;
1031
+ }
1032
+
1033
+ // `timeout [flags] DURATION cmd …` is not in CMD_PREFIXES because its first
1034
+ // operand is a duration, not the command. Returns the command that follows it.
1035
+ function unwrapTimeout(cmd) {
1036
+ let { base, operands } = cmd;
1037
+ while (base === 'timeout') {
1038
+ let k = 0;
1039
+ while (k < operands.length && operands[k].text.startsWith('-')) k++;
1040
+ k++; // the DURATION operand
1041
+ const rest = resolveCommand(operands.slice(k));
1042
+ if (!rest) return null;
1043
+ ({ base, operands } = rest);
1044
+ }
1045
+ return { base, operands };
1046
+ }
1047
+
1048
+ // Returns the `--agent` value text of a debugger launch, or null.
1049
+ function findDebuggerCliSpawn(command, depth) {
1050
+ const { tokens, nested } = tokenize(command);
1051
+
1052
+ for (const sub of nested) {
1053
+ if (depth < MAX_NESTING_DEPTH) {
1054
+ const hit = findDebuggerCliSpawn(sub, depth + 1);
1055
+ if (hit) return hit;
1056
+ }
1057
+ }
1058
+
1059
+ const bySeg = new Map();
1060
+ for (const t of tokens) {
1061
+ if (t.kind !== 'word' && t.kind !== 'op') continue;
1062
+ if (!bySeg.has(t.seg)) bySeg.set(t.seg, []);
1063
+ bySeg.get(t.seg).push(t);
1064
+ }
1065
+
1066
+ for (const segTokens of bySeg.values()) {
1067
+ const words = [];
1068
+ for (let k = 0; k < segTokens.length; k++) {
1069
+ const t = segTokens[k];
1070
+ if (t.kind === 'op') {
1071
+ // A redirect consumes its target word; it is never the command.
1072
+ if (!t.dup && segTokens[k + 1] && segTokens[k + 1].kind === 'word') k++;
1073
+ continue;
1074
+ }
1075
+ words.push(t);
1076
+ }
1077
+ if (!words.length) continue;
1078
+
1079
+ const first = resolveCommand(words);
1080
+ const cmd = first && unwrapTimeout(first);
1081
+ if (!cmd) continue;
1082
+ const { base, operands } = cmd;
1083
+
1084
+ if (base === 'eval') {
1085
+ if (depth < MAX_NESTING_DEPTH) {
1086
+ const hit = findDebuggerCliSpawn(operands.map((w) => w.text).join(' '), depth + 1);
1087
+ if (hit) return hit;
1088
+ }
1089
+ continue;
1090
+ }
1091
+
1092
+ if (SHELL_INTERPRETERS.has(base)) {
1093
+ const cIdx = operands.findIndex((w) => DASH_C_RE.test(w.text));
1094
+ if (cIdx !== -1 && operands[cIdx + 1] && depth < MAX_NESTING_DEPTH) {
1095
+ const hit = findDebuggerCliSpawn(operands[cIdx + 1].text, depth + 1);
1096
+ if (hit) return hit;
1097
+ }
1098
+ continue;
1099
+ }
1100
+
1101
+ if (!CLAUDE_BINARY_RE.test(base)) continue;
1102
+ for (let k = 0; k < operands.length; k++) {
1103
+ const text = operands[k].text;
1104
+ if (text === '--agent') {
1105
+ const value = operands[k + 1];
1106
+ if (value && isDebuggerAgent(value.text)) return value.text;
1107
+ } else if (text.startsWith('--agent=') && isDebuggerAgent(text.slice('--agent='.length))) {
1108
+ return text.slice('--agent='.length);
1109
+ }
1110
+ }
1111
+ }
1112
+ return null;
1113
+ }
1114
+
1007
1115
  // A shell interpreter's script comes from `-c`, a file operand, or stdin.
1008
1116
  function scanShellInterpreter(operands, heredocs, hereStrings, s, bySeg, sepAfter, depth) {
1009
1117
  const cIdx = operands.findIndex((w) => DASH_C_RE.test(w.text));
@@ -1102,6 +1210,12 @@ function reasonFor(code, tool, target) {
1102
1210
  return `Secret read guard: the Grep glob '${target}' expands to more than ${MAX_GLOB_ALTERNATIVES} ` +
1103
1211
  'alternatives and cannot be checked for secret-file matches. Use a narrower glob.';
1104
1212
  }
1213
+ if (code === 'debugger-cli-spawn') {
1214
+ return `Debugger spawn guard: this Bash command launches the debugger as a CLI process ('--agent ${target}'). ` +
1215
+ 'A shell-spawned debugger runs detached, returns no structured result, and the debug checkpoint never advances. ' +
1216
+ 'Spawn it with the Agent tool instead: Agent(subagent_type="gsd-debugger", run_in_background=false, ...) ' +
1217
+ 'and wait for its structured return.';
1218
+ }
1105
1219
  return `Secret read guard: ${tool} would read '${target}', which matches a protected secret-file ` +
1106
1220
  `pattern (${PATTERN_TEXT}). Secret values must not be read into the conversation. ` +
1107
1221
  'If you need a specific value, ask the user for it; if you need the variable NAMES, ' +
@@ -1253,6 +1367,8 @@ process.stdin.on('end', () => {
1253
1367
  if (command.length > MAX_COMMAND_LENGTH) emitBlock('command-too-large', tool, '');
1254
1368
  const hit = findSecretRead(command, 0);
1255
1369
  if (hit !== null) emitBlock('secret-read', tool, hit);
1370
+ const spawn = findDebuggerCliSpawn(command, 0);
1371
+ if (spawn !== null) emitBlock('debugger-cli-spawn', tool, spawn);
1256
1372
  allow(undefined);
1257
1373
  } catch {
1258
1374
  // Fail open — never block valid tool calls due to hook errors.