gsd-pi 2.80.0-dev.710c06e97 → 2.80.0-dev.8b3a59f5c

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 (67) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/gsd/auto-recovery.js +131 -9
  3. package/dist/resources/extensions/gsd/commands-extract-learnings.js +17 -12
  4. package/dist/resources/extensions/gsd/db-base-schema.js +14 -0
  5. package/dist/resources/extensions/gsd/db-migration-steps.js +16 -0
  6. package/dist/resources/extensions/gsd/gsd-db.js +102 -2
  7. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  8. package/dist/web/standalone/.next/BUILD_ID +1 -1
  9. package/dist/web/standalone/.next/app-path-routes-manifest.json +10 -10
  10. package/dist/web/standalone/.next/build-manifest.json +2 -2
  11. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  12. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  13. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  14. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  15. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  16. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  17. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  21. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/index.html +1 -1
  29. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app-paths-manifest.json +10 -10
  36. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  37. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  38. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  39. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  40. package/package.json +1 -1
  41. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +44 -0
  42. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -1
  43. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-execution.test.js +27 -1
  44. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-execution.test.js.map +1 -1
  45. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts +12 -0
  46. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  47. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +51 -0
  48. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  49. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  50. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +57 -1
  51. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  52. package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +51 -0
  53. package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/tool-execution.test.ts +45 -1
  54. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +55 -0
  55. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +72 -1
  56. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  57. package/src/resources/extensions/gsd/auto-recovery.ts +131 -7
  58. package/src/resources/extensions/gsd/commands-extract-learnings.ts +17 -12
  59. package/src/resources/extensions/gsd/db-base-schema.ts +15 -0
  60. package/src/resources/extensions/gsd/db-migration-steps.ts +17 -0
  61. package/src/resources/extensions/gsd/gsd-db.ts +119 -1
  62. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +125 -1
  63. package/src/resources/extensions/gsd/tests/commands-extract-learnings.test.ts +9 -0
  64. package/src/resources/extensions/gsd/tests/db-schema-metadata.test.ts +2 -2
  65. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +44 -0
  66. /package/dist/web/standalone/.next/static/{CaMwumvGbBPZrJicfsCzV → k-MDjzPMF62Rg1-FR437h}/_buildManifest.js +0 -0
  67. /package/dist/web/standalone/.next/static/{CaMwumvGbBPZrJicfsCzV → k-MDjzPMF62Rg1-FR437h}/_ssgManifest.js +0 -0
@@ -14,7 +14,7 @@ import { appendEvent } from "./workflow-events.js";
14
14
  import { atomicWriteSync } from "./atomic-write.js";
15
15
  import { clearParseCache } from "./files.js";
16
16
  import { parseRoadmap as parseLegacyRoadmap, parsePlan as parseLegacyPlan } from "./parsers-legacy.js";
17
- import { isDbAvailable, getTask, getSlice, getSliceTasks, getPendingGates, updateTaskStatus, updateSliceStatus, insertSlice, getMilestone, refreshOpenDatabaseFromDisk } from "./gsd-db.js";
17
+ import { isDbAvailable, getTask, getSlice, getSliceTasks, getPendingGates, updateTaskStatus, updateSliceStatus, insertSlice, getMilestone, refreshOpenDatabaseFromDisk, getCompletedMilestoneTaskFileHints, getMilestoneCommitAttributionShas, recordMilestoneCommitAttribution } from "./gsd-db.js";
18
18
  import { isValidationTerminal } from "./state.js";
19
19
  import { getErrorMessage } from "./error-utils.js";
20
20
  import { logWarning, logError } from "./workflow-logger.js";
