@opengsd/gsd-pi 1.3.0-dev.965d1788 → 1.3.0-dev.cca9c184

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 (168) hide show
  1. package/dist/cli.js +17 -4
  2. package/dist/headless-answers.js +1 -1
  3. package/dist/headless-ui.d.ts +1 -1
  4. package/dist/headless-ui.js +1 -1
  5. package/dist/headless.js +1 -1
  6. package/dist/resources/.managed-resources-content-hash +1 -1
  7. package/dist/resources/extensions/claude-code-cli/models.js +9 -0
  8. package/dist/resources/extensions/gsd/auto/session.js +8 -0
  9. package/dist/resources/extensions/gsd/auto-budget.js +5 -1
  10. package/dist/resources/extensions/gsd/auto-dispatch.js +14 -1
  11. package/dist/resources/extensions/gsd/auto-prompts.js +3 -3
  12. package/dist/resources/extensions/gsd/auto-recovery.js +3 -3
  13. package/dist/resources/extensions/gsd/auto.js +19 -10
  14. package/dist/resources/extensions/gsd/bootstrap/core-session-tools.js +1 -0
  15. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +5 -4
  16. package/dist/resources/extensions/gsd/bootstrap/register-extension.js +2 -0
  17. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +25 -2
  18. package/dist/resources/extensions/gsd/bootstrap/tool-call-loop-guard.js +32 -4
  19. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +15 -4
  20. package/dist/resources/extensions/gsd/commands/handlers/auto.js +1 -1
  21. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +1 -1
  22. package/dist/resources/extensions/gsd/commands-usage.js +1 -1
  23. package/dist/resources/extensions/gsd/db/engine.js +4 -1
  24. package/dist/resources/extensions/gsd/db-memory-fts-schema.js +34 -0
  25. package/dist/resources/extensions/gsd/delegation-policy.js +1 -1
  26. package/dist/resources/extensions/gsd/docs/preferences-reference.md +2 -2
  27. package/dist/resources/extensions/gsd/doctor-engine-checks.js +192 -4
  28. package/dist/resources/extensions/gsd/exec-sandbox.js +8 -2
  29. package/dist/resources/extensions/gsd/git-service.js +56 -0
  30. package/dist/resources/extensions/gsd/gitignore.js +1 -1
  31. package/dist/resources/extensions/gsd/migrate-external.js +11 -2
  32. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +2 -2
  33. package/dist/resources/extensions/gsd/prompts/plan-slice.md +3 -3
  34. package/dist/resources/extensions/gsd/root-write-leak-guard.js +7 -1
  35. package/dist/resources/extensions/gsd/safety/destructive-guard.js +8 -1
  36. package/dist/resources/extensions/gsd/safety/evidence-collector.js +16 -1
  37. package/dist/resources/extensions/gsd/tools/plan-slice.js +59 -44
  38. package/dist/resources/extensions/gsd/tools/plan-task.js +28 -5
  39. package/dist/resources/extensions/gsd/unit-registry.js +4 -2
  40. package/dist/resources/extensions/gsd/workflow-manifest.js +122 -12
  41. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  42. package/dist/web/standalone/.next/BUILD_ID +1 -1
  43. package/dist/web/standalone/.next/app-path-routes-manifest.json +11 -11
  44. package/dist/web/standalone/.next/build-manifest.json +2 -2
  45. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  46. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  47. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  55. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/index.html +1 -1
  63. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  64. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  66. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  67. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  68. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  69. package/dist/web/standalone/.next/server/app-paths-manifest.json +11 -11
  70. package/dist/web/standalone/.next/server/chunks/8357.js +1 -1
  71. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  72. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  73. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  74. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  75. package/package.json +1 -1
  76. package/packages/cloud-mcp-gateway/package.json +2 -2
  77. package/packages/contracts/package.json +1 -1
  78. package/packages/daemon/package.json +4 -4
  79. package/packages/gsd-agent-core/package.json +5 -5
  80. package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.d.ts +2 -0
  81. package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  82. package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.js +23 -5
  83. package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.js.map +1 -1
  84. package/packages/gsd-agent-modes/package.json +7 -7
  85. package/packages/mcp-server/dist/workflow-tools.js +1 -1
  86. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  87. package/packages/mcp-server/package.json +4 -4
  88. package/packages/native/package.json +1 -1
  89. package/packages/pi-agent-core/dist/agent-loop.js +3 -2
  90. package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
  91. package/packages/pi-agent-core/package.json +1 -1
  92. package/packages/pi-ai/dist/models.generated.d.ts +46 -0
  93. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  94. package/packages/pi-ai/dist/models.generated.js +38 -0
  95. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  96. package/packages/pi-ai/package.json +1 -1
  97. package/packages/pi-coding-agent/package.json +7 -7
  98. package/packages/pi-tui/package.json +2 -2
  99. package/packages/rpc-client/package.json +2 -2
  100. package/pkg/package.json +1 -1
  101. package/src/resources/extensions/claude-code-cli/models.ts +9 -0
  102. package/src/resources/extensions/claude-code-cli/tests/index.test.ts +14 -0
  103. package/src/resources/extensions/gsd/auto/loop-deps.ts +1 -0
  104. package/src/resources/extensions/gsd/auto/session.ts +8 -0
  105. package/src/resources/extensions/gsd/auto-budget.ts +12 -1
  106. package/src/resources/extensions/gsd/auto-dispatch.ts +14 -1
  107. package/src/resources/extensions/gsd/auto-prompts.ts +3 -3
  108. package/src/resources/extensions/gsd/auto-recovery.ts +3 -3
  109. package/src/resources/extensions/gsd/auto.ts +23 -8
  110. package/src/resources/extensions/gsd/bootstrap/core-session-tools.ts +1 -0
  111. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +5 -4
  112. package/src/resources/extensions/gsd/bootstrap/register-extension.ts +2 -0
  113. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +32 -2
  114. package/src/resources/extensions/gsd/bootstrap/tests/write-gate-basepath.test.ts +49 -1
  115. package/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts +34 -4
  116. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +16 -4
  117. package/src/resources/extensions/gsd/commands/handlers/auto.ts +1 -1
  118. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +1 -1
  119. package/src/resources/extensions/gsd/commands-usage.ts +1 -1
  120. package/src/resources/extensions/gsd/db/engine.ts +8 -1
  121. package/src/resources/extensions/gsd/db-memory-fts-schema.ts +45 -0
  122. package/src/resources/extensions/gsd/delegation-policy.ts +1 -1
  123. package/src/resources/extensions/gsd/docs/preferences-reference.md +2 -2
  124. package/src/resources/extensions/gsd/doctor-engine-checks.ts +233 -5
  125. package/src/resources/extensions/gsd/doctor-types.ts +3 -0
  126. package/src/resources/extensions/gsd/exec-sandbox.ts +7 -2
  127. package/src/resources/extensions/gsd/git-service.ts +63 -0
  128. package/src/resources/extensions/gsd/gitignore.ts +1 -1
  129. package/src/resources/extensions/gsd/migrate-external.ts +8 -2
  130. package/src/resources/extensions/gsd/prompts/plan-milestone.md +2 -2
  131. package/src/resources/extensions/gsd/prompts/plan-slice.md +3 -3
  132. package/src/resources/extensions/gsd/root-write-leak-guard.ts +13 -1
  133. package/src/resources/extensions/gsd/safety/destructive-guard.ts +9 -1
  134. package/src/resources/extensions/gsd/safety/evidence-collector.ts +16 -1
  135. package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +14 -5
  136. package/src/resources/extensions/gsd/tests/auto-pause-double-entry-guard.test.ts +36 -0
  137. package/src/resources/extensions/gsd/tests/auto-paused-ui-cleanup.test.ts +182 -4
  138. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +35 -0
  139. package/src/resources/extensions/gsd/tests/auto-retry-mcp-churn-fixes.test.ts +30 -0
  140. package/src/resources/extensions/gsd/tests/db-memory-fts-schema.test.ts +58 -3
  141. package/src/resources/extensions/gsd/tests/destructive-guard.test.ts +44 -0
  142. package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +143 -0
  143. package/src/resources/extensions/gsd/tests/doctor-scope-db-unavailable.test.ts +239 -2
  144. package/src/resources/extensions/gsd/tests/exec-sandbox.test.ts +16 -0
  145. package/src/resources/extensions/gsd/tests/memory-store.test.ts +48 -0
  146. package/src/resources/extensions/gsd/tests/migrate-external-worktree.test.ts +57 -0
  147. package/src/resources/extensions/gsd/tests/parsers-legacy-importers.test.ts +2 -0
  148. package/src/resources/extensions/gsd/tests/plan-slice.test.ts +103 -4
  149. package/src/resources/extensions/gsd/tests/plan-task.test.ts +44 -1
  150. package/src/resources/extensions/gsd/tests/post-mutation-hook.test.ts +4 -2
  151. package/src/resources/extensions/gsd/tests/prompt-budget-enforcement.test.ts +58 -0
  152. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +8 -6
  153. package/src/resources/extensions/gsd/tests/register-hooks-loop-guard-auto.test.ts +103 -0
  154. package/src/resources/extensions/gsd/tests/root-write-leak-guard.test.ts +20 -1
  155. package/src/resources/extensions/gsd/tests/runtime-invariant-modules.test.ts +8 -3
  156. package/src/resources/extensions/gsd/tests/tool-call-loop-guard.test.ts +62 -5
  157. package/src/resources/extensions/gsd/tests/tool-param-optionality.test.ts +6 -3
  158. package/src/resources/extensions/gsd/tests/uok-gitops-turn-action.test.ts +38 -0
  159. package/src/resources/extensions/gsd/tests/workflow-manifest.test.ts +44 -17
  160. package/src/resources/extensions/gsd/tests/workflow-mcp-preflight.test.ts +1 -0
  161. package/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +8 -4
  162. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +6 -5
  163. package/src/resources/extensions/gsd/tools/plan-slice.ts +60 -47
  164. package/src/resources/extensions/gsd/tools/plan-task.ts +30 -5
  165. package/src/resources/extensions/gsd/unit-registry.ts +4 -2
  166. package/src/resources/extensions/gsd/workflow-manifest.ts +137 -6
  167. /package/dist/web/standalone/.next/static/{-ji77QJck2IKIwrGza1Bs → Eud_8lddsyi_13HIlB2i7}/_buildManifest.js +0 -0
  168. /package/dist/web/standalone/.next/static/{-ji77QJck2IKIwrGza1Bs → Eud_8lddsyi_13HIlB2i7}/_ssgManifest.js +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gsd/pi-ai",
