@fitlab-ai/agent-infra 0.11.9 → 0.11.10

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 (124) hide show
  1. package/dist/bin/internal-cli.js +0 -6
  2. package/dist/lib/git/workflow.js +1 -1
  3. package/dist/lib/internal/cli-route-inventory.js +0 -3
  4. package/dist/lib/internal/sandbox-control.js +30 -5
  5. package/dist/lib/internal/task-finalization.js +14 -23
  6. package/dist/lib/internal/task-operation-registry.js +2 -3
  7. package/dist/lib/internal/task-platform-recovery.js +9 -4
  8. package/dist/lib/platform/completion-backfill.js +2 -1
  9. package/dist/lib/platform/context.js +4 -2
  10. package/dist/lib/platform/github-client.js +20 -17
  11. package/dist/lib/platform/github-provider.js +42 -22
  12. package/dist/lib/platform/github-release-notes.js +4 -2
  13. package/dist/lib/platform/in-label-sync.js +2 -1
  14. package/dist/lib/platform/issue-comments.js +13 -9
  15. package/dist/lib/platform/operation-coordinator.js +1 -1
  16. package/dist/lib/platform/pr-review.js +2 -1
  17. package/dist/lib/platform/provider-bridge.js +4 -5
  18. package/dist/lib/platform/provider-contract.d.ts +3 -6
  19. package/dist/lib/platform/provider-contract.js +3 -15
  20. package/dist/lib/platform/provider-validation.js +9 -4
  21. package/dist/lib/platform/pull-requests.js +4 -2
  22. package/dist/lib/sandbox/control/client.js +54 -6
  23. package/dist/lib/sandbox/control/executor.js +8 -3
  24. package/dist/lib/sandbox/removal.js +0 -1
  25. package/dist/lib/task/artifact-command.js +11 -1
  26. package/dist/lib/task/artifact-lifecycle.js +119 -62
  27. package/dist/lib/task/artifact-selection.js +1 -1
  28. package/dist/lib/task/capabilities.js +55 -21
  29. package/dist/lib/task/commands/status.js +0 -27
  30. package/dist/lib/task/commit-identity.js +22 -7
  31. package/dist/lib/task/commit-intent.js +67 -26
  32. package/dist/lib/task/commit-operation.js +19 -8
  33. package/dist/lib/task/completion-facts.js +32 -10
  34. package/dist/lib/task/control-authority.js +2 -4
  35. package/dist/lib/task/current-contract.js +1 -5
  36. package/dist/lib/task/events.js +8 -274
  37. package/dist/lib/task/execution-inputs.js +14 -0
  38. package/dist/lib/task/finalization-envelope.js +4 -0
  39. package/dist/lib/task/finalization.js +10 -36
  40. package/dist/lib/task/frontmatter.js +1 -1
  41. package/dist/lib/task/ledger-intents.js +4 -7
  42. package/dist/lib/task/orchestration.js +11 -10
  43. package/dist/lib/task/platform-operation-journal.js +17 -10
  44. package/dist/lib/task/platform-operation-recovery.js +95 -28
  45. package/dist/lib/task/review-fingerprint.js +4 -26
  46. package/dist/lib/task/verification-engine.js +95 -6
  47. package/dist/lib/task/write.js +0 -11
  48. package/dist/lifecycle-build-manifest.json +3 -3
  49. package/lib/git/workflow.ts +1 -1
  50. package/lib/internal/cli-route-inventory.ts +0 -2
  51. package/lib/internal/sandbox-control.ts +39 -6
  52. package/lib/internal/task-finalization.ts +16 -24
  53. package/lib/internal/task-operation-registry.ts +2 -3
  54. package/lib/internal/task-platform-recovery.ts +9 -4
  55. package/lib/platform/completion-backfill.ts +2 -1
  56. package/lib/platform/context.ts +8 -4
  57. package/lib/platform/github-client.ts +24 -20
  58. package/lib/platform/github-provider.ts +47 -26
  59. package/lib/platform/github-release-notes.ts +5 -3
  60. package/lib/platform/in-label-sync.ts +2 -1
  61. package/lib/platform/issue-comments.ts +14 -9
  62. package/lib/platform/operation-coordinator.ts +1 -1
  63. package/lib/platform/pr-review.ts +2 -1
  64. package/lib/platform/provider-bridge.ts +10 -9
  65. package/lib/platform/provider-contract.ts +6 -21
  66. package/lib/platform/provider-loader.ts +7 -6
  67. package/lib/platform/provider-validation.ts +30 -8
  68. package/lib/platform/pull-requests.ts +4 -2
  69. package/lib/platform/repository-metadata.ts +3 -2
  70. package/lib/platform/security-alerts.ts +1 -1
  71. package/lib/platform/types.ts +2 -0
  72. package/lib/sandbox/control/client.ts +57 -6
  73. package/lib/sandbox/control/executor.ts +8 -3
  74. package/lib/sandbox/removal.ts +2 -10
  75. package/lib/task/artifact-command.ts +10 -1
  76. package/lib/task/artifact-lifecycle.ts +100 -52
  77. package/lib/task/artifact-selection.ts +1 -1
  78. package/lib/task/capabilities.ts +47 -24
  79. package/lib/task/commands/status.ts +0 -31
  80. package/lib/task/commit-identity.ts +24 -7
  81. package/lib/task/commit-intent.ts +58 -24
  82. package/lib/task/commit-operation.ts +17 -8
  83. package/lib/task/completion-facts.ts +31 -9
  84. package/lib/task/control-authority.ts +1 -4
  85. package/lib/task/current-contract.ts +1 -6
  86. package/lib/task/events.ts +8 -247
  87. package/lib/task/execution-inputs.ts +25 -0
  88. package/lib/task/finalization-envelope.ts +21 -0
  89. package/lib/task/finalization.ts +10 -35
  90. package/lib/task/frontmatter.ts +1 -1
  91. package/lib/task/ledger-intents.ts +4 -7
  92. package/lib/task/orchestration.ts +11 -17
  93. package/lib/task/platform-operation-journal.ts +18 -11
  94. package/lib/task/platform-operation-recovery.ts +90 -31
  95. package/lib/task/review-fingerprint.ts +4 -26
  96. package/lib/task/verification-engine.ts +103 -9
  97. package/lib/task/write.ts +0 -19
  98. package/package.json +1 -1
  99. package/templates/.agents/rules/commit-and-pr.en.md +5 -5
  100. package/templates/.agents/rules/commit-and-pr.zh-CN.md +5 -5
  101. package/templates/.agents/rules/decision-qualification.en.md +1 -1
  102. package/templates/.agents/rules/decision-qualification.zh-CN.md +1 -1
  103. package/templates/.agents/skills/code-task/SKILL.en.md +25 -9
  104. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +26 -9
  105. package/templates/.agents/skills/code-task/reference/code-rules.en.md +1 -1
  106. package/templates/.agents/skills/code-task/reference/code-rules.zh-CN.md +1 -1
  107. package/templates/.agents/skills/code-task/reference/fix-mode.en.md +5 -16
  108. package/templates/.agents/skills/code-task/reference/fix-mode.zh-CN.md +5 -16
  109. package/templates/.agents/skills/code-task/reference/report-template.en.md +1 -1
  110. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +1 -1
  111. package/templates/.agents/skills/complete-task/SKILL.en.md +15 -12
  112. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +11 -10
  113. package/templates/.agents/skills/update-agent-infra/SKILL.en.md +1 -1
  114. package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +1 -1
  115. package/templates/.agents/templates/task.zh-CN.md +0 -12
  116. package/templates/.github/workflows/pr-label.yml +1 -2
  117. package/dist/lib/internal/task-invalidation.js +0 -63
  118. package/dist/lib/task/intermediate-cleanup.js +0 -327
  119. package/dist/lib/task/invalidation-command.js +0 -88
  120. package/dist/lib/task/invalidation.js +0 -242
  121. package/lib/internal/task-invalidation.ts +0 -41
  122. package/lib/task/intermediate-cleanup.ts +0 -393
  123. package/lib/task/invalidation-command.ts +0 -88
  124. package/lib/task/invalidation.ts +0 -313
