@fitlab-ai/agent-infra 0.11.6 → 0.11.7

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 (115) hide show
  1. package/dist/lib/git/primary-worktree.js +58 -0
  2. package/dist/lib/platform/pull-requests.js +29 -3
  3. package/dist/lib/sandbox/control/server.js +6 -2
  4. package/dist/lib/server/config.js +7 -27
  5. package/dist/lib/server/daemon.js +4 -3
  6. package/dist/lib/server/process-control.js +4 -2
  7. package/dist/lib/server/runner.js +4 -3
  8. package/dist/lib/task/artifact-lifecycle.js +42 -10
  9. package/dist/lib/task/artifact-receipts.js +62 -22
  10. package/dist/lib/task/capabilities.js +22 -4
  11. package/dist/lib/task/completion-facts.js +56 -0
  12. package/dist/lib/task/create-service.js +6 -2
  13. package/dist/lib/task/create.js +46 -82
  14. package/dist/lib/task/events.js +268 -195
  15. package/dist/lib/task/finalization-handoff.js +2 -1
  16. package/dist/lib/task/invalidation-command.js +4 -2
  17. package/dist/lib/task/invalidation.js +38 -9
  18. package/dist/lib/task/local-artifact-finalization.js +17 -3
  19. package/dist/lib/task/qualification-audit.js +23 -105
  20. package/dist/lib/task/review-finalization.js +7 -0
  21. package/dist/lib/task/review-identity.js +69 -0
  22. package/dist/lib/task/sections.js +6 -5
  23. package/dist/lib/task/task-execution-lock.js +6 -3
  24. package/dist/lib/task/verification-engine.js +14 -63
  25. package/dist/lib/task/verification.js +35 -38
  26. package/dist/lifecycle-build-manifest.json +3 -3
  27. package/lib/git/primary-worktree.ts +62 -0
  28. package/lib/platform/pull-requests.ts +27 -3
  29. package/lib/sandbox/control/server.ts +5 -2
  30. package/lib/server/config.ts +7 -29
  31. package/lib/server/daemon.ts +5 -3
  32. package/lib/server/process-control.ts +4 -2
  33. package/lib/server/runner.ts +7 -4
  34. package/lib/server/server.schema.json +0 -4
  35. package/lib/task/artifact-lifecycle.ts +35 -11
  36. package/lib/task/artifact-receipts.ts +55 -15
  37. package/lib/task/capabilities.ts +17 -3
  38. package/lib/task/completion-facts.ts +66 -0
  39. package/lib/task/create-service.ts +7 -3
  40. package/lib/task/create.ts +44 -71
  41. package/lib/task/events.ts +248 -182
  42. package/lib/task/finalization-handoff.ts +2 -1
  43. package/lib/task/invalidation-command.ts +4 -2
  44. package/lib/task/invalidation.ts +35 -10
  45. package/lib/task/local-artifact-finalization.ts +13 -3
  46. package/lib/task/qualification-audit.ts +21 -114
  47. package/lib/task/review-finalization.ts +7 -0
  48. package/lib/task/review-identity.ts +85 -0
  49. package/lib/task/sections.ts +6 -6
  50. package/lib/task/task-execution-lock.ts +7 -3
  51. package/lib/task/verification-engine.ts +13 -86
  52. package/lib/task/verification.ts +34 -38
  53. package/package.json +1 -1
  54. package/templates/.agents/rules/README.en.md +1 -0
  55. package/templates/.agents/rules/README.zh-CN.md +1 -0
  56. package/templates/.agents/rules/decision-qualification.en.md +4 -3
  57. package/templates/.agents/rules/decision-qualification.zh-CN.md +4 -3
  58. package/templates/.agents/rules/validation-output.en.md +19 -0
  59. package/templates/.agents/rules/validation-output.zh-CN.md +19 -0
  60. package/templates/.agents/skills/analyze-task/SKILL.en.md +3 -1
  61. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +3 -3
  62. package/templates/.agents/skills/block-task/SKILL.en.md +2 -0
  63. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +1 -1
  64. package/templates/.agents/skills/cancel-task/SKILL.en.md +2 -0
  65. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +1 -1
  66. package/templates/.agents/skills/code-task/SKILL.en.md +3 -1
  67. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -2
  68. package/templates/.agents/skills/code-task/reference/report-template.en.md +4 -8
  69. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +4 -8
  70. package/templates/.agents/skills/commit/SKILL.en.md +1 -1
  71. package/templates/.agents/skills/commit/SKILL.zh-CN.md +1 -1
  72. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +1 -1
  73. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +1 -1
  74. package/templates/.agents/skills/complete-task/SKILL.en.md +2 -0
  75. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -0
  76. package/templates/.agents/skills/create-pr/SKILL.en.md +2 -0
  77. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +1 -1
  78. package/templates/.agents/skills/create-task/SKILL.en.md +2 -0
  79. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -1
  80. package/templates/.agents/skills/import-codescan/SKILL.en.md +2 -0
  81. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +1 -1
  82. package/templates/.agents/skills/import-dependabot/SKILL.en.md +2 -0
  83. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +1 -1
  84. package/templates/.agents/skills/import-issue/SKILL.en.md +2 -0
  85. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -1
  86. package/templates/.agents/skills/plan-task/SKILL.en.md +3 -1
  87. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +3 -3
  88. package/templates/.agents/skills/review-analysis/SKILL.en.md +3 -1
  89. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +3 -1
  90. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +4 -8
  91. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +4 -8
  92. package/templates/.agents/skills/review-code/SKILL.en.md +13 -12
  93. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +11 -12
  94. package/templates/.agents/skills/review-code/config/verify.en.json +1 -3
  95. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -3
  96. package/templates/.agents/skills/review-code/reference/output-templates.en.md +23 -46
  97. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +27 -48
  98. package/templates/.agents/skills/review-code/reference/report-template.en.md +8 -11
  99. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +8 -11
  100. package/templates/.agents/skills/review-code/reference/review-criteria.en.md +2 -2
  101. package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +2 -2
  102. package/templates/.agents/skills/review-plan/SKILL.en.md +3 -1
  103. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +3 -1
  104. package/templates/.agents/skills/review-plan/reference/report-template.en.md +5 -9
  105. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +5 -9
  106. package/templates/.agents/skills/review-pr/SKILL.en.md +2 -0
  107. package/templates/.agents/skills/review-pr/SKILL.zh-CN.md +2 -0
  108. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +1 -1
  109. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +1 -1
  110. package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
  111. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
  112. package/templates/.agents/skills/watch-pr/SKILL.en.md +2 -0
  113. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +1 -1
  114. package/templates/.agents/templates/task.en.md +2 -2
  115. package/templates/.agents/templates/task.zh-CN.md +2 -2