3
- "version": "1.3.0-dev.965d1788",
3
+ "version": "1.3.0-dev.cca9c184",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "type": "module",
6
6
  "gsd": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gsd/pi-coding-agent",
3
- "version": "1.3.0-dev.965d1788",
3
+ "version": "1.3.0-dev.cca9c184",
4
4
  "description": "Coding agent CLI (vendored from earendil-works/pi)",
5
5
  "type": "module",
6
6
  "gsd": {
@@ -33,7 +33,7 @@
33
33
  "copy-assets": "node scripts/copy-assets.cjs"
34
34
  },
35
35
  "dependencies": {
36
- "@opengsd/contracts": "^1.3.0-dev.965d1788",
36
+ "@opengsd/contracts": "^1.3.0-dev.cca9c184",
37
37
  "@mariozechner/jiti": "^2.6.2",
38
38
  "@silvia-odwyer/photon-node": "0.3.4",
39
39
  "chalk": "5.6.2",
@@ -53,11 +53,11 @@
53
53
  "typebox": "1.1.38",
54
54
  "undici": "7.28.0",
55
55
  "yaml": "2.9.0",
56
- "@gsd/agent-core": "^1.3.0-dev.965d1788",
57
- "@gsd/native": "^1.3.0-dev.965d1788",
58
- "@gsd/pi-agent-core": "^1.3.0-dev.965d1788",
59
- "@gsd/pi-ai": "^1.3.0-dev.965d1788",
60
- "@gsd/pi-tui": "^1.3.0-dev.965d1788",
56
+ "@gsd/agent-core": "^1.3.0-dev.cca9c184",
57
+ "@gsd/native": "^1.3.0-dev.cca9c184",
58
+ "@gsd/pi-agent-core": "^1.3.0-dev.cca9c184",
59
+ "@gsd/pi-ai": "^1.3.0-dev.cca9c184",
60
+ "@gsd/pi-tui": "^1.3.0-dev.cca9c184",
61
61
  "@sinclair/typebox": "^0.34.41"
62
62
  },