@@ -4,15 +4,16 @@ import { ARTIFACT_FAMILY_CATALOG, artifactName, parseArtifactName } from './arti
4
4
  import { resolveTaskRef } from './resolve-ref.js';
5
5
  import { locateActivityLog, pairEntries, startedBackedRows } from './activity-log.js';
6
6
  import { parseImplementationInputs, selectPendingImplementationInput } from './implementation-inputs.js';
7
+ import { parseTypedTaskFrontmatter } from './frontmatter.js';
7
8
  import { parseVerdict } from './review-artifacts.js';
8
9
  import { extractSection, findSectionHeading } from './sections.js';
9
- import { receiptForOutput, sha256File } from './artifact-receipts.js';
10
- import { hasArtifactCompletionFact, hasArtifactCompletionLog } from './completion-facts.js';
10
+ import { receiptForOutput, receiptsForOutput, sha256File } from './artifact-receipts.js';
11
+ import { artifactCompletionOrder, hasArtifactCompletionFact, hasArtifactCompletionLog, parseCompletionFacts } from './completion-facts.js';
11
12
  import { inspectReviewIdentity } from './review-identity.js';
12
- import { isArtifactInvalidated, parseInvalidationDocument } from './invalidation.js';
13
13
  import { parseLifecyclePathDecision } from './lifecycle-path.js';
14
14
  import { parseReworkIntentDocument } from './rework-intent.js';
15
15
  import { selectArtifactDisposition } from './artifact-selection.js';
16
+ import { executionInputsMatchLatest } from './execution-inputs.js';
16
17
  const artifactFamilyCatalog = ARTIFACT_FAMILY_CATALOG;
