@fitlab-ai/agent-infra 0.11.4 → 0.11.5

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 (111) hide show
  1. package/dist/bin/internal-cli.js +0 -5
  2. package/dist/lib/decide.js +4 -41
  3. package/dist/lib/git/branch-worktree.js +45 -0
  4. package/dist/lib/internal/cli-route-inventory.js +0 -3
  5. package/dist/lib/internal/task-activity.js +7 -40
  6. package/dist/lib/internal/task-event.js +4 -37
  7. package/dist/lib/internal/task-finalization.js +9 -2
  8. package/dist/lib/internal/task-ledger.js +5 -43
  9. package/dist/lib/internal/task-lifecycle.js +1 -1
  10. package/dist/lib/internal/task-operation-registry.js +1 -4
  11. package/dist/lib/internal/task-review.js +5 -37
  12. package/dist/lib/internal/task-verify.js +3 -47
  13. package/dist/lib/internal/task-warning.js +4 -40
  14. package/dist/lib/platform/pr-summary.js +4 -23
  15. package/dist/lib/platform/verification-sync.js +12 -49
  16. package/dist/lib/sandbox/control/client.js +4 -1
  17. package/dist/lib/sandbox/control/executor.js +12 -4
  18. package/dist/lib/sandbox/control/protocol.js +2 -1
  19. package/dist/lib/sandbox/removal.js +15 -8
  20. package/dist/lib/task/activity-intent.js +1 -2
  21. package/dist/lib/task/artifact-operations.js +36 -2
  22. package/dist/lib/task/artifact-schema.js +7 -7
  23. package/dist/lib/task/control-authority.js +9 -166
  24. package/dist/lib/task/decision-intents.js +2 -4
  25. package/dist/lib/task/events.js +7 -28
  26. package/dist/lib/task/finalization-handoff.js +92 -0
  27. package/dist/lib/task/finalization.js +123 -12
  28. package/dist/lib/task/intermediate-cleanup.js +9 -148
  29. package/dist/lib/task/ledger-intents.js +3 -6
  30. package/dist/lib/task/lifecycle.js +50 -11
  31. package/dist/lib/task/local-artifact-finalization.js +13 -28
  32. package/dist/lib/task/review-command.js +1 -2
  33. package/dist/lib/task/review-finalization.js +1 -2
  34. package/dist/lib/task/snapshot.js +6 -6
  35. package/dist/lib/task/verification-engine.js +16 -11
  36. package/dist/lib/task/verification.js +1 -13
  37. package/dist/lib/task/workflow-warning-intents.js +2 -4
  38. package/dist/lib/task/write.js +1 -2
  39. package/dist/lifecycle-build-manifest.json +3 -3
  40. package/lib/decide.ts +5 -35
  41. package/lib/git/branch-worktree.ts +41 -0
  42. package/lib/internal/cli-route-inventory.ts +0 -2
  43. package/lib/internal/task-activity.ts +6 -34
  44. package/lib/internal/task-event.ts +4 -37
  45. package/lib/internal/task-finalization.ts +9 -2
  46. package/lib/internal/task-ledger.ts +5 -38
  47. package/lib/internal/task-lifecycle.ts +2 -2
  48. package/lib/internal/task-operation-registry.ts +1 -4
  49. package/lib/internal/task-review.ts +5 -31
  50. package/lib/internal/task-verify.ts +3 -39
  51. package/lib/internal/task-warning.ts +3 -32
  52. package/lib/platform/pr-summary.ts +4 -25
  53. package/lib/platform/verification-sync.ts +13 -53
  54. package/lib/sandbox/control/client.ts +5 -1
  55. package/lib/sandbox/control/executor.ts +13 -4
  56. package/lib/sandbox/control/protocol.ts +3 -2
  57. package/lib/sandbox/removal.ts +23 -7
  58. package/lib/task/activity-intent.ts +1 -2
  59. package/lib/task/artifact-operations.ts +58 -2
  60. package/lib/task/artifact-schema.ts +9 -7
  61. package/lib/task/control-authority.ts +14 -188
  62. package/lib/task/decision-intents.ts +2 -4
  63. package/lib/task/events.ts +8 -27
  64. package/lib/task/finalization-handoff.ts +104 -0
  65. package/lib/task/finalization.ts +120 -12
  66. package/lib/task/intermediate-cleanup.ts +9 -125
  67. package/lib/task/ledger-intents.ts +3 -6
  68. package/lib/task/lifecycle.ts +45 -13
  69. package/lib/task/local-artifact-finalization.ts +16 -35
  70. package/lib/task/review-command.ts +2 -5
  71. package/lib/task/review-finalization.ts +1 -4
  72. package/lib/task/snapshot.ts +6 -6
  73. package/lib/task/verification-engine.ts +20 -10
  74. package/lib/task/verification.ts +1 -15
  75. package/lib/task/workflow-warning-intents.ts +2 -4
  76. package/lib/task/write.ts +1 -4
  77. package/package.json +1 -1
  78. package/templates/.agents/skills/analyze-task/SKILL.en.md +11 -2
  79. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +11 -2
  80. package/templates/.agents/skills/code-task/reference/report-template.en.md +6 -6
  81. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +6 -6
  82. package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +2 -2
  83. package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +2 -2
  84. package/templates/.agents/skills/complete-manual-validation/reference/report-template.en.md +1 -3
  85. package/templates/.agents/skills/complete-manual-validation/reference/report-template.zh-CN.md +1 -3
  86. package/templates/.agents/skills/complete-task/config/verify.en.json +2 -2
  87. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -2
  88. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +3 -3
  89. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +3 -3
  90. package/templates/.agents/skills/review-code/reference/report-template.en.md +3 -3
  91. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +3 -3
  92. package/templates/.agents/skills/review-plan/reference/report-template.en.md +3 -3
  93. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +3 -3
  94. package/templates/.agents/skills/review-pr/config/verify.en.json +3 -1
  95. package/templates/.agents/skills/review-pr/config/verify.zh-CN.json +3 -1
  96. package/templates/.agents/skills/review-pr/reference/report-template.en.md +3 -3
  97. package/templates/.agents/skills/review-pr/reference/report-template.zh-CN.md +3 -3
  98. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +2 -2
  99. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +2 -2
  100. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +2 -2
  101. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +2 -2
  102. package/dist/lib/internal/task-override.js +0 -138
  103. package/dist/lib/task/artifact-recovery.js +0 -476
  104. package/dist/lib/task/artifact-repair-intent.js +0 -181
  105. package/dist/lib/task/guard-override.js +0 -31
  106. package/dist/lib/task/human-override.js +0 -1259
  107. package/lib/internal/task-override.ts +0 -144
  108. package/lib/task/artifact-recovery.ts +0 -572
  109. package/lib/task/artifact-repair-intent.ts +0 -227
  110. package/lib/task/guard-override.ts +0 -50
  111. package/lib/task/human-override.ts +0 -1522