63
63
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gsd/pi-tui",
3
- "version": "1.3.0-dev.965d1788",
3
+ "version": "1.3.0-dev.cca9c184",
4
4
  "description": "Terminal UI library (vendored from earendil-works/pi)",
5
5
  "type": "module",
6
6
  "gsd": {
@@ -21,7 +21,7 @@
21
21
  "build": "node ../../scripts/clean-package-dist.cjs && tsc -p tsconfig.json --incremental false"
22
22
  },
23
23
  "dependencies": {
24
- "@gsd/native": "^1.3.0-dev.965d1788",
24
+ "@gsd/native": "^1.3.0-dev.cca9c184",
25
25
  "get-east-asian-width": "1.6.0",
26
26
  "marked": "15.0.12",
27
27
  "@sinclair/typebox": "^0.34.41"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengsd/rpc-client",
3
- "version": "1.3.0-dev.965d1788",
3
+ "version": "1.3.0-dev.cca9c184",
4
4
  "description": "Standalone RPC client SDK for GSD — zero internal dependencies",
5
5
  "license": "MIT",
6
6
  "gsd": {
@@ -34,7 +34,7 @@
34
34
  "test": "node --test dist/rpc-client.test.js"
35
35
  },
36
36
  "dependencies": {
37
- "@opengsd/contracts": "^1.3.0-dev.965d1788"
37
+ "@opengsd/contracts": "^1.3.0-dev.cca9c184"
38
38
  },
39
39
  "engines": {
40
40
  "node": ">=22.0.0"
package/pkg/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glittercowboy/gsd",
3
- "version": "1.3.0-dev.965d1788",
3
+ "version": "1.3.0-dev.cca9c184",
4
4
  "piConfig": {
5
5
  "name": "gsd",
6
6
  "configDir": ".gsd"
@@ -48,6 +48,15 @@ export const CLAUDE_CODE_MODELS = [
48
48
  contextWindow: 1_000_000,
49
49
  maxTokens: 128_000,
50
50
  },
51
+ {
52
+ id: "claude-sonnet-5",
53
+ name: "Claude Sonnet 5 (via Claude Code)",
54
+ reasoning: true,
55
+ input: ["text", "image"] as ("text" | "image")[],
56
+ cost: ZERO_COST,
57
+ contextWindow: 1_000_000,
58
+ maxTokens: 128_000,
59
+ },
51
60
  {
52
61
  id: "claude-sonnet-4-6",
53
62
  name: "Claude Sonnet 4.6 (via Claude Code)",
@@ -30,6 +30,20 @@ test("registers the claude-code provider with a streamSimple delegate", () => {
30
30
  assert.equal(typeof providers[0].config.streamSimple, "function");
31
31
  });
32
32
 
33
+ test("registers Claude Sonnet 5 as a Claude Code CLI model", () => {
34
+ const { pi, providers } = makeMockPi();
35
+ claudeCodeCli(pi as never);
36
+
37
+ const models = providers[0].config.models as Array<Record<string, unknown>>;
38
+ const sonnet5 = models.find((model) => model.id === "claude-sonnet-5");
39
+
40
+ assert.ok(sonnet5, "claude-sonnet-5 must be selectable via the claude-code provider");
41
+ assert.equal(sonnet5.name, "Claude Sonnet 5 (via Claude Code)");
42
+ assert.equal(sonnet5.reasoning, true);
43
+ assert.equal(sonnet5.contextWindow, 1_000_000);
44
+ assert.equal(sonnet5.maxTokens, 128_000);
45
+ });
46
+
33
47
  test("registers a before_provider_request hook to capture the UI context", () => {
34
48
  const { pi, handlers } = makeMockPi();
35
49
  claudeCodeCli(pi as never);
@@ -52,6 +52,7 @@ export type PauseAutoUnitIdentity = {
52
52
 
53
53
  export interface PauseAutoOptions {
54
54
  expectedCurrentUnit?: PauseAutoUnitIdentity | null;
55
+ abortActiveTurn?: boolean;
55
56
  }
56
57
 
57
58
  type PauseAutoFn = (
@@ -212,6 +212,13 @@ export class AutoSession {
212
212
  * the issues after MAX_PRE_EXEC_RETRIES re-attempts.
213
213
  */
214
214
  readonly preExecRetryCount: Map<string, number> = new Map();
215
+ /**
216
+ * Tracks how many times each slice unit has been re-dispatched to plan-slice
217
+ * because task plan files were missing. Keyed by unitId (e.g. "M001/S01").
218
+ * Separate from preExecRetryCount so pre-exec gate failures do not block or
219
+ * conflate with missing-task-plan recovery.
220
+ */
221
+ readonly missingTaskPlanRetryCount: Map<string, number> = new Map();
215
222
 
216
223
  // ── Tool invocation errors (#2883) ──────────────────────────────────
217
224
  /** Set when a GSD tool execution ends with isError due to malformed/truncated
@@ -413,6 +420,7 @@ export class AutoSession {
413
420
  this.consecutiveCompleteBootstraps = 0;
414
421
  this.lastPreExecFailure = null;
415
422
  this.preExecRetryCount.clear();
423
+ this.missingTaskPlanRetryCount.clear();
416
424
  this.lastToolInvocationError = null;
417
425
  this.toolUnavailableRetries = 0;
418
426
  this.lastUnitAgentEndMessages = null;
@@ -79,7 +79,9 @@ export function resolveCompactionThresholdPercent(raw: number | undefined): numb
79
79
  return value <= 1 ? value * 100 : value;
80
80
  }
81
81
 
82
- export function shouldRerootStepSessionForContext(
82
+ export const HARD_CONTEXT_REROOT_THRESHOLD_PERCENT = 90;
83
+
84
+ export function shouldWarnStepSessionForContext(
83
85
  contextPercent: number | null | undefined,
84
86
  compactionThresholdPercent?: number,
85
87
  ): boolean {
@@ -88,3 +90,12 @@ export function shouldRerootStepSessionForContext(
88
90
  resolveCompactionThresholdPercent(compactionThresholdPercent),
89
91
  ) === "pause";
90
92
  }
93
+
94
+ export function shouldRerootStepSessionForContext(
95
+ contextPercent: number | null | undefined,
96
+ ): boolean {
97
+ return getContextPauseAction(
98
+ contextPercent,
99
+ HARD_CONTEXT_REROOT_THRESHOLD_PERCENT,
100
+ ) === "pause";
101
+ }
@@ -1523,6 +1523,7 @@ export const DISPATCH_RULES: DispatchRule[] = [
1523
1523
  const sid = state.activeSlice!.id;
1524
1524
  const sTitle = state.activeSlice!.title;
1525
1525
  const tid = state.activeTask.id;
1526
+ const unitId = `${mid}/${sid}`;
1526
1527
  const artifactBasePath = resolveArtifactBasePath(basePath, mid, session);
1527
1528
 
1528
1529
  // Guard: if the slice plan exists but the individual task plan files are
@@ -1562,6 +1563,17 @@ export const DISPATCH_RULES: DispatchRule[] = [
1562
1563
  !existsSync(projectionTaskPlanPath) &&
1563
1564
  !tasksEmbeddedInSlicePlan
1564
1565
  ) {
1566
+ const MAX_MISSING_TASK_PLAN_RETRIES = 2;
1567
+ const retryCount = session?.missingTaskPlanRetryCount?.get(unitId) ?? 0;
1568
+ if (retryCount >= MAX_MISSING_TASK_PLAN_RETRIES) {
1569
+ session?.missingTaskPlanRetryCount?.delete(unitId);
1570
+ return {
1571
+ action: "stop",
1572
+ reason: `Missing task-plan recovery failed ${retryCount} times for ${unitId} - manual intervention required. Task plan ${tid} is still missing after regenerating the slice plan. Fix the task-plan files manually, then run /gsd auto to resume.`,
1573
+ level: "error",
1574
+ };
1575
+ }
1576
+ session?.missingTaskPlanRetryCount?.set(unitId, retryCount + 1);
1565
1577
  if (isDebugEnabled()) {
1566
1578
  const expectedTaskPlanPath = join(artifactBasePath, relTaskFile(artifactBasePath, mid, sid, tid, "PLAN"));
1567
1579
  const originalProjectRoot = session?.originalBasePath || basePath;
@@ -1585,7 +1597,7 @@ export const DISPATCH_RULES: DispatchRule[] = [
1585
1597
  return {
1586
1598
  action: "dispatch",
1587
1599
  unitType: "plan-slice",
1588
- unitId: `${mid}/${sid}`,
1600
+ unitId,
1589
1601
  prompt: await buildPlanSlicePrompt(
1590
1602
  mid,
1591
1603
  midTitle,
@@ -1598,6 +1610,7 @@ export const DISPATCH_RULES: DispatchRule[] = [
1598
1610
  };
1599
1611
  }
1600
1612
 
1613
+ session?.missingTaskPlanRetryCount?.delete(unitId);
1601
1614
  return null;
1602
1615
  },
1603
1616
  },
@@ -17,7 +17,7 @@ import {
17
17
  resolveMilestoneFile, resolveSliceFile, resolveSlicePath,
18
18
  resolveTasksDir, resolveTaskFiles, resolveTaskFile,
19
19
  relMilestoneFile, relSliceFile, relSlicePath, relMilestonePath,
20
- resolveGsdRootFile, relGsdRootFile, resolveRuntimeFile,
20
+ relTaskFile, resolveGsdRootFile, relGsdRootFile, resolveRuntimeFile,
21
21
  } from "./paths.js";
22
22
  import { resolveInlineLevel, loadEffectiveGSDPreferences } from "./preferences.js";
23
23
  import { isContextModeEnabled } from "./preferences-types.js";
@@ -2662,7 +2662,7 @@ export async function buildExecuteTaskPrompt(
2662
2662
 
2663
2663
  const taskPlanPath = resolveTaskFile(base, mid, sid, tid, "PLAN");
2664
2664
  const taskPlanContent = taskPlanPath ? await loadFile(taskPlanPath) : null;
2665
- const taskPlanRelPath = relSlicePath(base, mid, sid) + `/tasks/${tid}-PLAN.md`;
2665
+ const taskPlanRelPath = relTaskFile(base, mid, sid, tid, "PLAN");
2666
2666
  const taskPlanContext = taskPlanContent
2667
2667
  ? [
2668
2668
  "## Inlined Task Plan (authoritative local execution contract)",
@@ -3901,7 +3901,7 @@ export async function buildReactiveExecutePrompt(
3901
3901
 
3902
3902
  const taskPlanPath = resolveTaskFile(base, mid, sid, tid, "PLAN");
3903
3903
  const taskPlanContent = taskPlanPath ? await loadFile(taskPlanPath) : null;
3904
- const taskPlanRelPath = `${relSlicePath(base, mid, sid)}/tasks/${tid}-PLAN.md`;
3904
+ const taskPlanRelPath = relTaskFile(base, mid, sid, tid, "PLAN");
3905
3905
  const taskPlanInline = taskPlanContent
3906
3906
  ? [
3907
3907
  "## Inlined Task Plan (authoritative local execution contract)",
@@ -35,7 +35,7 @@ import { isValidationTerminal } from "./state.js";
35
35
  import { getErrorMessage } from "./error-utils.js";
36
36
  import { logWarning, logError } from "./workflow-logger.js";
37
37
  import { readIntegrationBranch } from "./git-service.js";
38
- import { isClosedStatus } from "./status-guards.js";
38
+ import { isClosedStatus, isInactiveStatus } from "./status-guards.js";
39
39
  import {
40
40
  resolveSlicePath,
41
41
  resolveSliceFile,
@@ -295,7 +295,7 @@ export function writeReactiveExecuteBlocker(
295
295
  transaction(() => {
296
296
  for (const tid of summaryPresent) {
297
297
  const task = getTask(mid, sid, tid);
298
- if (!task || isClosedStatus(task.status)) {
298
+ if (!task || isInactiveStatus(task.status)) {
299
299
  unchangedTaskIds.push(tid);
300
300
  continue;
301
301
  }
@@ -304,7 +304,7 @@ export function writeReactiveExecuteBlocker(
304
304
  }
305
305
  for (const tid of summaryMissing) {
306
306
  const task = getTask(mid, sid, tid);
307
- if (!task || isClosedStatus(task.status)) {
307
+ if (!task || isInactiveStatus(task.status)) {
308
308
  unchangedTaskIds.push(tid);
309
309
  continue;
310
310
  }
@@ -97,8 +97,10 @@ import {
97
97
  getNewBudgetAlertLevel,
98
98
  getBudgetEnforcementAction,
99
99
  getContextPauseAction,
100
+ HARD_CONTEXT_REROOT_THRESHOLD_PERCENT,
100
101
  resolveCompactionThresholdPercent,
101
102
  shouldRerootStepSessionForContext,
103
+ shouldWarnStepSessionForContext,
102
104
  } from "./auto-budget.js";
103
105
  import {
104
106
  markToolStart as _markToolStart,
@@ -701,6 +703,7 @@ export {
701
703
  getContextPauseAction,
702
704
  resolveCompactionThresholdPercent,
703
705
  shouldRerootStepSessionForContext,
706
+ shouldWarnStepSessionForContext,
704
707
  } from "./auto-budget.js";
705
708
 
706
709
  function closeOutSignalInterruptedUnit(currentBasePath: string): void {
@@ -1375,9 +1378,10 @@ export async function rerootCommandSession(
1375
1378
  }
1376
1379
 
1377
1380
  /**
1378
- * After a completed step-mode unit, reclaim context headroom when usage is at
1379
- * or above the compaction threshold. Writes a context-mode snapshot first, then
1380
- * re-roots the visible command session while leaving the NEXT progress surface.
1381
+ * After a completed step-mode unit, warn when usage reaches the configurable
1382
+ * soft context threshold. At the fixed hard boundary, write a context-mode
1383
+ * snapshot and re-root the visible command session while leaving the NEXT
1384
+ * progress surface.
1381
1385
  */
1382
1386
  export async function maybeRerootStepSessionForHighContext(
1383
1387
  ctx: ExtensionContext,
@@ -1392,7 +1396,19 @@ export async function maybeRerootStepSessionForHighContext(
1392
1396
  const prefs = loadEffectiveGSDPreferences(workspaceRoot);
1393
1397
  const compactionThreshold = prefs?.preferences.context_management?.compaction_threshold_percent;
1394
1398
 
1395
- if (!shouldRerootStepSessionForContext(contextPercent, compactionThreshold)) {
1399
+ const shouldWarn = shouldWarnStepSessionForContext(contextPercent, compactionThreshold);
1400
+ const shouldReroot = shouldRerootStepSessionForContext(contextPercent);
1401
+ if (!shouldWarn && !shouldReroot) {
1402
+ return { rerooted: false };
1403
+ }
1404
+
1405
+ const displayPercent = (contextPercent as number).toFixed(1);
1406
+ const thresholdDisplay = resolveCompactionThresholdPercent(compactionThreshold).toFixed(0);
1407
+ if (!shouldReroot) {
1408
+ ctx.ui.notify(
1409
+ `Step complete — context at ${displayPercent}% (soft threshold: ${thresholdDisplay}%). Use /compact when convenient; continuing in this session is still safe.`,
1410
+ "info",
1411
+ );
1396
1412
  return { rerooted: false };
1397
1413
  }
1398
1414
 
@@ -1401,12 +1417,10 @@ export async function maybeRerootStepSessionForHighContext(
1401
1417
  const { writeContextModeCompactionSnapshot } = await import("./context-mode-snapshot.js");
1402
1418
  await writeContextModeCompactionSnapshot(snapshotBase);
1403
1419
 
1404
- const displayPercent = (contextPercent as number).toFixed(1);
1405
- const thresholdDisplay = resolveCompactionThresholdPercent(compactionThreshold).toFixed(0);
1406
1420
  const result = await rerootCommandSession(cmdCtx, workspaceRoot);
1407
1421
  if (result.status === "ok") {
1408
1422
  ctx.ui.notify(
1409
- `Step complete — context at ${displayPercent}% (threshold: ${thresholdDisplay}%). Fresh session ready for /gsd next.`,
1423
+ `Step complete — context at ${displayPercent}% (hard threshold: ${HARD_CONTEXT_REROOT_THRESHOLD_PERCENT}%). Fresh session ready for /gsd next.`,
1410
1424
  "info",
1411
1425
  );
1412
1426
  return { rerooted: true };
@@ -2102,7 +2116,7 @@ export function _selectStopAutoWorktreeExit(args: {
2102
2116
  /**
2103
2117
  * Pause auto-mode without destroying state. Context is preserved.
2104
2118
  * The user can interact with the agent, then `/gsd auto` resumes
2105
- * from disk state. Called when the user presses Escape during auto-mode.
2119
+ * from disk state. Called when the user presses Escape or runs `/gsd pause`.
2106
2120
  */
2107
2121
  export async function pauseAuto(
2108
2122
  ctx?: ExtensionContext,
@@ -2129,6 +2143,7 @@ export async function pauseAuto(
2129
2143
 
2130
2144
  s.active = false;
2131
2145
  s.paused = true;
2146
+ if (options.abortActiveTurn && ctx && !ctx.isIdle()) ctx.abort();
2132
2147
  clearUnitTimeout();
2133
2148
  stopAutoCommandPolling();
2134
2149
 
@@ -28,6 +28,7 @@ const NON_REPEATABLE_FROM_MINIMAL_BASE = new Set<string>([
28
28
 
29
29
  /** Additional core tools not in MINIMAL_AUTO_BASE but routinely multi-called per turn. */
30
30
  const EXTRA_INHERENTLY_REPEATABLE = [
31
+ "gsd_exec",
31
32
  "multi_edit",
32
33
  "todo_write",
33
34
  "notebook_edit",
@@ -690,8 +690,9 @@ export function registerDbTools(pi: ExtensionAPI): void {
690
690
  promptSnippet: "Plan a slice via DB write + PLAN render + cache invalidation",
691
691
  promptGuidelines: [
692
692
  "Use gsd_plan_slice for slice planning instead of writing S##-PLAN.md or task PLAN files directly.",
693
- "Keep parameters flat and provide the full slice planning payload, including tasks.",
694
- "The tool validates input, requires an existing parent slice, writes slice/task planning data, renders PLAN.md and task plan files from DB, and clears both state and parse caches after success.",
693
+ "For incremental planning, call gsd_plan_slice with slice metadata only, then call gsd_plan_task once per task.",
694
+ "When tasks is omitted or empty, the tool writes only slice planning metadata and preserves existing tasks.",
695
+ "When tasks is non-empty, the tool validates input, requires an existing parent slice, writes slice/task planning data, renders PLAN.md and task plan files from DB, and clears both state and parse caches after success.",
695
696
  "Use the canonical name gsd_plan_slice; gsd_slice_plan is only an alias.",
696
697
  ],
697
698
  parameters: Type.Object({
@@ -699,7 +700,7 @@ export function registerDbTools(pi: ExtensionAPI): void {
699
700
  milestoneId: Type.String({ description: "Milestone ID (e.g. M001)" }),
700
701
  sliceId: Type.String({ description: "Slice ID (e.g. S01)" }),
701
702
  goal: Type.String({ description: "Slice goal" }),
702
- tasks: Type.Array(Type.Object({
703
+ tasks: Type.Optional(Type.Array(Type.Object({
703
704
  taskId: Type.String({ description: "Task ID (e.g. T01)" }),
704
705
  title: Type.String({ description: "Task title" }),
705
706
  description: Type.String({ description: "Task description / steps block" }),
@@ -709,7 +710,7 @@ export function registerDbTools(pi: ExtensionAPI): void {
709
710
  inputs: Type.Array(Type.String(), { description: "Array<string> of input files or references; pass [\"path\"] or [], never a single string" }),
710
711
  expectedOutput: Type.Array(Type.String(), { description: "Array<string> of files this task creates or overwrites; pass [\"path\"] or [], never prose or a single string" }),
711
712
  observabilityImpact: Type.Optional(Type.String({ description: "Task observability impact" })),
712
- }), { description: "Planned tasks for the slice" }),
713
+ }), { description: "Optional full task replacement for the slice. Omit for incremental planning, then call gsd_plan_task once per task." })),
713
714
  // ── Enrichment metadata (optional — defaults to empty) ────────────
714
715
  successCriteria: Type.Optional(Type.String({ description: "Slice success criteria block" })),
715
716
  proofLevel: Type.Optional(Type.String({ description: "Slice proof level" })),
@@ -18,6 +18,7 @@ import { registerShortcuts } from "./register-shortcuts.js";
18
18
  import { writeCrashLog } from "./crash-log.js";
19
19
  import { logWarning, isGsdExtensionStderrEnabled } from "../workflow-logger.js";
20
20
  import { UNIT_TOOL_CONTRACTS } from "../unit-tool-contracts.js";
21
+ import { installManifestFlushOnProcessTeardown } from "../workflow-manifest.js";
21
22
  // Static import so cmux event listeners are registered synchronously during
22
23
  // extension bootstrap. Prior implementation used `void import().then()` which
23
24
  // queued listener registration as a microtask — any CMUX_CHANNELS emit fired
@@ -196,6 +197,7 @@ export function registerGsdExtension(pi: ExtensionAPI): void {
196
197
  });
197
198
 
198
199
  installEpipeGuard();
200
+ installManifestFlushOnProcessTeardown();
199
201
 
200
202
  // Ecosystem handlers captured by the GSDExtensionAPI wrapper for the
201
203
  // GSD-owned `before_agent_start` dispatch step (#3338).
@@ -34,7 +34,7 @@ import {
34
34
  } from "../auto-runtime-state.js";
35
35
  import { applyProviderPayloadPolicy } from "../provider-payload-policy.js";
36
36
 
37
- import { checkToolCallLoop, resetToolCallLoopGuard } from "./tool-call-loop-guard.js";
37
+ import { checkToolCallLoop, recordToolCallLoopMutation, resetToolCallLoopGuard } from "./tool-call-loop-guard.js";
38
38
  import { MINIMAL_AUTO_BASE_TOOL_NAMES } from "./core-session-tools.js";
39
39
  import { maybePauseAutoForApprovalGate, resetPendingGatePauseGuard } from "./pending-gate-pause.js";
40
40
  import { saveActivityLog } from "../activity-log.js";
@@ -76,6 +76,7 @@ import { supportsSourceObservationsForUnit } from "../source-observations.js";
76
76
  import { clearPendingAutoStart } from "../pending-auto-start.js";
77
77
  import { resolveWorkflowToolBasePath } from "./dynamic-tools.js";
78
78
  import { getRequiredWorkflowToolsForUnit } from "../unit-tool-contracts.js";
79
+ import { flushAllManifests } from "../workflow-manifest.js";
79
80
 
80
81
  let approvalQuestionAbortInFlight = false;
81
82
 
@@ -583,6 +584,21 @@ function contextBasePath(ctx?: { cwd?: string }): string {
583
584
  return typeof ctx?.cwd === "string" ? ctx.cwd : process.cwd();
584
585
  }
585
586
 
587
+ const LOOP_GUARD_INTERACTIVE_INSTRUCTIONS = [
588
+ "Do not retry this tool or call other tools this turn — stop and respond to the user in text.",
589
+ "Do not retry this tool or pivot to other tools this turn — stop and respond to the user in text.",
590
+ ];
591
+ const LOOP_GUARD_AUTO_INSTRUCTION =
592
+ "Do not re-issue this blocked tool. In /gsd auto, stop tool calls for this turn and return control to the auto-mode recovery/replan path.";
593
+
594
+ function formatLoopGuardBlockReason(reason: string | undefined): string | undefined {
595
+ if (!reason || !getAutoRuntimeSnapshot().active) return reason;
596
+ return LOOP_GUARD_INTERACTIVE_INSTRUCTIONS.reduce(
597
+ (formatted, instruction) => formatted.replace(instruction, LOOP_GUARD_AUTO_INSTRUCTION),
598
+ reason,
599
+ );
600
+ }
601
+
586
602
  function beginSourceObservationStoreForCurrentUnit(
587
603
  ctx?: { cwd?: string },
588
604
  ): ReturnType<typeof getSourceObservationStore> | null {
@@ -1080,6 +1096,19 @@ export function registerHooks(
1080
1096
  const { handleAgentEnd } = await import("./agent-end-recovery.js");
1081
1097
  const agentEndBasePath = contextBasePath(ctx);
1082
1098
  try {
1099
+ // The manifest is a non-critical projection (the append-only event log is
1100
+ // the authoritative recovery source), so a flush failure must not skip
1101
+ // agent_end recovery. drainManifestWrites still propagates write failures
1102
+ // to explicit flush callers; here we deliberately log and continue so
1103
+ // handleAgentEnd (and the finally below) always run.
1104
+ try {
1105
+ await flushAllManifests();
1106
+ } catch (err) {
1107
+ safetyLogWarning(
1108
+ "manifest",
1109
+ `flushAllManifests on agent_end failed: ${err instanceof Error ? err.message : String(err)}`,
1110
+ );
1111
+ }
1083
1112
  await handleAgentEnd(pi, event, ctx);
1084
1113
  } finally {
1085
1114
  activateDeferredApprovalGate(agentEndBasePath);
@@ -1286,7 +1315,7 @@ export function registerHooks(
1286
1315
  // ── Loop guard: block repeated identical tool calls ──
1287
1316
  const loopCheck = checkToolCallLoop(toolName, event.input as Record<string, unknown>);
1288
1317
  if (loopCheck.block) {
1289
- return { block: true, reason: loopCheck.reason };
1318
+ return { block: true, reason: formatLoopGuardBlockReason(loopCheck.reason) };
1290
1319
  }
1291
1320
 
1292
1321
  const deferredGateGuard = shouldBlockDeferredApprovalTool(
@@ -1558,6 +1587,7 @@ export function registerHooks(
1558
1587
  }
1559
1588
  }
1560
1589
  if (!event.isError) {
1590
+ recordToolCallLoopMutation(toolName);
1561
1591
  refreshSourceObservationAfterMutation(toolName, event.input, ctx);
1562
1592
  clearSourceObservationsAfterShell(toolName);
1563
1593
  }
@@ -9,13 +9,14 @@
9
9
 
10
10
  import { test, describe, before, after } from "node:test";
11
11
  import assert from "node:assert/strict";
12
- import { mkdtempSync, rmSync, existsSync } from "node:fs";
12
+ import { mkdtempSync, rmSync, existsSync, mkdirSync } from "node:fs";
13
13
  import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
 
16
16
  import {
17
17
  markDepthVerified,
18
18
  loadWriteGateSnapshot,
19
+ shouldBlockContextArtifactSaveInSnapshot,
19
20
  clearDiscussionFlowState,
20
21
  } from "../write-gate.js";
21
22
 
@@ -100,4 +101,51 @@ describe("write-gate basePath regression", () => {
100
101
  "loadWriteGateSnapshot(baseDirB) must not bleed state from baseDirA",
101
102
  );
102
103
  });
104
+
105
+ test("worktree basePath reads depth verification from project-root snapshot", (t) => {
106
+ const prev = process.env.GSD_PERSIST_WRITE_GATE_STATE;
107
+ t.after(() => {
108
+ if (prev === undefined) {
109
+ delete process.env.GSD_PERSIST_WRITE_GATE_STATE;
110
+ } else {
111
+ process.env.GSD_PERSIST_WRITE_GATE_STATE = prev;
112
+ }
113
+ });
114
+ process.env.GSD_PERSIST_WRITE_GATE_STATE = "1";
115
+
116
+ const projectRoot = makeTempDir();
117
+ const worktreePath = join(projectRoot, ".gsd-worktrees", "M020");
118
+ t.after(() => {
119
+ rmSync(projectRoot, { recursive: true, force: true });
120
+ });
121
+
122
+ clearDiscussionFlowState(projectRoot);
123
+ clearDiscussionFlowState(worktreePath);
124
+
125
+ // Simulate MCP routing: verification recorded at project root.
126
+ markDepthVerified("M020", projectRoot);
127
+
128
+ // Worktree has a local .gsd projection but no write-gate snapshot file.
129
+ mkdirSync(join(worktreePath, ".gsd", "runtime"), { recursive: true });
130
+ mkdirSync(join(projectRoot, ".gsd", "milestones"), { recursive: true });
131
+
132
+ const projectSnapshotPath = join(projectRoot, ".gsd", "runtime", "write-gate-state.json");
133
+ const worktreeSnapshotPath = join(worktreePath, ".gsd", "runtime", "write-gate-state.json");
134
+ assert.ok(existsSync(projectSnapshotPath), "verification snapshot lives at project root");
135
+ assert.ok(!existsSync(worktreeSnapshotPath), "worktree must not have its own snapshot file");
136
+
137
+ const snapshotFromWorktree = loadWriteGateSnapshot(worktreePath);
138
+ assert.ok(
139
+ snapshotFromWorktree.verifiedDepthMilestones.includes("M020"),
140
+ "loadWriteGateSnapshot(worktree) must inherit project-root verification",
141
+ );
142
+
143
+ const guard = shouldBlockContextArtifactSaveInSnapshot(
144
+ snapshotFromWorktree,
145
+ "CONTEXT",
146
+ "M020",
147
+ null,
148
+ );
149
+ assert.equal(guard.block, false, "CONTEXT save must unblock once project-root gate is verified");
150
+ });
103
151
  });
@@ -14,10 +14,12 @@
14
14
  * to stop tooling for the rest of that turn and answer in text.
15
15
  *
16
16
  * The per-tool-name check (#783 Brief C) tracks call counts within a
17
- * turn regardless of args. This catches improvisation
17
+ * turn regardless of args, decaying after successful state-mutating tools.
18
+ * This catches improvisation
18
19
  * loops where the model attempts the same missing workflow tool through
19
20
  * varied surfaces (bash → `node -e` → CLI), each with a different
20
- * signature, so the identical-args streak never trips. Whichever guard
21
+ * signature, so the identical-args streak never trips, while allowing
22
+ * tool-heavy turns that are making file-mutation progress. Whichever guard
21
23
  * trips first blocks.
22
24
  */
23
25
 
@@ -42,6 +44,8 @@ const MAX_CONSECUTIVE_STRICT = 1;
42
44
  */
43
45
  const PER_TOOL_DEFAULT_CAP = 6;
44
46
  const PER_TOOL_REPEATABLE_CAP = 15;
47
+ const PER_TOOL_CAP_EXEMPT_TOOLS = new Set(["find", "glob", "grep", "ls", "read", "search_and_read"]);
48
+ const STATE_MUTATING_TOOL_SET = new Set(["edit", "write", "multi_edit", "notebook_edit"]);
45
49
 
46
50
  let consecutiveCount = 0;
47
51
  let lastSignature = "";
@@ -50,6 +54,8 @@ let enabled = true;
50
54
 
51
55
  /** Per-tool-name call counts within the current turn (#783 Brief C). */
52
56
  const perToolCounts = new Map<string, number>();
57
+ let mutationEpoch = 0;
58
+ const perToolLastMutationEpoch = new Map<string, number>();
53
59
 
54
60
  /** Hash tool name + args into a compact signature for comparison. */
55
61
  function hashToolCall(toolName: string, args: Record<string, unknown>): string {
@@ -77,7 +83,8 @@ function hashToolCall(toolName: string, args: Record<string, unknown>): string {
77
83
  * 1. Identical-signature streak (MAX_CONSECUTIVE_IDENTICAL_CALLS, strict for
78
84
  * ask_user_questions).
79
85
  * 2. Per-tool-name cap (PER_TOOL_DEFAULT_CAP / PER_TOOL_REPEATABLE_CAP),
80
- * independent of args — catches improvisation loops (#783).
86
+ * independent of args, reset after file-mutation progress — catches
87
+ * improvisation loops (#783).
81
88
  */
82
89
  export function checkToolCallLoop(
83
90
  toolName: string,
@@ -114,8 +121,20 @@ export function checkToolCallLoop(
114
121
  // ── Guard 2: per-tool-name cap, independent of args (#783 Brief C) ──
115
122
  // Catches improvisation loops where the same tool is invoked many times with
116
123
  // varied args (e.g. retrying a missing workflow tool via bash/node -e/CLI).
117
- const perToolCount = (perToolCounts.get(toolName) ?? 0) + 1;
124
+ const priorPerToolCount =
125
+ (perToolLastMutationEpoch.get(toolName) ?? mutationEpoch) < mutationEpoch
126
+ ? 0
127
+ : (perToolCounts.get(toolName) ?? 0);
128
+ const perToolCount = priorPerToolCount + 1;
118
129
  perToolCounts.set(toolName, perToolCount);
130
+ perToolLastMutationEpoch.set(toolName, mutationEpoch);
131
+
132
+ // Read-only navigation tools are normal context gathering; Guard 1 still
133
+ // catches true reread loops with identical arguments.
134
+ if (PER_TOOL_CAP_EXEMPT_TOOLS.has(toolName)) {
135
+ return { block: false, count: consecutiveCount };
136
+ }
137
+
119
138
  const perToolCap = INHERENTLY_REPEATABLE_TOOL_SET.has(toolName)
120
139
  ? PER_TOOL_REPEATABLE_CAP
121
140
  : PER_TOOL_DEFAULT_CAP;
@@ -135,6 +154,13 @@ export function checkToolCallLoop(
135
154
  return { block: false, count: consecutiveCount };
136
155
  }
137
156
 
157
+ /** Record successful mutation progress so Guard 2 can decay on the next count. */
158
+ export function recordToolCallLoopMutation(toolName: string): void {
159
+ if (!enabled) return;
160
+ if (!STATE_MUTATING_TOOL_SET.has(toolName)) return;
161
+ mutationEpoch++;
162
+ }
163
+
138
164
  /** Reset the guard state. Call at agent turn boundaries. */
139
165
  export function resetToolCallLoopGuard(): void {
140
166
  consecutiveCount = 0;
@@ -142,6 +168,8 @@ export function resetToolCallLoopGuard(): void {
142
168
  lastToolName = "";
143
169
  enabled = true;
144
170
  perToolCounts.clear();
171
+ mutationEpoch = 0;
172
+ perToolLastMutationEpoch.clear();
145
173
  }
146
174
 
147
175
  /** Disable the guard (e.g. during shutdown). */
@@ -151,6 +179,8 @@ export function disableToolCallLoopGuard(): void {
151
179
  lastSignature = "";
152
180
  lastToolName = "";
153
181
  perToolCounts.clear();
182
+ mutationEpoch = 0;
183
+ perToolLastMutationEpoch.clear();
154
184
  }
155
185
 
156
186
  /** Get current consecutive count for diagnostics. */