@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
@@ -19,7 +19,7 @@ import { createCoordinationTablesV24 } from "../db-coordination-schema.js";
19
19
  import { createDbConnectionCache } from "../db-connection-cache.js";
20
20
  import { backupDatabaseBeforeMigration, isMigrationBackupError } from "../db-migration-backup.js";
21
21
  import { applyMigrationV2Artifacts, applyMigrationV3Memories, applyMigrationV4DecisionMadeBy, applyMigrationV5HierarchyTables, applyMigrationV6SliceSummaries, applyMigrationV7Dependencies, applyMigrationV8PlanningFields, applyMigrationV9Ordering, applyMigrationV10ReplanTrigger, applyMigrationV11TaskPlanning, applyMigrationV12QualityGates, applyMigrationV13HotPathIndexes, applyMigrationV14SliceDependencies, applyMigrationV15AuditTables, applyMigrationV16EscalationSource, applyMigrationV17TaskEscalation, applyMigrationV18MemorySources, applyMigrationV19MemoryFts, applyMigrationV20MemoryRelations, applyMigrationV21StructuredMemories, applyMigrationV22QualityGateRepair, applyMigrationV23MilestoneQueue, applyMigrationV26MilestoneCommitAttributions, applyMigrationV27ArtifactHash, applyMigrationV28MemoryLastHitAt, applyMigrationV29RepositoryTargets, } from "../db-migration-steps.js";
22
- import { isMemoriesFtsAvailableSchema, tryCreateMemoriesFtsSchema } from "../db-memory-fts-schema.js";
22
+ import { isMemoriesFtsAvailableSchema, rebuildMemoriesFtsSchemaOnce, tryCreateMemoriesFtsSchema, } from "../db-memory-fts-schema.js";
23
23
  import { createDbOpenState } from "../db-open-state.js";
24
24
  import { createRuntimeKvTableV25 } from "../db-runtime-kv-schema.js";
25
25
  import { getCurrentSchemaVersion, recordSchemaVersion } from "../db-schema-metadata.js";
@@ -101,6 +101,9 @@ function initSchema(db, fileBacked, dbPath) {
101
101
  throw err;
102
102
  }
103
103
  migrateSchema(db, dbPath);
104
+ rebuildMemoriesFtsSchemaOnce(db, {
105
+ onRebuildFailed: (message) => logWarning("db", message),
106
+ });
104
107
  }
