@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
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { parseArtifactName } from './artifact-name.ts';
2
3
  import { findSectionRange, parseTable } from './sections.ts';
3
4
  import { scanVisibleMarkdown } from './markdown.ts';
4
5
 
@@ -8,6 +9,10 @@ const OPERATION_COLUMNS = [
8
9
  'status', 'processed', 'total', 'created_at', 'updated_at', 'completed_at', 'error'
9
10
  ] as const;
10
11
  const TARGET_COLUMNS = [
12
+ 'target_id', 'operation_id', 'target_kind', 'target_family', 'target_artifact', 'target_input',
13
+ 'target_round', 'target_sha256', 'status', 'reason_code', 'updated_at'
14
+ ] as const;
15
+ const LEGACY_TARGET_COLUMNS = [
11
16
  'target_id', 'operation_id', 'target_kind', 'target_family', 'target_artifact',
12
17
  'target_round', 'target_sha256', 'status', 'reason_code', 'updated_at'
13
18
  ] as const;
@@ -34,6 +39,7 @@ type InvalidationTarget = {
34
39
  targetKind: InvalidationTargetKind;
35
40
  targetFamily: string;
36
41
  targetArtifact: string;
42
+ targetInput?: string;
37
43
  targetRound: number;
38
44
  targetSha256: string;
39
45
  status: InvalidationStatus;
@@ -69,8 +75,8 @@ function operationIdFor(source: Pick<InvalidationOperation, 'sourceFamily' | 'so
69
75
  return `INV-${hashIdentity([source.sourceFamily, source.sourceArtifact, source.sourceRound, source.sourceSha256])}`;
70
76
  }
71
77
 
72
- function targetIdFor(operationId: string, target: Pick<InvalidationTarget, 'targetKind' | 'targetFamily' | 'targetArtifact' | 'targetRound' | 'targetSha256'>): string {
73
- return `INV-T-${hashIdentity([operationId, target.targetKind, target.targetFamily, target.targetArtifact, target.targetRound, target.targetSha256])}`;
78
+ function targetIdFor(operationId: string, target: Pick<InvalidationTarget, 'targetKind' | 'targetFamily' | 'targetArtifact' | 'targetInput' | 'targetRound' | 'targetSha256'>): string {
79
+ return `INV-T-${hashIdentity([operationId, target.targetKind, target.targetFamily, target.targetArtifact, ...(target.targetKind === 'receipt' && target.targetInput ? [target.targetInput] : []), target.targetRound, target.targetSha256])}`;
74
80
  }
75
81
 
76
82
  function createInvalidationOperation(
@@ -86,14 +92,27 @@ function createInvalidationOperation(
86
92
  };
87
93
  }
88
94
 
89
- function parseInvalidationTable(body: string, heading: string, columns: readonly string[]) {
95
+ function parseInvalidationTable(body: string, heading: string, columnChoices: readonly (readonly string[])[]) {
90
96
  const section = findSectionRange(body, [heading], 3);
91
97
  const lines = scanVisibleMarkdown(body).lines.filter((line) => line.text.trim());
92
- const table = parseTable(lines.map((line) => line.text).join('\n'), { sectionAliases: [heading], columns });
98
+ let table: ReturnType<typeof parseTable> = null;
99
+ let legacy = false;
100
+ for (const columns of columnChoices) {
101
+ try {
102
+ const candidate = parseTable(lines.map((line) => line.text).join('\n'), { sectionAliases: [heading], columns });
103
+ if (candidate) {
104
+ table = candidate;
105
+ legacy = columns === LEGACY_TARGET_COLUMNS;
106
+ break;
107
+ }
108
+ } catch {
109
+ // Try the next explicitly supported schema.
110
+ }
111
+ }
93
112
  if (!section || !table || lines.filter((line) => line.start >= section.bodyStart && line.start < section.end).length !== table.rows.length + 2) {
94
113
  throw new Error(`invalidation ${heading} section must contain only its canonical table`);
95
114
  }
96
- return table.rows;
115
+ return { rows: table.rows, legacy };
97
116
  }
98
117
 
99
118
  function required(value: string, field: string): string {
@@ -117,9 +136,10 @@ function parseInvalidationDocument(content: string): InvalidationParseResult {
117
136
  if (section === null) return { ok: true, present: false, document: { operations: [], targets: [] } };
118
137
  try {
119
138
  const body = content.slice(section.bodyStart, section.end);
120
- const operationRows = parseInvalidationTable(body, 'Operations', OPERATION_COLUMNS);
121
- const targetRows = parseInvalidationTable(body, 'Targets', TARGET_COLUMNS);
122
- const operations = operationRows.map(({ values: row }) => ({
139
+ const operationRows = parseInvalidationTable(body, 'Operations', [OPERATION_COLUMNS]);
140
+ // TODO(compat): Remove legacy target table parsing and completed receipt targets without target_input once a workspace inventory confirms all persisted targets have input identities.
141
+ const targetRows = parseInvalidationTable(body, 'Targets', [TARGET_COLUMNS, LEGACY_TARGET_COLUMNS]);
142
+ const operations = operationRows.rows.map(({ values: row }) => ({
123
143
  operationId: required(row.operation_id!, 'operation_id'), sourceFamily: required(row.source_family!, 'source_family'),
124
144
  sourceArtifact: required(row.source_artifact!, 'source_artifact'), sourceRound: numberValue(row.source_round!, 'source_round', false),
125
145
  sourceSha256: required(row.source_sha256!, 'source_sha256'), status: status(row.status!, 'operation status'),
@@ -127,10 +147,11 @@ function parseInvalidationDocument(content: string): InvalidationParseResult {
127
147
  createdAt: required(row.created_at!, 'created_at'), updatedAt: required(row.updated_at!, 'updated_at'),
128
148
  completedAt: row.completed_at ?? '', error: row.error ?? ''
129
149
  } satisfies InvalidationOperation));
130
- const targets = targetRows.map(({ values: row }) => ({
150
+ const targets = targetRows.rows.map(({ values: row }) => ({
131
151
  targetId: required(row.target_id!, 'target_id'), operationId: required(row.operation_id!, 'operation_id'),
132
152
  targetKind: row.target_kind as InvalidationTargetKind, targetFamily: required(row.target_family!, 'target_family'),
133
153
  targetArtifact: required(row.target_artifact!, 'target_artifact'), targetRound: numberValue(row.target_round!, 'target_round', false),
154
+ ...(row.target_input ? { targetInput: row.target_input } : {}),
134
155
  targetSha256: required(row.target_sha256!, 'target_sha256'), status: status(row.status!, 'target status'),
135
156
  reasonCode: required(row.reason_code!, 'reason_code'), updatedAt: required(row.updated_at!, 'updated_at')
136
157
  } satisfies InvalidationTarget));
@@ -148,6 +169,10 @@ function parseInvalidationDocument(content: string): InvalidationParseResult {
148
169
  if (targetIds.has(target.targetId)) throw new Error(`duplicate target '${target.targetId}'`);
149
170
  targetIds.add(target.targetId);
150
171
  if (!operationIds.has(target.operationId)) throw new Error(`target '${target.targetId}' references an unknown operation`);
172
+ if (target.targetKind === 'receipt') {
173
+ if (!target.targetInput && target.status !== 'completed') throw new Error(`receipt target '${target.targetId}' input identity is required`);
174
+ if (target.targetInput && !parseArtifactName(target.targetInput)) throw new Error(`receipt target '${target.targetId}' input identity is invalid`);
175
+ } else if (target.targetInput) throw new Error(`non-receipt target '${target.targetId}' cannot have an input identity`);
151
176
  if (target.targetId !== targetIdFor(target.operationId, target)) throw new Error(`target '${target.targetId}' has a non-canonical identity`);
152
177
  if (!/^[a-f0-9]{64}$/.test(target.targetSha256)) throw new Error(`target '${target.targetId}' hash is invalid`);
153
178
  }
@@ -187,7 +212,7 @@ function renderInvalidation(document: InvalidationDocument): string {
187
212
  '### Targets', '',
188
213
  table(TARGET_COLUMNS, targets.map((row) => [
189
214
  row.targetId, row.operationId, row.targetKind, row.targetFamily, row.targetArtifact,
190
- row.targetRound, row.targetSha256, row.status, row.reasonCode, row.updatedAt
215
+ row.targetInput ?? '', row.targetRound, row.targetSha256, row.status, row.reasonCode, row.updatedAt
191
216
  ]))
192
217
  ].join('\n');
193
218
  }
@@ -7,6 +7,7 @@ import { parseLifecyclePathDecision } from './lifecycle-path.ts';
7
7
  import { resolveTaskRef } from './resolve-ref.ts';
8
8
  import { canonicalSemanticDigest, inspectArtifactContract, sha256Content } from './artifact-operations.ts';
9
9
  import { getArtifactSchema } from './artifact-schema.ts';
10
+ import { validateQualificationAudit } from './qualification-audit.ts';
10
11
 
11
12
  type LocalArtifactFamily = 'analysis' | 'plan' | 'code';
12
13
 
@@ -155,20 +156,29 @@ function prepareLocalArtifact(
155
156
  const validation = validateLocalArtifact(content, {
156
157
  family: request.family, requiredSections: request.requiredSections
157
158
  });
159
+ let qualificationError: string | null = null;
160
+ try {
161
+ const taskContent = fs.readFileSync(resolved.taskMdPath, 'utf8');
162
+ const qualification = validateQualificationAudit(taskContent, content, { family: request.family, artifact: request.artifact });
163
+ if (!qualification.ok) qualificationError = `${qualification.code}: ${qualification.message}`;
164
+ } catch (error) { qualificationError = `QUALIFICATION_AUDIT_INVALID: ${String(error)}`; }
158
165
  const artifactSha256 = sha256Content(content);
166
+ const diagnostics = qualificationError
167
+ ? [...validation.diagnostics, { code: 'LOCAL_STRUCTURAL_INVALID' as const, message: qualificationError, line: null }]
168
+ : validation.diagnostics;
159
169
  const result = failedFinalization(request, {
160
170
  code: 'LOCAL_ARTIFACT_INVALID',
161
- message: validation.diagnostics.map((item) => `${item.code}: ${item.message}`).join('; ')
171
+ message: diagnostics.map((item) => `${item.code}: ${item.message}`).join('; ')
162
172
  }, {
163
173
  taskId: resolved.taskId, taskDir: resolved.taskDir, artifactSha256,
164
- semanticDigest: validation.semanticDigest, diagnostics: validation.diagnostics
174
+ semanticDigest: validation.semanticDigest, diagnostics
165
175
  });
166
176
 
167
177
  // Trusted local operators edit the canonical artifact directly. A
168
178
  // finalization result is a fresh observation of those bytes, never a
169
179
  // capability minted by an earlier recovery attempt or generation.
170
180
  return {
171
- result: validation.ok ? { ...result, status: 'passed', error: null } : result,
181
+ result: validation.ok && !qualificationError ? { ...result, status: 'passed', error: null } : result,
172
182
  content,
173
183
  repoRoot: resolved.repoRoot,
174
184
  lockAlreadyHeld: request.lockAlreadyHeld
@@ -1,7 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
 
3
3
  import { parseArtifactName } from './artifact-name.ts';
4
- import { parseTypedTaskFrontmatter } from './frontmatter.ts';
5
4
  import { findSectionRange, parseTable } from './sections.ts';
6
5
  import { scanVisibleMarkdown } from './markdown.ts';
7
6
 
@@ -9,13 +8,12 @@ const TASK_CONSTRAINT_HEADINGS = ['约束', 'Constraints'] as const;
9
8
  const TASK_CANDIDATE_HEADINGS = ['候选与否决方案', 'Candidate and Rejected Options', 'Candidates and Rejected Options', 'Candidates and Rejected Alternatives'] as const;
10
9
  const AUDIT_HEADINGS = ['资格审计', '资格审计复核', 'Qualification Audit', 'Qualification Audit Review'] as const;
11
10
  const CONFIRMATION_HEADINGS = ['资格确认记录', 'Qualification Confirmations'] as const;
12
- const AUDIT_SUBSECTIONS = ['约束依赖', '候选资格', '分类结果', '上游关系', '依赖快照'] as const;
11
+ const AUDIT_SUBSECTIONS = ['约束依赖', '候选资格', '分类结果', '资格快照'] as const;
13
12
  const AUDIT_SUBSECTION_HEADINGS: Record<(typeof AUDIT_SUBSECTIONS)[number], readonly string[]> = {
14
13
  '约束依赖': ['约束依赖', 'Constraint Dependencies'],
15
14
  '候选资格': ['候选资格', 'Candidate Qualification'],
16
15
  '分类结果': ['分类结果', 'Classification Results'],
17
- '上游关系': ['上游关系', 'Upstream Relations'],
18
- '依赖快照': ['依赖快照', 'Dependency Snapshot']
16
+ '资格快照': ['资格快照', 'Qualification Snapshot']
19
17
  };
20
18
 
21
19
  const CONSTRAINT_COLUMNS = ['constraint_id', 'statement', 'status', 'authority', 'source', 'evidence', 'derived_from', 'approval_evidence'] as const;
@@ -23,12 +21,10 @@ const CANDIDATE_COLUMNS = ['candidate_id', 'statement', 'status', 'constraint_id
23
21
  const DEPENDENCY_COLUMNS = ['constraint_id', 'constraint_digest', 'role', 'evidence'] as const;
24
22
  const QUALIFICATION_COLUMNS = ['candidate_id', 'status', 'impact', 'constraint_ids', 'evidence'] as const;
25
23
  const CLASSIFICATION_COLUMNS = ['decision_id', 'classification', 'evidence'] as const;
26
- const RELATION_COLUMNS = ['upstream_family', 'upstream_artifact', 'upstream_round', 'upstream_sha256', 'relation'] as const;
27
- const SNAPSHOT_COLUMNS = ['task_input_digest', 'non_constraint_input_digest', 'upstream_artifact_digest'] as const;
24
+ const SNAPSHOT_COLUMNS = ['task_input_digest', 'non_constraint_input_digest'] as const;
28
25
  const CONFIRMATION_COLUMNS = ['qcr_id', 'constraint_id', 'actor', 'entrypoint', 'request_id', 'approved_digest', 'confirmed_at', 'rationale'] as const;
29
26
 
30
27
  const ARTIFACT_FAMILIES = ['analysis', 'review-analysis', 'plan', 'review-plan', 'code', 'review-code'] as const;
31
- const RELATIONS = ['required-input', 'reviewed-input', 'review-context', 'approval-context'] as const;
32
28
  const CONSTRAINT_STATUSES = ['confirmed', 'derived', 'assumption', 'open', 'conflicted', 'superseded'] as const;
33
29
  const CANDIDATE_STATUSES = ['qualified', 'rejected', 'pending', 'needs-human-decision', 'confirmed', 'excluded'] as const;
34
30
  const CLASSIFICATIONS = ['deterministic', 'qualified', 'rejected', 'needs-human-decision', 'not-applicable'] as const;
@@ -38,7 +34,6 @@ type ConstraintStatus = (typeof CONSTRAINT_STATUSES)[number];
38
34
  type CandidateStatus = (typeof CANDIDATE_STATUSES)[number];
39
35
  type QualificationClassification = (typeof CLASSIFICATIONS)[number];
40
36
  type ArtifactFamily = (typeof ARTIFACT_FAMILIES)[number];
41
- type QualificationRelation = (typeof RELATIONS)[number];
42
37
  type QualificationRole = (typeof ROLES)[number];
43
38
 
44
39
  type Constraint = {
@@ -86,24 +81,15 @@ type QualificationClassificationRow = {
86
81
  classification: QualificationClassification;
87
82
  evidence: string;
88
83
  };
89
- type UpstreamRelation = {
90
- upstreamFamily: ArtifactFamily;
91
- upstreamArtifact: string;
92
- upstreamRound: number;
93
- upstreamSha256: string;
94
- relation: QualificationRelation;
95
- };
96
84
  type DependencySnapshot = {
97
85
  taskInputDigest: string;
98
86
  nonConstraintInputDigest: string;
99
- upstreamArtifactDigest: string;
100
87
  };
101
88
  type QualificationAudit = {
102
89
  present: boolean;
103
90
  constraintDependencies: readonly ConstraintDependency[];
104
91
  candidateQualifications: readonly CandidateQualification[];
105
92
  classifications: readonly QualificationClassificationRow[];
106
- upstreamRelations: readonly UpstreamRelation[];
107
93
  snapshot: DependencySnapshot | null;
108
94
  };
109
95
  type QualificationConfirmation = {
@@ -304,80 +290,19 @@ function parseAuditTable(content: string, heading: (typeof AUDIT_SUBSECTIONS)[nu
304
290
  return table?.rows.map((row) => ({ ...row.values })) ?? null;
305
291
  }
306
292
 
307
- function parseAuditArtifactName(value: string): { family: ArtifactFamily; round: number } | null {
308
- const identity = parseArtifactName(value);
309
- return identity && ARTIFACT_FAMILIES.includes(identity.family as ArtifactFamily)
310
- ? { family: identity.family as ArtifactFamily, round: identity.round } : null;
311
- }
312
-
313
- function upstreamArtifactDigest(rows: readonly UpstreamRelation[]): string {
314
- return digest([...rows].map((row) => ({ ...row })).sort((a, b) =>
315
- `${a.upstreamFamily}/${a.upstreamArtifact}/${a.relation}`.localeCompare(`${b.upstreamFamily}/${b.upstreamArtifact}/${b.relation}`)));
316
- }
317
-
318
- function validateUpstreamRelation(row: UpstreamRelation): void {
319
- const identity = parseAuditArtifactName(row.upstreamArtifact);
320
- if (!ARTIFACT_FAMILIES.includes(row.upstreamFamily) || !identity || identity.family !== row.upstreamFamily
321
- || identity.round !== row.upstreamRound || !Number.isSafeInteger(row.upstreamRound)
322
- || !/^[a-f0-9]{64}$/i.test(row.upstreamSha256) || !RELATIONS.includes(row.relation)) {
323
- throw new Error(`invalid qualification upstream relation '${row.upstreamArtifact}'`);
324
- }
325
- }
326
-
327
- function expectedQualificationRelations(
328
- taskContent: string,
329
- family: ArtifactFamily
330
- ): { ok: true; relations: readonly UpstreamRelation[] | undefined } | { ok: false; code: string; message: string } {
331
- let frontmatter;
332
- try { frontmatter = parseTypedTaskFrontmatter(taskContent); }
333
- catch (error) { return { ok: false, code: 'QUALIFICATION_STARTED_INPUT_INVALID', message: error instanceof Error ? error.message : String(error) }; }
334
- const encoded = frontmatter.qualification_input_relations;
335
- if (typeof encoded === 'string' && encoded) {
336
- try {
337
- const parsed = JSON.parse(encoded) as unknown;
338
- if (!Array.isArray(parsed)) throw new Error('qualification_input_relations must be an array');
339
- const relations = parsed.map((value) => {
340
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('qualification input relation must be an object');
341
- const row = value as Record<string, unknown>;
342
- const relation: UpstreamRelation = {
343
- upstreamFamily: row.upstreamFamily as ArtifactFamily,
344
- upstreamArtifact: String(row.upstreamArtifact ?? ''),
345
- upstreamRound: Number(row.upstreamRound),
346
- upstreamSha256: String(row.upstreamSha256 ?? ''),
347
- relation: row.relation as QualificationRelation
348
- };
349
- validateUpstreamRelation(relation);
350
- return relation;
351
- });
352
- return { ok: true, relations };
353
- } catch (error) {
354
- return { ok: false, code: 'QUALIFICATION_STARTED_INPUT_INVALID', message: error instanceof Error ? error.message : String(error) };
355
- }
356
- }
357
- const inputName = family === 'code' ? frontmatter.code_input_artifact : family.startsWith('review-') ? frontmatter.review_input_artifact : '';
358
- const inputSha256 = family === 'code' ? frontmatter.code_input_sha256 : family.startsWith('review-') ? frontmatter.review_input_sha256 : '';
359
- if (typeof inputName !== 'string' || !inputName || typeof inputSha256 !== 'string' || !inputSha256) return { ok: true, relations: undefined };
360
- const identity = parseAuditArtifactName(inputName);
361
- if (!identity) return { ok: false, code: 'QUALIFICATION_STARTED_INPUT_INVALID', message: `started input '${inputName}' is not canonical` };
362
- const relation: UpstreamRelation = {
363
- upstreamFamily: identity.family, upstreamArtifact: inputName, upstreamRound: identity.round,
364
- upstreamSha256: inputSha256, relation: family === 'code' ? 'required-input' : 'reviewed-input'
365
- };
366
- try { validateUpstreamRelation(relation); }
367
- catch (error) { return { ok: false, code: 'QUALIFICATION_STARTED_INPUT_INVALID', message: error instanceof Error ? error.message : String(error) }; }
368
- return { ok: true, relations: [relation] };
369
- }
370
-
371
293
  function parseQualificationAudit(content: string): { ok: true; audit: QualificationAudit } | { ok: false; code: string; message: string } {
372
- if (sectionBody(content, AUDIT_HEADINGS) === null) return { ok: true, audit: { present: false, constraintDependencies: [], candidateQualifications: [], classifications: [], upstreamRelations: [], snapshot: null } };
294
+ const auditBody = sectionBody(content, AUDIT_HEADINGS);
295
+ if (auditBody === null) return { ok: true, audit: { present: false, constraintDependencies: [], candidateQualifications: [], classifications: [], snapshot: null } };
373
296
  try {
297
+ if (sectionBody(auditBody, ['上游关系', 'Upstream Relations'], 3) !== null) {
298
+ throw new Error('qualification audit uses the obsolete lifecycle-relation format; regenerate it using the current format');
299
+ }
374
300
  const dependencies = parseAuditTable(content, '约束依赖', DEPENDENCY_COLUMNS);
375
301
  const qualifications = parseAuditTable(content, '候选资格', QUALIFICATION_COLUMNS);
376
302
  const classifications = parseAuditTable(content, '分类结果', CLASSIFICATION_COLUMNS);
377
- const relations = parseAuditTable(content, '上游关系', RELATION_COLUMNS);
378
- const snapshots = parseAuditTable(content, '依赖快照', SNAPSHOT_COLUMNS);
379
- if (!dependencies || !qualifications || !classifications || !relations || !snapshots || snapshots.length !== 1) {
380
- throw new Error('qualification audit requires all five canonical tables and exactly one dependency snapshot');
303
+ const snapshots = parseAuditTable(content, '资格快照', SNAPSHOT_COLUMNS);
304
+ if (!dependencies || !qualifications || !classifications || !snapshots || snapshots.length !== 1) {
305
+ throw new Error('qualification audit requires three decision tables and exactly one qualification snapshot');
381
306
  }
382
307
  const constraintDependencies = dependencies.map((row) => {
383
308
  if (!/^C-[1-9]\d*$/.test(row.constraint_id ?? '') || !/^[a-f0-9]{64}$/i.test(row.constraint_digest ?? '') || !ROLES.includes(row.role as QualificationRole) || !row.evidence?.trim()) throw new Error(`invalid qualification constraint dependency '${row.constraint_id ?? ''}'`);
@@ -392,19 +317,14 @@ function parseQualificationAudit(content: string): { ok: true; audit: Qualificat
392
317
  if (!row.decision_id?.trim() || !CLASSIFICATIONS.includes(row.classification as QualificationClassification) || !row.evidence?.trim()) throw new Error(`invalid qualification classification '${row.decision_id ?? ''}'`);
393
318
  return { decisionId: row.decision_id, classification: row.classification as QualificationClassification, evidence: row.evidence };
394
319
  });
395
- const upstreamRelations = relations.map((row) => {
396
- const relation = { upstreamFamily: row.upstream_family as ArtifactFamily, upstreamArtifact: row.upstream_artifact ?? '', upstreamRound: Number(row.upstream_round), upstreamSha256: row.upstream_sha256 ?? '', relation: row.relation as QualificationRelation };
397
- validateUpstreamRelation(relation);
398
- return relation;
399
- });
400
320
  const snapshotRow = snapshots[0]!;
401
- if (!/^[a-f0-9]{64}$/i.test(snapshotRow.task_input_digest ?? '') || !/^[a-f0-9]{64}$/i.test(snapshotRow.non_constraint_input_digest ?? '') || !/^[a-f0-9]{64}$/i.test(snapshotRow.upstream_artifact_digest ?? '')) throw new Error('qualification dependency snapshot has invalid digest');
321
+ if (!/^[a-f0-9]{64}$/i.test(snapshotRow.task_input_digest ?? '') || !/^[a-f0-9]{64}$/i.test(snapshotRow.non_constraint_input_digest ?? '')) throw new Error('qualification snapshot has invalid digest');
402
322
  return {
403
323
  ok: true,
404
324
  audit: {
405
325
  present: true, constraintDependencies, candidateQualifications,
406
- classifications: classificationRows, upstreamRelations,
407
- snapshot: { taskInputDigest: snapshotRow.task_input_digest!, nonConstraintInputDigest: snapshotRow.non_constraint_input_digest!, upstreamArtifactDigest: snapshotRow.upstream_artifact_digest! }
326
+ classifications: classificationRows,
327
+ snapshot: { taskInputDigest: snapshotRow.task_input_digest!, nonConstraintInputDigest: snapshotRow.non_constraint_input_digest! }
408
328
  }
409
329
  };
410
330
  } catch (error) {
@@ -443,14 +363,13 @@ function validateQualificationAudit(
443
363
  family?: ArtifactFamily;
444
364
  artifact?: string;
445
365
  require?: boolean;
446
- expectedUpstreamRelations?: readonly UpstreamRelation[];
447
366
  } = {}
448
367
  ): QualificationValidationResult {
449
368
  const task = parseTaskQualification(taskContent);
450
369
  if (!task.ok) return task;
451
370
  const audit = parseQualificationAudit(artifactContent);
452
371
  if (!audit.ok) return audit;
453
- if (!task.qualification.present && !options.require) return { ok: true, qualification: task.qualification, audit: audit.audit };
372
+ if (!audit.audit.present && !options.require) return { ok: true, qualification: task.qualification, audit: audit.audit };
454
373
  if (!task.qualification.present) return { ok: false, code: 'QUALIFICATION_TASK_CONTRACT_MISSING', message: 'task qualification contract is missing' };
455
374
  if (!audit.audit.present) return { ok: false, code: 'QUALIFICATION_AUDIT_MISSING', message: 'artifact qualification audit is missing' };
456
375
  const constraintMap = new Map(task.qualification.constraints.map((row) => [row.constraintId, row]));
@@ -490,13 +409,8 @@ function validateQualificationAudit(
490
409
  const snapshot = audit.audit.snapshot!;
491
410
  if (snapshot.taskInputDigest !== task.qualification.taskInputDigest) return { ok: false, code: 'QUALIFICATION_TASK_DIGEST_MISMATCH', message: 'qualification audit task input digest is stale' };
492
411
  if (snapshot.nonConstraintInputDigest !== task.qualification.nonConstraintInputDigest) return { ok: false, code: 'QUALIFICATION_NON_CONSTRAINT_DIGEST_MISMATCH', message: 'qualification audit non-constraint input digest is stale' };
493
- if (snapshot.upstreamArtifactDigest !== upstreamArtifactDigest(audit.audit.upstreamRelations)) return { ok: false, code: 'QUALIFICATION_UPSTREAM_DIGEST_MISMATCH', message: 'qualification audit upstream digest does not match its relation rows' };
494
- if (options.expectedUpstreamRelations && (
495
- options.expectedUpstreamRelations.length !== audit.audit.upstreamRelations.length
496
- || upstreamArtifactDigest(options.expectedUpstreamRelations) !== upstreamArtifactDigest(audit.audit.upstreamRelations)
497
- )) return { ok: false, code: 'QUALIFICATION_UPSTREAM_RELATION_MISMATCH', message: 'qualification audit upstream relations do not match the started artifact inputs' };
498
412
  if (options.family && options.artifact) {
499
- const identity = parseAuditArtifactName(options.artifact);
413
+ const identity = parseArtifactName(options.artifact);
500
414
  if (!identity || identity.family !== options.family) return { ok: false, code: 'QUALIFICATION_ARTIFACT_IDENTITY_INVALID', message: `artifact '${options.artifact}' is not canonical for '${options.family}'` };
501
415
  }
502
416
  return { ok: true, qualification: task.qualification, audit: audit.audit };
@@ -509,19 +423,18 @@ function renderQualificationAudit(audit: QualificationAudit): string {
509
423
  ...rows.map((row) => `| ${row.map((value) => value.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/[\r\n]/g, ' ')).join(' | ')} |`)
510
424
  ].join('\n');
511
425
  const list = (values: readonly string[]) => values.join(',');
512
- const snapshot = audit.snapshot ?? { taskInputDigest: '', nonConstraintInputDigest: '', upstreamArtifactDigest: upstreamArtifactDigest(audit.upstreamRelations) };
426
+ const snapshot = audit.snapshot ?? { taskInputDigest: '', nonConstraintInputDigest: '' };
513
427
  return [
514
428
  '### 约束依赖', '', table(DEPENDENCY_COLUMNS, audit.constraintDependencies.map((row) => [row.constraintId, row.constraintDigest, row.role, row.evidence])), '',
515
429
  '### 候选资格', '', table(QUALIFICATION_COLUMNS, audit.candidateQualifications.map((row) => [row.candidateId, row.status, row.impact, list(row.constraintIds), row.evidence])), '',
516
430
  '### 分类结果', '', table(CLASSIFICATION_COLUMNS, audit.classifications.map((row) => [row.decisionId, row.classification, row.evidence])), '',
517
- '### 上游关系', '', table(RELATION_COLUMNS, audit.upstreamRelations.map((row) => [row.upstreamFamily, row.upstreamArtifact, String(row.upstreamRound), row.upstreamSha256, row.relation])), '',
518
- '### 依赖快照', '', table(SNAPSHOT_COLUMNS, [[snapshot.taskInputDigest, snapshot.nonConstraintInputDigest, snapshot.upstreamArtifactDigest]])
431
+ '### 资格快照', '', table(SNAPSHOT_COLUMNS, [[snapshot.taskInputDigest, snapshot.nonConstraintInputDigest]])
519
432
  ].join('\n');
520
433
  }
521
434
 
522
435
  function buildQualificationAudit(
523
436
  taskContent: string,
524
- input: { constraints?: readonly string[]; candidates?: readonly string[]; classifications?: readonly QualificationClassificationRow[]; upstreamRelations?: readonly UpstreamRelation[] } = {}
437
+ input: { constraints?: readonly string[]; candidates?: readonly string[]; classifications?: readonly QualificationClassificationRow[] } = {}
525
438
  ): { ok: true; audit: QualificationAudit } | { ok: false; code: string; message: string } {
526
439
  const parsed = parseTaskQualification(taskContent);
527
440
  if (!parsed.ok) return parsed;
@@ -530,13 +443,12 @@ function buildQualificationAudit(
530
443
  const candidates = input.candidates ?? parsed.qualification.candidates.map((row) => row.candidateId);
531
444
  const constraintDependencies = parsed.qualification.constraints.filter((row) => ids.has(row.constraintId)).map((row) => ({ constraintId: row.constraintId, constraintDigest: row.digest, role: 'required' as const, evidence: row.evidence }));
532
445
  const candidateQualifications = parsed.qualification.candidates.filter((row) => candidates.includes(row.candidateId)).map((row) => ({ candidateId: row.candidateId, status: row.status, impact: row.impact, constraintIds: row.constraintIds, evidence: row.evidence }));
533
- const upstreamRelations = [...(input.upstreamRelations ?? [])];
534
446
  return {
535
447
  ok: true,
536
448
  audit: {
537
449
  present: true, constraintDependencies, candidateQualifications,
538
- classifications: input.classifications ?? [], upstreamRelations,
539
- snapshot: { taskInputDigest: parsed.qualification.taskInputDigest, nonConstraintInputDigest: parsed.qualification.nonConstraintInputDigest, upstreamArtifactDigest: upstreamArtifactDigest(upstreamRelations) }
450
+ classifications: input.classifications ?? [],
451
+ snapshot: { taskInputDigest: parsed.qualification.taskInputDigest, nonConstraintInputDigest: parsed.qualification.nonConstraintInputDigest }
540
452
  }
541
453
  };
542
454
  }
@@ -552,10 +464,8 @@ export {
552
464
  DEPENDENCY_COLUMNS,
553
465
  QUALIFICATION_COLUMNS,
554
466
  CLASSIFICATION_COLUMNS,
555
- RELATION_COLUMNS,
556
467
  SNAPSHOT_COLUMNS,
557
468
  constraintDigest,
558
- expectedQualificationRelations,
559
469
  buildQualificationAudit,
560
470
  nonConstraintInputDigest,
561
471
  parseQualificationAudit,
@@ -563,7 +473,6 @@ export {
563
473
  parseTaskQualification,
564
474
  renderQualificationAudit,
565
475
  taskInputDigest,
566
- upstreamArtifactDigest,
567
476
  validateQualificationAudit
568
477
  };
569
478
  export type {
@@ -578,11 +487,9 @@ export type {
578
487
  QualificationConfirmation,
579
488
  QualificationClassification,
580
489
  QualificationClassificationRow,
581
- QualificationRelation,
582
490
  QualificationRole,
583
491
  QualificationValidationError,
584
492
  QualificationValidationResult,
585
493
  TaskQualification,
586
- UpstreamRelation,
587
494
  ArtifactFamily
588
495
  };
@@ -13,6 +13,7 @@ import { validateLifecycleExecution } from './lifecycle-execution.ts';
13
13
  import { TaskExecutionLockError, withTaskExecutionLock } from './task-execution-lock.ts';
14
14
  import type { ResolveTaskRefErrorCode } from './resolve-ref.ts';
15
15
  import { getArtifactSchema } from './artifact-schema.ts';
16
+ import { validateQualificationAudit } from './qualification-audit.ts';
16
17
  import { canonicalSemanticDigest, inspectArtifactContract, sha256Content } from './artifact-operations.ts';
17
18
 
18
19
  type ReviewFinalizationErrorCode =
@@ -258,6 +259,12 @@ function prepareReviewSummaryCandidate(
258
259
  artifactSha256, semanticDigest: structure.semanticDigest
259
260
  });
260
261
  }
262
+ const qualification = validateQualificationAudit(taskContent, artifactContent, { family: spec.family, artifact: request.artifact });
263
+ if (!qualification.ok) {
264
+ return reject('REVIEW_ARTIFACT_STRUCTURE_INVALID', `${qualification.code}: ${qualification.message}`, {
265
+ artifactSha256, semanticDigest
266
+ });
267
+ }
261
268
  const transformed = finalizeReviewSummaryContent(artifactContent, stageStatus.unresolvedFindingCounts, {
262
269
  refreshNumericCounts: true
263
270
  });
@@ -0,0 +1,85 @@
1
+ import fs from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+
5
+ import { resolveBranchWorktree } from '../git/branch-worktree.ts';
6
+ import { snapshotReview } from '../git/review-snapshot.ts';
7
+ import { resolveDeliveryTarget } from './delivery-target.ts';
8
+ import { parseTypedTaskFrontmatter } from './frontmatter.ts';
9
+ import {
10
+ extractReviewDiffBase,
11
+ extractReviewDiffFingerprint,
12
+ extractReviewedHead,
13
+ extractReviewTargetHead,
14
+ extractReviewedSnapshotTree,
15
+ loadPostReviewConfig,
16
+ resolvePostReviewGlobs
17
+ } from './review-fingerprint.ts';
18
+
19
+ export type ReviewIdentityResult = Readonly<{
20
+ status: 'matched' | 'different' | 'indeterminate';
21
+ message: string;
22
+ reviewedHead: string | null;
23
+ currentHead: string | null;
24
+ }>;
25
+
26
+ function git(cwd: string, args: string[]): string {
27
+ return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trim();
28
+ }
29
+
30
+ function result(
31
+ status: ReviewIdentityResult['status'], message: string,
32
+ reviewedHead: string | null = null, currentHead: string | null = null
33
+ ): ReviewIdentityResult {
34
+ return { status, message, reviewedHead, currentHead };
35
+ }
36
+
37
+ export function inspectReviewIdentity(taskDir: string, reportContent: string, repositoryRoot?: string): ReviewIdentityResult {
38
+ const reviewedHead = extractReviewedHead(reportContent);
39
+ const targetHead = extractReviewTargetHead(reportContent);
40
+ const diffBase = extractReviewDiffBase(reportContent);
41
+ const fingerprint = extractReviewDiffFingerprint(reportContent);
42
+ const reviewedTree = extractReviewedSnapshotTree(reportContent);
43
+ if (!reviewedHead || !targetHead || !diffBase || !fingerprint || !reviewedTree) {
44
+ return result('indeterminate', 'Review report is missing commit or snapshot identity', reviewedHead || null);
45
+ }
46
+ try {
47
+ const metadata = parseTypedTaskFrontmatter(fs.readFileSync(path.join(taskDir, 'task.md'), 'utf8'));
48
+ const taskRepositoryRoot = git(taskDir, ['rev-parse', '--show-toplevel']);
49
+ const branch = String(metadata.branch ?? '').trim();
50
+ const gitRoot = branch ? resolveBranchWorktree(taskRepositoryRoot, branch) : taskRepositoryRoot;
51
+ if (!gitRoot) return result('indeterminate', `Task branch '${branch}' is not checked out in a registered worktree`, reviewedHead);
52
+ const currentHead = git(gitRoot, ['rev-parse', 'HEAD']);
53
+ const baseline = git(gitRoot, ['rev-parse', `${reviewedHead}^{commit}`]);
54
+ if (baseline !== currentHead) return result('different', 'Reviewed commit differs from current HEAD', baseline, currentHead);
55
+
56
+ const remote = String(metadata.delivery_remote ?? '').trim();
57
+ const baseRef = String(metadata.delivery_base_ref ?? '').trim();
58
+ if (!remote || !baseRef) return result('indeterminate', 'Task delivery target is not bound', baseline, currentHead);
59
+ const target = resolveDeliveryTarget(gitRoot, { remote, baseRef });
60
+ if (!target.ok) return result('indeterminate', target.message, baseline, currentHead);
61
+ const currentTarget = git(gitRoot, ['rev-parse', `${targetHead}^{commit}`]);
62
+ const computedDiffBase = git(gitRoot, ['merge-base', baseline, currentTarget]);
63
+ if (git(gitRoot, ['rev-parse', `${diffBase}^{commit}`]) !== computedDiffBase) {
64
+ return result('different', 'Saved review diff base no longer matches the current target', baseline, currentHead);
65
+ }
66
+
67
+ const globs = resolvePostReviewGlobs({}, loadPostReviewConfig(repositoryRoot ?? taskRepositoryRoot));
68
+ const trackedChanges = git(gitRoot, ['diff', '--name-only', '-z', baseline, '--', ...globs]);
69
+ const untrackedChanges = git(gitRoot, ['ls-files', '-o', '--exclude-standard', '-z', '--', ...globs]);
70
+ if (trackedChanges || untrackedChanges) return result('different', 'Reviewed worktree contains uncommitted changes', baseline, currentHead);
71
+
72
+ const snapshot = snapshotReview({
73
+ cwd: gitRoot,
74
+ mode: 'worktree',
75
+ baseline,
76
+ diffBase: computedDiffBase,
77
+ globs
78
+ });
79
+ if (snapshot.fingerprint !== fingerprint) return result('different', 'Reviewed diff fingerprint differs from the current worktree', baseline, currentHead);
80
+ if (snapshot.tree !== reviewedTree) return result('different', 'Reviewed snapshot tree differs from the current worktree', baseline, currentHead);
81
+ return result('matched', 'Review report matches the current commit and clean worktree', baseline, currentHead);
82
+ } catch (error) {
83
+ return result('indeterminate', error instanceof Error ? error.message : String(error), reviewedHead);
84
+ }
85
+ }
@@ -74,7 +74,7 @@ class DocumentMutationError extends Error {
74
74
 
75
75
  function parseTable(
76
76
  content: string,
77
- input: { sectionAliases: readonly string[]; columns: readonly string[]; keyColumn?: string }
77
+ input: { sectionAliases: readonly string[]; columns: readonly string[]; keyColumn?: string | null }
78
78
  ): ParsedTable | null {
79
79
  validateAliases(input.sectionAliases, 'table section aliases');
80
80
  if (
@@ -84,8 +84,8 @@ function parseTable(
84
84
  ) {
85
85
  throw new DocumentMutationError('MUTATION_INVALID', 'table columns are invalid');
86
86
  }
87
- const keyColumn = input.keyColumn ?? input.columns[0]!;
88
- if (!input.columns.includes(keyColumn)) {
87
+ const keyColumn = input.keyColumn === undefined ? input.columns[0]! : input.keyColumn;
88
+ if (keyColumn !== null && !input.columns.includes(keyColumn)) {
89
89
  throw new DocumentMutationError('MUTATION_INVALID', 'table key column is invalid');
90
90
  }
91
91
  const sections = matchingSections(content, input.sectionAliases);
@@ -132,11 +132,11 @@ function parseTable(
132
132
  column,
133
133
  decodeCell(parsed.cells[columnIndex]!).trim()
134
134
  ]));
135
- const key = values[keyColumn]!;
136
- if (seen.has(key)) {
135
+ const key = keyColumn === null ? null : values[keyColumn]!;
136
+ if (key !== null && seen.has(key)) {
137
137
  throw new DocumentMutationError('TABLE_DUPLICATE_KEY', `duplicate table key '${key}'`);
138
138
  }
139
- seen.add(key);
139
+ if (key !== null) seen.add(key);
140
140
  rows.push({ values, sourceLine: index - Math.max(firstContent, 0) });
141
141
  }
142
142
  return { heading: section.heading, rows };
@@ -27,6 +27,7 @@ type LinkDisposition = 'exists' | 'missing' | TaskExecutionLockError;
27
27
 
28
28
  type TaskExecutionLockOptions = Readonly<{
29
29
  lockRoot?: string;
30
+ identityScope?: string;
30
31
  token?: () => string;
31
32
  now?: () => string;
32
33
  getStartTime?: (pid: number) => number | null;
@@ -74,9 +75,12 @@ function mapLinkError(operation: LinkOperation, error: unknown, key: string): Li
74
75
  );
75
76
  }
76
77
 
77
- function lockKey(repoRoot: string, taskId: string): Readonly<{ canonicalRepoRoot: string; key: string }> {
78
+ function lockKey(repoRoot: string, taskId: string, identityScope?: string): Readonly<{ canonicalRepoRoot: string; key: string }> {
78
79
  const canonicalRepoRoot = fs.realpathSync.native(repoRoot);
79
- const key = createHash('sha256').update(`${canonicalRepoRoot}\0${taskId}`).digest('hex');
80
+ const identity = identityScope === undefined
81
+ ? `${canonicalRepoRoot}\0${taskId}`
82
+ : `${identityScope}\0${taskId}`;
83
+ const key = createHash('sha256').update(identity).digest('hex');
80
84
  return { canonicalRepoRoot, key };
81
85
  }
82
86
 
@@ -190,7 +194,7 @@ function withTaskExecutionLock<T>(
190
194
  ): T {
191
195
  let identity: Readonly<{ canonicalRepoRoot: string; key: string }>;
192
196
  try {
193
- identity = lockKey(repoRoot, taskId);
197
+ identity = lockKey(repoRoot, taskId, options.identityScope);
194
198
  } catch (error) {
195
199
  throw new TaskExecutionLockError(
196
200
  'ORCHESTRATION_LOCK_FAILED',