@@ -7,24 +7,30 @@ import { extractSection, findSectionHeading, parseTable } from './sections.ts';
7
7
  const RECEIPT_SECTION_ALIASES = ['产物生命周期收据', 'Artifact Lifecycle Receipts'] as const;
8
8
  const RECEIPT_COLUMNS = ['event', 'output', 'input', 'input_sha256', 'completed_at'] as const;
9
9
  const RECEIPT_EVENTS = new Set([
10
+ 'analysis.completed',
11
+ 'plan.completed',
10
12
  'review-analysis.completed',
11
13
  'review-plan.completed',
14
+ 'code.completed',
12
15
  'review-code.completed',
13
- 'code.completed'
14
16
  ]);
15
17
  const SHA256_RE = /^[a-f0-9]{64}$/;
16
18
  const RECEIPT_SHAPES = {
19
+ 'analysis.completed': { output: 'analysis', input: 'review-analysis' },
20
+ 'plan.completed': { output: 'plan', input: 'analysis' },
17
21
  'review-analysis.completed': { output: 'review-analysis', input: 'analysis' },
18
22
  'review-plan.completed': { output: 'review-plan', input: 'plan' },
19
- 'review-code.completed': { output: 'review-code', input: 'code' },
20
- 'code.completed': { output: 'code', input: 'plan' }
23
+ 'code.completed': { output: 'code', input: 'plan' },
24
+ 'review-code.completed': { output: 'review-code', input: 'code' }
21
25
  } as const;
22
26
 
23
27
  type ArtifactReceiptEvent =
28
+ | 'analysis.completed'
29
+ | 'plan.completed'
24
30
  | 'review-analysis.completed'
25
31
  | 'review-plan.completed'