@@ -10,6 +10,7 @@ import { taskIssueIdentity } from '../platform/task-identities.ts';
10
10
  import { parseTaskFrontmatter } from './frontmatter.ts';
11
11
  import {
12
12
  applyTaskLifecycle,
13
+ inspectTaskLifecycleProgress,
13
14
  type TaskLifecycleOptions,
14
15
  type TaskLifecycleRequest,
15
16
  type TaskLifecycleResult
@@ -25,6 +26,9 @@ import {
25
26
  type OperationWarning,
26
27
  type OperationWarningSeverity
27
28
  } from './operation-outcome.ts';
29
+ import { readTaskFinalizationHandoff } from './finalization-handoff.ts';
30
+ import { readSandboxControlManifest } from '../sandbox/control/lifecycle.ts';
31
+ import { validateSandboxControlIdentity } from '../sandbox/control/identity-sentinel.ts';
28
32
 
29
33
  const RECEIPT_VERSION = 4 as const;
30
34
  const FINALIZATION_STEPS = ['lifecycle', 'task-comment', 'verification', 'summary'] as const;
@@ -55,11 +59,14 @@ type TaskFinalizationRequest = Readonly<{
55
59
  taskRef: string;
56
60
  intent: 'complete';
57
61
  agent: string;
62
+ handoffSha256?: string;
58
63
  }>;
59
64
 
60
65
  type TaskFinalizationOptions = Readonly<{
61
66
  repoRoot: string;
62
67
  controlBinding?: Readonly<{ generation: string; requestId: string }>;
68
+ handoffDirectory?: string;
69
+ manifestPath?: string;
63
70
  metadataProvider?: TaskLifecycleOptions['metadataProvider'];
64
71
  lifecycle?: typeof applyTaskLifecycle;
65
72
  backfill?: typeof backfillCompletionComments;
@@ -198,20 +205,32 @@ function validateReceipt(value: unknown, taskId: string): TaskFinalizationReceip
198
205
  const receipt = value as Record<string, unknown>;
199
206
  const controlBinding = receipt.controlBinding as Record<string, unknown> | null | undefined;
200
207
  const states = ['pending', 'done', 'skipped'];
208
+ const requiredKeys = [
209
+ 'version', 'taskId', 'intent', 'receiptId', 'revision', 'lifecycle', 'taskComment',
210
+ 'verification', 'summary', 'warningProjection', 'warnings', 'updatedAt', 'lastError'
211
+ ];
212
+ if (receipt.controlBinding !== undefined) requiredKeys.push('controlBinding');
201
213
  if (
214
+ Object.keys(receipt).sort().join('\0') !== requiredKeys.sort().join('\0')
215
+ ||
202
216
  receipt.version !== RECEIPT_VERSION || receipt.taskId !== taskId || receipt.intent !== 'complete'
203
217
  || typeof receipt.receiptId !== 'string' || receipt.receiptId.length === 0
204
218
  || !Number.isSafeInteger(receipt.revision) || Number(receipt.revision) < 0
205
- || !states.includes(String(receipt.lifecycle))
206
- || !states.includes(String(receipt.taskComment))
207
- || !states.includes(String(receipt.verification))
208
- || !states.includes(String(receipt.summary))
209
- || !['pending', 'done'].includes(String(receipt.warningProjection))
219
+ || !states.includes(receipt.lifecycle as string)
220
+ || !states.includes(receipt.taskComment as string)
221
+ || !states.includes(receipt.verification as string)
222
+ || !states.includes(receipt.summary as string)
223
+ || !['pending', 'done'].includes(receipt.warningProjection as string)
210
224
  || !Array.isArray(receipt.warnings) || receipt.warnings.some((warning) => !validWarning(warning))
211
- || typeof receipt.updatedAt !== 'string'
212
- || (receipt.lastError !== null && (typeof receipt.lastError !== 'object' || Array.isArray(receipt.lastError)))
225
+ || typeof receipt.updatedAt !== 'string' || receipt.updatedAt.length === 0
226
+ || (receipt.lastError !== null && (typeof receipt.lastError !== 'object' || Array.isArray(receipt.lastError)
227
+ || Object.keys(receipt.lastError).sort().join('\0') !== ['code', 'message', 'retryable'].join('\0')
228
+ || typeof (receipt.lastError as Record<string, unknown>).code !== 'string'
229
+ || typeof (receipt.lastError as Record<string, unknown>).message !== 'string'
230
+ || typeof (receipt.lastError as Record<string, unknown>).retryable !== 'boolean'))
213
231
  || (receipt.controlBinding !== undefined && (!controlBinding || typeof controlBinding !== 'object'
214
232
  || Array.isArray(controlBinding)
233
+ || Object.keys(controlBinding).sort().join('\0') !== ['generation', 'requestId'].join('\0')
215
234
  || typeof controlBinding.generation !== 'string' || controlBinding.generation.length === 0
216
235
  || typeof controlBinding.requestId !== 'string' || !/^[a-f0-9-]{16,64}$/u.test(controlBinding.requestId)))
217
236
  ) throw new Error('receipt schema is invalid');
@@ -243,6 +262,14 @@ function writeReceipt(repoRoot: string, receipt: TaskFinalizationReceipt): void
243
262
  }
244
263
  }
245
264
 