105
108
  export function _isLikelyWslDrvFsPathForTest(dbPath) {
106
109
  if (!dbPath || process.platform !== "linux")
@@ -1,5 +1,7 @@
1
1
  // Project/App: gsd-pi
2
2
  // File Purpose: Memory FTS5 SQLite schema helpers for the GSD database facade.
3
+ import { createRuntimeKvTableV25 } from "./db-runtime-kv-schema.js";
4
+ export const MEMORIES_FTS_REBUILT_KEY = "memories_fts_rebuilt_at";
3
5
  function formatFtsUnavailableError(err) {
4
6
  const message = err instanceof Error ? err.message : String(err);
5
7
  return message.replace(/\bmoduel\s*:\s*/gi, "module: ");
@@ -54,3 +56,35 @@ export function isMemoriesFtsAvailableSchema(db) {
54
56
  return false;
55
57
  }
56
58
  }
59
+ export function rebuildMemoriesFtsSchemaOnce(db, options = {}) {
60
+ if (!isMemoriesFtsAvailableSchema(db))
61
+ return;
62
+ createRuntimeKvTableV25(db);
63
+ const marker = db.prepare("SELECT 1 as present FROM runtime_kv WHERE scope = 'global' AND scope_id = '' AND key = :key").get({ ":key": MEMORIES_FTS_REBUILT_KEY });
64
+ if (marker)
65
+ return;
66
+ const now = new Date().toISOString();
67
+ try {
68
+ db.exec("BEGIN");
69
+ db.exec("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')");
70
+ db.prepare(`INSERT INTO runtime_kv (scope, scope_id, key, value_json, updated_at)
71
+ VALUES ('global', '', :key, :value_json, :updated_at)
72
+ ON CONFLICT (scope, scope_id, key) DO UPDATE SET
73
+ value_json = excluded.value_json,
74
+ updated_at = excluded.updated_at`).run({
75
+ ":key": MEMORIES_FTS_REBUILT_KEY,
76
+ ":value_json": JSON.stringify(now),
77
+ ":updated_at": now,
78
+ });
79
+ db.exec("COMMIT");
80
+ }
81
+ catch (err) {
82
+ try {
83
+ db.exec("ROLLBACK");
84
+ }
85
+ catch {
86
+ // Best effort: leave startup alive and retry the rebuild on next open.
87
+ }
88
+ options.onRebuildFailed?.(`FTS5 rebuild failed: ${err.message}`);
89
+ }
90
+ }
@@ -94,7 +94,7 @@ const POLICY = {
94
94
  gsd_plan_task: {
95
95
  toolName: "gsd_plan_task",
96
96
  verdict: "no",
97
- rationale: "plan-slice prompt explicitly forbids calling gsd_plan_task separately; per-task granularity multiplies manifest writes and projection re-renders with no payoff.",
97
+ rationale: "Incremental task planning mutates the active slice plan and re-renders projections; keep it ordered in the foreground plan-slice unit.",
98
98
  },
99
99
  };
100
100
  function resolveCanonical(name) {
@@ -273,10 +273,10 @@ This config sets a parent workspace with two child repositories. The implicit `p
273
273
  - `audit_unified.enabled`: boolean — dual-write unified audit envelope events. Default: `true`.
274
274
  - `plan_v2.enabled`: boolean — enable bounded clarify/research/draft/compile planning flow. Default: `true`.
275
275
 
276
- - `context_management`: configures context hygiene for auto-mode sessions. Keys:
276
+ - `context_management`: configures context hygiene for auto-mode sessions. In step mode, the configurable threshold is a soft warning; automatic session re-rooting happens only at the fixed 90% hard context boundary. Keys:
277
277
  - `observation_masking`: boolean — mask old tool results to reduce context bloat. Default: `true`.
278
278
  - `observation_mask_turns`: number — keep this many recent turns verbatim (1-50). Default: `8`.
279
- - `compaction_threshold_percent`: number — trigger compaction at this % of context window (0.5-0.95). Lower values fire compaction earlier, reducing drift. Default: `0.60`.
279
+ - `compaction_threshold_percent`: number — show a soft context warning at this fraction of the context window (0.5-0.95). Lower values warn earlier so operators can compact manually before drift accumulates. Default: `0.60`.
280
280
  - `tool_result_max_chars`: number — max chars per tool result in GSD sessions (200-10000). Default: `800`.
281
281
 
282
282
  - `auto_visualize`: boolean — show a visualizer hint after each milestone completion in auto-mode. Default: `false`.
@@ -1,11 +1,115 @@
1
- import { existsSync, statSync } from "node:fs";
2
- import { isDbAvailable, _getAdapter } from "./gsd-db.js";
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { isAbsolute, join, relative } from "node:path";
3
+ import { getAllMilestones, getMilestoneSlices, getSliceTasks, isDbAvailable, isMemoriesFtsAvailable, _getAdapter, } from "./gsd-db.js";
4
+ import { MEMORIES_FTS_REBUILT_KEY } from "./db-memory-fts-schema.js";
3
5
  import { isAfter, latestExplicitReopenAt } from "./milestone-reopen-events.js";
4
- import { resolveGsdPathContract, resolveMilestoneFile } from "./paths.js";
6
+ import { gsdProjectionRoot, gsdRoot, resolveGsdPathContract, resolveMilestoneFile, resolveSliceFile } from "./paths.js";
5
7
  import { deriveState } from "./state.js";
8
+ import { isClosedStatus } from "./status-guards.js";
6
9
  import { workflowEventLogPath } from "./workflow-event-ledger.js";
7
10
  import { readEvents } from "./workflow-events.js";
8
11
  import { flushWorkflowProjections } from "./projection-flush.js";
12
+ import { parseRoadmapSlices } from "./roadmap-slices.js";
13
+ import { parsePlan } from "./parsers-legacy.js";
14
+ function relativeFile(basePath, filePath) {
15
+ return relative(basePath, filePath).split("\\").join("/");
16
+ }
17
+ function reportCheckboxDbStatusDivergence(issues, basePath, filePath, scope, unitId, status, checkboxDone) {
18
+ const dbDone = isClosedStatus(status);
19
+ if (checkboxDone === dbDone)
20
+ return;
21
+ issues.push({
22
+ severity: "error",
23
+ code: "checkbox_db_status_divergence",
24
+ scope,
25
+ unitId,
26
+ message: `${scope === "slice" ? "Slice" : "Task"} ${unitId} is ${dbDone ? "closed" : "open"} in the database (status: ${status}) but the markdown checkbox is ${checkboxDone ? "checked" : "unchecked"}.`,
27
+ file: relativeFile(basePath, filePath),
28
+ fixable: false,
29
+ });
30
+ }
31
+ function checkProjectionCheckboxDbStatus(basePath, milestoneIds, issues) {
32
+ for (const milestoneId of milestoneIds) {
33
+ const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
34
+ const slices = getMilestoneSlices(milestoneId);
35
+ if (roadmapPath && existsSync(roadmapPath)) {
36
+ try {
37
+ const roadmap = readFileSync(roadmapPath, "utf-8");
38
+ const sliceDoneById = new Map(parseRoadmapSlices(roadmap).map((entry) => [entry.id, entry.done]));
39
+ for (const slice of slices) {
40
+ const checkboxDone = sliceDoneById.get(slice.id);
41
+ if (checkboxDone === undefined)
42
+ continue;
43
+ reportCheckboxDbStatusDivergence(issues, basePath, roadmapPath, "slice", `${milestoneId}/${slice.id}`, slice.status, checkboxDone);
44
+ }
45
+ }
46
+ catch {
47
+ // Non-fatal — checkbox drift diagnostics must never block doctor.
48
+ }
49
+ }
50
+ for (const slice of slices) {
51
+ const planPath = resolveSliceFile(basePath, milestoneId, slice.id, "PLAN");
52
+ if (!planPath || !existsSync(planPath))
53
+ continue;
54
+ try {
55
+ const plan = readFileSync(planPath, "utf-8");
56
+ // parsePlan reads the authoritative task checkboxes (the flat-phase
57
+ // <tasks> block / ## Tasks section), so a stray task-style checkbox
58
+ // line elsewhere in PLAN.md (e.g. a Must-Haves or Verification bullet
59
+ // above <tasks>) can no longer hide real drift or fake a divergence.
60
+ const taskDoneById = new Map(parsePlan(plan).tasks.map((entry) => [entry.id, entry.done]));
61
+ for (const task of getSliceTasks(milestoneId, slice.id)) {
62
+ const checkboxDone = taskDoneById.get(task.id);
63
+ if (checkboxDone === undefined)
64
+ continue;
65
+ reportCheckboxDbStatusDivergence(issues, basePath, planPath, "task", `${milestoneId}/${slice.id}/${task.id}`, task.status, checkboxDone);
66
+ }
67
+ }
68
+ catch {
69
+ // Non-fatal — checkbox drift diagnostics must never block doctor.
70
+ }
71
+ }
72
+ }
73
+ }
74
+ function isClearedByMilestoneShellProjectionFlush(basePath, issue, reRenderedMilestoneIds) {
75
+ if (issue.code !== "checkbox_db_status_divergence")
76
+ return false;
77
+ if (issue.scope !== "slice")
78
+ return false;
79
+ const milestoneId = issue.unitId.split("/")[0] ?? "";
80
+ if (!reRenderedMilestoneIds.has(milestoneId))
81
+ return false;
82
+ const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
83
+ if (!roadmapPath || !issue.file)
84
+ return false;
85
+ return issue.file === relativeFile(basePath, roadmapPath);
86
+ }
87
+ function artifactExistsOnDisk(basePath, artifactPath) {
88
+ if (isAbsolute(artifactPath))
89
+ return existsSync(artifactPath);
90
+ return [
91
+ join(gsdProjectionRoot(basePath), artifactPath),
92
+ join(gsdRoot(basePath), artifactPath),
93
+ ].some((candidate) => existsSync(candidate));
94
+ }
95
+ function artifactUnitId(row) {
96
+ if (!row.milestone_id)
97
+ return "project";
98
+ if (row.slice_id && row.task_id)
99
+ return `${row.milestone_id}/${row.slice_id}/${row.task_id}`;
100
+ if (row.slice_id)
101
+ return `${row.milestone_id}/${row.slice_id}`;
102
+ return row.milestone_id;
103
+ }
104
+ function artifactScope(row) {
105
+ if (row.task_id)
106
+ return "task";
107
+ if (row.slice_id)
108
+ return "slice";
109
+ if (row.milestone_id)
110
+ return "milestone";
111
+ return "project";
112
+ }
9
113
  export async function checkEngineHealth(basePath, issues, fixesApplied) {
10
114
  const dbPath = resolveGsdPathContract(basePath).projectDb;
11
115
  if (!isDbAvailable() && existsSync(dbPath)) {
@@ -23,6 +127,30 @@ export async function checkEngineHealth(basePath, issues, fixesApplied) {
23
127
  try {
24
128
  if (isDbAvailable()) {
25
129
  const adapter = _getAdapter();
130
+ try {
131
+ if (isMemoriesFtsAvailable(adapter)) {
132
+ const runtimeKv = adapter
133
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='runtime_kv'")
134
+ .get();
135
+ const marker = runtimeKv
136
+ ? adapter.prepare("SELECT 1 as present FROM runtime_kv WHERE scope = 'global' AND scope_id = '' AND key = :key").get({ ":key": MEMORIES_FTS_REBUILT_KEY })
137
+ : undefined;
138
+ if (!marker) {
139
+ issues.push({
140
+ severity: "warning",
141
+ code: "memories_fts_rebuild_missing",
142
+ scope: "project",
143
+ unitId: "project",
144
+ message: `Memory full-text index exists but runtime_kv has no ${MEMORIES_FTS_REBUILT_KEY} marker. The index may be stale or incomplete, so memory search can silently degrade to the LIKE fallback.`,
145
+ file: ".gsd/gsd.db",
146
+ fixable: false,
147
+ });
148
+ }
149
+ }
150
+ }
151
+ catch {
152
+ // Non-fatal — memory FTS health check failed
153
+ }
26
154
  // a. Orphaned tasks (task.slice_id points to non-existent slice)
27
155
  try {
28
156
  const orphanedTasks = adapter
@@ -172,7 +300,33 @@ export async function checkEngineHealth(basePath, issues, fixesApplied) {
172
300
  catch {
173
301
  // Non-fatal — completed-milestone reopen check failed
174
302
  }
175
- // f. Completion artifacts disagree with open DB hierarchy rows.
303
+ // f. Artifact rows reference files that no longer exist on disk.
304
+ try {
305
+ const artifactRows = adapter
306
+ .prepare(`SELECT path, artifact_type, milestone_id, slice_id, task_id
307
+ FROM artifacts
308
+ WHERE path != ''
309
+ ORDER BY path`)
310
+ .all();
311
+ for (const row of artifactRows) {
312
+ if (artifactExistsOnDisk(basePath, row.path))
313
+ continue;
314
+ const unitId = artifactUnitId(row);
315
+ issues.push({
316
+ severity: "error",
317
+ code: "artifact_file_missing",
318
+ scope: artifactScope(row),
319
+ unitId,
320
+ message: `Artifact ${row.path} is recorded in the database as ${row.artifact_type || "UNKNOWN"} but no matching file exists on disk`,
321
+ file: row.path,
322
+ fixable: false,
323
+ });
324
+ }
325
+ }
326
+ catch {
327
+ // Non-fatal — artifact file existence check failed
328
+ }
329
+ // g. Completion artifacts disagree with open DB hierarchy rows.
176
330
  try {
177
331
  const rows = adapter
178
332
  .prepare(`SELECT a.path, a.artifact_type, a.milestone_id, a.slice_id, a.task_id, a.imported_at,
@@ -189,6 +343,8 @@ export async function checkEngineHealth(basePath, issues, fixesApplied) {
189
343
  .all();
190
344
  const seen = new Set();
191
345
  for (const row of rows) {
346
+ if (!artifactExistsOnDisk(basePath, row.path))
347
+ continue;
192
348
  const reopenAt = latestExplicitReopenAt(basePath, row.milestone_id);
193
349
  if (!isAfter(row.imported_at, reopenAt))
194
350
  continue;
@@ -223,9 +379,24 @@ export async function checkEngineHealth(basePath, issues, fixesApplied) {
223
379
  catch {
224
380
  // Non-fatal — DB constraint checks failed entirely
225
381
  }
382
+ // Checkbox-vs-DB divergence detection runs before projection drift auto-fix
383
+ // so stale re-renders cannot overwrite manually edited markdown first. Runs
384
+ // inside its own try/catch: getAllMilestones / getMilestoneSlices /
385
+ // getSliceTasks issue prepared queries that can throw on a corrupt or locked
386
+ // DB, and like every other DB-touching check here this diagnostic must never
387
+ // block doctor.
388
+ try {
389
+ if (isDbAvailable()) {
390
+ checkProjectionCheckboxDbStatus(basePath, getAllMilestones().map((milestone) => milestone.id), issues);
391
+ }
392
+ }
393
+ catch {
394
+ // Non-fatal: checkbox-vs-DB divergence check must never block doctor
395
+ }
226
396
  // ── Projection drift detection ──────────────────────────────────────────
227
397
  // If the DB is available, check whether markdown projections are stale
228
398
  // relative to the event log and re-render them.
399
+ const reRenderedMilestoneIds = [];
229
400
  try {
230
401
  if (isDbAvailable()) {
231
402
  const eventLogPath = workflowEventLogPath(basePath);
@@ -241,6 +412,7 @@ export async function checkEngineHealth(basePath, issues, fixesApplied) {
241
412
  try {
242
413
  await flushWorkflowProjections(basePath, { milestoneId: milestone.id });
243
414
  fixesApplied.push(`re-rendered missing projections for ${milestone.id}`);
415
+ reRenderedMilestoneIds.push(milestone.id);
244
416
  }
245
417
  catch {
246
418
  // Non-fatal — projection re-render failed
@@ -252,6 +424,7 @@ export async function checkEngineHealth(basePath, issues, fixesApplied) {
252
424
  try {
253
425
  await flushWorkflowProjections(basePath, { milestoneId: milestone.id });
254
426
  fixesApplied.push(`re-rendered stale projections for ${milestone.id}`);
427
+ reRenderedMilestoneIds.push(milestone.id);
255
428
  }
256
429
  catch {
257
430
  // Non-fatal — projection re-render failed
@@ -264,4 +437,19 @@ export async function checkEngineHealth(basePath, issues, fixesApplied) {
264
437
  catch {
265
438
  // Non-fatal — projection drift check must never block doctor
266
439
  }
440
+ if (reRenderedMilestoneIds.length > 0) {
441
+ const reRendered = new Set(reRenderedMilestoneIds);
442
+ for (let i = issues.length - 1; i >= 0; i--) {
443
+ const issue = issues[i];
444
+ // flushWorkflowProjections re-renders milestone shell projections (not
445
+ // slice PLAN.md files), so only clear stale ROADMAP checkbox diagnostics.
446
+ if (isClearedByMilestoneShellProjectionFlush(basePath, issue, reRendered)) {
447
+ issues.splice(i, 1);
448
+ continue;
449
+ }
450
+ if (issue.code === "artifact_file_missing" && issue.file && artifactExistsOnDisk(basePath, issue.file)) {
451
+ issues.splice(i, 1);
452
+ }
453
+ }
454
+ }
267
455
  }
@@ -11,7 +11,7 @@ import { spawn } from "node:child_process";
11
11
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
12
12
  import { randomUUID } from "node:crypto";
13
13
  import { resolve } from "node:path";
14
- import { killProcessTree, SIGKILL_GRACE_MS, HARD_DEADLINE_MS } from "@gsd/pi-coding-agent";
14
+ import { getShellConfig, killProcessTree, SIGKILL_GRACE_MS, HARD_DEADLINE_MS } from "@gsd/pi-coding-agent";
15
15
  const ALWAYS_FORWARD_ENV = ["PATH", "HOME"];
16
16
  // SIGKILL_GRACE_MS / HARD_DEADLINE_MS are imported from @gsd/pi-coding-agent
17
17
  // (shell.ts) — the single source of truth for the graceful-kill timing ladder —
@@ -60,7 +60,13 @@ function clampTimeout(request, opts) {
60
60
  function resolveCommand(runtime) {
61
61
  switch (runtime) {
62
62
  case "bash":
63
- return { cmd: "bash", args: ["-c"] };
63
+ try {
64
+ const { shell, args } = getShellConfig();
65
+ return { cmd: shell, args };
66
+ }
67
+ catch {
68
+ return { cmd: "bash", args: ["-c"] };
69
+ }
64
70
  case "node":
65
71
  return { cmd: process.execPath, args: ["-e"] };
66
72
  case "python":
@@ -1028,6 +1028,7 @@ function collectRepositoryDirtyStatus(basePath) {
1028
1028
  const preferences = loadEffectiveGSDPreferences(basePath)?.preferences;
1029
1029
  const registry = createRepositoryRegistryFromPreferences(basePath, preferences);
1030
1030
  const dirtyByRepository = {};
1031
+ const registeredRoots = new Set(registry.repositories.map((repo) => resolve(repo.root)));
1031
1032
  for (const repo of registry.repositories) {
1032
1033
  try {
1033
1034
  dirtyByRepository[repo.id] = runGit(repo.root, ["status", "--porcelain"]).length > 0;
@@ -1037,8 +1038,63 @@ function collectRepositoryDirtyStatus(basePath) {
1037
1038
  dirtyByRepository[repo.id] = nativeHasChanges(repo.root);
1038
1039
  }
1039
1040
  }
1041
+ for (const repoRoot of findUndeclaredNestedGitRepositories(registry.projectRoot, registeredRoots)) {
1042
+ let dirty = false;
1043
+ try {
1044
+ dirty = runGit(repoRoot, ["status", "--porcelain"]).length > 0;
1045
+ }
1046
+ catch {
1047
+ dirty = nativeHasChanges(repoRoot);
1048
+ }
1049
+ if (!dirty)
1050
+ continue;
1051
+ const relPath = relative(registry.projectRoot, repoRoot).split(sep).join("/") || repoRoot;
1052
+ const label = `${relPath} (undeclared)`;
1053
+ dirtyByRepository[label] = true;
1054
+ logWarning("engine", `undeclared dirty nested git repo ${relPath}; declare it under workspace.repositories or commit manually`, { file: "git-service.ts" });
1055
+ }
1040
1056
  return dirtyByRepository;
1041
1057
  }
1058
+ const UNDECLARED_NESTED_REPO_SCAN_MAX_DEPTH = 4;
1059
+ const UNDECLARED_NESTED_REPO_SKIP_DIRS = new Set([
1060
+ ".git",
1061
+ ".gsd",
1062
+ ".gsd-worktrees",
1063
+ "node_modules",
1064
+ "dist",
1065
+ "dist-test",
1066
+ ]);
1067
+ function findUndeclaredNestedGitRepositories(projectRoot, registeredRoots) {
1068
+ const found = [];
1069
+ function visit(dir, depth) {
1070
+ if (depth >= UNDECLARED_NESTED_REPO_SCAN_MAX_DEPTH)
1071
+ return;
1072
+ let entries;
1073
+ try {
1074
+ entries = readdirSync(dir, { withFileTypes: true });
1075
+ }
1076
+ catch {
1077
+ return;
1078
+ }
1079
+ for (const entry of entries) {
1080
+ if (!entry.isDirectory())
1081
+ continue;
1082
+ if (UNDECLARED_NESTED_REPO_SKIP_DIRS.has(entry.name))
1083
+ continue;
1084
+ const child = join(dir, entry.name);
1085
+ const childRoot = resolve(child);
1086
+ const hasGitDir = existsSync(join(child, ".git"));
1087
+ const isRegistered = registeredRoots.has(childRoot);
1088
+ if (hasGitDir && !isRegistered) {
1089
+ found.push(childRoot);
1090
+ continue;
1091
+ }
1092
+ visit(child, depth + 1);
1093
+ }
1094
+ }
1095
+ visit(projectRoot, 0);
1096
+ return found;
1097
+ }
1042
1098
  function runPerRepositoryCommitAction(args) {
1043
1099
  const preferences = loadEffectiveGSDPreferences(args.basePath)?.preferences;
1044
1100
  const registry = createRepositoryRegistryFromPreferences(args.basePath, preferences);
@@ -22,7 +22,7 @@ import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
22
22
  * With external state (symlink), these are a no-op in most cases,
23
23
  * but retained for backwards compatibility during migration.
24
24
  */
25
- const GSD_RUNTIME_PATTERNS = [
25
+ export const GSD_RUNTIME_PATTERNS = [
26
26
  ".gsd-worktrees/",
27
27
  ".gsd-backups/",
28
28
  ".gsd/activity/",
@@ -110,6 +110,7 @@ export function migrateToExternalState(basePath) {
110
110
  }
111
111
  // Copy contents to external dir, skipping worktrees/
112
112
  const entries = readdirSync(migratingPath, { withFileTypes: true });
113
+ const copyFailures = [];
113
114
  for (const entry of entries) {
114
115
  if (entry.name === "worktrees")
115
116
  continue; // worktrees stay local
@@ -123,10 +124,18 @@ export function migrateToExternalState(basePath) {
123
124
  cpSync(src, dst, { force: true });
124
125
  }
125
126
  }
126
- catch {
127
- // Non-fatal: continue with other files
127
+ catch (copyErr) {
128
+ copyFailures.push(`${entry.name}: ${getErrorMessage(copyErr)}`);
128
129
  }
129
130
  }
131
+ if (copyFailures.length > 0) {
132
+ try {
133
+ rmSync(localGsd, { force: true });
134
+ }
135
+ catch { /* may not exist */ }
136
+ renameSync(migratingPath, localGsd);
137
+ return { migrated: false, error: `Migration copy failed: ${copyFailures.join("; ")}` };
138
+ }
130
139
  // Create symlink .gsd -> external path
131
140
  symlinkSync(externalPath, localGsd, "junction");
132
141
  // Verify the symlink resolves correctly before removing the backup (#1377).
@@ -104,8 +104,8 @@ If the preference is off, ignore this section and plan every slice fully.
104
104
 
105
105
  If the roadmap has one slice, also plan S01 and its tasks inline:
106
106
 
107
- 1. After `gsd_plan_milestone` returns, call `gsd_plan_slice` for S01 with full task breakdown.
108
- 2. Use inlined **Slice Plan** and **Task Plan** templates for tool parameters.
107
+ 1. After `gsd_plan_milestone` returns, call `gsd_plan_slice` for S01 with slice metadata only; omit `tasks`.
108
+ 2. Then call `gsd_plan_task` once per S01 task. Use inlined **Slice Plan** and **Task Plan** templates for tool parameters.
109
109
  3. Keep simple slices lean. Omit Proof Level, Integration Closure, and Observability if all would be "none"; executable verification commands are enough.
110
110
 
111
111
  Do **not** write plan files manually; use DB-backed tools so state stays consistent.
@@ -45,8 +45,8 @@ If slice research is inlined, trust its architectural findings, but verify every
45
45
  6. Include Threat Surface (Q3), Requirement Impact (Q4), proof level, observability, integration closure, Failure Modes (Q5), Load Profile (Q6), and Negative Tests (Q7) only where applicable.
46
46
  7. Right-size tasks. Simple slices can be one task; split only when context, ownership, or verification boundaries justify it.
47
47
  8. Task `verify` commands must be safe, simple commands. Do not use shell pipes, redirects, semicolons, backticks, command substitution, output trimming, or grep regex alternation with `|`. If multiple checks are needed, create a small test file and run it with `node --test` or a package test script, or use separate simple commands joined only with `&&`. For absence checks, verify a pattern does not exist with `! grep -q 'pattern' file` or `! rg -q 'pattern' file`; do not use `grep -c` or `rg -c` to assert zero matches because count commands exit 1 when they find zero matches, and the verification gate treats that as failure.
48
- 9. Each task needs the exact `gsd_plan_slice.tasks[]` shape: `taskId`, `title`, `description`, `estimate`, `files`, `verify`, `inputs`, `expectedOutput`, and optional `observabilityImpact`. `description` should contain the Why / Do / Done-when narrative. Any npm package name in a task description's install/add instructions (`npm install`, `yarn add`, `pnpm add`) or in `require('...')` or `import ... from '...'` code examples is subject to pre-execution registry validation; reference only packages that exist on the public npm registry, and remove or correct any package name that is not real. `files`, `inputs`, and `expectedOutput` must be JSON arrays of strings, even when there is only one path (for example, `"inputs": ["src/index.ts"]`, never `"inputs": "src/index.ts"`). Use paths relative to `{{workingDirectory}}`; do not put absolute paths to the original checkout or any directory outside `{{workingDirectory}}` in `files`, `inputs`, `expectedOutput`, or verification commands. **`expectedOutput` must only list files the task actually creates or overwrites on disk.** Do NOT include files the task merely reads, verifies, tests, or describes — those belong in `inputs`, `verify`, `description`, or slice success criteria. If a task is a pure verification or test task that produces no new files, `expectedOutput` must be `[]`; if it writes a test-result log or assertion output file, list only that concrete file path. A file that does not yet exist on disk and is needed as an `input` must be produced by an earlier task's `expectedOutput` — if no prior task creates it, add a task before this one that does. Never list GSD planning artifacts — anything under `.gsd/`, `.planning/`, or `.audits/`, or artifact names like `M001-CONTEXT.md` / `S01-PLAN.md` — in `inputs`, `files`, or `expectedOutput`: their content is preloaded as context, and they are written by workflow tools, not by tasks.
49
- 10. Persist with `gsd_plan_slice` using `milestoneId`, `sliceId`, `goal`, optional `successCriteria`/`proofLevel`/`integrationClosure`/`observabilityImpact`, and `tasks`. `gsd_plan_slice` handles task persistence transactionally and renders `{{outputPath}}` plus task plans; do not call `gsd_plan_task`. The DB-backed tool is the canonical write path. Do **not** rely on direct `PLAN.md` writes as the source of truth.
48
+ 9. Each task persisted with `gsd_plan_task` needs the exact shape: `milestoneId`, `sliceId`, `taskId`, `title`, `description`, `estimate`, `files`, `verify`, `inputs`, `expectedOutput`, and optional `observabilityImpact`. `description` should contain the Why / Do / Done-when narrative. Any npm package name in a task description's install/add instructions (`npm install`, `yarn add`, `pnpm add`) or in `require('...')` or `import ... from '...'` code examples is subject to pre-execution registry validation; reference only packages that exist on the public npm registry, and remove or correct any package name that is not real. `files`, `inputs`, and `expectedOutput` must be JSON arrays of strings, even when there is only one path (for example, `"inputs": ["src/index.ts"]`, never `"inputs": "src/index.ts"`). Use paths relative to `{{workingDirectory}}`; do not put absolute paths to the original checkout or any directory outside `{{workingDirectory}}` in `files`, `inputs`, `expectedOutput`, or verification commands. **`expectedOutput` must only list files the task actually creates or overwrites on disk.** Do NOT include files the task merely reads, verifies, tests, or describes — those belong in `inputs`, `verify`, `description`, or slice success criteria. If a task is a pure verification or test task that produces no new files, `expectedOutput` must be `[]`; if it writes a test-result log or assertion output file, list only that concrete file path. A file that does not yet exist on disk and is needed as an `input` must be produced by an earlier task's `expectedOutput` — if no prior task creates it, add a task before this one that does. Never list GSD planning artifacts — anything under `.gsd/`, `.planning/`, or `.audits/`, or artifact names like `M001-CONTEXT.md` / `S01-PLAN.md` — in `inputs`, `files`, or `expectedOutput`: their content is preloaded as context, and they are written by workflow tools, not by tasks.
49
+ 10. Persist incrementally through DB-backed tools. First call `gsd_plan_slice` with `milestoneId`, `sliceId`, `goal`, and optional `successCriteria`/`proofLevel`/`integrationClosure`/`observabilityImpact`; omit `tasks` for this metadata call. Then call `gsd_plan_task` once per task, using the task shape above. Each successful tool call saves progress and re-renders `{{outputPath}}` from DB. The DB-backed tools are the canonical write path. Do **not** rely on direct `PLAN.md` writes as the source of truth.
50
50
  11. Self-audit before finishing: goal/demo closure, requirement coverage, deliverable coverage audit (cross-check every file listed in CONTEXT.md `## Scope` / `### In Scope` against task `files` or `expectedOutput`), locked decisions, concrete paths, dependency order, wiring, scope size, proof truthfulness, feature completeness, and quality gates. Quality gates: non-trivial slices/tasks include specific Q3-Q7 coverage where applicable.
51
51
  12. If planning creates structural decisions, call `gsd_decision_save` for each; the tool persists the decision and regenerates `.gsd/DECISIONS.md`.
52
52
  13. {{commitInstruction}}
@@ -55,6 +55,6 @@ The slice directory already exists. Do not mkdir.
55
55
 
56
56
  **Autonomous execution:** no human is available. Do not call `ask_user_questions` or `secure_env_collect`; make reasonable assumptions and document them.
57
57
 
58
- **You MUST call `gsd_plan_slice` to persist planning state before finishing, unless the stale-path safety rule above stops the unit before safe planning can occur.**
58
+ **You MUST call `gsd_plan_slice` and then `gsd_plan_task` once for each planned task before finishing, unless the stale-path safety rule above stops the unit before safe planning can occur.**
59
59
 
60
60
  When done, say: "Slice {{sliceId}} planned." Say this exactly once — if you already said it in a prior message, do not repeat it.
@@ -4,9 +4,15 @@ import { execFileSync } from "node:child_process";
4
4
  import { createHash } from "node:crypto";
5
5
  import { existsSync, readFileSync, statSync } from "node:fs";
6
6
  import { join } from "node:path";
7
+ import { GSD_RUNTIME_PATTERNS } from "./gitignore.js";
7
8
  const MAX_SYNC_FINGERPRINT_BYTES = 1_500_000_000;
9
+ const ROOT_RUNTIME_PATH_PREFIXES = Array.from(new Set(GSD_RUNTIME_PATTERNS.map((pattern) => {
10
+ const path = pattern.replace(/\/+$/, "");
11
+ const slash = path.indexOf("/");
12
+ return slash === -1 ? path : path.slice(0, slash);
13
+ })));
8
14
  function isRootRuntimePath(path) {
9
- return path === ".gsd" || path.startsWith(".gsd/");
15
+ return ROOT_RUNTIME_PATH_PREFIXES.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
10
16
  }
11
17
  function fileFingerprint(rootPath, relPath) {
12
18
  const absPath = join(rootPath, relPath);
@@ -20,14 +20,21 @@ const DESTRUCTIVE_PATTERNS = [
20
20
  { pattern: /\baws\s+\w+\s+(delete|create|put|remove|terminate)\b/i, label: "AWS mutation" },
21
21
  { pattern: /\bkubectl\s+(delete|apply)\b/i, label: "kubectl mutation" },
22
22
  ];
23
+ function stripQuotedAndComments(command) {
24
+ return command
25
+ .replace(/"(?:[^"\\]|\\.)*"/g, '""')
26
+ .replace(/'(?:[^'\\]|\\.)*'/g, "''")
27
+ .replace(/(^|\s)#.*$/g, "$1");
28
+ }
23
29
  /**
24
30
  * Classify a bash command for destructive operations.
25
31
  * Returns the list of matched destructive pattern labels.
26
32
  */
27
33
  export function classifyCommand(command) {
34
+ const commandToClassify = stripQuotedAndComments(command);
28
35
  const labels = [];
29
36
  for (const { pattern, label } of DESTRUCTIVE_PATTERNS) {
30
- if (pattern.test(command)) {
37
+ if (pattern.test(commandToClassify)) {
31
38
  // Deduplicate labels (e.g., two force-push patterns)
32
39
  if (!labels.includes(label))
33
40
  labels.push(label);
@@ -27,10 +27,14 @@ const EXECUTION_TOOL_NAMES = new Set([
27
27
  const MCP_EXECUTION_TOOL_RE = /^mcp__.+__gsd_(?:uat_)?exec(?:_search)?$/;
28
28
  // ─── Module State ───────────────────────────────────────────────────────────
29
29
  let unitEvidence = [];
30
+ let lastWritePath = null;
31
+ let lastWriteSig = null;
30
32
  // ─── Public API ─────────────────────────────────────────────────────────────
31
33
  /** Reset all evidence for a new unit. Call at unit start. */
32
34
  export function resetEvidence() {
33
35
  unitEvidence = [];
36
+ lastWritePath = null;
37
+ lastWriteSig = null;
34
38
  }
35
39
  /** Get a read-only view of all evidence collected for the current unit. */
36
40
  export function getEvidence() {
@@ -95,10 +99,15 @@ function isEvidenceArray(data) {
95
99
  export function saveEvidenceToDisk(basePath, milestoneId, sliceId, taskId) {
96
100
  try {
97
101
  const path = evidencePath(basePath, milestoneId, sliceId, taskId);
102
+ const body = JSON.stringify(unitEvidence, null, 2) + "\n";
103
+ if (path === lastWritePath && body === lastWriteSig)
104
+ return;
98
105
  mkdirSync(dirname(path), { recursive: true });
99
106
  const tmp = `${path}.tmp.${randomBytes(4).toString("hex")}`;
100
- writeFileSync(tmp, JSON.stringify(unitEvidence, null, 2) + "\n", "utf-8");
107
+ writeFileSync(tmp, body, "utf-8");
101
108
  renameSync(tmp, path);
109
+ lastWritePath = path;
110
+ lastWriteSig = body;
102
111
  }
103
112
  catch {
104
113
  // Non-fatal — don't let persistence failures break unit execution
@@ -112,6 +121,8 @@ export function saveEvidenceToDisk(basePath, milestoneId, sliceId, taskId) {
112
121
  */
113
122
  export function loadEvidenceFromDisk(basePath, milestoneId, sliceId, taskId) {
114
123
  try {
124
+ lastWritePath = null;
125
+ lastWriteSig = null;
115
126
  const path = evidencePath(basePath, milestoneId, sliceId, taskId);
116
127
  if (!existsSync(path))
117
128
  return;
@@ -136,6 +147,10 @@ export function clearEvidenceFromDisk(basePath, milestoneId, sliceId, taskId) {
136
147
  if (existsSync(path)) {
137
148
  unlinkSync(path);
138
149
  }
150
+ if (path === lastWritePath) {
151
+ lastWritePath = null;
152
+ lastWriteSig = null;
153
+ }
139
154
  }
140
155
  catch {
141
156
  // Non-fatal