26
- | 'review-code.completed'
27
- | 'code.completed';
32
+ | 'code.completed'
33
+ | 'review-code.completed';
28
34
  type ArtifactReceipt = {
29
35
  event: ArtifactReceiptEvent;
30
36
  output: string;
@@ -65,8 +71,16 @@ function validateReceiptShape(event: ArtifactReceiptEvent, output: string, input
65
71
  if (!outputIdentity || !inputIdentity) throw new ArtifactReceiptError(`receipt artifact identity is invalid: ${output} -> ${input}`);
66
72
  const shape = RECEIPT_SHAPES[event];
67
73
  const inputMatches = event === 'code.completed'
68
- ? inputIdentity.family === 'analysis' || inputIdentity.family === 'plan'
69
- : inputIdentity.family === shape.input;
74
+ ? inputIdentity.family === 'analysis' || inputIdentity.family === 'plan' || inputIdentity.family === 'review-analysis' || inputIdentity.family === 'review-plan' || inputIdentity.family === 'review-code'
75
+ : event === 'plan.completed'
76
+ ? inputIdentity.family === 'analysis' || inputIdentity.family === 'review-analysis' || inputIdentity.family === 'review-plan'
77
+ : event === 'analysis.completed'
78
+ ? inputIdentity.family === 'review-analysis'
79
+ : event === 'review-plan.completed'
80
+ ? inputIdentity.family === 'plan' || inputIdentity.family === 'review-analysis'
81
+ : event === 'review-code.completed'
82
+ ? inputIdentity.family === 'code' || inputIdentity.family === 'plan' || inputIdentity.family === 'review-plan'
83
+ : inputIdentity.family === shape.input;
70
84
  if (outputIdentity.family !== shape.output || !inputMatches) {
71
85
  throw new ArtifactReceiptError(`receipt event '${event}' does not match ${output} -> ${input}`);
72
86
  }
@@ -81,7 +95,9 @@ function parseArtifactReceipts(content: string): ArtifactReceiptParseResult {
81
95
  table = parseTable(content, {
82
96
  sectionAliases: [...RECEIPT_SECTION_ALIASES],
83
97
  columns: [...RECEIPT_COLUMNS],
84
- keyColumn: 'output'
98
+ // A completed artifact can have multiple lifecycle inputs. Pair
99
+ // uniqueness is checked below because the table parser supports one key.
100
+ keyColumn: null
85
101
  });
86
102
  } catch (error) {
87
103
  throw new ArtifactReceiptError(error instanceof Error ? error.message : String(error));
@@ -109,15 +125,31 @@ function parseArtifactReceipts(content: string): ArtifactReceiptParseResult {
109
125
  completedAt
110
126
  };
111
127
  });
128
+ const edges = new Set<string>();
129
+ for (const row of rows) {
130
+ const edge = `${row.output}\0${row.input}`;
131
+ if (edges.has(edge)) throw new ArtifactReceiptError(`duplicate receipt edge '${row.output}' <- '${row.input}'`);
132
+ edges.add(edge);
133
+ }
112
134
  return { present: true, rows };
113
135
  }
114
136
 
137
+ function receiptsForOutput(content: string, output: string): readonly ArtifactReceipt[] {
138
+ return parseArtifactReceipts(content).rows.filter((row) => row.output === output);
139
+ }
140
+
115
141
  function receiptForOutput(content: string, output: string): ArtifactReceipt | null {
116
- const parsed = parseArtifactReceipts(content);
117
- return parsed.rows.find((row) => row.output === output) ?? null;
142
+ return receiptsForOutput(content, output)[0] ?? null;
118
143
  }
119
144
 
120
145
  function upsertArtifactReceipt(content: string, receipt: ArtifactReceipt): ReceiptSectionMutation {
146
+ return upsertArtifactReceipts(content, [receipt]);
147
+ }
148
+
149
+ function upsertArtifactReceipts(content: string, receiptsToAdd: readonly ArtifactReceipt[]): ReceiptSectionMutation {
150
+ if (receiptsToAdd.length === 0) throw new ArtifactReceiptError('at least one receipt is required');
151
+ const additions = new Map<string, ArtifactReceipt>();
152
+ for (const receipt of receiptsToAdd) {
121
153
  if (!RECEIPT_EVENTS.has(receipt.event)) throw new ArtifactReceiptError(`unknown receipt event '${receipt.event}'`);
122
154
  if (!receipt.output || !receipt.input) throw new ArtifactReceiptError('receipt output and input are required');
123
155
  validateReceiptShape(receipt.event, receipt.output, receipt.input);
@@ -126,12 +158,18 @@ function upsertArtifactReceipt(content: string, receipt: ArtifactReceipt): Recei
126
158
  throw new ArtifactReceiptError(`receipt completion time for '${receipt.output}' is invalid`);
127
159
  }
128
160
 
161
+ const key = `${receipt.output}\0${receipt.input}`;
162
+ const duplicate = additions.get(key);
163
+ if (duplicate && JSON.stringify(duplicate) !== JSON.stringify(receipt)) throw new ArtifactReceiptError(`receipt for '${receipt.output}' from '${receipt.input}' has conflicting evidence`);
164
+ additions.set(key, receipt);
165
+ }
129
166
  const existing = parseArtifactReceipts(content).rows;