@@ -147,9 +147,9 @@ export function hasImplementationArtifacts(basePath: string, milestoneId?: strin
147
147
  // milestone commits instead of treating the self-diff as proof of no work.
148
148
  if (changedFiles.length === 0) {
149
149
  if (milestoneId && currentBranch === integrationBranch) {
150
- const tagged = getChangedFilesFromMilestoneTaggedCommits(basePath, milestoneId);
151
- if (!tagged.ok) return "unknown";
152
- if (tagged.matched) return classifyImplementationFiles(tagged.files);
150
+ const milestoneEvidence = getChangedFilesFromMilestoneEvidence(basePath, milestoneId);
151
+ if (!milestoneEvidence.ok) return "unknown";
152
+ if (milestoneEvidence.matched) return classifyImplementationFiles(milestoneEvidence.files);
153
153
  }
154
154
  if (currentBranch && currentBranch !== "HEAD") return "absent";
155
155
  return "unknown";
@@ -164,9 +164,9 @@ export function hasImplementationArtifacts(basePath: string, milestoneId?: strin
164
164
  // insufficient; use the same milestone-tagged evidence fallback as the
165
165
  // self-diff retry path before declaring the milestone implementation-free.
166
166
  if (milestoneId) {
167
- const tagged = getChangedFilesFromMilestoneTaggedCommits(basePath, milestoneId);
168
- if (!tagged.ok) return "unknown";
169
- if (tagged.matched) return classifyImplementationFiles(tagged.files);
167
+ const milestoneEvidence = getChangedFilesFromMilestoneEvidence(basePath, milestoneId);
168
+ if (!milestoneEvidence.ok) return "unknown";
169
+ if (milestoneEvidence.matched) return classifyImplementationFiles(milestoneEvidence.files);
170
170
  }
171
171
 
172
172
  return "absent";
@@ -199,6 +199,10 @@ function isImplementationPath(file: string): boolean {
199
199
  return !file.startsWith(".gsd/") && !file.startsWith(".gsd\\");
200
200
  }
201
201
 
202
+ function normalizeRepoPath(file: string): string {
203
+ return file.trim().replace(/\\/g, "/").replace(/^\.\/+/, "");
204
+ }
205
+
202
206
  /**
203
207
  * Detect the main/master branch name.
204
208
  */
@@ -301,6 +305,126 @@ function getChangedFilesFromMilestoneTaggedCommits(
301
305
  };
302
306
  }
303
307
 
308
+ function getChangedFilesFromMilestoneEvidence(
309
+ basePath: string,
310
+ milestoneId: string,
311
+ ): { ok: boolean; matched: boolean; files: string[] } {
312
+ const tagged = getChangedFilesFromMilestoneTaggedCommits(basePath, milestoneId);
313
+ if (!tagged.ok) return tagged;
314
+ if (tagged.matched && classifyImplementationFiles(tagged.files) === "present") return tagged;
315
+
316
+ const attributed = getChangedFilesFromAttributedMilestoneCommits(basePath, milestoneId);
317
+ if (!attributed.ok) return tagged.matched ? tagged : attributed;
318
+ if (attributed.matched && classifyImplementationFiles(attributed.files) === "present") return attributed;
319
+
320
+ const backfilled = backfillChangedFilesFromUntaggedMilestoneCommits(basePath, milestoneId);
321
+ if (!backfilled.ok) return tagged.matched ? tagged : attributed.matched ? attributed : backfilled;
322
+ if (!backfilled.matched) {
323
+ if (tagged.matched) return tagged;
324
+ return attributed.matched ? attributed : backfilled;
325
+ }
326
+
327
+ return {
328
+ ok: true,
329
+ matched: true,
330
+ files: [...new Set([...tagged.files, ...attributed.files, ...backfilled.files])],
331
+ };
332
+ }
333
+
334
+ function getChangedFilesFromAttributedMilestoneCommits(
335
+ basePath: string,
336
+ milestoneId: string,
337
+ ): { ok: boolean; matched: boolean; files: string[] } {
338
+ try {
339
+ const shas = getMilestoneCommitAttributionShas(milestoneId);
340
+ if (shas.length === 0) return { ok: true, matched: false, files: [] };
341
+
342
+ const files = new Set<string>();
343
+ let matched = false;
344
+ for (const sha of shas) {
345
+ if (!isFullCommitSha(sha)) continue;
346
+ const commitFiles = getChangedFilesForCommit(basePath, sha);
347
+ if (commitFiles.length === 0) continue;
348
+ matched = true;
349
+ for (const file of commitFiles) files.add(file);
350
+ }
351
+ return { ok: true, matched, files: [...files] };
352
+ } catch (e) {
353
+ logWarning("recovery", `milestone attribution scan failed: ${(e as Error).message}`);
354
+ return { ok: false, matched: false, files: [] };
355
+ }
356
+ }
357
+
358
+ function backfillChangedFilesFromUntaggedMilestoneCommits(
359
+ basePath: string,
360
+ milestoneId: string,
361
+ ): { ok: boolean; matched: boolean; files: string[] } {
362
+ try {
363
+ const milestone = getMilestone(milestoneId);
364
+ const milestoneStartedAt = milestone?.created_at ? Math.floor(Date.parse(milestone.created_at) / 1000) * 1000 : NaN;
365
+ if (!Number.isFinite(milestoneStartedAt)) return { ok: true, matched: false, files: [] };
366
+
367
+ const taskFileHints = getCompletedMilestoneTaskFileHints(milestoneId);
368
+ if (taskFileHints.length === 0) return { ok: true, matched: false, files: [] };
369
+
370
+ const hintSet = new Set(taskFileHints.map(normalizeRepoPath).filter(Boolean));
371
+ if (hintSet.size === 0) return { ok: true, matched: false, files: [] };
372
+
373
+ const records = getCommitRecords(basePath);
374
+ const files = new Set<string>();
375
+ let matched = false;
376
+ for (const record of records) {
377
+ if (!isFullCommitSha(record.hash)) continue;
378
+ if (Date.parse(record.committedAt) < milestoneStartedAt) continue;
379
+ if (record.parents.trim().split(/\s+/).filter(Boolean).length > 1) continue;
380
+ if (commitMessageHasGsdTrailer(record.message)) continue;
381
+
382
+ const commitFiles = getChangedFilesForCommit(basePath, record.hash);
383
+ const implementationFiles = commitFiles.map(normalizeRepoPath).filter(isImplementationPath);
384
+ if (implementationFiles.length === 0) continue;
385
+ if (!implementationFiles.some((file) => hintSet.has(file))) continue;
386
+
387
+ matched = true;
388
+ for (const file of implementationFiles) files.add(file);
389
+ recordMilestoneCommitAttribution({
390
+ commitSha: record.hash,
391
+ milestoneId,
392
+ source: "backfill",
393
+ confidence: 0.8,
394
+ files: implementationFiles,
395
+ createdAt: new Date().toISOString(),
396
+ });
397
+ }
398
+
399
+ return { ok: true, matched, files: [...files] };
400
+ } catch (e) {
401
+ logWarning("recovery", `milestone attribution backfill failed: ${(e as Error).message}`);
402
+ return { ok: false, matched: false, files: [] };
403
+ }
404
+ }
405
+
406
+ function getCommitRecords(basePath: string): Array<{ hash: string; parents: string; committedAt: string; message: string }> {
407
+ const logOutput = execFileSync("git", ["log", "--format=%H%x1f%P%x1f%cI%x1f%B%x1e", "HEAD"], {
408
+ cwd: basePath,
409
+ stdio: ["ignore", "pipe", "pipe"],
410
+ encoding: "utf-8",
411
+ });
412
+ return logOutput
413
+ .split("\x1e")
414
+ .map((record) => record.trim())
415
+ .filter(Boolean)
416
+ .flatMap((record) => {
417
+ const parts = record.split("\x1f");
418
+ if (parts.length < 4) return [];
419
+ const [hash, parents, committedAt, ...messageParts] = parts;
420
+ return [{ hash: hash.trim(), parents: parents.trim(), committedAt: committedAt.trim(), message: messageParts.join("\x1f") }];
421
+ });
422
+ }
423
+
424
+ function isFullCommitSha(value: string): boolean {
425
+ return /^[0-9a-f]{40}$/i.test(value);
426
+ }
427
+
304
428
  function scanGsdTaggedCommits(
305
429
  basePath: string,
306
430
  milestoneId: string,
@@ -222,14 +222,19 @@ Using the \`write\` tool, persist the full structured report to
222
222
  LEARNINGS.md is the full, cited audit trail. Write it first — subsequent steps
223
223
  feed from its content.
224
224
 
225
- ### Step 3 — Optionally pre-query the memory store for semantic duplicates
225
+ ### Step 3 — Run one bounded duplicate check
226
226
 
227
- Before persisting any extracted item in Steps 4–6, you may call
228
- \`memory_query\` with 2–3 keywords from the item to check whether the
229
- memory store already holds a semantically equivalent entry at high
230
- confidence. Skip those items in their respective steps. The memory store
231
- is the single source of truth for cross-session durable knowledge — no
232
- other persistence call is part of this flow.
227
+ Before persisting extracted items in Steps 4–6, do at most one duplicate-check
228
+ pass for the durable Decisions, Lessons, and Patterns from this milestone. Use
229
+ the already-written LEARNINGS.md content and call \`memory_query\` once with a
230
+ compact keyword summary for the whole batch. If that result clearly shows a
231
+ semantically equivalent high-confidence memory for an item, mark only that item
232
+ as already captured and skip it in its respective persistence step.
233
+
234
+ Do not re-read milestone artefacts or repeat memory queries category-by-category
235
+ after this point. The memory store is the single source of truth for
236
+ cross-session durable knowledge — no other persistence call is part of this
237
+ flow.
233
238
 
234
239
  ### Step 4 — Persist Patterns via \`capture_thought\`
235
240
 
@@ -268,11 +273,11 @@ later projection back to a human-visible decisions register stays lossless
268
273
 
269
274
  ### Step 7 — Deduplication rule (applies to Steps 4, 5, 6)
270
275
 
271
- Before each \`capture_thought\` call, optionally call \`memory_query\` with 2–3
272
- keywords from the entry. If a semantically equivalent memory is returned at
273
- high confidence, skip the capture entirely. Prefer skipping a near-duplicate
274
- over creating a second slightly-different row — redundancy degrades the
275
- signal.
276
+ Use only the duplicate-check result from Step 3. If that bounded check returned
277
+ a semantically equivalent memory at high confidence for an extracted item, skip
278
+ the capture entirely. Otherwise, persist the item once via \`capture_thought\`.
279
+ Prefer skipping a near-duplicate over creating a second slightly-different row
280
+ — redundancy degrades the signal.
276
281
 
277
282
  ### Step 8 — Surprises stay only in LEARNINGS.md
278
283
 
@@ -323,6 +323,20 @@ export function createBaseSchemaObjects(db: DbAdapter, hooks: BaseSchemaHooks):
323
323
  )
324
324
  `);
325
325
 
326
+ db.exec(`
327
+ CREATE TABLE IF NOT EXISTS milestone_commit_attributions (
328
+ commit_sha TEXT NOT NULL,
329
+ milestone_id TEXT NOT NULL,
330
+ slice_id TEXT DEFAULT NULL,
331
+ task_id TEXT DEFAULT NULL,
332
+ source TEXT NOT NULL DEFAULT 'recorded',
333
+ confidence REAL NOT NULL DEFAULT 1.0,
334
+ files_json TEXT NOT NULL DEFAULT '[]',
335
+ created_at TEXT NOT NULL DEFAULT '',
336
+ PRIMARY KEY (commit_sha, milestone_id)
337
+ )
338
+ `);
339
+
326
340
  db.exec(`
327
341
  CREATE TABLE IF NOT EXISTS audit_events (
328
342
  event_id TEXT PRIMARY KEY,
@@ -359,6 +373,7 @@ export function createBaseSchemaObjects(db: DbAdapter, hooks: BaseSchemaHooks):
359
373
  db.exec("CREATE INDEX IF NOT EXISTS idx_gate_runs_turn ON gate_runs(trace_id, turn_id)");
360
374
  db.exec("CREATE INDEX IF NOT EXISTS idx_gate_runs_lookup ON gate_runs(milestone_id, slice_id, task_id, gate_id)");
361
375
  db.exec("CREATE INDEX IF NOT EXISTS idx_turn_git_tx_turn ON turn_git_transactions(trace_id, turn_id)");
376
+ db.exec("CREATE INDEX IF NOT EXISTS idx_milestone_commit_attr_milestone ON milestone_commit_attributions(milestone_id)");
362
377
  db.exec("CREATE INDEX IF NOT EXISTS idx_audit_events_trace ON audit_events(trace_id, ts)");
363
378
  db.exec("CREATE INDEX IF NOT EXISTS idx_audit_events_turn ON audit_events(trace_id, turn_id, ts)");
364
379
 
@@ -399,6 +399,23 @@ export function applyMigrationV23MilestoneQueue(db: DbAdapter): void {
399
399
  ensureColumn(db, "milestones", "sequence", "ALTER TABLE milestones ADD COLUMN sequence INTEGER DEFAULT 0");
400
400
  }
401
401
 
402
+ export function applyMigrationV26MilestoneCommitAttributions(db: DbAdapter): void {
403
+ db.exec(`
404
+ CREATE TABLE IF NOT EXISTS milestone_commit_attributions (
405
+ commit_sha TEXT NOT NULL,
406
+ milestone_id TEXT NOT NULL,
407
+ slice_id TEXT DEFAULT NULL,
408
+ task_id TEXT DEFAULT NULL,
409
+ source TEXT NOT NULL DEFAULT 'recorded',
410
+ confidence REAL NOT NULL DEFAULT 1.0,
411
+ files_json TEXT NOT NULL DEFAULT '[]',
412
+ created_at TEXT NOT NULL DEFAULT '',
413
+ PRIMARY KEY (commit_sha, milestone_id)
414
+ )
415
+ `);
416
+ db.exec("CREATE INDEX IF NOT EXISTS idx_milestone_commit_attr_milestone ON milestone_commit_attributions(milestone_id)");
417
+ }
418
+
402
419
  export interface MigrationV22Hooks {
403
420
  copyQualityGateRowsToRepairedTable(db: DbAdapter): void;
404
421
  }
@@ -77,6 +77,7 @@ import {
77
77
  applyMigrationV21StructuredMemories,
78
78
  applyMigrationV22QualityGateRepair,
79
79
  applyMigrationV23MilestoneQueue,
80
+ applyMigrationV26MilestoneCommitAttributions,
80
81
  } from "./db-migration-steps.js";
81
82
  import { isMemoriesFtsAvailableSchema, tryCreateMemoriesFtsSchema } from "./db-memory-fts-schema.js";
82
83
  import { createDbOpenState, type DbOpenPhase } from "./db-open-state.js";
@@ -105,7 +106,7 @@ const providerLoader = createSqliteProviderLoader({
105
106
  writeStderr: (message: string) => process.stderr.write(message),
106
107
  });
107
108
 
108
- export const SCHEMA_VERSION = 25;
109
+ export const SCHEMA_VERSION = 26;
109
110
 
110
111
  function initSchema(db: DbAdapter, fileBacked: boolean): void {
111
112
  if (fileBacked) db.exec("PRAGMA journal_mode=WAL");
@@ -329,6 +330,11 @@ function migrateSchema(db: DbAdapter): void {
329
330
  recordSchemaVersion(db, 25);
330
331
  }
331
332
 
333
+ if (currentVersion < 26) {
334
+ applyMigrationV26MilestoneCommitAttributions(db);
335
+ recordSchemaVersion(db, 26);
336
+ }
337
+
332
338
  db.exec("COMMIT");
333
339
  } catch (err) {
334
340
  db.exec("ROLLBACK");
@@ -1349,6 +1355,45 @@ export function getSliceTasks(milestoneId: string, sliceId: string): TaskRow[] {
1349
1355
  return rows.map(rowToTask);
1350
1356
  }
1351
1357
 
1358
+ export function getCompletedMilestoneTaskFileHints(milestoneId: string): string[] {
1359
+ if (!currentDb) return [];
1360
+ const rows = currentDb.prepare(
1361
+ `SELECT files, key_files
1362
+ FROM tasks
1363
+ WHERE milestone_id = :mid AND status IN ('complete', 'done')`,
1364
+ ).all({ ":mid": milestoneId }) as Array<Record<string, unknown>>;
1365
+
1366
+ const hints = new Set<string>();
1367
+ for (const row of rows) {
1368
+ for (const raw of [row["files"], row["key_files"]]) {
1369
+ for (const file of parseStringArrayColumn(raw)) {
1370
+ const normalized = normalizeRepoPath(file);
1371
+ if (normalized) hints.add(normalized);
1372
+ }
1373
+ }
1374
+ }
1375
+ return [...hints];
1376
+ }
1377
+
1378
+ function parseStringArrayColumn(raw: unknown): string[] {
1379
+ if (Array.isArray(raw)) return raw.filter((entry): entry is string => typeof entry === "string");
1380
+ if (typeof raw !== "string") return [];
1381
+ const trimmed = raw.trim();
1382
+ if (!trimmed) return [];
1383
+ try {
1384
+ const parsed = JSON.parse(trimmed);
1385
+ if (Array.isArray(parsed)) return parsed.filter((entry): entry is string => typeof entry === "string");
1386
+ if (typeof parsed === "string") return [parsed];
1387
+ } catch {
1388
+ return trimmed.split(",");
1389
+ }
1390
+ return [];
1391
+ }
1392
+
1393
+ function normalizeRepoPath(file: string): string {
1394
+ return file.trim().replace(/\\/g, "/").replace(/^\.\/+/, "");
1395
+ }
1396
+
1352
1397
  // ─── ADR-011 Phase 2 escalation helpers ──────────────────────────────────
1353
1398
 
1354
1399
  /** Set pause-on-escalation state on a completed task. Mutually exclusive with awaiting_review. */
@@ -2073,6 +2118,9 @@ export function deleteMilestone(milestoneId: string): void {
2073
2118
  currentDb!.prepare(
2074
2119
  `DELETE FROM artifacts WHERE milestone_id = :mid`,
2075
2120
  ).run({ ":mid": milestoneId });
2121
+ currentDb!.prepare(
2122
+ `DELETE FROM milestone_commit_attributions WHERE milestone_id = :mid`,
2123
+ ).run({ ":mid": milestoneId });
2076
2124
  currentDb!.prepare(
2077
2125
  `DELETE FROM milestone_leases WHERE milestone_id = :mid`,
2078
2126
  ).run({ ":mid": milestoneId });
@@ -2391,6 +2439,75 @@ export function upsertTurnGitTransaction(entry: {
2391
2439
  });
2392
2440
  }
2393
2441
 
2442
+ export function getMilestoneCommitAttributionShas(milestoneId: string): string[] {
2443
+ if (!currentDb) return [];
2444
+ const rows = currentDb.prepare(
2445
+ `SELECT commit_sha
2446
+ FROM milestone_commit_attributions
2447
+ WHERE milestone_id = :mid
2448
+ ORDER BY created_at, commit_sha`,
2449
+ ).all({ ":mid": milestoneId }) as Array<Record<string, unknown>>;
2450
+ return rows
2451
+ .map((row) => typeof row["commit_sha"] === "string" ? row["commit_sha"] : "")
2452
+ .filter(Boolean);
2453
+ }
2454
+
2455
+ export function recordMilestoneCommitAttribution(entry: {
2456
+ commitSha: string;
2457
+ milestoneId: string;
2458
+ sliceId?: string;
2459
+ taskId?: string;
2460
+ source: "recorded" | "backfill";
2461
+ confidence: number;
2462
+ files: string[];
2463
+ createdAt: string;
2464
+ }): void {
2465
+ if (!currentDb) return;
2466
+ transaction(() => {
2467
+ currentDb!.prepare(
2468
+ `INSERT OR REPLACE INTO milestone_commit_attributions (
2469
+ commit_sha, milestone_id, slice_id, task_id, source, confidence, files_json, created_at
2470
+ ) VALUES (
2471
+ :commit_sha, :milestone_id, :slice_id, :task_id, :source, :confidence, :files_json, :created_at
2472
+ )`,
2473
+ ).run({
2474
+ ":commit_sha": entry.commitSha,
2475
+ ":milestone_id": entry.milestoneId,
2476
+ ":slice_id": entry.sliceId ?? null,
2477
+ ":task_id": entry.taskId ?? null,
2478
+ ":source": entry.source,
2479
+ ":confidence": entry.confidence,
2480
+ ":files_json": JSON.stringify(entry.files),
2481
+ ":created_at": entry.createdAt,
2482
+ });
2483
+
2484
+ currentDb!.prepare(
2485
+ `INSERT OR IGNORE INTO audit_events (
2486
+ event_id, trace_id, turn_id, caused_by, category, type, ts, payload_json
2487
+ ) VALUES (
2488
+ :event_id, :trace_id, :turn_id, :caused_by, :category, :type, :ts, :payload_json
2489
+ )`,
2490
+ ).run({
2491
+ ":event_id": `milestone-commit-attribution:${entry.milestoneId}:${entry.commitSha}`,
2492
+ ":trace_id": "milestone-commit-attribution",
2493
+ ":turn_id": null,
2494
+ ":caused_by": null,
2495
+ ":category": "git",
2496
+ ":type": "milestone-commit-attribution-recorded",
2497
+ ":ts": entry.createdAt,
2498
+ ":payload_json": JSON.stringify({
2499
+ commitSha: entry.commitSha,
2500
+ milestoneId: entry.milestoneId,
2501
+ sliceId: entry.sliceId ?? null,
2502
+ taskId: entry.taskId ?? null,
2503
+ source: entry.source,
2504
+ confidence: entry.confidence,
2505
+ files: entry.files,
2506
+ }),
2507
+ });
2508
+ });
2509
+ }
2510
+
2394
2511
  export function insertAuditEvent(entry: {
2395
2512
  eventId: string;
2396
2513
  traceId: string;
@@ -2494,6 +2611,7 @@ export function clearEngineHierarchy(): void {
2494
2611
  currentDb!.exec("DELETE FROM slice_dependencies");
2495
2612
  currentDb!.exec("DELETE FROM assessments");
2496
2613
  currentDb!.exec("DELETE FROM replan_history");
2614
+ currentDb!.exec("DELETE FROM milestone_commit_attributions");
2497
2615
  currentDb!.exec("DELETE FROM tasks");
2498
2616
  currentDb!.exec("DELETE FROM slices");
2499
2617
  currentDb!.exec("DELETE FROM milestone_leases");
@@ -7,7 +7,7 @@ import { randomUUID } from "node:crypto";
7
7
 
8
8
  import { verifyExpectedArtifact, hasImplementationArtifacts, resolveExpectedArtifactPath, diagnoseExpectedArtifact, buildLoopRemediationSteps, writeBlockerPlaceholder } from "../auto-recovery.ts";
9
9
  import { resolveMilestoneFile } from "../paths.ts";
10
- import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertGateRow, insertTask } from "../gsd-db.ts";
10
+ import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertGateRow, insertTask, getMilestoneCommitAttributionShas } from "../gsd-db.ts";
11
11
  import { clearParseCache } from "../files.ts";
12
12
  import { parseRoadmap } from "../parsers-legacy.ts";
13
13
  import { invalidateAllCaches } from "../cache.ts";
@@ -780,6 +780,130 @@ test("hasImplementationArtifacts finds integration implementation-only commits w
780
780
  }
781
781
  });
782
782
 
783
+ test("hasImplementationArtifacts backfills untagged main implementation commits from completed task file hints", () => {
784
+ const base = makeGitBase();
785
+ try {
786
+ mkdirSync(join(base, ".gsd"), { recursive: true });
787
+ openDatabase(join(base, ".gsd", "gsd.db"));
788
+ insertMilestone({ id: "M001", title: "Milestone One", status: "active" });
789
+ insertSlice({
790
+ id: "S01",
791
+ milestoneId: "M001",
792
+ title: "Slice One",
793
+ status: "complete",
794
+ risk: "low",
795
+ depends: [],
796
+ });
797
+ insertTask({
798
+ id: "T01",
799
+ sliceId: "S01",
800
+ milestoneId: "M001",
801
+ title: "Task One",
802
+ status: "complete",
803
+ keyFiles: ["index.html", "style.css", "app.js"],
804
+ planning: { files: ["index.html", "style.css", "app.js"] },
805
+ });
806
+
807
+ writeFileSync(join(base, "index.html"), "<main></main>\n");
808
+ writeFileSync(join(base, "style.css"), "main { display: block; }\n");
809
+ writeFileSync(join(base, "app.js"), "document.body.dataset.ready = 'true';\n");
810
+ execFileSync("git", ["add", "index.html", "style.css", "app.js"], { cwd: base, stdio: "ignore" });
811
+ execFileSync("git", ["commit", "-m", "feat: add to-do app with CRUD and localStorage persistence"], { cwd: base, stdio: "ignore" });
812
+ const commitSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: base, encoding: "utf-8" }).trim();
813
+
814
+ const result = hasImplementationArtifacts(base, "M001");
815
+ assert.equal(
816
+ result,
817
+ "present",
818
+ "completed task file hints should repair prior untagged implementation commits on main",
819
+ );
820
+ assert.deepEqual(getMilestoneCommitAttributionShas("M001"), [commitSha]);
821
+ } finally {
822
+ cleanup(base);
823
+ }
824
+ });
825
+
826
+ test("hasImplementationArtifacts does not backfill untagged commits before milestone creation", () => {
827
+ const base = makeGitBase();
828
+ try {
829
+ writeFileSync(join(base, "app.js"), "document.body.dataset.ready = 'old';\n");
830
+ execFileSync("git", ["add", "app.js"], { cwd: base, stdio: "ignore" });
831
+ execFileSync("git", ["commit", "-m", "feat: old app work"], {
832
+ cwd: base,
833
+ stdio: "ignore",
834
+ env: {
835
+ ...process.env,
836
+ GIT_AUTHOR_DATE: "2020-01-01T00:00:00Z",
837
+ GIT_COMMITTER_DATE: "2020-01-01T00:00:00Z",
838
+ },
839
+ });
840
+
841
+ mkdirSync(join(base, ".gsd"), { recursive: true });
842
+ openDatabase(join(base, ".gsd", "gsd.db"));
843
+ insertMilestone({ id: "M001", title: "Milestone One", status: "active" });
844
+ insertSlice({
845
+ id: "S01",
846
+ milestoneId: "M001",
847
+ title: "Slice One",
848
+ status: "complete",
849
+ risk: "low",
850
+ depends: [],
851
+ });
852
+ insertTask({
853
+ id: "T01",
854
+ sliceId: "S01",
855
+ milestoneId: "M001",
856
+ title: "Task One",
857
+ status: "complete",
858
+ keyFiles: ["app.js"],
859
+ planning: { files: ["app.js"] },
860
+ });
861
+
862
+ const result = hasImplementationArtifacts(base, "M001");
863
+ assert.equal(result, "absent", "pre-milestone commits must not be attributed to the milestone");
864
+ assert.deepEqual(getMilestoneCommitAttributionShas("M001"), []);
865
+ } finally {
866
+ cleanup(base);
867
+ }
868
+ });
869
+
870
+ test("hasImplementationArtifacts does not backfill unrelated untagged implementation commits", () => {
871
+ const base = makeGitBase();
872
+ try {
873
+ mkdirSync(join(base, ".gsd"), { recursive: true });
874
+ openDatabase(join(base, ".gsd", "gsd.db"));
875
+ insertMilestone({ id: "M001", title: "Milestone One", status: "active" });
876
+ insertSlice({
877
+ id: "S01",
878
+ milestoneId: "M001",
879
+ title: "Slice One",
880
+ status: "complete",
881
+ risk: "low",
882
+ depends: [],
883
+ });
884
+ insertTask({
885
+ id: "T01",
886
+ sliceId: "S01",
887
+ milestoneId: "M001",
888
+ title: "Task One",
889
+ status: "complete",
890
+ keyFiles: ["src/expected.ts"],
891
+ planning: { files: ["src/expected.ts"] },
892
+ });
893
+
894
+ mkdirSync(join(base, "src"), { recursive: true });
895
+ writeFileSync(join(base, "src", "unrelated.ts"), "export const unrelated = true;\n");
896
+ execFileSync("git", ["add", "src/unrelated.ts"], { cwd: base, stdio: "ignore" });
897
+ execFileSync("git", ["commit", "-m", "feat: unrelated work"], { cwd: base, stdio: "ignore" });
898
+
899
+ const result = hasImplementationArtifacts(base, "M001");
900
+ assert.equal(result, "absent", "backfill must require overlap with completed task file hints");
901
+ assert.deepEqual(getMilestoneCommitAttributionShas("M001"), []);
902
+ } finally {
903
+ cleanup(base);
904
+ }
905
+ });
906
+
783
907
  test("hasImplementationArtifacts treats empty non-integration branch diff as absent (#4699)", () => {
784
908
  const base = makeGitBase();
785
909
  try {
@@ -1,3 +1,4 @@
1
+ // GSD2 commands-extract-learnings tests
1
2
  import { describe, it, beforeEach, afterEach } from "node:test";
2
3
  import assert from "node:assert/strict";
3
4
  import { mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
@@ -484,6 +485,14 @@ describe("buildExtractionStepsBlock", () => {
484
485
  assert.ok(/skip/i.test(block));
485
486
  });
486
487
 
488
+ it("limits duplicate checks to one milestone-scoped memory query", () => {
489
+ const block = buildExtractionStepsBlock(ctx);
490
+ const memoryQueryMatches = block.match(/memory_query/g) ?? [];
491
+ assert.equal(memoryQueryMatches.length, 1);
492
+ assert.ok(block.includes("Do not re-read milestone artefacts or repeat memory queries category-by-category"));
493
+ assert.ok(!block.includes("Before each `capture_thought` call, optionally call `memory_query`"));
494
+ });
495
+
487
496
  it("instructs capture_thought as the sole persistence path for Patterns, Lessons, and Decisions (ADR-013 step 6 cutover)", () => {
488
497
  const block = buildExtractionStepsBlock(ctx);
489
498
  // Each of the three persistence steps must call capture_thought.
@@ -106,10 +106,10 @@ describe("db-schema-metadata", () => {
106
106
  test("records schema version rows with timestamps", () => {
107
107
  const db = new FakeAdapter();
108
108
 
109
- recordSchemaVersion(db, 25);
109
+ recordSchemaVersion(db, 26);
110
110
 
111
111
  assert.equal(db.runCalls.length, 1);
112
- assert.equal((db.runCalls[0][0] as Record<string, unknown>)[":version"], 25);
112
+ assert.equal((db.runCalls[0][0] as Record<string, unknown>)[":version"], 26);
113
113
  assert.equal(typeof (db.runCalls[0][0] as Record<string, unknown>)[":applied_at"], "string");
114
114
  });
115
115
  });
@@ -30,6 +30,10 @@ import {
30
30
  insertTask,
31
31
  getTask,
32
32
  getSliceTasks,
33
+ deleteMilestone,
34
+ clearEngineHierarchy,
35
+ recordMilestoneCommitAttribution,
36
+ getMilestoneCommitAttributionShas,
33
37
  checkpointDatabase,
34
38
  refreshOpenDatabaseFromDisk,
35
39
  tryCreateMemoriesFts,
@@ -1047,6 +1051,46 @@ describe('gsd-db', () => {
1047
1051
  });
1048
1052
  });
1049
1053
 
1054
+ // ─── milestone_commit_attributions teardown ───────────────────────────────
1055
+
1056
+ describe('milestone commit attribution teardown', () => {
1057
+ test('deleteMilestone removes persisted milestone commit attributions', () => {
1058
+ openDatabase(':memory:');
1059
+ insertMilestone({ id: 'M001', title: 'Milestone', status: 'active' });
1060
+ recordMilestoneCommitAttribution({
1061
+ commitSha: '0123456789abcdef0123456789abcdef01234567',
1062
+ milestoneId: 'M001',
1063
+ source: 'backfill',
1064
+ confidence: 0.8,
1065
+ files: ['app.js'],
1066
+ createdAt: '2026-05-05T00:00:00.000Z',
1067
+ });
1068
+
1069
+ assert.deepEqual(getMilestoneCommitAttributionShas('M001'), ['0123456789abcdef0123456789abcdef01234567']);
1070
+ deleteMilestone('M001');
1071
+ assert.deepEqual(getMilestoneCommitAttributionShas('M001'), []);
1072
+ closeDatabase();
1073
+ });
1074
+
1075
+ test('clearEngineHierarchy removes persisted milestone commit attributions', () => {
1076
+ openDatabase(':memory:');
1077
+ insertMilestone({ id: 'M001', title: 'Milestone', status: 'active' });
1078
+ recordMilestoneCommitAttribution({
1079
+ commitSha: 'fedcba9876543210fedcba9876543210fedcba98',
1080
+ milestoneId: 'M001',
1081
+ source: 'backfill',
1082
+ confidence: 0.8,
1083
+ files: ['app.js'],
1084
+ createdAt: '2026-05-05T00:00:00.000Z',
1085
+ });
1086
+
1087
+ assert.deepEqual(getMilestoneCommitAttributionShas('M001'), ['fedcba9876543210fedcba9876543210fedcba98']);
1088
+ clearEngineHierarchy();
1089
+ assert.deepEqual(getMilestoneCommitAttributionShas('M001'), []);
1090
+ closeDatabase();
1091
+ });
1092
+ });
1093
+
1050
1094
  // ─── getDbStatus ───────────────────────────────────────────────────────────
1051
1095
 
1052
1096
  describe('getDbStatus', () => {