17
18
  const ARTIFACT_STEPS = {
18
19
  analysis: 'Analyze Task', 'review-analysis': 'Review Analysis',
@@ -21,28 +22,46 @@ const ARTIFACT_STEPS = {
21
22
  };
22
23
  /** Init, repair and finalization share the same authoritative round identity. */
23
24
  export function hasOpenArtifactRound(content, family, round) {
25
+ return openArtifactRounds(content, family).includes(round);
26
+ }
27
+ function openArtifactRounds(content, family) {
24
28
  const activity = locateActivityLog(content);
25
29
  const step = ARTIFACT_STEPS[family];
26
30
  if (!activity || !step)
27
- return false;
31
+ return [];
28
32
  const qualifier = family === 'code'
29
33
  ? '(?:, (?:fix for review-code(?:-r(?:[2-9]|[1-9]\\d+))?\\.md|decision II-[1-9]\\d*))?'
30
34
  : '';
31
- const expected = new RegExp(`^${step} \\(Round ${round}${qualifier}\\)$`);
32
- return startedBackedRows(pairEntries(activity.entries)).filter((row) => expected.test(row.step) && !row.done).length === 1;
35
+ const expected = new RegExp(`^${step} \\(Round ([1-9]\\d*)${qualifier}\\)$`);
36
+ return [...new Set(startedBackedRows(pairEntries(activity.entries))
37
+ .filter((row) => expected.test(row.step) && !row.done)
38
+ .map((row) => Number(expected.exec(row.step)?.[1])))].sort((left, right) => left - right);
33
39
  }
34
40
  function hasCompletedArtifactRound(content, family, round, name) {
35
- const activity = locateActivityLog(content);
36
- const step = ARTIFACT_STEPS[family];
37
- if (!activity || !step)
38
- return false;
39
- const expected = new RegExp(`^${escapeRegExp(step)} \\(Round ${round}(?:, [^)]+)?\\)$`);
40
- return startedBackedRows(pairEntries(activity.entries)).some((row) => expected.test(row.step) && Boolean(row.done) && row.note.includes(`→ ${name}`));
41
+ const event = completionEvent(family);
42
+ return Boolean(event && parseArtifactName(name)?.round === round
43
+ && artifactCompletionOrder(content, name, event) !== null
44
+ && hasArtifactCompletionFact(content, name, event));
45
+ }
46
+ function completionEvent(family) {
47
+ if (family === 'analysis')
48
+ return 'analyze.completed';
49
+ if (family === 'review-analysis')
50
+ return 'review-analysis.completed';
51
+ if (family === 'plan')
52
+ return 'plan.completed';
53
+ if (family === 'review-plan')
54
+ return 'review-plan.completed';
55
+ if (family === 'code')
56
+ return 'code.completed';
57
+ if (family === 'review-code')
58
+ return 'review-code.completed';
59
+ return null;
41
60
  }
42
61
  const BLOCKING_DIAGNOSTICS = new Set([
43
62
  'NONCANONICAL_NAME', 'ROUND_OUT_OF_RANGE', 'MISSING_BASE', 'ROUND_GAP',
44
63
  'DUPLICATE_LOGICAL_ROUND', 'NON_REGULAR_FILE', 'SYMBOLIC_LINK',
45
- 'FILESYSTEM_RACE', 'CATALOG_CONFLICT'
64
+ 'FILESYSTEM_RACE', 'CATALOG_CONFLICT', 'MULTIPLE_OPEN_ROUNDS'
46
65
  ]);
47
66
  function familySpec(family) {
48
67
  return artifactFamilyCatalog.find((item) => item.family === family) ?? null;
@@ -53,7 +72,7 @@ function escapeRegExp(value) {
53
72
  function failure(requestRef, family, error, extra = {}) {
54
73
  return {
55
74
  status: 'failed', changed: false, requestRef, taskId: null, taskDir: null,
56
- taskState: null, family, artifacts: [], latest: null, next: null,
75
+ taskState: null, family, artifacts: [], completed: [], latest: null, next: null, openRounds: [],
57
76
  reviewedInput: null, diagnostics: [], error, ...extra
58
77
  };
59
78
  }
@@ -82,19 +101,12 @@ function inspectArtifactDirectory(taskDir, family, identity = {}) {
82
101
  taskId: identity.taskId ?? null, taskDir, taskState: identity.taskState ?? null
83
102
  });
84
103
  }
85
- let invalidation = { operations: [], targets: [] };
104
+ let taskContent;
86
105
  try {
87
- const taskContent = fs.readFileSync(path.join(taskDir, 'task.md'), 'utf8');
88
- const parsed = parseInvalidationDocument(taskContent);
89
- if (!parsed.ok) {
90
- return failure(requestRef, family, { code: 'ARTIFACT_INVALIDATION_INVALID', message: parsed.message }, {
91
- taskId: identity.taskId ?? null, taskDir, taskState: identity.taskState ?? null
92
- });
93
- }
94
- invalidation = parsed.document;
106
+ taskContent = fs.readFileSync(path.join(taskDir, 'task.md'), 'utf8');
95
107
  }
96
108
  catch (error) {
97
- return failure(requestRef, family, { code: 'ARTIFACT_INVALIDATION_INVALID', message: String(error) }, {
109
+ return failure(requestRef, family, { code: 'ARTIFACT_DIRECTORY_READ_FAILED', message: String(error) }, {
98
110
  taskId: identity.taskId ?? null, taskDir, taskState: identity.taskState ?? null
99
111
  });
100
112
  }
@@ -133,8 +145,7 @@ function inspectArtifactDirectory(taskDir, family, identity = {}) {
133
145
  }
134
146
  const identity = { ...parsed, path: abs, size: stat.size, mtimeMs: stat.mtimeMs };
135
147
  knownArtifacts.push(identity);
136
- if (!isArtifactInvalidated(invalidation, family, entry.name))
137
- artifacts.push(identity);
148
+ artifacts.push(identity);
138
149
  }
139
150
  catch (error) {
140
151
  diagnostics.push(diagnostic('FILESYSTEM_RACE', family, entry.name, String(error)));
@@ -142,19 +153,32 @@ function inspectArtifactDirectory(taskDir, family, identity = {}) {
142
153
  }
143
154
  artifacts.sort((left, right) => left.round - right.round);
144
155
  knownArtifacts.sort((left, right) => left.round - right.round);
156
+ const openRounds = openArtifactRounds(taskContent, family);
145
157
  const knownRounds = new Set(knownArtifacts.map((item) => item.round));
146
- if (knownArtifacts.length > 0 && !knownRounds.has(1))
158
+ for (const round of openRounds)
159
+ knownRounds.add(round);
160
+ const max = Math.max(...knownRounds, 0);
161
+ if (max > 0 && !knownRounds.has(1))
147
162
  diagnostics.push(diagnostic('MISSING_BASE', family, null, `${family}.md is missing`));
148
- const max = knownArtifacts.at(-1)?.round ?? 0;
149
163
  for (let round = 1; round <= max; round += 1) {
150
164
  if (!knownRounds.has(round))
151
165
  diagnostics.push(diagnostic('ROUND_GAP', family, null, `${artifactName(family, round)} is missing`));
152
166
  }
153
- const latest = artifacts.at(-1) ?? null;
167
+ const event = completionEvent(family);
168
+ const completed = event
169
+ ? artifacts.flatMap((artifact) => {
170
+ const order = artifactCompletionOrder(taskContent, artifact.name, event);
171
+ return order !== null && hasArtifactCompletionFact(taskContent, artifact.path, event)
172
+ ? [{ artifact, order }] : [];
173
+ }).sort((left, right) => left.order - right.order).map(({ artifact }) => artifact)
174
+ : [];
175
+ if (openRounds.length > 1)
176
+ diagnostics.push(diagnostic('MULTIPLE_OPEN_ROUNDS', family, null, `multiple incomplete ${family} rounds are open: ${openRounds.join(', ')}`));
177
+ const latest = completed.at(-1) ?? null;
154
178
  let hasReviewCodeReceipt = false;
155
179
  if (family === 'review-code' && latest) {
156
180
  try {
157
- hasReviewCodeReceipt = Boolean(receiptForOutput(fs.readFileSync(path.join(taskDir, 'task.md'), 'utf8'), latest.name));
181
+ hasReviewCodeReceipt = Boolean(receiptForOutput(taskContent, latest.name));
158
182
  }
159
183
  catch {
160
184
  hasReviewCodeReceipt = false;
@@ -165,9 +189,9 @@ function inspectArtifactDirectory(taskDir, family, identity = {}) {
165
189
  : null;
166
190
  return {
167
191
  status: 'ready', changed: false, requestRef, taskId: identity.taskId ?? null,
168
- taskDir, taskState: identity.taskState ?? null, family, artifacts, latest,
192
+ taskDir, taskState: identity.taskState ?? null, family, artifacts, completed, latest,
169
193
  next: { round: max + 1, name: artifactName(family, max + 1) },
170
- reviewedInput, diagnostics, error: null
194
+ openRounds, reviewedInput, diagnostics, error: null
171
195
  };
172
196
  }
173
197
  function resolveReviewedInput(taskDir, review, expectedFamily, diagnostics) {
@@ -184,11 +208,6 @@ function resolveReviewedInput(taskDir, review, expectedFamily, diagnostics) {
184
208
  const stat = fs.lstatSync(abs);
185
209
  if (!stat.isFile() || stat.isSymbolicLink())
186
210
  throw new Error('referenced input is not a regular file');
187
- const invalidation = parseInvalidationDocument(taskContent);
188
- if (!invalidation.ok)
189
- throw new Error(invalidation.message);
190
- if (isArtifactInvalidated(invalidation.document, expectedFamily, input.name))
191
- throw new Error(`${input.name} is invalidated`);
192
211
  const actualSha256 = sha256File(abs);
193
212
  if (actualSha256 !== receipt.inputSha256) {
194
213
  throw new Error(`${input.name} content digest does not match receipt`);
@@ -270,11 +289,27 @@ const OPTIONAL_CONTEXT = {
270
289
  'manual-validation': ['review-code'],
271
290
  'validation-run': ['review-code']
272
291
  };
273
- function hasSelectionEvidence(content, family, options, context) {
292
+ function hasSelectionEvidence(content, family, options, context, pathState) {
274
293
  if (options.sourceFinding && options.sourceArtifact && options.sourceSha256)
275
294
  return true;
276
295
  if (options.reasonCode === 'new-requirement' || options.reasonCode === 'upstream-fact-doubt')
277
296
  return true;
297
+ if ((family === 'plan' || family === 'code') && context.latest) {
298
+ const latestInputs = Object.fromEntries(context.inputs.map((input) => [input.family, input.name]));
299
+ const capturedInputs = receiptsForOutput(content, context.latest.name)
300
+ .filter((receipt) => receipt.event === `${family}.completed`)
301
+ .filter((receipt) => {
302
+ try {
303
+ return sha256File(path.join(context.taskDir, receipt.input)) === receipt.inputSha256;
304
+ }
305
+ catch {
306
+ return false;
307
+ }
308
+ })
309
+ .map((receipt) => receipt.input);
310
+ if (!executionInputsMatchLatest(family, pathState, latestInputs, capturedInputs))
311
+ return true;
312
+ }
278
313
  if (family.startsWith('review-')) {
279
314
  const reviewedFamily = family === 'review-analysis' ? 'analysis' : family === 'review-plan' ? 'plan' : 'code';
280
315
  const currentInput = context.inputs.find((input) => input.family === reviewedFamily);
@@ -337,16 +372,20 @@ function attachArtifactSelection(context, pathState, options) {
337
372
  catch (error) {
338
373
  return fail('ARTIFACT_SELECTION_INDETERMINATE', error instanceof Error ? error.message : String(error));
339
374
  }
340
- const openLatest = context.latest ? hasOpenArtifactRound(taskContent, family, context.latest.round) : false;
341
- const openNext = hasOpenArtifactRound(taskContent, family, context.next.round);
342
- const open = openLatest || openNext;
343
- const selectedIdentity = openLatest ? context.latest : context.next;
375
+ const openRounds = openArtifactRounds(taskContent, family);
376
+ if (openRounds.length > 1)
377
+ return fail('ARTIFACT_TOPOLOGY_CONFLICT', `multiple incomplete ${family} rounds are open: ${openRounds.join(', ')}`);
378
+ const openRound = openRounds[0] ?? null;
379
+ const selectedIdentity = openRound === null ? context.next : {
380
+ family, round: openRound, name: artifactName(family, openRound),
381
+ path: path.join(context.taskDir, artifactName(family, openRound)), size: 0, mtimeMs: 0
382
+ };
344
383
  try {
345
- const hasChangeEvidence = hasSelectionEvidence(taskContent, family, options, context);
384
+ const hasChangeEvidence = hasSelectionEvidence(taskContent, family, options, context, pathState);
346
385
  const selection = selectArtifactDisposition({
347
386
  next: { family, round: selectedIdentity.round, name: selectedIdentity.name },
348
387
  latest: context.latest,
349
- open,
388
+ open: openRound !== null,
350
389
  hasChangeEvidence
351
390
  });
352
391
  return {
@@ -377,7 +416,8 @@ function resolveArtifactContext(taskRef, family, options = {}) {
377
416
  return selected;
378
417
  if (selected.selection.disposition === 'reuse')
379
418
  return selected;
380
- if (selected.codeMode?.mode === 'error') {
419
+ if (selected.codeMode?.mode === 'error'
420
+ && selected.codeMode.message.includes('is not captured by the latest code input receipt')) {
381
421
  return {
382
422
  ...selected,
383
423
  status: 'ready',
@@ -435,32 +475,42 @@ function resolveCodeContext(inventory, options) {
435
475
  if (source.status === 'failed' || !source.latest)
436
476
  return contextFailure(inventory, 'ARTIFACT_INPUT_MISSING', `latest ${inputFamily} artifact is required`);
437
477
  const reviewCode = inspectTaskArtifacts(taskRef, 'review-code', options);
478
+ try {
479
+ const taskContent = fs.readFileSync(path.join(inventory.taskDir, 'task.md'), 'utf8');
480
+ const hasPendingImplementation = parseImplementationInputs(taskContent).rows.some((input) => input.needsImplementation && input.status === 'pending');
481
+ if (hasPendingImplementation && !reviewCode.latest) {
482
+ const candidate = reviewCode.artifacts.at(-1)?.name ?? 'review-code.md';
483
+ return contextFailure(inventory, 'ARTIFACT_REFERENCE_INVALID', `cannot find completed Activity Log identity for ${candidate}`);
484
+ }
485
+ }
486
+ catch (error) {
487
+ return contextFailure(inventory, 'ARTIFACT_REFERENCE_INVALID', error instanceof Error ? error.message : String(error));
488
+ }
438
489
  const latestCode = inventory.latest;
439
- const codeMax = latestCode?.round ?? 0;
490
+ const codeMax = inventory.next?.round ? inventory.next.round - 1 : latestCode?.round ?? 0;
440
491
  const reviewMax = reviewCode.latest?.round ?? 0;
441
492
  const inputs = [source.latest];
442
493
  const sourceReviewFamily = inputFamily === 'analysis' ? 'review-analysis' : 'review-plan';
443
494
  const sourceReview = inspectTaskArtifacts(taskRef, sourceReviewFamily, options);
444
495
  if (sourceReview.status === 'ready' && sourceReview.latest && sourceReview.reviewedInput?.name === source.latest.name)
445
496
  inputs.push(sourceReview.latest);
446
- if (latestCode) {
497
+ if (inventory.openRounds.length > 0) {
447
498
  try {
448
499
  const taskContent = fs.readFileSync(path.join(inventory.taskDir, 'task.md'), 'utf8');
449
- if (hasOpenArtifactRound(taskContent, 'code', latestCode.round)) {
450
- const review = reviewCode.latest;
451
- const openInputs = review ? [...inputs, review] : inputs;
452
- const openVerdict = review ? parseVerdict(review.path) : null;
453
- const openRow = locateActivityLog(taskContent)
454
- ? startedBackedRows(pairEntries(locateActivityLog(taskContent).entries))
455
- .find((row) => row.step.startsWith(`Code Task (Round ${latestCode.round}`) && !row.done)
456
- : null;
457
- const decisionInput = /, decision (II-[1-9]\d*)\)$/u.exec(openRow?.step ?? '')?.[1] ?? null;
458
- const fixInput = /, fix for (review-code(?:-r\d+)?\.md)\)$/u.exec(openRow?.step ?? '')?.[1] ?? null;
459
- const mode = decisionInput ? 'decision'
460
- : fixInput || (openVerdict?.ok && openVerdict.verdict === 'Changes Requested') ? 'fix'
461
- : 'init';
462
- return withCodeMode(inventory, openInputs, 'ready', mode, codeMax, reviewMax, openVerdict?.ok ? openVerdict.verdict : null, fixInput ?? review?.name ?? null, `Resuming open code round ${latestCode.round}.`, decisionInput);
463
- }
500
+ const openRound = inventory.openRounds[0];
501
+ const review = reviewCode.latest;
502
+ const openInputs = review ? [...inputs, review] : inputs;
503
+ const openVerdict = review ? parseVerdict(review.path) : null;
504
+ const openRow = locateActivityLog(taskContent)
505
+ ? startedBackedRows(pairEntries(locateActivityLog(taskContent).entries))
506
+ .find((row) => row.step.startsWith(`Code Task (Round ${openRound}`) && !row.done)
507
+ : null;
508
+ const decisionInput = /, decision (II-[1-9]\d*)\)$/u.exec(openRow?.step ?? '')?.[1] ?? null;
509
+ const fixInput = /, fix for (review-code(?:-r\d+)?\.md)\)$/u.exec(openRow?.step ?? '')?.[1] ?? null;
510
+ const mode = decisionInput ? 'decision'
511
+ : fixInput || (openVerdict?.ok && openVerdict.verdict === 'Changes Requested') ? 'fix'
512
+ : 'init';
513
+ return withCodeMode(inventory, openInputs, 'ready', mode, codeMax, reviewMax, openVerdict?.ok ? openVerdict.verdict : null, fixInput ?? review?.name ?? null, `Resuming open code round ${openRound}.`, decisionInput);
464
514
  }
465
515
  catch (error) {
466
516
  return contextFailure(inventory, 'ARTIFACT_REFERENCE_INVALID', error instanceof Error ? error.message : String(error));
@@ -599,6 +649,13 @@ export function validateArtifactPublication(taskDir, family, name) {
599
649
  if (hasCompletedArtifactRound(task, family, existing.round, name)) {
600
650
  return { code: 'ARTIFACT_IDENTITY_INVALID', message: `completed artifact '${name}' is immutable` };
601
651
  }
652
+ const event = completionEvent(family);
653
+ const frontmatter = parseTypedTaskFrontmatter(task);
654
+ const completionFactExists = event !== null && parseCompletionFacts(frontmatter.completion_facts)
655
+ .some((fact) => fact.event === event && fact.output === name);
656
+ if (event && completionFactExists && artifactCompletionOrder(task, name, event) !== null) {
657
+ return { code: 'ARTIFACT_IDENTITY_INVALID', message: `completion evidence for '${name}' does not match its current content` };
658
+ }
602
659
  }
603
660
  catch (error) {
604
661
  return { code: 'ARTIFACT_SELECTION_INDETERMINATE', message: error instanceof Error ? error.message : String(error) };
@@ -8,7 +8,7 @@ function selectArtifactDisposition(request) {
8
8
  if (!request.latest) {
9
9
  return {
10
10
  disposition: 'create',
11
- reasonCode: request.next.round === 1 ? 'no-history' : 'invalidated-history',
11
+ reasonCode: 'no-completed-history',
12
12
  artifact: request.next, writeRequired: true
13
13
  };
14
14
  }
@@ -1,7 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { parseArtifactName } from './artifact-name.js';
4
- import { invalidationBlocks, isArtifactInvalidated, parseInvalidationDocument } from './invalidation.js';
5
4
  import { parseArtifactReceipts, receiptForOutput } from './artifact-receipts.js';
6
5
  import { parseTypedTaskFrontmatter } from './frontmatter.js';
7
6
  import { parseLedgerDocument, summarizeLedgerStage, validateLedgerRows } from './ledger.js';
@@ -14,6 +13,8 @@ import { parseLifecyclePathDecision, pathIncludes } from './lifecycle-path.js';
14
13
  import { parseImplementationInputs } from './implementation-inputs.js';
15
14
  import { hasArtifactCompletionFact, hasArtifactCompletionLog } from './completion-facts.js';
16
15
  import { inspectReviewIdentity } from './review-identity.js';
16
+ import { inspectArtifactDirectory } from './artifact-lifecycle.js';
17
+ import { executionInputsMatchLatest as executionReceiptsMatchLatest } from './execution-inputs.js';
17
18
  const ARTIFACT_AUDIT_FAMILIES = new Set(['analysis', 'review-analysis', 'plan', 'review-plan', 'code', 'review-code']);
18
19
  const REASONS = new Set([
19
20
  'user-request', 'new-requirement', 'upstream-fact-doubt', 'review-finding', 'retry', 'validation-rerun'
@@ -52,8 +53,6 @@ function canStart(action, facts, trigger) {
52
53
  }
53
54
  if (facts.taskState !== 'active')
54
55
  return deny('TASK_NOT_ACTIVE', `state=${facts.taskState}`);
55
- if (invalidationBlocks(facts.invalidation))
56
- return deny('INVALIDATION_INCOMPLETE');
57
56
  if (facts.executionBusy)
58
57
  return deny('EXECUTION_BUSY');
59
58
  const pendingIntent = (facts.reworkIntents ?? []).find((intent) => intent.status === 'pending');
@@ -102,7 +101,9 @@ function canStart(action, facts, trigger) {
102
101
  return allow('analysis-review-approved');
103
102
  }
104
103
  if (action === 'review-plan') {
105
- return hasArtifact(facts, 'plan') ? allow('plan-artifact') : deny('PLAN_ARTIFACT_REQUIRED');
104
+ if (!hasArtifact(facts, 'plan'))
105
+ return deny('PLAN_ARTIFACT_REQUIRED');
106
+ return executionInputsMatchLatest(facts, 'plan') ? allow('plan-artifact') : deny('PLAN_INPUT_STALE');
106
107
  }
107
108
  if (action === 'code') {
108
109
  if (trigger.implementationInput) {
@@ -141,11 +142,15 @@ function canStart(action, facts, trigger) {
141
142
  return allow('plan-review-approved');
142
143
  }
143
144
  if (action === 'review-code') {
145
+ if (hasArtifact(facts, 'code') && !executionInputsMatchLatest(facts, 'code'))
146
+ return deny('CODE_INPUT_STALE');
144
147
  return allow(hasArtifact(facts, 'code') ? 'optional-code-context' : 'review-current-git-diff');
145
148
  }
146
149
  if (action === 'manual-validation' || action === 'validation-run') {
147
150
  if (!hasArtifact(facts, 'review-code'))
148
151
  return deny('CODE_REVIEW_REQUIRED');
152
+ if (!executionInputsMatchLatest(facts, 'code'))
153
+ return deny('CODE_INPUT_STALE');
149
154
  if (hasArtifact(facts, 'code') && !reviewMatchesLatest(facts, 'code', 'review-code'))
150
155
  return deny('CODE_REVIEW_NOT_LATEST');
151
156
  if (!facts.reviewCodeIdentityValid)
@@ -159,13 +164,22 @@ function canStart(action, facts, trigger) {
159
164
  return deny('CAPABILITY_ACTION_UNKNOWN');
160
165
  }
161
166
  function latestArtifact(names) {
162
- return names.map(parseArtifactName).filter((identity) => identity !== null)
163
- .sort((left, right) => right.round - left.round || left.name.localeCompare(right.name))[0]?.name ?? null;
167
+ return names.at(-1) ?? null;
164
168
  }
165
169
  function reviewMatchesLatest(facts, source, review) {
166
170
  const latestSource = latestArtifact(facts.artifacts[source] ?? []);
167
171
  return Boolean(latestSource && facts.reviewedInputs?.[review] === latestSource);
168
172
  }
173
+ function executionInputsMatchLatest(facts, stage) {
174
+ if (!facts.pathState || facts.pathState.status !== 'valid' || facts.executionInputs === undefined)
175
+ return true;
176
+ return executionReceiptsMatchLatest(stage, facts.pathState, {
177
+ analysis: latestArtifact(facts.artifacts.analysis ?? []) ?? undefined,
178
+ 'review-analysis': latestArtifact(facts.artifacts['review-analysis'] ?? []) ?? undefined,
179
+ plan: latestArtifact(facts.artifacts.plan ?? []) ?? undefined,
180
+ 'review-plan': latestArtifact(facts.artifacts['review-plan'] ?? []) ?? undefined
181
+ }, facts.executionInputs[stage] ?? []);
182
+ }
169
183
  function qualificationCandidateSnapshotMatches(audit, qualification) {
170
184
  const current = new Map(qualification.candidates.map((candidate) => [candidate.candidateId, candidate]));
171
185
  return audit.candidateQualifications.length === current.size && audit.candidateQualifications.every((row) => {
@@ -201,6 +215,8 @@ function recommendNext(facts) {
201
215
  }
202
216
  if (stage === 'plan' && !hasArtifact(facts, stage))
203
217
  return { action: stage, reasonCode: 'PLAN_ARTIFACT_MISSING', evidence: ['plan artifact is absent'] };
218
+ if (stage === 'plan' && !executionInputsMatchLatest(facts, stage))
219
+ return { action: stage, reasonCode: 'PLAN_INPUT_STALE', evidence: ['latest plan does not record the current selected analysis inputs'] };
204
220
  if (stage === 'review-plan' && (!reviewMatchesLatest(facts, 'plan', stage) || facts.reviews[stage] !== 'approved')) {
205
221
  if (facts.reviews[stage] === 'changes-requested' && facts.resolvedHumanDecisions?.plan === 'review') {
206
222
  return { action: stage, reasonCode: 'HUMAN_DECISION_REVIEW_REQUIRED', evidence: ['plan decision was resolved'] };
@@ -211,6 +227,8 @@ function recommendNext(facts) {
211
227
  }
212
228
  if (stage === 'code' && !hasArtifact(facts, stage))
213
229
  return { action: stage, reasonCode: 'CODE_ARTIFACT_MISSING', evidence: ['code artifact is absent'] };
230
+ if (stage === 'code' && !executionInputsMatchLatest(facts, stage))
231
+ return { action: stage, reasonCode: 'CODE_INPUT_STALE', evidence: ['latest code does not record the current selected execution inputs'] };
214
232
  if (stage === 'review-code' && (!reviewMatchesLatest(facts, 'code', stage) || facts.reviews[stage] !== 'approved')) {
215
233
  if (facts.reviews[stage] === 'changes-requested' && facts.resolvedHumanDecisions?.code) {
216
234
  return facts.resolvedHumanDecisions.code === 'implementation'
@@ -234,6 +252,8 @@ function recommendNext(facts) {
234
252
  }
235
253
  if (!hasArtifact(facts, 'plan'))
236
254
  return { action: 'plan', reasonCode: 'PLAN_ARTIFACT_MISSING', evidence: ['plan artifact is absent'] };
255
+ if (!executionInputsMatchLatest(facts, 'plan'))
256
+ return { action: 'plan', reasonCode: 'PLAN_INPUT_STALE', evidence: ['latest plan does not record the current selected analysis inputs'] };
237
257
  if (!hasArtifact(facts, 'review-plan') || !reviewMatchesLatest(facts, 'plan', 'review-plan')) {
238
258
  return { action: 'review-plan', reasonCode: 'PLAN_REVIEW_MISSING', evidence: ['plan review does not bind the latest plan artifact'] };
239
259
  }
@@ -242,6 +262,8 @@ function recommendNext(facts) {
242
262
  }
243
263
  if (!hasArtifact(facts, 'code'))
244
264
  return { action: 'code', reasonCode: 'CODE_ARTIFACT_MISSING', evidence: ['code artifact is absent'] };
265
+ if (!executionInputsMatchLatest(facts, 'code'))
266
+ return { action: 'code', reasonCode: 'CODE_INPUT_STALE', evidence: ['latest code does not record the current selected execution inputs'] };
245
267
  if (!hasArtifact(facts, 'review-code') || !reviewMatchesLatest(facts, 'code', 'review-code')) {
246
268
  return { action: 'review-code', reasonCode: 'CODE_REVIEW_MISSING', evidence: ['code review does not bind the latest code artifact'] };
247
269
  }
@@ -253,9 +275,6 @@ function recommendNext(facts) {
253
275
  function buildLifecycleFacts(taskDir, content, taskState = 'active', executionBusy = false) {
254
276
  try {
255
277
  const metadata = parseTypedTaskFrontmatter(content);
256
- const invalidation = parseInvalidationDocument(content);
257
- if (!invalidation.ok)
258
- return { ok: false, code: 'TASK_CAPABILITY_FACTS_INVALID', message: invalidation.message };
259
278
  const rework = parseReworkIntentDocument(content);
260
279
  if (!rework.ok)
261
280
  return { ok: false, code: 'TASK_CAPABILITY_FACTS_INVALID', message: rework.message };
@@ -276,22 +295,38 @@ function buildLifecycleFacts(taskDir, content, taskState = 'active', executionBu
276
295
  const family = parseArtifactName(name)?.family;
277
296
  return artifactFamilies.find((candidate) => candidate === family) ?? null;
278
297
  };
279
- const activeFiles = files.filter((name) => {
280
- const family = familyFor(name);
281
- return !family || !isArtifactInvalidated(invalidation.document, family, name);
282
- });
283
298
  const allArtifactHashes = {};
284
299
  for (const name of files)
285
300
  allArtifactHashes[name] = sha256File(path.join(taskDir, name));
286
301
  const artifactHashes = {};
287
- for (const name of activeFiles)
302
+ for (const name of files)
288
303
  artifactHashes[name] = allArtifactHashes[name];
304
+ const completedByFamily = new Map();
305
+ for (const family of artifactFamilies) {
306
+ const inventory = inspectArtifactDirectory(taskDir, family);
307
+ if (inventory.status !== 'ready')
308
+ continue;
309
+ completedByFamily.set(family, inventory.completed.map((artifact) => artifact.name));
310
+ }
311
+ const completedFiles = new Set([...completedByFamily.values()].flat());
312
+ const activeFiles = [
313
+ ...files.filter((name) => !familyFor(name)),
314
+ ...artifactFamilies.flatMap((family) => completedByFamily.get(family) ?? [])
315
+ ];
289
316
  const artifacts = Object.fromEntries(artifactFamilies.map((family) => [
290
317
  family, activeFiles.filter((name) => familyFor(name) === family)
291
318
  ]));
292
- const staleArtifacts = Object.fromEntries(artifactFamilies.map((family) => [
293
- family, files.filter((name) => familyFor(name) === family && isArtifactInvalidated(invalidation.document, family, name))
294
- ]));
319
+ const receipts = parseArtifactReceipts(content).rows;
320
+ const executionInputs = {};
321
+ for (const family of ['plan', 'code']) {
322
+ const output = latestArtifact(artifacts[family] ?? []);
323
+ if (!output)
324
+ continue;
325
+ executionInputs[family] = receipts.filter((receipt) => receipt.output === output
326
+ && receipt.event === `${family}.completed`
327
+ && completedFiles.has(receipt.input)
328
+ && artifactHashes[receipt.input] === receipt.inputSha256).map((receipt) => receipt.input);
329
+ }
295
330
  const qualification = parseTaskQualification(content);
296
331
  if (!qualification.ok)
297
332
  return { ok: false, code: 'TASK_CAPABILITY_FACTS_INVALID', message: qualification.message };
@@ -362,9 +397,8 @@ function buildLifecycleFacts(taskDir, content, taskState = 'active', executionBu
362
397
  reviews[family] = verdict.verdict === 'Approved' ? 'approved' : verdict.verdict === 'Changes Requested' ? 'changes-requested' : 'rejected';
363
398
  }
364
399
  const reworkClassificationRequired = [];
365
- const receipts = parseArtifactReceipts(content).rows;
366
400
  for (const [reviewFamily, stage] of [['review-analysis', 'analysis'], ['review-plan', 'plan'], ['review-code', 'code']]) {
367
- const cycles = receipts.filter((receipt) => receipt.event === `${reviewFamily}.completed` && files.includes(receipt.output)
401
+ const cycles = receipts.filter((receipt) => receipt.event === `${reviewFamily}.completed` && completedFiles.has(receipt.output)
368
402
  && allArtifactHashes[receipt.input] === receipt.inputSha256).map((receipt) => {
369
403
  const parsed = parseReviewSummary(fs.readFileSync(path.join(taskDir, receipt.output), 'utf8'));
370
404
  const verdict = parsed.ok ? resolveCanonicalVerdict(parsed.summary) : null;
@@ -415,8 +449,8 @@ function buildLifecycleFacts(taskDir, content, taskState = 'active', executionBu
415
449
  }
416
450
  const facts = {
417
451
  taskState, currentStep: String(metadata.current_step ?? ''), artifacts, reviews,
418
- staleArtifacts, reviewedInputs, artifactHashes,
419
- invalidation: invalidation.document, reworkIntents: rework.intents,
452
+ reviewedInputs, executionInputs, artifactHashes,
453
+ reworkIntents: rework.intents,
420
454
  unresolvedLedger,
421
455
  executionBusy: executionBusy || hasOpenLifecycleExecution(content),
422
456
  recommendedAction: null,
@@ -11,8 +11,6 @@ import { getOpenWorkflowWarnings, formatWorkflowWarningSummary } from '../workfl
11
11
  import { parseActivityLog, pairEntries, startedBackedRows } from '../activity-log.js';
12
12
  import { readRun } from '../orchestration.js';
13
13
  import { statusCard } from '../../server/display.js';
14
- import { invalidationBlocks, parseInvalidationDocument } from '../invalidation.js';
15
- import { parseReworkIntentDocument } from '../rework-intent.js';
16
14
  import { buildLifecycleFacts, recommendNext } from '../capabilities.js';
17
15
  const USAGE = `Usage: ai task status [--task <ref> | -t <ref>]
18
16
 
@@ -320,11 +318,6 @@ function renderStatus(model) {
320
318
  else {
321
319
  lines.push(...renderPairs(model.artifacts.groups.map((group) => [group.stage, group.files.join(', ')])));
322
320
  }
323
- lines.push('', 'Invalidation', ...renderPairs([
324
- ['status', model.invalidation?.status ?? 'unknown'],
325
- ['processed', `${model.invalidation?.processed ?? 0}/${model.invalidation?.total ?? 0}`],
326
- ['pending_intents', String(model.invalidation?.pendingIntents ?? 0)]
327
- ]));
328
321
  lines.push('', 'Recommendation', ...renderPairs([
329
322
  ['path', model.lifecyclePath ?? DASH],
330
323
  ['action', model.recommendation ?? DASH],
@@ -374,10 +367,6 @@ function renderStatus(model) {
374
367
  return lines;
375
368
  }
376
369
  function modelTone(model) {
377
- if (model.invalidation?.status === 'invalid')
378
- return 'danger';
379
- if (model.invalidation?.status === 'pending' || model.invalidation?.status === 'in-progress')
380
- return 'warning';
381
370
  if (model.orchestration.status === 'paused')
382
371
  return 'warning';
383
372
  if (model.orchestration.status === 'running')
@@ -399,21 +388,6 @@ function buildFromResolved(input) {
399
388
  const run = input.run ?? makeRunner(input.repoRoot);
400
389
  const artifacts = enumerateArtifacts(input.taskDir);
401
390
  const workflow = collectWorkflow(content, input.now);
402
- const invalidation = parseInvalidationDocument(content);
403
- const rework = parseReworkIntentDocument(content);
404
- const pendingIntents = rework.ok ? rework.intents.filter((intent) => intent.status === 'pending').length : 0;
405
- const invalidationInfo = !invalidation.ok
406
- ? { status: 'invalid', processed: 0, total: 0, pendingIntents }
407
- : {
408
- status: invalidation.present
409
- ? invalidationBlocks(invalidation.document)
410
- ? invalidation.document.operations.some((operation) => operation.status === 'in-progress') ? 'in-progress' : 'pending'
411
- : 'completed'
412
- : 'none',
413
- processed: invalidation.document.operations.reduce((sum, operation) => sum + operation.processed, 0),
414
- total: invalidation.document.operations.reduce((sum, operation) => sum + operation.total, 0),
415
- pendingIntents
416
- };
417
391
  const facts = buildLifecycleFacts(input.taskDir, content, fm.status ?? 'active');
418
392
  const next = facts.ok ? recommendNext(facts.facts) : null;
419
393
  const recommendation = next?.action ?? DASH;
@@ -427,7 +401,6 @@ function buildFromResolved(input) {
427
401
  workflow,
428
402
  runtime: collectRuntime(input.taskDir, workflow, run),
429
403
  orchestration: collectOrchestration(input.taskDir),
430
- invalidation: invalidationInfo,
431
404
  recommendation,
432
405
  recommendationReason: next?.reasonCode ?? 'TASK_CAPABILITY_FACTS_INVALID',
433
406
  lifecyclePath: facts.ok && facts.facts.pathState?.status === 'valid' ? facts.facts.pathState.decision.path : facts.ok ? facts.facts.pathState?.status ?? DASH : DASH,
@@ -6,19 +6,34 @@ function gitText(repoRoot, args) {
6
6
  stdio: ['ignore', 'pipe', 'pipe']
7
7
  }).trim();
8
8
  }
9
+ function gitOutput(repoRoot, args) {
10
+ return execFileSync('git', [...args], {
11
+ cwd: repoRoot,
12
+ encoding: 'utf8',
13
+ stdio: ['ignore', 'pipe', 'pipe']
14
+ });
15
+ }
16
+ function commitPaths(repoRoot, head, pathspec) {
17
+ const args = ['diff-tree', '--no-commit-id', '--name-only', '-r', '-z', head];
18
+ if (pathspec && pathspec.length > 0)
19
+ args.push('--', ...pathspec);
20
+ return gitOutput(repoRoot, args).split('\0').filter(Boolean).sort();
21
+ }
22
+ function canonicalMessage(message) {
23
+ return message.replace(/\r\n/g, '\n').replace(/\n+$/, '');
24
+ }
9
25
  function checkpointCommitMatches(repoRoot, intent, head) {
10
26
  try {
11
27
  const parent = gitText(repoRoot, ['rev-parse', `${head}^`]);
12
28
  const tree = gitText(repoRoot, ['rev-parse', `${head}^{tree}`]);
13
- const message = gitText(repoRoot, ['show', '-s', '--format=%s', head]);
14
- const changed = gitText(repoRoot, ['diff-tree', '--no-commit-id', '--name-only', '-r', head])
15
- .split('\n')
16
- .filter(Boolean)
17
- .sort();
29
+ const message = canonicalMessage(gitOutput(repoRoot, ['show', '-s', '--format=%B', head]));
30
+ const changed = commitPaths(repoRoot, head);
31
+ const selected = commitPaths(repoRoot, head, intent.paths);
18
32
  return parent === intent.expectedHead
19
33
  && tree === intent.expectedTree
20
- && message === intent.message
21
- && JSON.stringify(changed) === JSON.stringify([...intent.paths].sort());
34
+ && message === canonicalMessage(intent.message)
35
+ && changed.length > 0
36
+ && JSON.stringify(changed) === JSON.stringify(selected);
22
37
  }
23
38
  catch {
24
39
  return false;