130
- const previous = existing.find((row) => row.output === receipt.output);
131
- if (previous && JSON.stringify(previous) !== JSON.stringify(receipt)) {
132
- throw new ArtifactReceiptError(`receipt for '${receipt.output}' already exists with different evidence`);
167
+ for (const receipt of additions.values()) {
168
+ const previous = existing.find((row) => row.output === receipt.output && row.input === receipt.input);
169
+ if (previous && JSON.stringify(previous) !== JSON.stringify(receipt)) throw new ArtifactReceiptError(`receipt for '${receipt.output}' from '${receipt.input}' already exists with different evidence`);
133
170
  }
134
- const rows = previous ? existing : [...existing, receipt];
171
+ const rows = [...existing];
172
+ for (const receipt of additions.values()) if (!rows.some((row) => row.output === receipt.output && row.input === receipt.input)) rows.push(receipt);
135
173
  const heading = findSectionHeading(content, [...RECEIPT_SECTION_ALIASES]);
136
174
  const body = [
137
175
  `| ${RECEIPT_COLUMNS.join(' | ')} |`,
@@ -146,8 +184,10 @@ export {
146
184
  RECEIPT_SECTION_ALIASES,
147
185
  parseArtifactReceipts,
148
186
  receiptForOutput,
187
+ receiptsForOutput,
149
188
  sha256Bytes,
150
189
  sha256File,
151
- upsertArtifactReceipt
190
+ upsertArtifactReceipt,
191
+ upsertArtifactReceipts
152
192
  };
153
193
  export type { ArtifactReceipt, ArtifactReceiptEvent, ArtifactReceiptParseResult, ReceiptSectionMutation };
@@ -17,6 +17,8 @@ import type { QualificationAudit, TaskQualification } from './qualification-audi
17
17
  import { parseLifecyclePathDecision, pathIncludes } from './lifecycle-path.ts';
18
18
  import type { LifecyclePathState } from './lifecycle-path.ts';
19
19
  import { parseImplementationInputs } from './implementation-inputs.ts';
20
+ import { hasArtifactCompletionFact, hasArtifactCompletionLog } from './completion-facts.ts';
21
+ import { inspectReviewIdentity } from './review-identity.ts';
20
22
 
21
23
  const ARTIFACT_AUDIT_FAMILIES = new Set(['analysis', 'review-analysis', 'plan', 'review-plan', 'code', 'review-code']);
22
24
 
@@ -55,6 +57,7 @@ type LifecycleFacts = {
55
57
  pathState?: LifecyclePathState;
56
58
  reworkClassificationRequired?: readonly ('analysis' | 'plan' | 'code')[];
57
59
  resolvedHumanDecisions?: Partial<Record<'analysis' | 'plan' | 'code', 'review' | 'implementation'>>;
60
+ reviewCodeIdentityValid?: boolean;
58
61
  };
59
62
  type CapabilityResult = {
60
63
  allowed: boolean;
@@ -176,11 +179,12 @@ function canStart(action: LifecycleAction, facts: LifecycleFacts, trigger: Expli
176
179
  return allow('plan-review-approved');
177
180
  }
178
181
  if (action === 'review-code') {
179
- return hasArtifact(facts, 'code') ? allow('code-artifact') : deny('CODE_ARTIFACT_REQUIRED');
182
+ return allow(hasArtifact(facts, 'code') ? 'optional-code-context' : 'review-current-git-diff');
180
183
  }
181
184
  if (action === 'manual-validation' || action === 'validation-run') {
182
185
  if (!hasArtifact(facts, 'review-code')) return deny('CODE_REVIEW_REQUIRED');
183
- if (!reviewMatchesLatest(facts, 'code', 'review-code')) return deny('CODE_REVIEW_NOT_LATEST');
186
+ if (hasArtifact(facts, 'code') && !reviewMatchesLatest(facts, 'code', 'review-code')) return deny('CODE_REVIEW_NOT_LATEST');
187
+ if (!facts.reviewCodeIdentityValid) return deny('CODE_REVIEW_IDENTITY_INVALID');
184
188
  if (facts.reviews['review-code'] !== 'approved') return deny('CODE_REVIEW_NOT_APPROVED');
185
189
  if (facts.unresolvedLedger.code > 0) return deny('CODE_LEDGER_BLOCKED', `unresolved=${facts.unresolvedLedger.code}`);
186
190
  return allow('code-review-approved');
@@ -334,7 +338,9 @@ function buildLifecycleFacts(taskDir: string, content: string, taskState = 'acti
334
338
  try { auditContent = fs.readFileSync(path.join(taskDir, name), 'utf8'); }
335
339
  catch { qualificationStaleArtifacts.push(name); continue; }
336
340
  const audit = parseQualificationAudit(auditContent);
337
- if (!audit.ok || !audit.audit.present || !audit.audit.snapshot) { qualificationStaleArtifacts.push(name); continue; }
341
+ if (!audit.ok) { qualificationStaleArtifacts.push(name); continue; }
342
+ if (!audit.audit.present) continue;
343
+ if (!audit.audit.snapshot) { qualificationStaleArtifacts.push(name); continue; }
338
344
  const constraintsChanged = audit.audit.constraintDependencies.some((dependency) => constraints.get(dependency.constraintId) !== dependency.constraintDigest);
339
345
  const taskInputChanged = audit.audit.snapshot.taskInputDigest !== qualification.qualification.taskInputDigest;
340
346
  if (audit.audit.snapshot.nonConstraintInputDigest !== qualification.qualification.nonConstraintInputDigest
@@ -345,6 +351,7 @@ function buildLifecycleFacts(taskDir: string, content: string, taskState = 'acti
345
351
  }
346
352
  const reviews: LifecycleFacts['reviews'] = {};
347
353
  const reviewedInputs: NonNullable<LifecycleFacts['reviewedInputs']> = {};
354
+ let reviewCodeIdentityValid = false;
348
355
  for (const family of ['review-analysis', 'review-plan', 'review-code'] as const) {
349
356
  const latest = latestArtifact(artifacts[family] ?? []);
350
357
  if (!latest) continue;
@@ -358,6 +365,12 @@ function buildLifecycleFacts(taskDir: string, content: string, taskState = 'acti
358
365
  && (artifacts[expectedFamily] ?? []).includes(input)
359
366
  && artifactHashes[input] === receipt.inputSha256
360
367
  ) reviewedInputs[family] = input;
368
+ if (family === 'review-code') {
369
+ const identity = inspectReviewIdentity(taskDir, reviewContent);
370
+ reviewCodeIdentityValid = identity.status === 'matched'
371
+ && hasArtifactCompletionFact(content, path.join(taskDir, latest), 'review-code.completed')
372
+ && hasArtifactCompletionLog(content, latest, 'review-code.completed');
373
+ }
361
374
  const parsed = parseReviewSummary(reviewContent);
362
375
  if (!parsed.ok) continue;
363
376
  const verdict = resolveCanonicalVerdict(parsed.summary);
@@ -423,6 +436,7 @@ function buildLifecycleFacts(taskDir: string, content: string, taskState = 'acti
423
436
  , pathState
424
437
  , reworkClassificationRequired
425
438
  , resolvedHumanDecisions
439
+ , reviewCodeIdentityValid
426
440
  };
427
441
  facts.recommendedAction = recommendNext(facts).action;
428
442
  return { ok: true, facts };
@@ -0,0 +1,66 @@
1
+ import fs from 'node:fs';
2
+
3
+ import { canonicalSemanticDigest } from './artifact-operations.ts';
4
+ import { inspectActivityLog } from './activity-log.ts';
5
+ import { parseTypedTaskFrontmatter } from './frontmatter.ts';
6
+ import { parseArtifactName } from './artifact-name.ts';
7
+ import { sha256File } from './artifact-receipts.ts';
8
+
9
+ export type CompletionFact = Readonly<{
10
+ event: string;
11
+ output: string;
12
+ outputSha256: string;
13
+ semanticDigest: string;
14
+ requestId: string;
15
+ result: string;
16
+ lifecycleInputs?: readonly Readonly<{ name: string; sha256: string }>[];
17
+ }>;
18
+
19
+ export function parseCompletionFacts(value: unknown): CompletionFact[] {
20
+ if (typeof value !== 'string') return [];
21
+ try {
22
+ const parsed: unknown = JSON.parse(value);
23
+ if (!Array.isArray(parsed)) return [];
24
+ return parsed.filter((fact): fact is CompletionFact => Boolean(
25
+ fact && typeof fact === 'object' && !Array.isArray(fact)
26
+ && typeof (fact as CompletionFact).event === 'string'
27
+ && typeof (fact as CompletionFact).output === 'string'
28
+ && /^[a-f0-9]{64}$/u.test((fact as CompletionFact).outputSha256)
29
+ && /^[a-f0-9]{64}$/u.test((fact as CompletionFact).semanticDigest)
30
+ && typeof (fact as CompletionFact).requestId === 'string'
31
+ && typeof (fact as CompletionFact).result === 'string'
32
+ ));
33
+ } catch {
34
+ return [];
35
+ }
36
+ }
37
+
38
+ export function hasArtifactCompletionFact(taskContent: string, artifactPath: string, event: string): boolean {
39
+ try {
40
+ const frontmatter = parseTypedTaskFrontmatter(taskContent);
41
+ const output = artifactPath.split(/[\\/]/u).at(-1) ?? '';
42
+ const content = fs.readFileSync(artifactPath, 'utf8');
43
+ const outputSha256 = sha256File(artifactPath);
44
+ const semanticDigest = canonicalSemanticDigest(content);
45
+ return parseCompletionFacts(frontmatter.completion_facts).some((fact) =>
46
+ fact.event === event && fact.output === output
47
+ && fact.outputSha256 === outputSha256 && fact.semanticDigest === semanticDigest
48
+ && fact.requestId.length > 0
49
+ );
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ export function hasArtifactCompletionLog(taskContent: string, artifact: string, event: string): boolean {
56
+ const identity = parseArtifactName(artifact);
57
+ if (!identity) return false;
58
+ const action = event === 'review-code.completed' ? 'Review Code'
59
+ : event === 'review-plan.completed' ? 'Review Plan'
60
+ : event === 'review-analysis.completed' ? 'Review Analysis' : null;
61
+ if (!action) return false;
62
+ const { section, invalidEntries } = inspectActivityLog(taskContent);
63
+ if (!section || invalidEntries.length > 0) return false;
64
+ const expected = `${action} (Round ${identity.round})`;
65
+ return section.entries.some((entry) => entry.step === expected && entry.note.includes(`→ ${artifact}`));
66
+ }
@@ -19,7 +19,7 @@ type TaskCreateError = { code: string; message: string; retryable: boolean };
19
19
  type TaskCreateResult = Readonly<{
20
20
  status: TaskCreateStatus;
21
21
  changed: boolean;
22
- task: { id: string | null; shortId: string | null };
22
+ task: { id: string | null; shortId: string | null; state: 'active' | 'blocked' | 'completed' | 'archive' | null };
23
23
  issue: { number: number; url: string } | null;
24
24
  operations: readonly TaskCreateOperation[];
25
25
  warnings: readonly TaskCreateWarning[];
@@ -87,9 +87,10 @@ export function parseTaskCreateResult(value: unknown): TaskCreateResult {
87
87
  if (!['applied', 'no-op', 'degraded', 'failed', 'blocked'].includes(result.status as string)
88
88
  || typeof result.changed !== 'boolean'
89
89
  || !result.task || typeof result.task !== 'object' || Array.isArray(result.task)
90
- || !exactKeys(result.task as Record<string, unknown>, ['id', 'shortId'])
90
+ || !exactKeys(result.task as Record<string, unknown>, ['id', 'shortId', 'state'])
91
91
  || !validTaskReference((result.task as { id?: unknown }).id)
92
92
  || !validTaskReference((result.task as { shortId?: unknown }).shortId)
93
+ || ![null, 'active', 'blocked', 'completed', 'archive'].includes((result.task as { state?: unknown }).state as string | null)
93
94
  || !Array.isArray(result.operations)
94
95
  || !result.operations.every((operation) => {
95
96
  if (!operation || typeof operation !== 'object' || Array.isArray(operation)) return false;
@@ -136,7 +137,7 @@ export function taskCreateFailure(
136
137
  return {
137
138
  status: error.retryable ? 'blocked' : 'failed',
138
139
  changed: false,
139
- task: { id: null, shortId: null },
140
+ task: { id: null, shortId: null, state: null },
140
141
  issue: null,
141
142
  operations: [],
142
143
  warnings: [],
@@ -206,6 +207,9 @@ async function createTask(value: unknown, options: CreateTaskOptions): Promise<T
206
207
  const operations: TaskCreateOperation[] = [{
207
208
  name: 'task:local', status: local.status, reasonCode: null
208
209
  }];
210
+ if (local.status === 'no-op' && local.task.state !== 'active') {
211
+ return { status: 'no-op', changed: false, task: local.task, issue: null, operations, warnings: [], error: null };
212
+ }
209
213
  let issue: TaskCreateResult['issue'] = null;
210
214
  const created = await dependencies.createIssue(local.task.id, { cwd: options.repoRoot, agent: candidate.agent });
211
215
  operations.push(...platformOperations('platform-create', created.operations));
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
 
5
6
  import { isValidAgentInfraVersion, VERSION } from '../version.ts';
@@ -8,6 +9,8 @@ import { TaskExecutionLockError, withTaskExecutionLock } from './task-execution-
8
9
  import { readDeliveryDefaults, validateBaseRef, validateRemote } from './delivery-target.ts';
9
10
  import { buildUnboundFact, encodePrDeliveryFact } from './pr-delivery-fact.ts';
10
11
  import { CANDIDATE_COLUMNS, CONSTRAINT_COLUMNS, parseTaskQualification } from './qualification-audit.ts';
12
+ import { enumerateAllTaskDirs } from './resolve-ref.ts';
13
+ import { parseTaskFrontmatter } from './frontmatter.ts';
11
14
 
12
15
  const AGENTS = ['claude', 'codex', 'antigravity', 'opencode', 'cursor'] as const;
13
16
  const TYPES = ['feature', 'bugfix', 'refactor', 'docs', 'chore'] as const;
@@ -41,7 +44,7 @@ type TaskCreateCandidateV1 = Readonly<{
41
44
  type LocalTaskCreateResult = Readonly<{
42
45
  status: 'applied' | 'no-op';
43
46
  changed: boolean;
44
- task: { id: string; shortId: string };
47
+ task: { id: string; shortId: string | null; state: 'active' | 'blocked' | 'completed' | 'archive' };
45
48
  }>;
46
49
 
47
50
  type LocalTaskCreateOptions = Readonly<{
@@ -345,16 +348,32 @@ function ensureRealDirectory(directory: string, code: string): void {
345
348
  fs.mkdirSync(directory, { mode: 0o700 });
346
349
  }
347
350
 
348
- function withCreateLock<T>(repoRoot: string, workspaceRoot: string, operation: () => T): T {
349
- const lockRoot = path.join(workspaceRoot, '.task-create.lock');
350
- fs.mkdirSync(lockRoot, { recursive: true, mode: 0o700 });
351
- assertRealDirectory(lockRoot, 'TASK_CREATE_LOCK_FAILED');
351
+ function createLockRoot(project: string): string {
352
+ const root = path.join(os.homedir(), '.agent-infra');
353
+ const directories = [root, path.join(root, 'run'), path.join(root, 'run', project), path.join(root, 'run', project, 'task-create-locks')];
354
+ for (const directory of directories) {
355
+ if (fs.existsSync(directory)) {
356
+ assertRealDirectory(directory, 'TASK_CREATE_LOCK_FAILED');
357
+ continue;
358
+ }
359
+ try {
360
+ fs.mkdirSync(directory, { mode: 0o700 });
361
+ } catch (error) {
362
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
363
+ assertRealDirectory(directory, 'TASK_CREATE_LOCK_FAILED');
364
+ }
365
+ }
366
+ return directories.at(-1)!;
367
+ }
368
+
369
+ function withCreateLock<T>(repoRoot: string, project: string, operation: () => T): T {
370
+ const lockRoot = createLockRoot(project);
352
371
  let callbackError: unknown;
353
372
  try {
354
373
  return withTaskExecutionLock(repoRoot, 'task-create', 'task-create', () => {
355
374
  try { return operation(); }
356
375
  catch (error) { callbackError = error; throw error; }
357
- }, { lockRoot });
376
+ }, { lockRoot, identityScope: `task-create\0${project}` });
358
377
  } catch (error) {
359
378
  if (callbackError !== undefined) throw callbackError;
360
379
  if (error instanceof TaskExecutionLockError && error.code === 'ORCHESTRATION_LOCK_BUSY') {
@@ -365,35 +384,21 @@ function withCreateLock<T>(repoRoot: string, workspaceRoot: string, operation: (
365
384
  }
366
385
  }
367
386
 
368
- function writeReceipt(receiptPath: string, value: unknown): void {
369
- const temporary = `${receiptPath}.tmp.${process.pid}.${randomUUID()}`;
370
- fs.writeFileSync(temporary, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 });
371
- try { fs.renameSync(temporary, receiptPath); }
372
- catch (error) {
373
- try { fs.unlinkSync(temporary); } catch { /* best effort */ }
374
- throw error;
375
- }
376
- }
377
-
378
- function recoverPublishedTask(activeRoot: string, repoRoot: string, keyDigest: string, candidateDigest: string): { taskId: string; shortId: string } | null {
379
- for (const name of fs.readdirSync(activeRoot)) {
380
- if (!/^TASK-\d{8}-\d{6}$/.test(name)) continue;
381
- const taskDir = path.join(activeRoot, name);
382
- const stat = fs.lstatSync(taskDir);
383
- if (!stat.isDirectory() || stat.isSymbolicLink()) continue;
384
- const taskMd = path.join(taskDir, 'task.md');
385
- if (!fs.existsSync(taskMd)) continue;
387
+ function recoverPublishedTask(repoRoot: string, keyDigest: string, candidateDigest: string): { taskId: string; shortId: string | null; state: 'active' | 'blocked' | 'completed' | 'archive' } | null {
388
+ for (const task of enumerateAllTaskDirs(repoRoot)) {
389
+ const taskMd = path.join(task.taskDir, 'task.md');
386
390
  const taskStat = fs.lstatSync(taskMd);
387
- if (!taskStat.isFile() || taskStat.isSymbolicLink()) continue;
388
- const content = fs.readFileSync(taskMd, 'utf8');
389
- if (!content.includes(`task_create_key_digest: ${keyDigest}`)) continue;
390
- if (!content.includes(`task_create_candidate_digest: ${candidateDigest}`)) {
391
+ if (!taskStat.isFile() || taskStat.isSymbolicLink()) throw new Error('TASK_CREATE_TASK_INVALID');
392
+ const frontmatter = parseTaskFrontmatter(fs.readFileSync(taskMd, 'utf8'));
393
+ if (frontmatter.task_create_key_digest !== keyDigest) continue;
394
+ if (frontmatter.task_create_candidate_digest !== candidateDigest) {
391
395
  throw new Error('TASK_CREATE_IDEMPOTENCY_CONFLICT');
392
396
  }
393
- const shortId = loadShortIdByTaskId(repoRoot).get(name)
394
- ?? mutateShortIdRegistry(repoRoot, name, 'alloc').shortId;
395
- if (!shortId) throw new Error('TASK_CREATE_RECEIPT_INVALID');
396
- return { taskId: name, shortId };
397
+ const shortId = task.state === 'active'
398
+ ? loadShortIdByTaskId(repoRoot).get(task.taskId) ?? mutateShortIdRegistry(repoRoot, task.taskId, 'alloc').shortId
399
+ : null;
400
+ if (task.state === 'active' && !shortId) throw new Error('TASK_CREATE_SHORT_ID_FAILED');
401
+ return { taskId: task.taskId, shortId, state: task.state };
397
402
  }
398
403
  return null;
399
404
  }
@@ -414,29 +419,10 @@ function createLocalTask(value: unknown, options: LocalTaskCreateOptions): Local
414
419
  const canonical = canonicalTaskCreateCandidate(candidate);
415
420
  const candidateDigest = sha256(canonical);
416
421
  const keyDigest = sha256(candidate.idempotencyKey);
417
- const receipts = path.join(workspaceRoot, '.task-create');
418
-
419
- return withCreateLock(repoRoot, workspaceRoot, () => {
420
- if (fs.existsSync(receipts)) assertRealDirectory(receipts, 'TASK_CREATE_RECEIPT_INVALID');
421
- const receiptPath = path.join(receipts, `${keyDigest}.json`);
422
- if (fs.existsSync(receiptPath)) {
423
- const stat = fs.lstatSync(receiptPath);
424
- if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('TASK_CREATE_RECEIPT_INVALID');
425
- const receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8')) as { candidateDigest?: string; taskId?: string; shortId?: string };
426
- if (receipt.candidateDigest !== candidateDigest) throw new Error('TASK_CREATE_IDEMPOTENCY_CONFLICT');
427
- if (!receipt.taskId || !receipt.shortId || !fs.existsSync(path.join(activeRoot, receipt.taskId, 'task.md'))) {
428
- throw new Error('TASK_CREATE_RECEIPT_INVALID');
429
- }
430
- return { status: 'no-op', changed: false, task: { id: receipt.taskId, shortId: receipt.shortId } };
431
- }
432
-
433
- const recovered = recoverPublishedTask(activeRoot, repoRoot, keyDigest, candidateDigest);
422
+ return withCreateLock(repoRoot, projectConfig.project, () => {
423
+ const recovered = recoverPublishedTask(repoRoot, keyDigest, candidateDigest);
434
424
  if (recovered) {
435
- ensureRealDirectory(receipts, 'TASK_CREATE_RECEIPT_INVALID');
436
- writeReceipt(receiptPath, {
437
- version: 1, candidateDigest, taskId: recovered.taskId, shortId: recovered.shortId, status: 'recovered'
438
- });
439
- return { status: 'no-op', changed: false, task: { id: recovered.taskId, shortId: recovered.shortId } };
425
+ return { status: 'no-op', changed: false, task: { id: recovered.taskId, shortId: recovered.shortId, state: recovered.state } };
440
426
  }
441
427
 
442
428
  const base = options.now?.() ?? new Date();
@@ -455,29 +441,16 @@ function createLocalTask(value: unknown, options: LocalTaskCreateOptions): Local
455
441
  agentInfraVersion, keyDigest, candidateDigest
456
442
  });
457
443
  validateRenderedQualification(rendered, candidate);
458
- ensureRealDirectory(receipts, 'TASK_CREATE_RECEIPT_INVALID');
459
444
  const temporary = path.join(workspaceRoot, `.task-create.tmp.${process.pid}.${randomUUID()}`);
460
445
  fs.mkdirSync(temporary, { mode: 0o700 });
461
446
  fs.writeFileSync(path.join(temporary, 'task.md'), rendered, { flag: 'wx', mode: 0o600 });
462
447
  const destination = path.join(activeRoot, taskId);
463
448
  fs.renameSync(temporary, destination);
464
449
  let shortId: string;
465
- try {
466
- const allocated = mutateShortIdRegistry(repoRoot, taskId, 'alloc');
467
- if (!allocated.shortId) throw new Error('TASK_CREATE_SHORT_ID_FAILED');
468
- shortId = allocated.shortId;
469
- } catch (error) {
470
- fs.rmSync(destination, { recursive: true, force: true });
471
- throw error;
472
- }
473
- try {
474
- writeReceipt(receiptPath, { version: 1, candidateDigest, taskId, shortId, status: 'created' });
475
- } catch (error) {
476
- mutateShortIdRegistry(repoRoot, taskId, 'release');
477
- fs.rmSync(destination, { recursive: true, force: true });
478
- throw error;
479
- }
480
- return { status: 'applied', changed: true, task: { id: taskId, shortId } };
450
+ const allocated = mutateShortIdRegistry(repoRoot, taskId, 'alloc');
451
+ if (!allocated.shortId) throw new Error('TASK_CREATE_SHORT_ID_FAILED');
452
+ shortId = allocated.shortId;
453
+ return { status: 'applied', changed: true, task: { id: taskId, shortId, state: 'active' } };
481
454
  });
482
455
  }
483
456