265
+ export function bindTaskFinalizationReceipt(
266
+ repoRoot: string, taskId: string, controlBinding: Readonly<{ generation: string; requestId: string }>
267
+ ): TaskFinalizationReceipt {
268
+ const receipt = readReceipt(path.resolve(repoRoot), taskId);
269
+ if (!receipt) throw new Error('TASK_FINALIZATION_PREPARATION_REQUIRED');
270
+ return updateReceipt(path.resolve(repoRoot), receipt, { controlBinding, lastError: null });
271
+ }
272
+
246
273
  function updateReceipt(
247
274
  repoRoot: string,
248
275
  receipt: TaskFinalizationReceipt,
@@ -798,10 +825,16 @@ async function prepareUnderLock(
798
825
  } catch (error) {
799
826
  return failed(taskId, errorOf(error, 'TASK_FINALIZATION_RECEIPT_INVALID'));
800
827
  }
801
- if (options.controlBinding && (
802
- receipt.controlBinding?.generation !== options.controlBinding.generation
803
- || receipt.controlBinding.requestId !== options.controlBinding.requestId
804
- )) receipt = updateReceipt(repoRoot, receipt, { controlBinding: options.controlBinding });
828
+ if (options.controlBinding && (receipt.controlBinding?.generation !== options.controlBinding.generation
829
+ || receipt.controlBinding.requestId !== options.controlBinding.requestId)) {
830
+ const state = resolveTaskRef(taskId, { repoRoot });
831
+ if (!(receipt.lifecycle === 'done' && state.ok && state.state === 'completed')) {
832
+ return failed(taskId, {
833
+ code: 'TASK_FINALIZATION_RECOVERY_PROOF_UNAVAILABLE',
834
+ message: 'canonical receipt is not bound to the current sandbox request', retryable: false
835
+ });
836
+ }
837
+ }
805
838
 
806
839
  const preflightState = resolveTaskRef(taskId, { repoRoot });
807
840
  if (options.preflight && preflightState.ok && preflightState.state === 'active') {
@@ -1043,13 +1076,88 @@ async function commitPreparedTaskFinalization(request: TaskFinalizationRequest,
1043
1076
  if (!resolved.ok) return failed(resolved.taskId, { code: resolved.code, message: resolved.message, retryable: false });
1044
1077
  try {
1045
1078
  return await withTaskExecutionLock(repoRoot, resolved.taskId, 'task-finalization.commit', () => {
1046
- const receipt = readReceipt(repoRoot, resolved.taskId);
1079
+ if (options.controlBinding && options.manifestPath) {
1080
+ try {
1081
+ const manifest = readSandboxControlManifest(options.manifestPath);
1082
+ const identity = validateSandboxControlIdentity({
1083
+ publicStatusDir: manifest.publicStatusDir,
1084
+ root: path.dirname(path.resolve(options.manifestPath)),
1085
+ mode: manifest.mode,
1086
+ taskId: manifest.taskId,
1087
+ generation: manifest.generation,
1088
+ controlRootId: manifest.controlRootId
1089
+ });
1090
+ if (identity.state !== 'valid' || manifest.taskId !== resolved.taskId
1091
+ || manifest.generation !== options.controlBinding.generation
1092
+ || fs.realpathSync.native(manifest.repoRoot) !== fs.realpathSync.native(repoRoot)) {
1093
+ throw new Error('current sandbox manifest does not match finalization request');
1094
+ }
1095
+ } catch (error) {
1096
+ return failed(resolved.taskId, {
1097
+ code: 'TASK_FINALIZATION_RECOVERY_PROOF_UNAVAILABLE',
1098
+ message: error instanceof Error ? error.message : String(error), retryable: false
1099
+ });
1100
+ }
1101
+ }
1102
+ let receipt = readReceipt(repoRoot, resolved.taskId);
1103
+ if (!receipt && options.controlBinding && options.handoffDirectory && request.handoffSha256) {
1104
+ try {
1105
+ const handoff = readTaskFinalizationHandoff(
1106
+ options.handoffDirectory, resolved.taskId, options.controlBinding, request.handoffSha256
1107
+ );
1108
+ receipt = validateReceipt(handoff.receipt, resolved.taskId);
1109
+ if (receipt.lifecycle !== 'pending') throw new Error('handoff lifecycle must be pending');
1110
+ if (inspectTaskLifecycleProgress(repoRoot, resolved.taskId, request.agent) === 'unknown') {
1111
+ throw new Error('TASK_FINALIZATION_RECOVERY_PROOF_UNAVAILABLE');
1112
+ }
1113
+ writeReceipt(repoRoot, receipt);
1114
+ try { handoff.cleanup(); } catch { /* keep the persisted canonical receipt usable */ }
1115
+ } catch (error) {
1116
+ return failed(resolved.taskId, {
1117
+ code: 'TASK_FINALIZATION_HANDOFF_INVALID',
1118
+ message: error instanceof Error ? error.message : String(error), retryable: false
1119
+ });
1120
+ }
1121
+ }
1047
1122
  if (!receipt) return failed(resolved.taskId, {
1048
1123
  code: 'TASK_FINALIZATION_PREPARATION_REQUIRED', message: 'sandbox preparation receipt is unavailable', retryable: true
1049
1124
  });
1050
1125
  if (receipt.lifecycle === 'done' && resolved.state === 'completed') {
1051
1126
  return terminalResult(resolved.taskId, receipt, { lifecycle: { status: 'no-op', changed: false, error: null } }, false);
1052
1127
  }
1128
+ if (options.controlBinding) {
1129
+ const proof = inspectTaskLifecycleProgress(repoRoot, resolved.taskId, request.agent);
1130
+ const oldBinding = receipt.controlBinding;
1131
+ if (!oldBinding || oldBinding.generation !== options.controlBinding.generation || proof === 'unknown') {
1132
+ return failed(resolved.taskId, {
1133
+ code: 'TASK_FINALIZATION_RECOVERY_PROOF_UNAVAILABLE',
1134
+ message: 'canonical receipt has no valid host recovery proof', retryable: false
1135
+ });
1136
+ }
1137
+ if (oldBinding.requestId !== options.controlBinding.requestId && proof === 'not-started') {
1138
+ if (receipt.lifecycle !== 'pending' || !options.handoffDirectory || !request.handoffSha256) {
1139
+ return failed(resolved.taskId, {
1140
+ code: 'TASK_FINALIZATION_RECOVERY_PROOF_UNAVAILABLE',
1141
+ message: 'pending receipt cannot be rebound without a current handoff', retryable: false
1142
+ });
1143
+ }
1144
+ try {
1145
+ const handoff = readTaskFinalizationHandoff(
1146
+ options.handoffDirectory, resolved.taskId, options.controlBinding, request.handoffSha256
1147
+ );
1148
+ const nextReceipt = validateReceipt(handoff.receipt, resolved.taskId);
1149
+ if (nextReceipt.receiptId !== receipt.receiptId || nextReceipt.intent !== receipt.intent
1150
+ || nextReceipt.lifecycle !== 'pending') throw new Error('handoff is not continuous with canonical receipt');
1151
+ receipt = updateReceipt(repoRoot, receipt, { controlBinding: options.controlBinding });
1152
+ try { handoff.cleanup(); } catch { /* keep the persisted canonical receipt usable */ }
1153
+ } catch (error) {
1154
+ return failed(resolved.taskId, {
1155
+ code: 'TASK_FINALIZATION_HANDOFF_INVALID',
1156
+ message: error instanceof Error ? error.message : String(error), retryable: false
1157
+ });
1158
+ }
1159
+ }
1160
+ }
1053
1161
  if (receipt.taskComment === 'pending' || receipt.verification === 'pending' || receipt.summary === 'pending' || receipt.warningProjection === 'pending') {
1054
1162
  return failed(resolved.taskId, {
1055
1163
  code: 'TASK_FINALIZATION_PREPARATION_REQUIRED', message: 'sandbox preparation has pending external steps', retryable: true
@@ -14,26 +14,14 @@ import {
14
14
  readTaskFinalizationReceipt,
15
15
  type TaskFinalizationReceipt
16
16
  } from './finalization.ts';
17
- import {
18
- semanticDigest,
19
- sha256Content
20
- } from './local-artifact-finalization.ts';
21
- import { artifactRecoveryRoot, readArtifactRecoveryIntent } from './artifact-repair-intent.ts';
22
- import type { ArtifactRecoveryIntent } from './artifact-repair-intent.ts';
23
- import { parseArtifactName } from './artifact-name.ts';
24
- import type { ArtifactSchemaFamily } from './artifact-schema.ts';
25
17
  import { enumerateAllTaskDirs, type TaskWorkspaceState } from './resolve-ref.ts';
26
18
  import type { TaskControlBindingEvidence } from './finalization-state.ts';
27
19
 
28
20
  const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
29
- const LOCAL_INTENT_RE = /^(TASK-\d{8}-\d{6})-(analysis|review-analysis|plan|review-plan|code|review-code)-(.+\.md)\.json$/;
30
21
  const COMMIT_INTENT_RE = /^(TASK-\d{8}-\d{6})\.json$/;
31
- const AUXILIARY_ROOTS = [
32
- '.local-artifact-finalization-intents',
33
- '.task-commit-intents'
34
- ] as const;
22
+ const AUXILIARY_ROOTS = ['.task-commit-intents'] as const;
35
23
 
36
- type CleanupKind = 'LFAI-CONSUMED' | 'COMMIT-SYNCED' | 'EMPTY-AUX-PARENT';
24
+ type CleanupKind = 'COMMIT-SYNCED' | 'EMPTY-AUX-PARENT';
37
25
  type CleanupDisposition = 'planned' | 'protected' | 'skipped' | 'deleted' | 'failed';
38
26
  type FileIdentity = Readonly<{ dev: string; ino: string; size: number; mtimeMs: number }>;
39
27
 
@@ -153,47 +141,6 @@ function receiptGate(
153
141
  return null;
154
142
  }
155
143
 
156
- function canonicalLocalIntentPath(taskId: string, family: string, artifact: string): string {
157
- return `${taskId}-${family}-${artifact}.json`;
158
- }
159
-
160
- function localIntentCandidate(
161
- repoRoot: string,
162
- task: TaskRecord | undefined,
163
- filePath: string,
164
- taskId: string,
165
- family: ArtifactSchemaFamily,
166
- artifact: string,
167
- identity: FileIdentity,
168
- options: ScanOptions
169
- ): IntermediateCleanupItem {
170
- const protectedReason = taskGate(task);
171
- if (protectedReason) return item('LFAI-CONSUMED', taskId, filePath, 'protected', protectedReason, identity);
172
- if (path.basename(filePath) !== canonicalLocalIntentPath(taskId, family, artifact)
173
- || !parseArtifactName(artifact)?.family || parseArtifactName(artifact)!.family !== family) {
174
- return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'PATH_IDENTITY_MISMATCH', identity);
175
- }
176
- let intent: ArtifactRecoveryIntent | null;
177
- try { intent = readArtifactRecoveryIntent(repoRoot, taskId, family, artifact); }
178
- catch { return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'LFAI_SCHEMA_INVALID', identity); }
179
- if (!intent || intent.state !== 'consumed') {
180
- return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'LFAI_STATE_PROTECTED', identity);
181
- }
182
- const artifactPath = path.join(task!.taskDir, artifact);
183
- if (safeLstat(task!.taskDir, artifactPath, 'file') === null) {
184
- return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'ARTIFACT_IDENTITY_MISMATCH', identity);
185
- }
186
- let content: string;
187
- try { content = fs.readFileSync(artifactPath, 'utf8'); }
188
- catch { return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'ARTIFACT_UNREADABLE', identity); }
189
- if (intent.finalArtifactSha256 !== sha256Content(content) || intent.finalSemanticDigest !== semanticDigest(content)) {
190
- return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'ARTIFACT_DIGEST_MISMATCH', identity);
191
- }
192
- const receiptReason = receiptGate(repoRoot, task!, options);
193
- if (receiptReason) return item('LFAI-CONSUMED', taskId, filePath, 'protected', receiptReason, identity);
194
- return item('LFAI-CONSUMED', taskId, filePath, 'planned', 'LFAI_CONSUMED_AND_VERIFIED', identity);
195
- }
196
-
197
144
  function commitIntentCandidate(
198
145
  repoRoot: string,
199
146
  task: TaskRecord | undefined,
@@ -258,23 +205,12 @@ function readAuxiliaryCandidates(
258
205
  candidates.push(item('AUXILIARY-UNKNOWN', null, filePath, 'protected', 'PATH_IDENTITY_MISMATCH'));
259
206
  continue;
260
207
  }
261
- if (rootName === '.local-artifact-finalization-intents') {
262
- const match = LOCAL_INTENT_RE.exec(entry.name);
263
- if (!match || (selected && !selected.has(match[1]!))) {
264
- if (!selected || !match) candidates.push(item('AUXILIARY-UNKNOWN', null, filePath, 'protected', 'PATH_IDENTITY_MISMATCH', identity));
265
- continue;
266
- }
267
- candidates.push(localIntentCandidate(
268
- repoRoot, tasks.get(match[1]!), filePath, match[1]!, match[2] as 'analysis' | 'plan' | 'code', match[3]!, identity, options
269
- ));
270
- } else {
271
- const match = COMMIT_INTENT_RE.exec(entry.name);
272
- if (!match || (selected && !selected.has(match[1]!))) {
273
- if (!selected || !match) candidates.push(item('AUXILIARY-UNKNOWN', null, filePath, 'protected', 'PATH_IDENTITY_MISMATCH', identity));
274
- continue;
275
- }
276
- candidates.push(commitIntentCandidate(repoRoot, tasks.get(match[1]!), filePath, match[1]!, identity, options));
208
+ const match = COMMIT_INTENT_RE.exec(entry.name);
209
+ if (!match || (selected && !selected.has(match[1]!))) {
210
+ if (!selected || !match) candidates.push(item('AUXILIARY-UNKNOWN', null, filePath, 'protected', 'PATH_IDENTITY_MISMATCH', identity));
211
+ continue;
277
212
  }
213
+ candidates.push(commitIntentCandidate(repoRoot, tasks.get(match[1]!), filePath, match[1]!, identity, options));
278
214
  }
279
215
  if (entries.length === 0) {
280
216
  candidates.push(item('EMPTY-AUX-PARENT', null, root, 'planned', 'EMPTY_CANONICAL_AUXILIARY_ROOT', rootIdentity));
@@ -307,56 +243,7 @@ function scanIntermediateCleanup(repoRoot: string, options: ScanOptions = {}): I
307
243
  return buildIntermediateCleanupReport(candidates, true);
308
244
  }
309
245
 
310
- function cleanupConsumedRecoveryFiles(repoRoot: string, task: TaskRecord | undefined, candidate: IntermediateCleanupItem): boolean {
311
- if (candidate.kind !== 'LFAI-CONSUMED' || !candidate.taskId) return true;
312
- if (!task) return false;
313
- const match = LOCAL_INTENT_RE.exec(path.basename(candidate.path));
314
- if (!match) return false;
315
- let intent: ReturnType<typeof readArtifactRecoveryIntent>;
316
- try { intent = readArtifactRecoveryIntent(repoRoot, candidate.taskId, match[2] as ArtifactSchemaFamily, match[3]!); }
317
- catch { return false; }
318
- if (!intent || intent.state !== 'consumed') return false;
319
-
320
- const recovery = artifactRecoveryRoot(task.taskDir, intent.recoveryOperationId);
321
- let stat: fs.Stats | null = null;
322
- try { stat = fs.lstatSync(recovery); } catch (error) {
323
- if (errorCode(error) === 'ENOENT') return true;
324
- return false;
325
- }
326
- if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
327
- let entries: fs.Dirent[];
328
- try { entries = fs.readdirSync(recovery, { withFileTypes: true }); } catch { return false; }
329
- for (const entry of entries) {
330
- if (entry.isSymbolicLink()) return false;
331
- if (entry.isFile()) {
332
- if (!['baseline.md', 'candidate.md', 'final.md', 'publish.md'].includes(entry.name)) return false;
333
- continue;
334
- }
335
- if (!entry.isDirectory() || entry.name !== 'generations') return false;
336
- let generations: fs.Dirent[];
337
- try { generations = fs.readdirSync(path.join(recovery, entry.name), { withFileTypes: true }); } catch { return false; }
338
- if (generations.some((generation) => generation.isSymbolicLink()
339
- || !generation.isFile() || !/^[a-f0-9]{64}\.md$/u.test(generation.name))) return false;
340
- }
341
- try {
342
- for (const entry of entries) {
343
- const target = path.join(recovery, entry.name);
344
- if (entry.isFile()) fs.unlinkSync(target);
345
- else {
346
- for (const generation of fs.readdirSync(target, { withFileTypes: true })) fs.unlinkSync(path.join(target, generation.name));
347
- fs.rmdirSync(target);
348
- }
349
- }
350
- fs.rmdirSync(recovery);
351
- const recoveryRoot = path.dirname(recovery);
352
- if (fs.readdirSync(recoveryRoot).length === 0) fs.rmdirSync(recoveryRoot);
353
- return true;
354
- } catch (error) {
355
- return errorCode(error) === 'ENOENT';
356
- }
357
- }
358
-
359
- function removeCandidate(repoRoot: string, candidate: IntermediateCleanupItem, task?: TaskRecord): IntermediateCleanupItem {
246
+ function removeCandidate(repoRoot: string, candidate: IntermediateCleanupItem): IntermediateCleanupItem {
360
247
  if (candidate.disposition !== 'planned') return candidate;
361
248
  const root = candidate.kind === 'EMPTY-AUX-PARENT'
362
249
  ? path.dirname(path.dirname(candidate.path))
@@ -373,9 +260,6 @@ function removeCandidate(repoRoot: string, candidate: IntermediateCleanupItem, t
373
260
  const actual = safeLstat(root, candidate.path, candidate.kind === 'EMPTY-AUX-PARENT' ? 'directory' : 'file');
374
261
  if (!actual) return { ...candidate, disposition: 'protected', reason: 'PATH_IDENTITY_CHANGED' };
375
262
  if (!sameFileIdentity(expected, actual)) return { ...candidate, disposition: 'protected', reason: 'PATH_IDENTITY_CHANGED' };
376
- if (!cleanupConsumedRecoveryFiles(repoRoot, task, candidate)) {
377
- return { ...candidate, disposition: 'protected', reason: 'RECOVERY_STAGING_NOT_SAFE_TO_REMOVE' };
378
- }
379
263
  try {
380
264
  if (candidate.kind === 'EMPTY-AUX-PARENT') {
381
265
  if (fs.readdirSync(candidate.path).length !== 0) return { ...candidate, disposition: 'protected', reason: 'AUXILIARY_ROOT_NOT_EMPTY' };
@@ -434,7 +318,7 @@ function cleanupIntermediateUnderRemovalCoordinator(
434
318
  if (initialCandidate.disposition !== 'planned') continue;
435
319
  const current = refreshedByKey.get(intermediateCleanupKey(initialCandidate));
436
320
  items[index] = current
437
- ? removeCandidate(repoRoot, current, tasks.get(taskId))
321
+ ? removeCandidate(repoRoot, current)
438
322
  : { ...initialCandidate, disposition: 'protected', reason: 'CANDIDATE_NO_LONGER_ELIGIBLE' };
439
323
  }
440
324
  };
@@ -14,7 +14,6 @@ import type { LedgerRow, ReviewStage } from './ledger.ts';
14
14
  import { resolveTaskRef } from './resolve-ref.ts';
15
15
  import { writeTask } from './write.ts';
16
16
  import type { TaskMutation, TaskOperationSummary, TaskWriteOptions } from './write.ts';
17
- import { allowsManualOverride } from './guard-override.ts';
18
17
  import { parseLegacyReworkIntentDocument, parseReworkIntentDocument, reworkIntentMutation, upsertReworkIntent } from './rework-intent.ts';
19
18
  import type { ReworkClassification, ReworkIntent, ReworkTarget } from './rework-intent.ts';
20
19
  import { extractSection, extractSubSection } from './sections.ts';
@@ -149,8 +148,7 @@ function mapWrite(intent: LedgerIntent, entityId: string, before: LedgerRow | nu
149
148
  function applyLedgerIntent(intent: LedgerIntent, options: TaskWriteOptions = {}): LedgerIntentResult {
150
149
  const resolved = resolveTaskRef(intent.taskRef, { repoRoot: options.repoRoot });
151
150
  if (!resolved.ok) return failed(intent, resolved.code, resolved.message, resolved.taskId);
152
- const stateOverride = allowsManualOverride(options.manualOverride, 'ledger-intent', 'TASK_STATE_MISMATCH');
153
- if (resolved.state !== 'active' && !stateOverride) return failed(intent, 'TASK_STATE_MISMATCH', `task ${resolved.taskId} is ${resolved.state}, expected active`, resolved.taskId);
151
+ if (resolved.state !== 'active') return failed(intent, 'TASK_STATE_MISMATCH', `task ${resolved.taskId} is ${resolved.state}, expected active`, resolved.taskId);
154
152
  let content: string;
155
153
  let rows: LedgerRow[];
156
154
  try {
@@ -353,8 +351,7 @@ function applyLedgerIntent(intent: LedgerIntent, options: TaskWriteOptions = {})
353
351
  ? new Set(['confirmed', 'open', 'needs-human-decision'])
354
352
  : before.status === 'cannot-judge' ? new Set(['open', 'needs-human-decision']) : new Set<string>();
355
353
  const sameRoundMinorClose = before.status === 'open' && before.severity === 'minor' && intent.status === 'closed';
356
- if ((!sameRoundMinorClose && !allowed.has(intent.status))
357
- && !allowsManualOverride(options.manualOverride, 'ledger-intent', 'LEDGER_TRANSITION_INVALID')) {
354
+ if (!sameRoundMinorClose && !allowed.has(intent.status)) {
358
355
  return failed(intent, 'LEDGER_TRANSITION_INVALID', `finding '${intent.id}' cannot transition from ${before.status} to ${intent.status}`, resolved.taskId, intent.id);
359
356
  }
360
357
  after = { ...before, status: intent.status, evidence };
@@ -388,7 +385,7 @@ function applyLedgerIntent(intent: LedgerIntent, options: TaskWriteOptions = {})
388
385
  }
389
386
 
390
387
  const result = writeTask({
391
- taskRef: intent.taskRef, expectedState: stateOverride ? resolved.state : 'active',
388
+ taskRef: intent.taskRef, expectedState: 'active',
392
389
  mutations: [rowMutation(after), ...(implementationMutation ? [implementationMutation] : [])],
393
390
  dryRun: 'dryRun' in intent ? intent.dryRun : false
394
391
  }, { ...options, taskLocation: { repoRoot: resolved.repoRoot, taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, state: resolved.state } });
@@ -107,16 +107,11 @@ type TaskLifecycleOptions = {
107
107
  fileSystem?: Partial<LifecycleFileSystem>;
108
108
  taskFileSystem?: Partial<TaskFileSystem>;
109
109
  directoryRenameSync?: (source: string, target: string) => void;
110
- manualOverride?: Readonly<{ failureId: string; operator: string; reason: string }>;
111
110
  /** Write the terminal task document and durable journal, but leave the move
112
111
  * and short-id mutation for a separately authorized commit. */
113
112
  prepareOnly?: boolean;
114
113
  };
115
114
 
116
- function allowsManualOverride(options: TaskLifecycleOptions, code: string): boolean {
117
- return options.manualOverride?.failureId === `lifecycle.apply:${code}`;
118
- }
119
-
120
115
  const STEPS: readonly LifecycleStep[] = ['task-written', 'directory-moved', 'registry-committed'];
121
116
  const DEFAULT_IO: LifecycleFileSystem = {
122
117
  renameSync: (source, target) => fs.renameSync(source, target),
@@ -362,6 +357,44 @@ export function readLifecycleJournalEvidence(repoRoot: string, taskId: string):
362
357
  } catch { return { exists: true, completedSteps: [], failure: 'SANDBOX_CONTROL_LIFECYCLE_JOURNAL_INVALID' }; }
363
358
  }
364
359
 
360
+ export function inspectTaskLifecycleProgress(
361
+ repoRoot: string, taskId: string, agent: string
362
+ ): 'not-started' | 'started-recoverable' | 'unknown' {
363
+ try {
364
+ const hot = locateHotTaskDirs(repoRoot, taskId);
365
+ if (hot.length !== 1 || (hot[0]!.state !== 'active' && hot[0]!.state !== 'completed')) return 'unknown';
366
+ const { state, taskDir } = hot[0]!;
367
+ const content = fs.readFileSync(path.join(taskDir, 'task.md'), 'utf8');
368
+ const frontmatter = parseTypedTaskFrontmatter(content);
369
+ if (frontmatter.id !== taskId) return 'unknown';
370
+ const journalPath = path.join(taskDir, '.task-lifecycle.json');
371
+ if (!fs.existsSync(journalPath)) {
372
+ if (state === 'active') return frontmatter.status === 'active' ? 'not-started' : 'unknown';
373
+ return matchingCompletion(content, { taskRef: taskId, intent: 'complete', agent })
374
+ ? 'started-recoverable' : 'unknown';
375
+ }
376
+ const journal = parseJournal(fs.readFileSync(journalPath, 'utf8'));
377
+ const request: TaskLifecycleRequest = { taskRef: taskId, intent: 'complete', agent };
378
+ if (journal.taskId !== taskId || journal.intent !== 'complete'
379
+ || journal.intentDigest !== intentIdentity(request, taskId)
380
+ || journal.sourceState !== 'active' || journal.targetState !== 'completed') return 'unknown';
381
+ if (journal.completedSteps.some((step, index) => STEPS[index] !== step)) return 'unknown';
382
+ const directoryMoved = journal.completedSteps.includes('directory-moved');
383
+ const registryCommitted = journal.completedSteps.includes('registry-committed');
384
+ const hasShortId = loadShortIdByTaskId(repoRoot).has(taskId);
385
+ if (state === 'active' && (directoryMoved || !hasShortId)) return 'unknown';
386
+ if (state === 'completed' && (!journal.completedSteps.includes('task-written')
387
+ || !matchingCompletion(content, request) || (registryCommitted && hasShortId))) return 'unknown';
388
+ if (frontmatter.status !== state
389
+ && !(state === 'active' && frontmatter.status === 'completed' && matchingCompletion(content, request))) {
390
+ return 'unknown';
391
+ }
392
+ return 'started-recoverable';
393
+ } catch {
394
+ return 'unknown';
395
+ }
396
+ }
397
+
365
398
  type DirectoryEntry = Readonly<{ relativePath: string; kind: 'file' | 'directory'; size: number; digest: string }>;
366
399
 
367
400
  function directoryManifest(root: string): DirectoryEntry[] {
@@ -468,7 +501,7 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
468
501
  }
469
502
  return failed(request, { code: 'LIFECYCLE_INTENT_CONFLICT', message: `task ${taskId} is already ${spec.target} with a different lifecycle intent` }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
470
503
  }
471
- if (!spec.sources.includes(sourceState) && !existingJournal && !allowsManualOverride(options, 'LIFECYCLE_SOURCE_INVALID')) {
504
+ if (!spec.sources.includes(sourceState) && !existingJournal) {
472
505
  return failed(request, { code: 'LIFECYCLE_SOURCE_INVALID', message: `${request.intent} is not allowed from ${sourceState}` }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
473
506
  }
474
507
  if (sourcePath !== targetPath && fs.existsSync(targetPath) && !existingJournal) {
@@ -479,7 +512,7 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
479
512
  try { frontmatter = parseTypedTaskFrontmatter(content); }
480
513
  catch (error) { return failed(request, { code: 'LIFECYCLE_DOCUMENT_INVALID', message: error instanceof Error ? error.message : String(error) }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath }); }
481
514
  if (frontmatter.id !== taskId) return failed(request, { code: 'LIFECYCLE_IDENTITY_INVALID', message: 'task.md id does not match its lifecycle task id' }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
482
- if (!locateActivityLog(content) && !allowsManualOverride(options, 'LIFECYCLE_LOG_MISSING')) return failed(request, { code: 'LIFECYCLE_LOG_MISSING', message: 'task has no unique Activity Log section' }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
515
+ if (!locateActivityLog(content)) return failed(request, { code: 'LIFECYCLE_LOG_MISSING', message: 'task has no unique Activity Log section' }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
483
516
  if (spec.target === 'active') {
484
517
  const contract = validateCurrentTaskContract(content);
485
518
  if (!contract.ok) return failed(request, { code: 'LIFECYCLE_DOCUMENT_INVALID', message: contract.message }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
@@ -493,10 +526,10 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
493
526
  }
494
527
  const registry = JSON.parse(registryRequest.output || '{"version":1,"ids":{}}') as { ids: Record<string, string> };
495
528
  const registeredKey = Object.entries(registry.ids).find(([, candidate]) => candidate === taskId)?.[0];
496
- if (spec.registry === 'release' && sourceState === 'active' && !registeredKey && !allowsManualOverride(options, 'LIFECYCLE_SHORT_ID_PRECONDITION')) {
529
+ if (spec.registry === 'release' && sourceState === 'active' && !registeredKey) {
497
530
  return failed(request, { code: 'LIFECYCLE_SHORT_ID_PRECONDITION', message: 'active source has no short-id registry entry' }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
498
531
  }
499
- if (spec.registry === 'alloc' && !registeredKey && Object.keys(registry.ids).length >= 10 ** configuredShortIdLength(repoRoot) - 1 && !allowsManualOverride(options, 'SHORT_ID_CAPACITY_EXCEEDED')) {
532
+ if (spec.registry === 'alloc' && !registeredKey && Object.keys(registry.ids).length >= 10 ** configuredShortIdLength(repoRoot) - 1) {
500
533
  return failed(request, { code: 'SHORT_ID_CAPACITY_EXCEEDED', message: 'short-id registry has no free slot' }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
501
534
  }
502
535
  const digest = intentIdentity(request, taskId);
@@ -514,8 +547,7 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
514
547
  if (!journal.failure) return failed(request, { code: 'LIFECYCLE_JOURNAL_INVALID', message: 'existing lifecycle journal has no durable failure evidence for a read-only probe' }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath: initialJournalPath });
515
548
  return failed(request, journal.failure, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath: initialJournalPath, completedSteps: journal.completedSteps, pendingSteps: STEPS.filter((step) => !journal.completedSteps.includes(step)) });
516
549
  }
517
- const overrideResumesRecordedFailure = journal.failure && allowsManualOverride(options, journal.failure.code);
518
- if (journal.intentDigest !== digest && !overrideResumesRecordedFailure) return failed(request, { code: 'LIFECYCLE_INTENT_CONFLICT', message: 'an in-progress lifecycle journal belongs to a different request' }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath: initialJournalPath });
550
+ if (journal.intentDigest !== digest) return failed(request, { code: 'LIFECYCLE_INTENT_CONFLICT', message: 'an in-progress lifecycle journal belongs to a different request' }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath: initialJournalPath });
519
551
  } else {
520
552
  let metadata: TaskWriteMetadata;
521
553
  try { metadata = (options.metadataProvider ?? captureTaskWriteMetadata)(); }
@@ -525,7 +557,7 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
525
557
  }
526
558
  journal = { version: 1, taskId, intent: request.intent, intentDigest: digest, sourceState, targetState: spec.target, metadata, completedSteps: [] };
527
559
  if (request.dryRun) {
528
- const plannedMutations = mutationsFor(request, content, metadata, restoredFiles, allowsManualOverride(options, 'LIFECYCLE_LOG_MISSING'));
560
+ const plannedMutations = mutationsFor(request, content, metadata, restoredFiles, false);
529
561
  if ('code' in plannedMutations) return failed(request, plannedMutations, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
530
562
  const taskPlan = writeTask(
531
563
  { taskRef: taskId, expectedState: sourceState === 'blocked' ? 'blocked' : 'active', mutations: plannedMutations, dryRun: true },
@@ -561,7 +593,7 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
561
593
  if (matchingCompletion(io.readFileSync(path.join(sourcePath, 'task.md')), request, restoredFiles)) completed.add('task-written');
562
594
  if (!completed.has('task-written')) {
563
595
  const current = io.readFileSync(path.join(sourcePath, 'task.md'));
564
- const mutations = mutationsFor(request, current, journal.metadata, restoredFiles, allowsManualOverride(options, 'LIFECYCLE_LOG_MISSING'));
596
+ const mutations = mutationsFor(request, current, journal.metadata, restoredFiles, false);
565
597
  if ('code' in mutations) return failed(request, mutations, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath, completedSteps: [...completed], changed: completed.size > 0 });
566
598
  const writeResult = writeTask(
567
599
  { taskRef: taskId, expectedState: sourceState === 'blocked' ? 'blocked' : 'active', mutations },
@@ -2,10 +2,10 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
4
  import { inspectDecisionDetailDuplicates } from './decision-details.ts';
5
- import { scanVisibleMarkdown } from './markdown.ts';
6
5
  import { parseArtifactName } from './artifact-name.ts';
6
+ import { parseLifecyclePathDecision } from './lifecycle-path.ts';
7
7
  import { resolveTaskRef } from './resolve-ref.ts';
8
- import { canonicalSemanticDigest, inspectArtifactPatterns, inspectArtifactStructure, sha256Content } from './artifact-operations.ts';
8
+ import { canonicalSemanticDigest, inspectArtifactContract, sha256Content } from './artifact-operations.ts';
9
9
  import { getArtifactSchema } from './artifact-schema.ts';
10
10
 
11
11
  type LocalArtifactFamily = 'analysis' | 'plan' | 'code';
@@ -24,7 +24,8 @@ type LocalArtifactDiagnosticCode =
24
24
  | 'LOCAL_DECISION_DETAIL_DUPLICATE'
25
25
  | 'LOCAL_REQUIRED_PATTERN_MISSING'
26
26
  | 'LOCAL_STRUCTURAL_INVALID'
27
- | 'LOCAL_SECTION_HEADING_TRAILING_PUNCTUATION';
27
+ | 'LOCAL_SECTION_HEADING_TRAILING_PUNCTUATION'
28
+ | 'LOCAL_FLOW_DECISION_INVALID';
28
29
 
29
30
  type LocalArtifactDiagnostic = {
30
31
  code: LocalArtifactDiagnosticCode;
@@ -71,7 +72,9 @@ function localDiagnosticCode(code: string): LocalArtifactDiagnosticCode {
71
72
  ARTIFACT_EMPTY: 'LOCAL_ARTIFACT_EMPTY',
72
73
  ARTIFACT_MISSING_SECTION: 'LOCAL_ARTIFACT_MISSING_SECTION',
73
74
  ARTIFACT_DUPLICATE_SECTION: 'LOCAL_ARTIFACT_DUPLICATE_SECTION',
74
- ARTIFACT_HEADING_TRAILING_PUNCTUATION: 'LOCAL_SECTION_HEADING_TRAILING_PUNCTUATION'
75
+ ARTIFACT_HEADING_TRAILING_PUNCTUATION: 'LOCAL_SECTION_HEADING_TRAILING_PUNCTUATION',
76
+ ARTIFACT_STATE_CHECK_PATTERN_MISSING: 'LOCAL_STATUS_COMMAND_MISSING',
77
+ ARTIFACT_REQUIRED_PATTERN_MISSING: 'LOCAL_REQUIRED_PATTERN_MISSING'
75
78
  };
76
79
  return map[code] ?? 'LOCAL_STRUCTURAL_INVALID';
77
80
  }
@@ -80,47 +83,25 @@ function localDiagnostic(item: { code: string; message: string; line: number | n
80
83
  return { code: localDiagnosticCode(item.code), message: item.message, line: item.line };
81
84
  }
82
85
 
83
- function isStatusPattern(pattern: string): boolean {
84
- return pattern === '^\\$ ';
85
- }
86
-
87
86
  function validateLocalArtifact(
88
87
  content: string,
89
88
  options: LocalArtifactValidationOptions
90
89
  ): LocalArtifactValidationResult {
91
90
  const schema = getArtifactSchema(options.family)!;
92
91
  const diagnostics: LocalArtifactDiagnostic[] = [];
93
- const scanned = scanVisibleMarkdown(content);
94
- const structure = inspectArtifactStructure(content, schema);
95
- for (const item of structure.diagnostics) diagnostics.push(localDiagnostic(item));
96
-
97
- const statusSection = schema.sections.find((section) => section.id === 'state-check');
98
- if (statusSection) {
99
- const statusHeading = scanned.headings.find((heading) => (
100
- heading.level === 2 && [statusSection.headings.zh, statusSection.headings.en].some((name) => (
101
- heading.text === name || heading.text === `${name}:` || heading.text === `${name}:`
102
- ))
103
- ));
104
- if (statusHeading) {
105
- const next = scanned.headings.find((candidate) => candidate.start > statusHeading.start && candidate.level <= 2);
106
- const body = content.slice(statusHeading.end, next?.start ?? content.length);
107
- for (const pattern of schema.requiredPatterns.filter(isStatusPattern)) {
108
- if (!new RegExp(pattern, 'm').test(body)) {
109
- diagnostics.push({ code: 'LOCAL_STATUS_COMMAND_MISSING', message: `status section '${statusHeading.text}' is missing required command output`, line: content.slice(0, statusHeading.start).split('\n').length });
110
- }
111
- }
112
- }
113
- }
114
-
115
- const patternInspection = inspectArtifactPatterns(content, {
116
- ...schema,
117
- requiredPatterns: schema.requiredPatterns.filter((item) => !isStatusPattern(item))
118
- });
119
- for (const item of patternInspection.diagnostics) diagnostics.push({ code: 'LOCAL_REQUIRED_PATTERN_MISSING', message: item.message, line: null });
92
+ const inspection = inspectArtifactContract(content, schema, { scopeStateCheckSection: true });
93
+ for (const item of inspection.diagnostics) diagnostics.push(localDiagnostic(item));
120
94
 
121
95
  const decisionDetails = inspectDecisionDetailDuplicates(content);
122
96
  if (!decisionDetails.ok) diagnostics.push({ code: 'LOCAL_DECISION_DETAIL_DUPLICATE', message: decisionDetails.message, line: null });
123
97
 
98
+ if (options.family === 'analysis') {
99
+ const flowDecision = parseLifecyclePathDecision(content);
100
+ if (flowDecision.status !== 'valid') {
101
+ diagnostics.push({ code: 'LOCAL_FLOW_DECISION_INVALID', message: flowDecision.message, line: null });
102
+ }
103
+ }
104
+
124
105
  return {
125
106
  ok: diagnostics.length === 0,
126
107
  family: options.family,
@@ -1,13 +1,10 @@
1
1
  import type { ReviewFinalizationRequest } from './review-finalization.ts';
2
2
 
3
- export function parseReviewCommand(args: readonly string[]): ReviewFinalizationRequest & {
4
- overrideTicket?: string; overrideTarget?: string; overrideScope?: string;
5
- } {
3
+ export function parseReviewCommand(args: readonly string[]): ReviewFinalizationRequest {
6
4
  if (!args[0] || !['preflight', 'finalize-summary'].includes(args[1] ?? '')) throw new Error('task ref and a supported review operation are required');
7
5
  const values: Record<string, string | boolean> = {};
8
6
  const flags = {
9
- '--stage': 'stage', '--artifact': 'artifact', '--orchestrated': 'orchestrated', '--dry-run': 'dryRun',
10
- '--override-ticket': 'overrideTicket', '--override-target': 'overrideTarget', '--override-scope': 'overrideScope'
7
+ '--stage': 'stage', '--artifact': 'artifact', '--orchestrated': 'orchestrated', '--dry-run': 'dryRun'
11
8
  } as const;
12
9
  for (let index = 2; index < args.length; index += 1) {
13
10
  const flag = args[index]!;