sdd-agent-platform 0.1.0

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.
@@ -0,0 +1,4824 @@
1
+ import { appendFile, access, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import { applyAiToolEntries, checkAiToolEntryDrift } from './ai-tools.js';
7
+ export * from './ai-tools.js';
8
+ export * from './instructions.js';
9
+ const execFileAsync = promisify(execFile);
10
+ export const RUNTIME_VERSION = 'phase-1.2-runtime-skeleton';
11
+ export const PROJECT_CONFIG_CONTRACT = 'phase-1.2-project-contract';
12
+ export const RUN_STATE_CONTRACT = 'phase-1.2-run-state-contract';
13
+ export const EVENT_LOG_CONTRACT = 'phase-1.2-event-log-contract';
14
+ export const ARTIFACT_PATH_CONTRACT = 'phase-1.2-artifact-path-contract';
15
+ export const LEGACY_LIFECYCLE_DECISION_CONTRACT = 'phase-1.2-lifecycle-decision-contract';
16
+ export const LIFECYCLE_DECISION_CONTRACT = 'sdd-lifecycle-decision-v1';
17
+ export const LIFECYCLE_DECISION_VERSION = '1.3.0';
18
+ export const SDD_RESULT_CONTRACT = 'sdd-result-v1';
19
+ export const SDD_RESULT_VERSION = '1.3.0';
20
+ export const DELEGATION_LIVENESS_CONTRACT = 'sdd-delegation-liveness-v1';
21
+ export const DELEGATION_LIVENESS_VERSION = '1.3.0';
22
+ export const ARTIFACT_RESULT_INGESTION_CONTRACT_VERSION = 'phase-3.6-artifact-result-ingestion-v1';
23
+ export const TASK_GRAPH_PLANNER_CONTRACT_VERSION = 'phase-3.9-task-graph-planner-v1';
24
+ export const WAVE_PLANNER_CONTRACT_VERSION = 'phase-3.10-wave-planner-v1';
25
+ export const BACKGROUND_EXECUTOR_CONTRACT_VERSION = 'phase-3.11-background-executor-v1';
26
+ export const WAVE_EXECUTOR_CONTRACT_VERSION = 'phase-3.12-wave-executor-v1';
27
+ export const LOCAL_RUN_INDEX_CONTRACT_VERSION = 'phase-3.13-local-run-index-v1';
28
+ export const GOVERNANCE_POLICY_CONTRACT_VERSION = 'phase-3.14-governance-policy-v1';
29
+ export function getWorktreesDir(projectRoot) {
30
+ return path.join(getSddDir(projectRoot), 'worktrees');
31
+ }
32
+ export function getSddDir(projectRoot) {
33
+ return path.join(projectRoot, '.sdd');
34
+ }
35
+ export function getProjectConfigPath(projectRoot) {
36
+ return path.join(getSddDir(projectRoot), 'project.yml');
37
+ }
38
+ export function getRunsDir(projectRoot) {
39
+ return path.join(getSddDir(projectRoot), 'runs');
40
+ }
41
+ export function getLocalRunIndexPath(projectRoot) {
42
+ return path.join(getSddDir(projectRoot), 'run-index.json');
43
+ }
44
+ export function getRunDir(projectRoot, runId) {
45
+ assertSafePathSegment(runId, 'runId');
46
+ return path.join(getRunsDir(projectRoot), runId);
47
+ }
48
+ export function getArtifactsDir(projectRoot, runId) {
49
+ return path.join(getRunDir(projectRoot, runId), 'artifacts');
50
+ }
51
+ export function getArtifactPath(projectRoot, runId, relativeArtifactPath) {
52
+ const artifactsDir = getArtifactsDir(projectRoot, runId);
53
+ const resolved = path.resolve(artifactsDir, relativeArtifactPath);
54
+ if (!resolved.startsWith(path.resolve(artifactsDir) + path.sep) && resolved !== path.resolve(artifactsDir)) {
55
+ throw new Error(`Artifact path escapes artifacts directory: ${relativeArtifactPath}`);
56
+ }
57
+ return resolved;
58
+ }
59
+ export function getRunRelativeArtifactPath(artifactRootRelativePath) {
60
+ return `artifacts/${normalizeArtifactRootRelativePath(artifactRootRelativePath)}`;
61
+ }
62
+ export function toArtifactRootRelativePath(runRelativeArtifactPath) {
63
+ const portablePath = runRelativeArtifactPath.replace(/\\/g, '/');
64
+ if (!portablePath.startsWith('artifacts/')) {
65
+ throw new Error(`Run-relative artifact path must start with artifacts/: ${runRelativeArtifactPath}`);
66
+ }
67
+ return normalizeArtifactRootRelativePath(portablePath.slice('artifacts/'.length));
68
+ }
69
+ export async function writeArtifact(projectRoot, runId, artifactRootRelativePath, content) {
70
+ const normalized = normalizeArtifactRootRelativePath(artifactRootRelativePath);
71
+ const absolutePath = getArtifactPath(projectRoot, runId, normalized);
72
+ await mkdir(path.dirname(absolutePath), { recursive: true });
73
+ await writeFile(absolutePath, content, 'utf8');
74
+ return { absolutePath, runRelativePath: getRunRelativeArtifactPath(normalized) };
75
+ }
76
+ export async function readArtifact(projectRoot, runId, artifactRootRelativePath) {
77
+ return readFile(getArtifactPath(projectRoot, runId, normalizeArtifactRootRelativePath(artifactRootRelativePath)), 'utf8');
78
+ }
79
+ export async function initProject(projectRoot, options = {}) {
80
+ const sddDir = getSddDir(projectRoot);
81
+ const runsDir = getRunsDir(projectRoot);
82
+ const configPath = getProjectConfigPath(projectRoot);
83
+ const branch = options.branch ?? 'master';
84
+ await mkdir(runsDir, { recursive: true });
85
+ let created = false;
86
+ if (options.force || !await exists(configPath)) {
87
+ const projectName = path.basename(path.resolve(projectRoot));
88
+ const config = await detectProjectConfig(projectRoot, projectName);
89
+ await writeFile(configPath, renderProjectConfig(config), 'utf8');
90
+ created = true;
91
+ }
92
+ const documents = await applyInitDocuments(projectRoot, {
93
+ branch,
94
+ force: options.force,
95
+ scaffoldDocuments: options.scaffoldDocuments ?? true
96
+ });
97
+ await mkdir(sddDir, { recursive: true });
98
+ const aiTools = await applyAiToolEntries(projectRoot, { tool: options.aiTool ?? 'auto' });
99
+ return { configPath, created, documents, aiTools };
100
+ }
101
+ async function applyInitDocuments(projectRoot, options) {
102
+ assertSafePathSegment(options.branch, 'branch');
103
+ const docsRoot = path.join(projectRoot, 'specs', options.branch);
104
+ const now = new Date().toISOString();
105
+ const documents = [
106
+ { name: 'spec.md', content: renderInitSpecDocument(options.branch, now) },
107
+ { name: 'plan.md', content: renderInitPlanDocument(options.branch, now) },
108
+ { name: 'tasks.md', content: renderInitTasksDocument(options.branch, now) }
109
+ ];
110
+ if (!options.scaffoldDocuments) {
111
+ return {
112
+ branch: options.branch,
113
+ root: path.relative(projectRoot, docsRoot),
114
+ documents: documents.map((document) => ({
115
+ branch: options.branch,
116
+ relativePath: `specs/${options.branch}/${document.name}`,
117
+ status: 'skipped',
118
+ message: 'Starter semantic document scaffold skipped.'
119
+ }))
120
+ };
121
+ }
122
+ await mkdir(docsRoot, { recursive: true });
123
+ const reports = [];
124
+ for (const document of documents) {
125
+ const absolutePath = path.join(docsRoot, document.name);
126
+ const relativePath = `specs/${options.branch}/${document.name}`;
127
+ const existed = await exists(absolutePath);
128
+ if (existed && !options.force) {
129
+ reports.push({
130
+ branch: options.branch,
131
+ relativePath,
132
+ status: 'unchanged',
133
+ message: 'Existing semantic document preserved.'
134
+ });
135
+ continue;
136
+ }
137
+ await writeFile(absolutePath, document.content, 'utf8');
138
+ reports.push({
139
+ branch: options.branch,
140
+ relativePath,
141
+ status: existed ? 'overwritten' : 'created',
142
+ message: existed ? 'Starter semantic document overwritten by explicit force.' : 'Starter semantic document created.'
143
+ });
144
+ }
145
+ return { branch: options.branch, root: path.relative(projectRoot, docsRoot), documents: reports };
146
+ }
147
+ function renderInitSpecDocument(branch, timestamp) {
148
+ return `---
149
+ template: sdd-init-onboarding-spec-v1
150
+ version: 1.3.0
151
+ contract: sdd-spec-doc-v1
152
+ sdd_managed_starter: true
153
+ ---
154
+
155
+ # Spec: Project Onboarding
156
+
157
+ ## Metadata
158
+
159
+ - spec_id: \`onboarding\`
160
+ - branch: \`${branch}\`
161
+ - lifecycle_profile: \`direct\`
162
+ - source_request: \`Created by sdd init\`
163
+ - created_at: \`${timestamp}\`
164
+ - updated_at: \`${timestamp}\`
165
+
166
+ ## Problem / Intent
167
+
168
+ This project has been initialized for SDD. Replace this onboarding spec with the first real feature or change request before implementation.
169
+
170
+ ## Scope
171
+
172
+ ### In Scope
173
+
174
+ - Confirm the project is initialized.
175
+ - Replace onboarding placeholders with a real spec, plan, and tasks when ready.
176
+
177
+ ### Out of Scope
178
+
179
+ - Running background agents.
180
+ - Creating worktrees.
181
+ - Applying sync-back without explicit user approval.
182
+
183
+ ## Requirements
184
+
185
+ ### Functional Requirements
186
+
187
+ - FR-1: \`sdd init\` creates the SDD runtime config and starter semantic documents.
188
+ - FR-2: \`sdd status --branch ${branch}\` can inspect the initialized branch without missing document gaps.
189
+
190
+ ### Non-functional Requirements
191
+
192
+ - NFR-1: Initialization must not overwrite user-authored SDD documents unless force is explicitly requested.
193
+
194
+ ## Acceptance Criteria
195
+
196
+ - AC-1: \`sdd status --branch ${branch}\` reports all three semantic documents as present.
197
+ - AC-2: Existing user-authored semantic documents are preserved by default.
198
+
199
+ ## Risks / Hard Gates
200
+
201
+ - Do not treat this onboarding scaffold as an approved implementation plan.
202
+
203
+ ## Open Questions
204
+
205
+ - Replace this section with project-specific questions before implementation.
206
+
207
+ ## Lifecycle Decision Reference
208
+
209
+ - decision_artifact: \`pending\`
210
+ - canonical_model: \`docs/architecture/lifecycle-decision-model.md\`
211
+ `;
212
+ }
213
+ function renderInitPlanDocument(branch, timestamp) {
214
+ return `---
215
+ template: sdd-init-onboarding-plan-v1
216
+ version: 1.3.0
217
+ contract: sdd-plan-doc-v1
218
+ sdd_managed_starter: true
219
+ ---
220
+
221
+ # Plan: Project Onboarding
222
+
223
+ ## Metadata
224
+
225
+ - spec_id: \`onboarding\`
226
+ - plan_id: \`onboarding\`
227
+ - branch: \`${branch}\`
228
+ - created_at: \`${timestamp}\`
229
+ - updated_at: \`${timestamp}\`
230
+
231
+ ## Recommended Approach
232
+
233
+ Replace this starter plan with the technical approach for the first real feature or change request before implementation begins.
234
+
235
+ ## Implementation Outline
236
+
237
+ 1. Refine \`specs/${branch}/spec.md\` with a real request.
238
+ 2. Refine this plan with concrete files, validation, and rollout notes.
239
+ 3. Replace \`specs/${branch}/tasks.md\` with executable task blocks for the real work.
240
+ 4. Run \`sdd status --branch ${branch}\` and inspect the selected task before implementation.
241
+
242
+ ## Validation Strategy
243
+
244
+ - Run \`sdd status --branch ${branch}\` after replacing the starter docs.
245
+ - Add project-specific validation commands to each real task block.
246
+
247
+ ## Safety Notes
248
+
249
+ - Do not run background agents from this starter plan.
250
+ - Do not create worktrees from this starter plan.
251
+ - Do not apply sync-back unless the user explicitly approves writing \`tasks.md\`.
252
+ `;
253
+ }
254
+ function renderInitTasksDocument(branch, timestamp) {
255
+ return `---
256
+ template: sdd-init-onboarding-tasks-v1
257
+ version: 1.3.0
258
+ contract: sdd-tasks-doc-v1
259
+ sdd_managed_starter: true
260
+ ---
261
+
262
+ # Tasks: Project Onboarding
263
+
264
+ ## Metadata
265
+
266
+ - spec_id: \`onboarding\`
267
+ - plan_id: \`onboarding\`
268
+ - branch: \`${branch}\`
269
+ - created_at: \`${timestamp}\`
270
+ - updated_at: \`${timestamp}\`
271
+
272
+ ## Task List
273
+
274
+ ### ONBOARDING-1: Replace starter SDD documents with the first real task
275
+
276
+ \`\`\`sdd-task
277
+ id: ONBOARDING-1
278
+ status: pending
279
+ wave: 1
280
+ depends_on: []
281
+ affected_files:
282
+ - specs/${branch}/spec.md
283
+ - specs/${branch}/plan.md
284
+ - specs/${branch}/tasks.md
285
+ validation:
286
+ - sdd status --branch ${branch}
287
+ risk: []
288
+ \`\`\`
289
+
290
+ #### Boundary
291
+
292
+ Allowed scope is limited to replacing this starter onboarding scaffold with project-specific SDD requirements, plan, and tasks. Do not create worktrees, start background agents, commit changes, or apply sync-back automatically.
293
+
294
+ #### Acceptance
295
+
296
+ - \`specs/${branch}/spec.md\` describes a real user request.
297
+ - \`specs/${branch}/plan.md\` describes a concrete technical approach and validation strategy.
298
+ - \`specs/${branch}/tasks.md\` contains executable task blocks for the real work.
299
+ - \`sdd status --branch ${branch}\` reports no blocking document or task parser gaps.
300
+
301
+ #### Implementation Notes
302
+
303
+ Created by \`sdd init\` as a safe onboarding placeholder. Replace before real implementation.
304
+
305
+ ## Dependency Notes
306
+
307
+ - Single starter task only.
308
+ - The \`wave: 1\` field is present only because the current parser requires a positive wave value; it must not be interpreted as permission to run background agents or multi-wave orchestration.
309
+
310
+ ## Phase Gate Checkpoint
311
+
312
+ - ready_for_implementation: \`false\`
313
+ - blockers:
314
+ - Replace onboarding placeholders with real project requirements before implementation.
315
+ - required_user_decisions:
316
+ - Confirm the first real feature/change request.
317
+ `;
318
+ }
319
+ export async function readProjectConfig(projectRoot) {
320
+ const configPath = getProjectConfigPath(projectRoot);
321
+ const raw = await readFile(configPath, 'utf8');
322
+ return parseProjectConfig(raw, configPath);
323
+ }
324
+ export async function createRun(projectRoot, options = {}) {
325
+ await readProjectConfig(projectRoot);
326
+ await mkdir(getRunsDir(projectRoot), { recursive: true });
327
+ const runId = options.runId ?? await createUniqueRunId(projectRoot);
328
+ const runDir = getRunDir(projectRoot, runId);
329
+ const artifactsDir = getArtifactsDir(projectRoot, runId);
330
+ await mkdir(artifactsDir, { recursive: true });
331
+ const now = new Date().toISOString();
332
+ const state = {
333
+ contract: RUN_STATE_CONTRACT,
334
+ runtimeVersion: RUNTIME_VERSION,
335
+ runId,
336
+ status: 'created',
337
+ phase: null,
338
+ currentTask: null,
339
+ createdAt: now,
340
+ updatedAt: now,
341
+ projectRoot: path.resolve(projectRoot),
342
+ projectConfigPath: path.relative(projectRoot, getProjectConfigPath(projectRoot)),
343
+ eventLogPath: path.relative(projectRoot, path.join(runDir, 'events.jsonl')),
344
+ artifactRoot: path.relative(projectRoot, artifactsDir),
345
+ lifecycleDecision: options.lifecycleDecision ?? emptyLifecycleDecisionRecord(),
346
+ tasks: {},
347
+ agents: {},
348
+ delegations: {},
349
+ artifacts: [],
350
+ artifactIngestions: {},
351
+ worktrees: {},
352
+ validation: {
353
+ status: 'not_run',
354
+ commands: [],
355
+ evidence: []
356
+ },
357
+ syncBack: {
358
+ mode: 'proposal',
359
+ proposalPath: null,
360
+ status: 'not_created'
361
+ }
362
+ };
363
+ await writeRunState(projectRoot, state);
364
+ await appendEvent(projectRoot, runId, {
365
+ event: 'run_started',
366
+ runId,
367
+ summary: 'Run created by Phase 1.2 runtime skeleton',
368
+ data: {
369
+ runtimeVersion: RUNTIME_VERSION,
370
+ statePath: path.relative(projectRoot, path.join(runDir, 'state.json'))
371
+ }
372
+ });
373
+ if (state.lifecycleDecision) {
374
+ await appendEvent(projectRoot, runId, {
375
+ event: 'lifecycle_decision_recorded',
376
+ runId,
377
+ summary: 'Lifecycle decision placeholder recorded for Phase 1.7 command gate',
378
+ data: {
379
+ contract: LIFECYCLE_DECISION_CONTRACT,
380
+ modelVersion: state.lifecycleDecision.model_version,
381
+ profile: state.lifecycleDecision.decision.profile,
382
+ confidence: state.lifecycleDecision.decision.confidence
383
+ }
384
+ });
385
+ }
386
+ return state;
387
+ }
388
+ export async function readRunState(projectRoot, runId) {
389
+ const statePath = path.join(getRunDir(projectRoot, runId), 'state.json');
390
+ const raw = await readFile(statePath, 'utf8');
391
+ return JSON.parse(raw);
392
+ }
393
+ export async function writeRunState(projectRoot, state) {
394
+ const nextState = {
395
+ ...state,
396
+ updatedAt: new Date().toISOString()
397
+ };
398
+ const statePath = path.join(getRunDir(projectRoot, state.runId), 'state.json');
399
+ await writeFile(statePath, `${JSON.stringify(nextState, null, 2)}\n`, 'utf8');
400
+ }
401
+ export async function appendEvent(projectRoot, runId, event) {
402
+ const nextEvent = {
403
+ contract: EVENT_LOG_CONTRACT,
404
+ time: new Date().toISOString(),
405
+ ...event
406
+ };
407
+ const eventPath = path.join(getRunDir(projectRoot, runId), 'events.jsonl');
408
+ await appendFile(eventPath, `${JSON.stringify(nextEvent)}\n`, 'utf8');
409
+ return nextEvent;
410
+ }
411
+ export async function archiveRun(projectRoot, runId, options = {}) {
412
+ const state = await readRunState(projectRoot, runId);
413
+ const terminalEventAt = new Date().toISOString();
414
+ const delegations = Object.fromEntries(Object.entries(state.delegations).map(([delegationId, delegation]) => [
415
+ delegationId,
416
+ delegation.status === 'RUNNING'
417
+ ? { ...delegation, status: 'CANCELLED', terminalEventAt }
418
+ : delegation
419
+ ]));
420
+ for (const delegation of Object.values(delegations)) {
421
+ if (delegation.status === 'CANCELLED' && state.delegations[delegation.delegationId]?.status === 'RUNNING') {
422
+ await appendEvent(projectRoot, runId, {
423
+ event: 'delegation_cancelled',
424
+ runId,
425
+ summary: `${delegation.delegationId} cancelled because run was archived.`,
426
+ data: { delegationId: delegation.delegationId, reason: options.reason ?? null }
427
+ });
428
+ }
429
+ }
430
+ await writeRunState(projectRoot, {
431
+ ...state,
432
+ status: 'archived',
433
+ delegations
434
+ });
435
+ await appendEvent(projectRoot, runId, {
436
+ event: 'run_archived',
437
+ runId,
438
+ summary: options.reason ? `Run archived: ${options.reason}` : 'Run archived.',
439
+ data: { reason: options.reason ?? null }
440
+ });
441
+ return readRunState(projectRoot, runId);
442
+ }
443
+ export async function listRuns(projectRoot) {
444
+ const runsDir = getRunsDir(projectRoot);
445
+ if (!await exists(runsDir)) {
446
+ return [];
447
+ }
448
+ const entries = await readdir(runsDir, { withFileTypes: true });
449
+ const summaries = [];
450
+ for (const entry of entries.filter((candidate) => candidate.isDirectory())) {
451
+ try {
452
+ const state = await readRunState(projectRoot, entry.name);
453
+ summaries.push(summarizeRunState(state));
454
+ }
455
+ catch {
456
+ continue;
457
+ }
458
+ }
459
+ return summaries.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
460
+ }
461
+ export async function rebuildLocalRunIndex(projectRoot) {
462
+ const index = await buildLocalRunIndexSnapshot(projectRoot);
463
+ await writeFile(getLocalRunIndexPath(projectRoot), `${JSON.stringify(index, null, 2)}\n`, 'utf8');
464
+ return index;
465
+ }
466
+ export async function readLocalRunIndex(projectRoot) {
467
+ const raw = await readFile(getLocalRunIndexPath(projectRoot), 'utf8');
468
+ return JSON.parse(raw);
469
+ }
470
+ export async function queryLocalRunIndex(projectRoot, query = {}) {
471
+ const index = await readLocalRunIndex(projectRoot);
472
+ const runIds = new Set(index.runs
473
+ .filter((run) => !query.runId || run.runId === query.runId)
474
+ .filter((run) => !query.status || run.status === query.status)
475
+ .filter((run) => !query.taskId || run.taskIds.includes(query.taskId))
476
+ .filter((run) => !query.artifact || index.artifacts.some((artifact) => artifact.runId === run.runId && artifact.path === query.artifact))
477
+ .map((run) => run.runId));
478
+ return {
479
+ ...index,
480
+ runs: index.runs.filter((run) => runIds.has(run.runId)),
481
+ tasks: index.tasks.filter((task) => runIds.has(task.runId) && (!query.taskId || task.taskId === query.taskId)),
482
+ delegations: index.delegations.filter((delegation) => runIds.has(delegation.runId) && (!query.taskId || delegation.taskId === query.taskId)),
483
+ artifacts: index.artifacts.filter((artifact) => runIds.has(artifact.runId) && (!query.taskId || artifact.task === query.taskId) && (!query.artifact || artifact.path === query.artifact)),
484
+ waves: index.waves.filter((wave) => runIds.has(wave.runId))
485
+ };
486
+ }
487
+ export async function inspectLocalRunIndex(projectRoot) {
488
+ const indexPath = getLocalRunIndexPath(projectRoot);
489
+ if (!await exists(indexPath)) {
490
+ return {
491
+ valid: false,
492
+ exists: false,
493
+ indexPath,
494
+ index: null,
495
+ issues: [contractIssue('run_index', 'Local run index is missing.', 'Run sdd run index rebuild to recreate the derived index from .sdd/runs.')]
496
+ };
497
+ }
498
+ try {
499
+ const index = await readLocalRunIndex(projectRoot);
500
+ const rebuilt = await buildLocalRunIndexSnapshot(projectRoot);
501
+ const issues = [];
502
+ if (index.contract !== LOCAL_RUN_INDEX_CONTRACT_VERSION) {
503
+ issues.push(contractIssue('contract', `Local run index contract is ${index.contract}.`, 'Run sdd run index rebuild to refresh the index contract.'));
504
+ }
505
+ if (JSON.stringify(index.runs) !== JSON.stringify(rebuilt.runs)) {
506
+ issues.push(contractIssue('runs', 'Local run index run summaries differ from .sdd/runs state.', 'Run sdd run index rebuild.'));
507
+ }
508
+ if (JSON.stringify(index.tasks) !== JSON.stringify(rebuilt.tasks)) {
509
+ issues.push(contractIssue('tasks', 'Local run index task entries differ from .sdd/runs state.', 'Run sdd run index rebuild.'));
510
+ }
511
+ if (JSON.stringify(index.delegations) !== JSON.stringify(rebuilt.delegations)) {
512
+ issues.push(contractIssue('delegations', 'Local run index delegation entries differ from .sdd/runs state.', 'Run sdd run index rebuild.'));
513
+ }
514
+ if (JSON.stringify(index.artifacts) !== JSON.stringify(rebuilt.artifacts)) {
515
+ issues.push(contractIssue('artifacts', 'Local run index artifact entries differ from .sdd/runs state.', 'Run sdd run index rebuild.'));
516
+ }
517
+ if (JSON.stringify(index.waves) !== JSON.stringify(rebuilt.waves)) {
518
+ issues.push(contractIssue('waves', 'Local run index wave summaries differ from .sdd/runs events.', 'Run sdd run index rebuild.'));
519
+ }
520
+ return {
521
+ valid: issues.length === 0,
522
+ exists: true,
523
+ indexPath,
524
+ index,
525
+ issues
526
+ };
527
+ }
528
+ catch (error) {
529
+ return {
530
+ valid: false,
531
+ exists: true,
532
+ indexPath,
533
+ index: null,
534
+ issues: [contractIssue('run_index', `Cannot read local run index: ${messageFromError(error)}`, 'Run sdd run index rebuild to recreate the derived index.')]
535
+ };
536
+ }
537
+ }
538
+ async function buildLocalRunIndexSnapshot(projectRoot) {
539
+ const runsDir = getRunsDir(projectRoot);
540
+ const states = [];
541
+ if (await exists(runsDir)) {
542
+ const entries = await readdir(runsDir, { withFileTypes: true });
543
+ for (const entry of entries.filter((candidate) => candidate.isDirectory())) {
544
+ try {
545
+ states.push(await readRunState(projectRoot, entry.name));
546
+ }
547
+ catch {
548
+ continue;
549
+ }
550
+ }
551
+ }
552
+ const tasks = [];
553
+ const artifacts = [];
554
+ const delegations = [];
555
+ const waves = [];
556
+ for (const state of states) {
557
+ for (const [taskId, taskState] of Object.entries(state.tasks)) {
558
+ tasks.push({
559
+ taskId,
560
+ status: runtimeTaskStatus(taskState),
561
+ runId: state.runId,
562
+ runStatus: state.status,
563
+ updatedAt: state.updatedAt
564
+ });
565
+ }
566
+ for (const artifact of state.artifacts) {
567
+ artifacts.push({
568
+ ...artifact,
569
+ runId: state.runId
570
+ });
571
+ }
572
+ delegations.push(...Object.values(state.delegations).map((delegation) => delegationQueueItemFromRunState(state, delegation)));
573
+ const waveEvents = (await readRunEvents(projectRoot, state.runId)).filter((event) => event.event.startsWith('wave_executor_'));
574
+ if (waveEvents.length > 0) {
575
+ waves.push({
576
+ runId: state.runId,
577
+ eventCount: waveEvents.length,
578
+ lastEvent: waveEvents[waveEvents.length - 1]?.event ?? null
579
+ });
580
+ }
581
+ }
582
+ return {
583
+ contract: LOCAL_RUN_INDEX_CONTRACT_VERSION,
584
+ generatedAt: new Date().toISOString(),
585
+ runs: states.map((state) => summarizeRunState(state)).sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)),
586
+ tasks: tasks.sort((left, right) => left.taskId.localeCompare(right.taskId) || left.runId.localeCompare(right.runId)),
587
+ delegations: delegations.sort((left, right) => left.id.localeCompare(right.id)),
588
+ artifacts: artifacts.sort((left, right) => left.path.localeCompare(right.path) || left.runId.localeCompare(right.runId)),
589
+ waves: waves.sort((left, right) => left.runId.localeCompare(right.runId))
590
+ };
591
+ }
592
+ export async function inspectRun(projectRoot, runId) {
593
+ const state = await readRunState(projectRoot, runId);
594
+ const events = await readRunEvents(projectRoot, runId);
595
+ return {
596
+ summary: summarizeRunState(state),
597
+ state,
598
+ events,
599
+ eventCount: events.length,
600
+ recentEvents: events.slice(-10),
601
+ artifacts: state.artifacts,
602
+ artifactIngestions: Object.values(state.artifactIngestions ?? {}),
603
+ worktrees: Object.values(state.worktrees ?? {}),
604
+ validation: state.validation,
605
+ syncBack: state.syncBack,
606
+ tasks: state.tasks
607
+ };
608
+ }
609
+ export async function getProjectStatus(projectRoot, options = {}) {
610
+ const branch = options.branch ?? 'master';
611
+ const [model, runs] = await Promise.all([parseSddBranch(projectRoot, branch), listRuns(projectRoot)]);
612
+ const pendingTask = model.tasks.find((task) => task.status === 'pending');
613
+ const blockingGaps = model.gaps.filter((gap) => gap.severity === 'blocking');
614
+ return {
615
+ branch,
616
+ documents: model.documents,
617
+ tasks: {
618
+ total: model.tasks.length,
619
+ pending: model.tasks.filter((task) => task.status === 'pending').length,
620
+ inProgress: model.tasks.filter((task) => task.status === 'in_progress').length,
621
+ completed: model.tasks.filter((task) => task.status === 'completed').length,
622
+ blocked: model.tasks.filter((task) => task.status === 'blocked').length,
623
+ deferred: model.tasks.filter((task) => task.status === 'deferred').length,
624
+ unknown: model.tasks.filter((task) => task.status === 'unknown').length,
625
+ gaps: model.gaps.length
626
+ },
627
+ latestRun: runs[0] ?? null,
628
+ recommendedNextCommand: blockingGaps.length > 0
629
+ ? `sdd tasks gaps --branch ${branch}`
630
+ : runs[0]?.syncBackStatus === 'proposed' && runs[0].currentTask
631
+ ? `sdd sync-back inspect ${runs[0].runId} --branch ${branch} --task ${runs[0].currentTask}`
632
+ : pendingTask
633
+ ? `sdd tasks inspect ${pendingTask.id} --branch ${branch}`
634
+ : `sdd tasks list --branch ${branch}`,
635
+ gaps: model.gaps
636
+ };
637
+ }
638
+ function deriveSyncBackApplyPolicy(state, task) {
639
+ const reasons = [];
640
+ const decision = state.lifecycleDecision?.decision;
641
+ if (!task) {
642
+ reasons.push('Target task is missing, so sync-back apply cannot be classified as direct-safe.');
643
+ }
644
+ if (decision?.human_checkpoint_required === true) {
645
+ reasons.push('Lifecycle decision requires a human checkpoint.');
646
+ }
647
+ if (decision?.profile === 'full' || decision?.profile === 'research') {
648
+ reasons.push(`Lifecycle profile is ${decision.profile}.`);
649
+ }
650
+ if ((decision?.hard_gate_hits.length ?? 0) > 0) {
651
+ reasons.push(`Lifecycle hard gates were hit: ${decision?.hard_gate_hits.join(', ')}.`);
652
+ }
653
+ if ((task?.risk.length ?? 0) > 0) {
654
+ reasons.push(`Task declares risk tags: ${task?.risk.join(', ')}.`);
655
+ }
656
+ if ((task?.dependsOn.length ?? 0) > 0) {
657
+ reasons.push(`Task depends on ${task?.dependsOn.length} other task(s).`);
658
+ }
659
+ if ((task?.affectedFiles.length ?? 0) > 3) {
660
+ reasons.push(`Task affects ${task?.affectedFiles.length} files.`);
661
+ }
662
+ if (reasons.length > 0) {
663
+ return {
664
+ mode: 'confirm',
665
+ requiresApproval: true,
666
+ reasons
667
+ };
668
+ }
669
+ return {
670
+ mode: 'direct',
671
+ requiresApproval: false,
672
+ reasons: ['Task is direct-safe: no checkpoint, hard gate, risk tag, dependency, or broad file fan-out was detected.']
673
+ };
674
+ }
675
+ export async function inspectSyncBack(projectRoot, options) {
676
+ const branch = options.branch ?? 'master';
677
+ const state = await readRunState(projectRoot, options.runId);
678
+ const taskId = options.taskId ?? state.currentTask;
679
+ const model = await parseSddBranch(projectRoot, branch);
680
+ const reasons = [];
681
+ let markdownTask = null;
682
+ let taskGaps = [];
683
+ if (!taskId) {
684
+ reasons.push('Run has no current task; pass --task <task_id>.');
685
+ }
686
+ else {
687
+ const inspected = inspectSddTask(model, taskId);
688
+ markdownTask = inspected.task;
689
+ taskGaps = inspected.gaps;
690
+ if (!inspected.task) {
691
+ reasons.push(`Task ${taskId} is missing or ambiguous in specs/${branch}/tasks.md.`);
692
+ }
693
+ }
694
+ const proposalPath = state.syncBack.proposalPath;
695
+ let proposal = null;
696
+ if (!proposalPath) {
697
+ reasons.push('Run has no sync-back proposal.');
698
+ }
699
+ else {
700
+ try {
701
+ proposal = await readArtifact(projectRoot, state.runId, toArtifactRootRelativePath(proposalPath));
702
+ }
703
+ catch (error) {
704
+ reasons.push(`Cannot read sync-back proposal ${proposalPath}: ${messageFromError(error)}`);
705
+ }
706
+ }
707
+ const runtimeGaps = taskId ? runtimeTaskGaps(state.tasks[taskId]) : [];
708
+ const blockingGaps = [...taskGaps, ...runtimeGaps].filter((gap) => gap.severity === 'blocking');
709
+ if (state.status !== 'completed') {
710
+ reasons.push(`Run status is ${state.status}, expected completed.`);
711
+ }
712
+ if (state.validation.status !== 'pass') {
713
+ reasons.push(`Run validation status is ${state.validation.status}, expected pass.`);
714
+ }
715
+ if (blockingGaps.length > 0) {
716
+ reasons.push(`Sync-back is blocked by ${blockingGaps.length} blocking gap(s).`);
717
+ }
718
+ const applyPolicy = deriveSyncBackApplyPolicy(state, markdownTask);
719
+ return {
720
+ runId: state.runId,
721
+ branch,
722
+ taskId,
723
+ status: state.syncBack.status === 'applied' ? 'applied' : reasons.length === 0 ? 'ready' : 'blocked',
724
+ reasons,
725
+ proposalPath,
726
+ proposal,
727
+ runTaskStatus: taskId ? runtimeTaskStatus(state.tasks[taskId]) : null,
728
+ markdownTask,
729
+ markdownStatus: markdownTask?.status ?? null,
730
+ targetTasksPath: model.tasksPath,
731
+ artifacts: state.validation.evidence.length > 0 ? state.validation.evidence : state.artifacts.map((artifact) => artifact.path),
732
+ gaps: [...taskGaps, ...runtimeGaps],
733
+ applyPolicy
734
+ };
735
+ }
736
+ export async function applySyncBack(projectRoot, options) {
737
+ const inspection = await inspectSyncBack(projectRoot, options);
738
+ if (!inspection.taskId) {
739
+ throw new Error('Cannot apply sync-back without a task id.');
740
+ }
741
+ if (inspection.status === 'blocked') {
742
+ throw new Error(`Cannot apply sync-back for ${options.runId}: ${inspection.reasons.join(' ')}`);
743
+ }
744
+ if (!inspection.markdownTask) {
745
+ throw new Error(`Cannot apply sync-back for ${options.runId}: target task is missing.`);
746
+ }
747
+ if (inspection.status === 'applied') {
748
+ return {
749
+ runId: inspection.runId,
750
+ taskId: inspection.taskId,
751
+ applied: false,
752
+ tasksPath: inspection.markdownTask.source.filePath,
753
+ inspection,
754
+ message: `Sync-back for ${inspection.runId}/${inspection.taskId} was already applied.`
755
+ };
756
+ }
757
+ if (inspection.applyPolicy.requiresApproval && options.approved !== true) {
758
+ throw new Error(`Cannot apply sync-back for ${options.runId}: ${inspection.applyPolicy.reasons.join(' ')} Re-run with --approved after human confirmation.`);
759
+ }
760
+ const state = await readRunState(projectRoot, options.runId);
761
+ const tasksPath = inspection.markdownTask.source.filePath;
762
+ const rawTasks = await readFile(tasksPath, 'utf8');
763
+ const note = syncBackImplementationNote(state, inspection);
764
+ const nextTasks = applySyncBackToTasksMarkdown(rawTasks, inspection.markdownTask, note);
765
+ await writeFile(tasksPath, nextTasks, 'utf8');
766
+ await writeRunState(projectRoot, {
767
+ ...state,
768
+ syncBack: {
769
+ ...state.syncBack,
770
+ status: 'applied'
771
+ }
772
+ });
773
+ await appendEvent(projectRoot, state.runId, {
774
+ event: 'sync_back_applied',
775
+ runId: state.runId,
776
+ summary: `Sync-back applied for ${inspection.taskId}`,
777
+ data: {
778
+ task: inspection.taskId,
779
+ branch: inspection.branch,
780
+ tasksPath: path.relative(projectRoot, tasksPath),
781
+ proposal: inspection.proposalPath
782
+ }
783
+ });
784
+ const appliedInspection = await inspectSyncBack(projectRoot, options);
785
+ return {
786
+ runId: state.runId,
787
+ taskId: inspection.taskId,
788
+ applied: true,
789
+ tasksPath,
790
+ inspection: appliedInspection,
791
+ message: `Sync-back applied for ${state.runId}/${inspection.taskId}.`
792
+ };
793
+ }
794
+ export async function doctor(projectRoot, options = {}) {
795
+ const checks = [];
796
+ const gitRoot = await getGitRoot(projectRoot);
797
+ checks.push(gitRoot
798
+ ? { level: 'PASS', check: 'git_repo', message: `Git repository detected at ${gitRoot}` }
799
+ : { level: 'FAIL', check: 'git_repo', message: 'Current directory is not inside a Git repository.', action: 'Run sdd commands from a project Git repository.' });
800
+ const configPath = getProjectConfigPath(projectRoot);
801
+ if (await exists(configPath)) {
802
+ try {
803
+ await readProjectConfig(projectRoot);
804
+ checks.push({ level: 'PASS', check: 'project_config', message: `.sdd/project.yml is readable and uses ${PROJECT_CONFIG_CONTRACT}.` });
805
+ }
806
+ catch (error) {
807
+ checks.push({ level: 'FAIL', check: 'project_config', message: `Cannot parse .sdd/project.yml: ${messageFromError(error)}`, action: 'Run sdd init or fix the required project.yml keys.' });
808
+ }
809
+ }
810
+ else {
811
+ checks.push({ level: 'FAIL', check: 'project_config', message: '.sdd/project.yml is missing.', action: 'Run sdd init.' });
812
+ }
813
+ const runsDir = getRunsDir(projectRoot);
814
+ if (await exists(runsDir)) {
815
+ try {
816
+ await access(runsDir, constants.R_OK | constants.W_OK);
817
+ checks.push({ level: 'PASS', check: 'runs_dir', message: '.sdd/runs exists and is readable/writable.' });
818
+ checks.push(...await inspectRunEvidence(projectRoot, options));
819
+ checks.push(...await inspectLocalRunIndexEvidence(projectRoot));
820
+ }
821
+ catch {
822
+ checks.push({ level: 'FAIL', check: 'runs_dir', message: '.sdd/runs is not readable/writable.', action: 'Fix filesystem permissions for .sdd/runs.' });
823
+ }
824
+ }
825
+ else {
826
+ checks.push({ level: 'WARN', check: 'runs_dir', message: '.sdd/runs does not exist yet.', action: 'Run sdd init or sdd run create.' });
827
+ }
828
+ const specsDir = path.join(projectRoot, 'specs');
829
+ checks.push(await exists(specsDir)
830
+ ? { level: 'PASS', check: 'specs_dir', message: 'specs directory exists.' }
831
+ : { level: 'WARN', check: 'specs_dir', message: 'specs directory is missing.', action: 'Create specs/<branch>/ documents before full SDD execution.' });
832
+ if (await exists(configPath)) {
833
+ checks.push(...await inspectAiToolEntryEvidence(projectRoot));
834
+ }
835
+ checks.push(...await inspectCapabilityRegistry(projectRoot));
836
+ checks.push(...await inspectToolPluginContracts(projectRoot));
837
+ checks.push(...await inspectDelegationQueueContract(projectRoot));
838
+ checks.push(...await inspectDelegationStateMachineContract(projectRoot));
839
+ checks.push(...await inspectWorkerAdapterContracts(projectRoot));
840
+ checks.push(...await inspectWorktreeIsolationContract(projectRoot));
841
+ checks.push(...await inspectWorktreeLifecycleContract(projectRoot));
842
+ checks.push(...await inspectTaskGraphPlannerContract(projectRoot));
843
+ checks.push(...await inspectWavePlannerContract(projectRoot));
844
+ checks.push(...await inspectBackgroundExecutorContract(projectRoot));
845
+ checks.push(...await inspectWaveExecutorContract(projectRoot));
846
+ checks.push(...await inspectLocalRunIndexContract(projectRoot));
847
+ checks.push(...await inspectGovernancePolicyContract(projectRoot));
848
+ return {
849
+ status: summarizeDoctorStatus(checks),
850
+ checks
851
+ };
852
+ }
853
+ export async function parseSddBranch(projectRoot, branch = 'master') {
854
+ assertSafePathSegment(branch, 'branch');
855
+ const specPath = path.join(projectRoot, 'specs', branch, 'spec.md');
856
+ const planPath = path.join(projectRoot, 'specs', branch, 'plan.md');
857
+ const tasksPath = path.join(projectRoot, 'specs', branch, 'tasks.md');
858
+ const [specExists, planExists, tasksExists] = await Promise.all([exists(specPath), exists(planPath), exists(tasksPath)]);
859
+ const gaps = [];
860
+ if (!specExists) {
861
+ gaps.push(documentGap('spec.md', 'Spec document is missing.', 'Create or restore specs/<branch>/spec.md before full SDD execution.'));
862
+ }
863
+ if (!planExists) {
864
+ gaps.push(documentGap('plan.md', 'Plan document is missing.', 'Create or restore specs/<branch>/plan.md before task execution.'));
865
+ }
866
+ if (!tasksExists) {
867
+ gaps.push(documentGap('tasks.md', 'Tasks document is missing.', 'Create specs/<branch>/tasks.md with sdd-task fenced blocks.'));
868
+ return {
869
+ branch,
870
+ specPath,
871
+ planPath,
872
+ tasksPath,
873
+ documents: { specExists, planExists, tasksExists },
874
+ tasks: [],
875
+ gaps
876
+ };
877
+ }
878
+ const rawTasks = await readFile(tasksPath, 'utf8');
879
+ const taskModel = parseSddTasksMarkdown(rawTasks, { tasksPath });
880
+ if (taskModel.tasks.length === 0 && !path.basename(tasksPath).startsWith('phase')) {
881
+ const retainedModel = await parseRetainedPhaseTasks(path.dirname(tasksPath));
882
+ if (retainedModel.tasks.length > 0) {
883
+ return {
884
+ branch,
885
+ specPath,
886
+ planPath,
887
+ tasksPath,
888
+ documents: { specExists, planExists, tasksExists },
889
+ tasks: retainedModel.tasks,
890
+ gaps: [...gaps, ...retainedModel.gaps]
891
+ };
892
+ }
893
+ }
894
+ return {
895
+ branch,
896
+ specPath,
897
+ planPath,
898
+ tasksPath,
899
+ documents: { specExists, planExists, tasksExists },
900
+ tasks: taskModel.tasks,
901
+ gaps: [...gaps, ...taskModel.gaps]
902
+ };
903
+ }
904
+ export function parseSddTasksMarkdown(raw, options = {}) {
905
+ const tasksPath = options.tasksPath ?? 'tasks.md';
906
+ const fencedBlocks = Array.from(raw.matchAll(/^\s*```sdd-task\s*\r?\n([\s\S]*?)\r?^\s*```\s*$/gm));
907
+ const tasks = [];
908
+ const gaps = [];
909
+ if (fencedBlocks.length === 0) {
910
+ gaps.push({
911
+ type: 'Task Gap',
912
+ severity: 'blocking',
913
+ taskId: null,
914
+ field: 'sdd-task',
915
+ message: 'No sdd-task fenced blocks found in tasks.md.',
916
+ recommendation: 'Add one sdd-task fenced block per executable task.'
917
+ });
918
+ return { tasks, gaps };
919
+ }
920
+ const seenIds = new Map();
921
+ for (const blockMatch of fencedBlocks) {
922
+ const block = blockMatch[1] ?? '';
923
+ const blockStart = blockMatch.index ?? 0;
924
+ const blockEnd = blockStart + blockMatch[0].length;
925
+ const lineStart = lineNumberAt(raw, blockStart);
926
+ const lineEnd = lineNumberAt(raw, blockEnd);
927
+ const heading = nearestTaskHeading(raw.slice(0, blockStart));
928
+ const metadata = parseSimpleYamlBlock(block);
929
+ const id = scalarValue(metadata.id);
930
+ const taskId = id || heading?.id || null;
931
+ const section = raw.slice(blockEnd, nextTaskStart(raw, blockEnd));
932
+ const parsedSections = parseTaskCompanionSections(section);
933
+ if (!taskId) {
934
+ gaps.push({
935
+ type: 'Task Gap',
936
+ severity: 'blocking',
937
+ taskId: null,
938
+ field: 'id',
939
+ message: `sdd-task block starting at line ${lineStart} is missing id.`,
940
+ recommendation: 'Add a stable id field such as id: T1.'
941
+ });
942
+ continue;
943
+ }
944
+ const source = {
945
+ filePath: tasksPath,
946
+ heading: heading?.raw ?? null,
947
+ lineStart,
948
+ lineEnd
949
+ };
950
+ const priorSource = seenIds.get(taskId);
951
+ if (priorSource) {
952
+ gaps.push({
953
+ type: 'Task Gap',
954
+ severity: 'blocking',
955
+ taskId,
956
+ field: 'id',
957
+ message: `Duplicate task id ${taskId} in ${taskSourceEvidence({ id: taskId, source })} and ${sourceLocationEvidence(priorSource)}.`,
958
+ recommendation: 'Keep task ids unique within a spec branch.'
959
+ });
960
+ }
961
+ seenIds.set(taskId, source);
962
+ const task = {
963
+ id: taskId,
964
+ title: heading?.title ?? null,
965
+ status: parseTaskStatus(scalarValue(metadata.status)),
966
+ wave: parseWave(scalarValue(metadata.wave)),
967
+ dependsOn: listValue(metadata.depends_on),
968
+ affectedFiles: listValue(metadata.affected_files),
969
+ validation: listValue(metadata.validation),
970
+ risk: listValue(metadata.risk),
971
+ boundary: parsedSections.boundary,
972
+ acceptance: parsedSections.acceptance,
973
+ implementationNotes: parsedSections.implementationNotes,
974
+ rawMetadata: metadata,
975
+ source
976
+ };
977
+ tasks.push(task);
978
+ gaps.push(...validateTask(task));
979
+ }
980
+ if (options.validateDependencies !== false) {
981
+ gaps.push(...validateAggregateTaskSet(tasks));
982
+ }
983
+ return { tasks, gaps };
984
+ }
985
+ export async function renderSddResultArtifactTemplate(projectRoot, options) {
986
+ const branch = options.branch ?? 'master';
987
+ const status = options.status ?? 'PASS';
988
+ if (!isSddResultStatus(status)) {
989
+ throw new Error(`Unsupported sdd-result status ${status}.`);
990
+ }
991
+ const artifactRootRelativePath = toArtifactRootRelativePath(options.artifactPath);
992
+ const runRelativeArtifactPath = getRunRelativeArtifactPath(artifactRootRelativePath);
993
+ const lines = [
994
+ `# ${options.agent} result`,
995
+ '',
996
+ '```sdd-result',
997
+ `contract: ${SDD_RESULT_CONTRACT}`,
998
+ `version: ${SDD_RESULT_VERSION}`,
999
+ `agent: ${options.agent}`,
1000
+ `task: ${options.taskId}`,
1001
+ `status: ${status}`,
1002
+ 'artifacts:',
1003
+ ` - ${runRelativeArtifactPath}`,
1004
+ '```',
1005
+ ''
1006
+ ];
1007
+ let warning = null;
1008
+ let task = null;
1009
+ try {
1010
+ const model = await parseSddBranch(projectRoot, branch);
1011
+ const inspected = inspectSddTask(model, options.taskId);
1012
+ task = inspected.task;
1013
+ if (!task) {
1014
+ warning = `Task ${options.taskId} was not found in specs/${branch}/tasks.md.`;
1015
+ }
1016
+ }
1017
+ catch (error) {
1018
+ warning = `Could not inspect task ${options.taskId} on branch ${branch}: ${messageFromError(error)}`;
1019
+ }
1020
+ if (warning) {
1021
+ lines.push('## Warning', '', `- ${warning}`, '');
1022
+ }
1023
+ if (options.agent === 'validator' && task) {
1024
+ lines.push('## Acceptance Mapping', '');
1025
+ lines.push(...(task.acceptance.length > 0
1026
+ ? task.acceptance.map((acceptance) => `- Acceptance ${acceptance}: TODO. Add validation evidence.`)
1027
+ : ['- No Acceptance items are declared for this task.']));
1028
+ lines.push('', '## Evidence', '');
1029
+ lines.push(...(task.validation.length > 0
1030
+ ? task.validation.map((command) => `- TODO run validation command: ${command}`)
1031
+ : ['- TODO add validation evidence.']));
1032
+ lines.push('');
1033
+ }
1034
+ else {
1035
+ lines.push('## Evidence', '', `- TODO cite files, commands, and task ${options.taskId} evidence here.`, '');
1036
+ }
1037
+ return lines.join('\n');
1038
+ }
1039
+ export async function validateSddResultArtifact(projectRoot, runId, runRelativeArtifactPath, options = {}) {
1040
+ const issues = [];
1041
+ let artifactRootRelativePath;
1042
+ try {
1043
+ artifactRootRelativePath = toArtifactRootRelativePath(runRelativeArtifactPath);
1044
+ }
1045
+ catch (error) {
1046
+ return { valid: false, result: null, issues: [contractIssue('artifacts', messageFromError(error), 'Use a run-relative artifacts/<file> path. Source/test files belong in ## Evidence, not in sdd-result.artifacts.')] };
1047
+ }
1048
+ let raw;
1049
+ try {
1050
+ raw = await readArtifact(projectRoot, runId, artifactRootRelativePath);
1051
+ }
1052
+ catch (error) {
1053
+ return { valid: false, result: null, issues: [contractIssue('artifacts', `Cannot read artifact ${runRelativeArtifactPath}: ${messageFromError(error)}`, 'Create the expected artifact before marking the delegation complete.')] };
1054
+ }
1055
+ if (raw.trim().length === 0) {
1056
+ issues.push(contractIssue('artifacts', `Artifact ${runRelativeArtifactPath} is empty.`, 'Write non-empty evidence and an sdd-result block.'));
1057
+ }
1058
+ const parsed = parseSddResultMarkdown(raw);
1059
+ issues.push(...parsed.issues);
1060
+ if (parsed.result) {
1061
+ issues.push(...validateSddResult(parsed.result, { ...options, runRelativeArtifactPath }));
1062
+ }
1063
+ return { valid: issues.length === 0 && parsed.result !== null, result: parsed.result, issues };
1064
+ }
1065
+ export function parseSddResultMarkdown(raw) {
1066
+ const matches = Array.from(raw.matchAll(/^\s*```sdd-result\s*\r?\n([\s\S]*?)\r?^\s*```\s*$/gm));
1067
+ if (matches.length !== 1) {
1068
+ return {
1069
+ valid: false,
1070
+ result: null,
1071
+ issues: [contractIssue('sdd-result', matches.length === 0 ? 'No sdd-result fenced block found.' : `Expected exactly one sdd-result fenced block, found ${matches.length}.`, 'Embed one machine-readable sdd-result block in the artifact.')]
1072
+ };
1073
+ }
1074
+ const metadata = parseSimpleYamlBlock(matches[0][1] ?? '');
1075
+ const result = buildSddResult(metadata);
1076
+ const issues = result ? validateSddResult(result) : validateSddResultMetadata(metadata);
1077
+ return { valid: issues.length === 0 && result !== null, result, issues };
1078
+ }
1079
+ export function validateSddResult(result, options = {}) {
1080
+ const issues = validateSddResultMetadata(result.rawMetadata);
1081
+ if (options.expectedTask && result.task !== options.expectedTask) {
1082
+ issues.push(contractIssue('task', `sdd-result task ${result.task} does not match expected task ${options.expectedTask}.`, 'Write the delegated task id into the sdd-result task field.'));
1083
+ }
1084
+ if (options.expectedAgent && result.agent !== options.expectedAgent) {
1085
+ issues.push(contractIssue('agent', `sdd-result agent ${result.agent} does not match expected agent ${options.expectedAgent}.`, 'Write the delegated agent name into the sdd-result agent field.'));
1086
+ }
1087
+ if (options.runRelativeArtifactPath && !result.artifacts.includes(options.runRelativeArtifactPath)) {
1088
+ issues.push(contractIssue('artifacts', `sdd-result artifacts does not include its own path ${options.runRelativeArtifactPath}.`, `Add the current artifact path exactly: ${options.runRelativeArtifactPath}.`));
1089
+ }
1090
+ return issues;
1091
+ }
1092
+ export function createDelegationRecord(input) {
1093
+ return {
1094
+ contract: DELEGATION_LIVENESS_CONTRACT,
1095
+ version: DELEGATION_LIVENESS_VERSION,
1096
+ delegationId: input.delegationId,
1097
+ task: input.task,
1098
+ agent: input.agent,
1099
+ runMode: input.runMode ?? 'foreground',
1100
+ blocking: input.blocking ?? true,
1101
+ requiredForPhaseExit: input.requiredForPhaseExit ?? true,
1102
+ status: input.status ?? 'RUNNING',
1103
+ startedAt: input.startedAt ?? new Date().toISOString(),
1104
+ lastHeartbeatAt: null,
1105
+ timeoutSeconds: input.timeoutSeconds ?? 900,
1106
+ expectedArtifact: input.expectedArtifact,
1107
+ terminalEventRequired: true,
1108
+ terminalEventAt: null
1109
+ };
1110
+ }
1111
+ export function isDelegationTerminal(status) {
1112
+ return status === 'COMPLETED' || status === 'FAILED' || status === 'TIMED_OUT' || status === 'CANCELLED';
1113
+ }
1114
+ export function isDelegationStale(delegation, now = new Date()) {
1115
+ if (delegation.status !== 'RUNNING') {
1116
+ return false;
1117
+ }
1118
+ const heartbeatOrStart = delegation.lastHeartbeatAt ?? delegation.startedAt;
1119
+ const timestamp = Date.parse(heartbeatOrStart);
1120
+ if (Number.isNaN(timestamp) || delegation.timeoutSeconds <= 0) {
1121
+ return true;
1122
+ }
1123
+ return now.getTime() - timestamp > delegation.timeoutSeconds * 1000;
1124
+ }
1125
+ export async function validateDelegationRecord(projectRoot, runId, delegation, now = new Date()) {
1126
+ const issues = [];
1127
+ const terminal = isDelegationTerminal(delegation.status);
1128
+ const stale = isDelegationStale(delegation, now);
1129
+ issues.push(...validateDelegationShape(delegation));
1130
+ if (stale) {
1131
+ issues.push(contractIssue('status', `Delegation ${delegation.delegationId} is RUNNING but stale.`, 'Record TIMED_OUT/FAILED/CANCELLED or heartbeat before phase exit.'));
1132
+ }
1133
+ if (delegation.requiredForPhaseExit && delegation.terminalEventRequired && terminal && !delegation.terminalEventAt) {
1134
+ issues.push(contractIssue('terminalEventAt', `Delegation ${delegation.delegationId} is terminal without a terminal event timestamp.`, 'Append and persist the terminal delegation event timestamp.'));
1135
+ }
1136
+ if (delegation.status === 'COMPLETED') {
1137
+ const resultReport = await validateSddResultArtifact(projectRoot, runId, delegation.expectedArtifact, { expectedTask: delegation.task, expectedAgent: delegation.agent });
1138
+ issues.push(...resultReport.issues);
1139
+ }
1140
+ return { valid: issues.length === 0, delegation, terminal, stale, issues };
1141
+ }
1142
+ export async function ingestArtifactResult(projectRoot, runId, input) {
1143
+ const state = await readRunState(projectRoot, runId);
1144
+ const delegation = state.delegations[input.delegationId];
1145
+ if (!delegation) {
1146
+ throw new Error(`Unknown delegation ${input.delegationId} in run ${runId}.`);
1147
+ }
1148
+ const artifactPath = getRunRelativeArtifactPath(toArtifactRootRelativePath(input.artifactPath));
1149
+ const key = artifactIngestionKey(input.delegationId, artifactPath);
1150
+ const existing = (state.artifactIngestions ?? {})[key];
1151
+ if (existing) {
1152
+ return { valid: existing.status === 'accepted', duplicate: true, record: existing, delegation };
1153
+ }
1154
+ const report = await validateSddResultArtifact(projectRoot, runId, artifactPath, { expectedTask: delegation.task, expectedAgent: delegation.agent });
1155
+ const issues = [...report.issues];
1156
+ if (isDelegationTerminal(delegation.status)) {
1157
+ issues.push(contractIssue('delegation', `Delegation ${delegation.delegationId} is already terminal with status ${delegation.status}.`, 'Do not ingest a new artifact into a terminal delegation; create a new delegation id for retry.'));
1158
+ }
1159
+ if (report.valid && delegation.status !== 'RUNNING') {
1160
+ issues.push(contractIssue('delegation', `Delegation ${delegation.delegationId} must be RUNNING before accepting artifact ingestion.`, 'Start or retry the delegation before ingesting terminal result evidence.'));
1161
+ }
1162
+ const now = new Date().toISOString();
1163
+ const targetStatus = report.valid && report.result ? delegationStatusFromResultStatus(report.result.status) : null;
1164
+ const accepted = issues.length === 0 && report.valid && report.result !== null && targetStatus !== null;
1165
+ const gaps = report.result ? artifactIngestionGaps(delegation, report.result.status) : [];
1166
+ const record = {
1167
+ contract: ARTIFACT_RESULT_INGESTION_CONTRACT_VERSION,
1168
+ runId,
1169
+ delegationId: delegation.delegationId,
1170
+ task: delegation.task,
1171
+ agent: delegation.agent,
1172
+ artifactPath,
1173
+ status: accepted ? 'accepted' : 'rejected',
1174
+ resultStatus: report.result?.status ?? null,
1175
+ delegationStatus: accepted ? targetStatus : (report.valid ? null : 'RECOVERABLE'),
1176
+ ingestedAt: now,
1177
+ issues,
1178
+ gaps: accepted ? gaps : []
1179
+ };
1180
+ const nextDelegation = accepted
1181
+ ? { ...delegation, status: targetStatus, expectedArtifact: artifactPath, terminalEventAt: now }
1182
+ : report.valid || delegation.status !== 'RUNNING'
1183
+ ? delegation
1184
+ : { ...delegation, status: 'RECOVERABLE', expectedArtifact: artifactPath };
1185
+ const knownArtifacts = new Set(state.artifacts.map((artifact) => artifact.path));
1186
+ const nextArtifacts = accepted && !knownArtifacts.has(artifactPath)
1187
+ ? [...state.artifacts, { path: artifactPath, kind: artifactKind(artifactPath), task: delegation.task, agent: delegation.agent, createdAt: now }]
1188
+ : state.artifacts;
1189
+ await writeRunState(projectRoot, {
1190
+ ...state,
1191
+ status: accepted && targetStatus === 'COMPLETED' ? state.status : accepted && targetStatus !== 'COMPLETED' ? 'blocked' : state.status,
1192
+ delegations: {
1193
+ ...state.delegations,
1194
+ [delegation.delegationId]: nextDelegation
1195
+ },
1196
+ artifacts: nextArtifacts,
1197
+ artifactIngestions: {
1198
+ ...(state.artifactIngestions ?? {}),
1199
+ [key]: record
1200
+ }
1201
+ });
1202
+ if (!accepted && delegation.status === 'RUNNING' && !report.valid) {
1203
+ await appendEvent(projectRoot, runId, { event: 'artifact_invalid', runId, summary: `Artifact ingestion rejected for ${delegation.delegationId}`, data: { delegationId: delegation.delegationId, artifact: artifactPath, issues } });
1204
+ }
1205
+ await appendEvent(projectRoot, runId, { event: accepted ? 'artifact_ingested' : 'artifact_ingestion_rejected', runId, summary: `Artifact ingestion ${record.status} for ${delegation.delegationId}`, data: { delegationId: delegation.delegationId, artifact: artifactPath, status: record.resultStatus, issues } });
1206
+ if (accepted) {
1207
+ await appendEvent(projectRoot, runId, { event: terminalEventForDelegationStatus(targetStatus), runId, summary: `Delegation ${delegation.delegationId} finished through artifact ingestion`, data: { delegationId: delegation.delegationId, artifact: artifactPath, status: record.resultStatus } });
1208
+ }
1209
+ return { valid: accepted, duplicate: false, record, delegation: nextDelegation };
1210
+ }
1211
+ export async function inspectArtifactResultIngestions(projectRoot, runId) {
1212
+ const state = await readRunState(projectRoot, runId);
1213
+ const records = Object.values(state.artifactIngestions ?? {}).sort((left, right) => left.ingestedAt.localeCompare(right.ingestedAt));
1214
+ const issues = [];
1215
+ const hasLedger = Object.prototype.hasOwnProperty.call(state, 'artifactIngestions');
1216
+ const artifactPaths = new Set(state.artifacts.map((artifact) => artifact.path));
1217
+ for (const record of records) {
1218
+ const delegation = state.delegations[record.delegationId];
1219
+ if (!delegation) {
1220
+ issues.push(contractIssue('delegation', `Ingestion record references missing delegation ${record.delegationId}.`, 'Restore the delegation state or remove the invalid ingestion record from the run evidence.'));
1221
+ continue;
1222
+ }
1223
+ const report = await validateSddResultArtifact(projectRoot, runId, record.artifactPath, { expectedTask: record.task, expectedAgent: record.agent });
1224
+ if (record.status === 'accepted') {
1225
+ if (!report.valid) {
1226
+ issues.push(contractIssue('artifact', `Accepted ingestion artifact ${record.artifactPath} is no longer valid.`, 'Restore the accepted sdd-result artifact or mark the delegation with a new retry id.'));
1227
+ }
1228
+ if (delegation.expectedArtifact !== record.artifactPath || delegation.status !== record.delegationStatus) {
1229
+ issues.push(contractIssue('delegation', `Accepted ingestion ${record.delegationId}/${record.artifactPath} does not match delegation state.`, 'Keep delegation expectedArtifact/status aligned with accepted artifact ingestion evidence.'));
1230
+ }
1231
+ if (!artifactPaths.has(record.artifactPath)) {
1232
+ issues.push(contractIssue('artifacts', `Accepted ingestion artifact ${record.artifactPath} is missing from run artifact index.`, 'Add accepted artifact evidence to state.artifacts through artifact ingestion.'));
1233
+ }
1234
+ }
1235
+ else if (artifactPaths.has(record.artifactPath)) {
1236
+ issues.push(contractIssue('artifacts', `Rejected ingestion artifact ${record.artifactPath} is present in run artifact index.`, 'Rejected artifacts must not be indexed as accepted run evidence.'));
1237
+ }
1238
+ }
1239
+ if (hasLedger) {
1240
+ for (const delegation of Object.values(state.delegations)) {
1241
+ if (isDelegationTerminal(delegation.status) && delegation.expectedArtifact) {
1242
+ const key = artifactIngestionKey(delegation.delegationId, delegation.expectedArtifact);
1243
+ if (!(state.artifactIngestions ?? {})[key]) {
1244
+ issues.push(contractIssue('artifactIngestions', `Terminal delegation ${delegation.delegationId} has no artifact ingestion record for ${delegation.expectedArtifact}.`, 'Ingest terminal artifact evidence through the Phase 3.6 artifact ingestion API.'));
1245
+ }
1246
+ }
1247
+ }
1248
+ }
1249
+ return { runId, contract: ARTIFACT_RESULT_INGESTION_CONTRACT_VERSION, records, valid: issues.length === 0, issues };
1250
+ }
1251
+ export function evaluateLifecycleDecisionGate(input = {}, decidedAt = new Date()) {
1252
+ const signals = normalizeLifecycleSignals(input);
1253
+ const hardGateHits = evaluateLifecycleHardGates(signals);
1254
+ const reasons = [];
1255
+ const escalationTriggers = defaultEscalationTriggers();
1256
+ let profile;
1257
+ if (hardGateHits.includes('external_unknown') || hardGateHits.includes('architecture_decision_required')) {
1258
+ profile = 'research';
1259
+ reasons.push('External unknowns or architecture decisions require research before implementation.');
1260
+ }
1261
+ else if (hardGateHits.some((gate) => FULL_PROFILE_HARD_GATES.includes(gate))) {
1262
+ profile = 'full';
1263
+ reasons.push('Hard gate requires full SDD path with explicit spec, plan, tasks, review, and validation evidence.');
1264
+ }
1265
+ else if (signals.impact_confidence === 'low') {
1266
+ profile = signals.can_scout_impact ? 'compact' : 'research';
1267
+ reasons.push(signals.can_scout_impact ? 'Impact confidence is low but can be bounded by scout evidence, so direct is not allowed.' : 'Impact cannot be estimated safely, so research is required.');
1268
+ }
1269
+ else if (signals.acceptance_clarity === 'low' || signals.validation_clarity === 'unclear') {
1270
+ profile = signals.external_unknown ? 'research' : 'compact';
1271
+ reasons.push('Acceptance or validation signals are insufficient for direct execution.');
1272
+ }
1273
+ else if (matchesDirectWhitelist(signals, hardGateHits)) {
1274
+ profile = 'direct';
1275
+ reasons.push('All direct whitelist conditions hold: clear intent, clear acceptance, high impact confidence, no risk tags, reversible change, and cheap local validation.');
1276
+ }
1277
+ else if (isBoundedCompactChange(signals)) {
1278
+ profile = 'compact';
1279
+ reasons.push('Change is bounded but needs lightweight task boundary or validation context.');
1280
+ }
1281
+ else {
1282
+ profile = 'full';
1283
+ reasons.push('Signals exceed compact boundary or require multi-step SDD evidence.');
1284
+ }
1285
+ if (hardGateHits.length > 0) {
1286
+ reasons.push(`Hard gates hit: ${hardGateHits.join(', ')}.`);
1287
+ }
1288
+ if (signals.policy_hits.length > 0) {
1289
+ reasons.push(`Policy hits require checkpoint attention: ${signals.policy_hits.join(', ')}.`);
1290
+ }
1291
+ if (signals.permission_required.length > 0) {
1292
+ reasons.push(`Permissions require explicit user/Claude Code confirmation: ${signals.permission_required.join(', ')}.`);
1293
+ }
1294
+ const confidence = estimateLifecycleConfidence(signals, hardGateHits);
1295
+ const checkpointRequired = signals.human_checkpoint_required || hardGateHits.includes('database_or_data_loss') || signals.reversibility === 'irreversible' || signals.policy_hits.length > 0 || signals.permission_required.length > 0 || (confidence === 'low' && profile !== 'research');
1296
+ const record = {
1297
+ contract: LIFECYCLE_DECISION_CONTRACT,
1298
+ version: LIFECYCLE_DECISION_VERSION,
1299
+ model_version: 'phase1.0-final',
1300
+ input_summary: {
1301
+ intent_clarity: signals.intent_clarity,
1302
+ acceptance_clarity: signals.acceptance_clarity,
1303
+ estimated_change_size: signals.estimated_change_size,
1304
+ task_count_estimate: signals.task_count_estimate,
1305
+ file_count_estimate: signals.file_count_estimate,
1306
+ impact_surface: signals.affected_layers,
1307
+ affected_contracts: signals.affected_contracts,
1308
+ dependency_fanout: signals.dependency_fanout,
1309
+ impact_confidence: signals.impact_confidence,
1310
+ risk_tags: signals.risk_tags,
1311
+ reversibility: signals.reversibility,
1312
+ validation_clarity: signals.validation_clarity,
1313
+ validation_available: signals.validation_available,
1314
+ validation_cost: signals.validation_cost,
1315
+ orchestration_uncertainty: signals.orchestration_uncertainty,
1316
+ policy_hits: signals.policy_hits,
1317
+ permission_required: signals.permission_required
1318
+ },
1319
+ decision: {
1320
+ profile,
1321
+ confidence,
1322
+ hard_gate_hits: hardGateHits,
1323
+ required_stages: requiredStagesForProfile(profile),
1324
+ skipped_stages: skippedStagesForProfile(profile),
1325
+ human_checkpoint_required: checkpointRequired
1326
+ },
1327
+ reasons,
1328
+ escalation_triggers: escalationTriggers,
1329
+ downgrade_reason: null,
1330
+ audit: {
1331
+ decided_at: decidedAt.toISOString(),
1332
+ decided_by: 'command',
1333
+ policy_version: 'phase1.0-final',
1334
+ source_artifacts: uniqueStrings(['docs/architecture/lifecycle-decision-model.md', ...signals.source_artifacts])
1335
+ }
1336
+ };
1337
+ return {
1338
+ record,
1339
+ checkpointRequired,
1340
+ boundaries: commandIntegrationBoundaries(profile)
1341
+ };
1342
+ }
1343
+ export async function recordLifecycleDecision(projectRoot, runId, record) {
1344
+ const state = await readRunState(projectRoot, runId);
1345
+ const nextState = {
1346
+ ...state,
1347
+ lifecycleDecision: record
1348
+ };
1349
+ await writeRunState(projectRoot, nextState);
1350
+ await appendEvent(projectRoot, runId, {
1351
+ event: 'lifecycle_decision_recorded',
1352
+ runId,
1353
+ summary: `Lifecycle decision recorded by Phase 1.7 command gate: ${record.decision.profile ?? 'unknown'} / ${record.decision.confidence ?? 'unknown'}`,
1354
+ data: {
1355
+ contract: record.contract,
1356
+ modelVersion: record.model_version,
1357
+ profile: record.decision.profile,
1358
+ confidence: record.decision.confidence,
1359
+ hardGateHits: record.decision.hard_gate_hits,
1360
+ humanCheckpointRequired: record.decision.human_checkpoint_required
1361
+ }
1362
+ });
1363
+ return readRunState(projectRoot, runId);
1364
+ }
1365
+ export async function runSingleTaskLoop(projectRoot, options) {
1366
+ const branch = options.branch ?? 'master';
1367
+ const model = await parseSddBranch(projectRoot, branch);
1368
+ const inspected = inspectSddTask(model, options.taskId);
1369
+ const runState = options.runId ? await readRunState(projectRoot, options.runId) : await createRun(projectRoot);
1370
+ const runId = runState.runId;
1371
+ await appendEvent(projectRoot, runId, {
1372
+ event: 'phase_started',
1373
+ runId,
1374
+ summary: `Phase 3.15 ingestion-aware task loop started for ${options.taskId}`,
1375
+ data: { phase: 'do', branch, task: options.taskId }
1376
+ });
1377
+ if (!inspected.task || inspected.gaps.some((gap) => gap.severity === 'blocking')) {
1378
+ const gapArtifact = await writeArtifact(projectRoot, runId, `gap-report-${options.taskId}.md`, renderLoopGapReport(options.taskId, inspected.gaps));
1379
+ const proposal = await writeSyncBackProposal(projectRoot, runId, options.taskId, 'blocked', [gapArtifact.runRelativePath], inspected.gaps, 'Task selection is blocked by parser/task gaps.');
1380
+ await persistLoopState(projectRoot, runId, {
1381
+ status: 'blocked',
1382
+ phase: 'do',
1383
+ taskId: options.taskId,
1384
+ taskState: { status: 'blocked', gaps: inspected.gaps, artifacts: [gapArtifact.runRelativePath] },
1385
+ validationStatus: 'blocked',
1386
+ syncBackProposalPath: proposal.runRelativePath,
1387
+ artifacts: [{ path: gapArtifact.runRelativePath, kind: 'gap-report', task: options.taskId, agent: 'runtime' }]
1388
+ });
1389
+ await appendEvent(projectRoot, runId, {
1390
+ event: 'gap_detected',
1391
+ runId,
1392
+ summary: `Task ${options.taskId} is blocked before implementation.`,
1393
+ data: { gaps: inspected.gaps, artifact: gapArtifact.runRelativePath }
1394
+ });
1395
+ return {
1396
+ runId,
1397
+ taskId: options.taskId,
1398
+ status: 'blocked',
1399
+ task: inspected.task,
1400
+ gaps: inspected.gaps,
1401
+ requiredArtifacts: [],
1402
+ acceptedArtifacts: [gapArtifact.runRelativePath],
1403
+ syncBackProposalPath: proposal.runRelativePath,
1404
+ message: 'Task loop blocked before implementation by task gaps.'
1405
+ };
1406
+ }
1407
+ await appendEvent(projectRoot, runId, {
1408
+ event: 'task_selected',
1409
+ runId,
1410
+ summary: `Task selected for ingestion-aware task loop: ${options.taskId}`,
1411
+ data: { task: options.taskId, title: inspected.task.title, source: inspected.task.source }
1412
+ });
1413
+ const steps = buildLoopSteps(options.taskId, options);
1414
+ const acceptedArtifacts = [];
1415
+ const gaps = [];
1416
+ let terminalStatus = 'completed';
1417
+ let validationStatus = 'pass';
1418
+ for (const step of steps) {
1419
+ if (!step.suppliedArtifact) {
1420
+ if (!step.required) {
1421
+ await appendEvent(projectRoot, runId, {
1422
+ event: 'delegation_cancelled',
1423
+ runId,
1424
+ summary: `${step.agent} artifact not supplied; optional step skipped for ${options.taskId}`,
1425
+ data: { agent: step.agent, expectedArtifact: step.expectedArtifact }
1426
+ });
1427
+ continue;
1428
+ }
1429
+ const gap = taskGap(options.taskId, step.agent, `${step.agent} artifact was not supplied; the task loop facade does not invoke external agents directly.`, `Run the ${step.agent} step in Claude Code and pass ${artifactOptionName(step.agent)} artifacts/<file>.`);
1430
+ gaps.push(gap);
1431
+ await appendEvent(projectRoot, runId, {
1432
+ event: 'delegation_failed',
1433
+ runId,
1434
+ summary: `${step.agent} artifact missing for ${options.taskId}`,
1435
+ data: { agent: step.agent, expectedArtifact: step.expectedArtifact }
1436
+ });
1437
+ terminalStatus = 'blocked';
1438
+ validationStatus = step.agent === 'validator' ? 'blocked' : validationStatus;
1439
+ break;
1440
+ }
1441
+ const result = await runBackgroundExecutor(projectRoot, {
1442
+ branch,
1443
+ runId,
1444
+ taskId: options.taskId,
1445
+ agent: step.agent,
1446
+ artifactPath: step.suppliedArtifact,
1447
+ delegationId: `B-${options.taskId}-${step.agent}-001`
1448
+ });
1449
+ if (!result.ingestion || !result.ingestion.resultStatus) {
1450
+ const issueText = result.issues.map((issue) => issue.message).join('; ') || result.message;
1451
+ gaps.push(taskGap(options.taskId, step.agent, `${step.agent} artifact ${step.suppliedArtifact} could not be ingested: ${issueText}`, `Fix ${step.suppliedArtifact} so the Phase 3 executor can ingest one valid sdd-result block for ${step.agent}/${options.taskId}.`));
1452
+ terminalStatus = 'blocked';
1453
+ validationStatus = step.agent === 'validator' ? 'blocked' : validationStatus;
1454
+ break;
1455
+ }
1456
+ acceptedArtifacts.push(result.ingestion.artifactPath);
1457
+ if (step.agent === 'reviewer') {
1458
+ if (result.ingestion.resultStatus === 'PASS') {
1459
+ await appendEvent(projectRoot, runId, { event: 'review_passed', runId, summary: `Review passed for ${options.taskId}`, data: { artifact: result.ingestion.artifactPath } });
1460
+ }
1461
+ else {
1462
+ await appendEvent(projectRoot, runId, { event: 'review_failed', runId, summary: `Review did not pass for ${options.taskId}; debugger may be supplied once.`, data: { artifact: result.ingestion.artifactPath, status: result.ingestion.resultStatus } });
1463
+ if (!options.debugArtifact) {
1464
+ gaps.push(taskGap(options.taskId, 'debugger', 'Review did not pass and no debugger artifact was supplied.', 'Run one debugger attempt or create a gap report; the task loop allows only one debugger pass.'));
1465
+ terminalStatus = result.ingestion.resultStatus === 'BLOCKED' ? 'blocked' : 'failed';
1466
+ validationStatus = 'fail';
1467
+ break;
1468
+ }
1469
+ }
1470
+ }
1471
+ if (step.agent === 'validator') {
1472
+ if (result.ingestion.resultStatus === 'PASS') {
1473
+ await appendEvent(projectRoot, runId, { event: 'validation_passed', runId, summary: `Validation passed for ${options.taskId}`, data: { artifact: result.ingestion.artifactPath } });
1474
+ validationStatus = 'pass';
1475
+ }
1476
+ else if (result.ingestion.resultStatus === 'PASS_WITH_GAPS') {
1477
+ await appendEvent(projectRoot, runId, { event: 'validation_passed', runId, summary: `Validation passed with gaps for ${options.taskId}; task remains blocked until gaps are resolved.`, data: { artifact: result.ingestion.artifactPath, status: result.ingestion.resultStatus } });
1478
+ gaps.push(taskGap(options.taskId, 'validation_gaps', 'Validator returned PASS_WITH_GAPS; the task loop cannot mark the task completed without structured gap evidence and explicit sync-back proposal semantics.', 'Inspect the validator artifact, resolve or defer each validation gap, then rerun with PASS validation evidence.'));
1479
+ validationStatus = 'pass_with_gaps';
1480
+ terminalStatus = 'blocked';
1481
+ }
1482
+ else {
1483
+ await appendEvent(projectRoot, runId, { event: 'validation_failed', runId, summary: `Validation failed for ${options.taskId}`, data: { artifact: result.ingestion.artifactPath, status: result.ingestion.resultStatus } });
1484
+ gaps.push(taskGap(options.taskId, 'validation', `Validator returned ${result.ingestion.resultStatus}.`, 'Do not mark the task completed; create a gap report or revise the task/plan.'));
1485
+ validationStatus = result.ingestion.resultStatus === 'BLOCKED' ? 'blocked' : 'fail';
1486
+ terminalStatus = result.ingestion.resultStatus === 'BLOCKED' ? 'blocked' : 'failed';
1487
+ }
1488
+ }
1489
+ }
1490
+ if (gaps.length > 0 && terminalStatus !== 'completed') {
1491
+ const gapArtifact = await writeArtifact(projectRoot, runId, `gap-report-${options.taskId}.md`, renderLoopGapReport(options.taskId, gaps));
1492
+ acceptedArtifacts.push(gapArtifact.runRelativePath);
1493
+ await appendEvent(projectRoot, runId, {
1494
+ event: 'gap_created',
1495
+ runId,
1496
+ summary: `Gap report created for ${options.taskId}`,
1497
+ data: { artifact: gapArtifact.runRelativePath, gaps }
1498
+ });
1499
+ }
1500
+ const proposal = await writeSyncBackProposal(projectRoot, runId, options.taskId, terminalStatus === 'completed' ? 'completed' : terminalStatus, acceptedArtifacts, gaps, terminalStatus === 'completed' ? 'Ingestion-aware task loop has accepted required artifacts through the Phase 3 executor.' : terminalStatus === 'blocked' && validationStatus === 'pass_with_gaps' ? 'Ingestion-aware task loop stopped because validator returned PASS_WITH_GAPS; sync-back is a blocked gap proposal, not task completion.' : 'Ingestion-aware task loop stopped with blocking/failing evidence.');
1501
+ await persistLoopState(projectRoot, runId, {
1502
+ status: terminalStatus,
1503
+ phase: 'do',
1504
+ taskId: options.taskId,
1505
+ taskState: { status: terminalStatus, gaps, artifacts: acceptedArtifacts },
1506
+ validationStatus,
1507
+ syncBackProposalPath: proposal.runRelativePath,
1508
+ artifacts: acceptedArtifacts.map((artifactPath) => ({ path: artifactPath, kind: artifactKind(artifactPath), task: options.taskId, agent: agentFromArtifactPath(artifactPath) }))
1509
+ });
1510
+ await appendEvent(projectRoot, runId, {
1511
+ event: 'sync_back_proposed',
1512
+ runId,
1513
+ summary: `Sync-back proposal created for ${options.taskId}`,
1514
+ data: { proposal: proposal.runRelativePath, status: terminalStatus }
1515
+ });
1516
+ await appendEvent(projectRoot, runId, {
1517
+ event: terminalStatus === 'completed' ? 'run_completed' : terminalStatus === 'blocked' ? 'gap_escalated' : 'validation_failed',
1518
+ runId,
1519
+ summary: `Phase 3.15 ingestion-aware task loop ${terminalStatus} for ${options.taskId}`,
1520
+ data: { task: options.taskId, artifacts: acceptedArtifacts, gaps }
1521
+ });
1522
+ return {
1523
+ runId,
1524
+ taskId: options.taskId,
1525
+ status: terminalStatus,
1526
+ task: inspected.task,
1527
+ gaps,
1528
+ requiredArtifacts: steps.map((step) => step.expectedArtifact),
1529
+ acceptedArtifacts,
1530
+ syncBackProposalPath: proposal.runRelativePath,
1531
+ message: terminalStatus === 'completed' ? 'Task loop completed through Phase 3 executor artifact ingestion.' : validationStatus === 'pass_with_gaps' ? 'Task loop blocked because validator returned PASS_WITH_GAPS; inspect gap report and sync-back proposal.' : 'Task loop stopped; inspect gap report and sync-back proposal.'
1532
+ };
1533
+ }
1534
+ export async function runGoalVerify(projectRoot, options) {
1535
+ const branch = options.branch ?? 'master';
1536
+ const model = await parseSddBranch(projectRoot, branch);
1537
+ const inspected = inspectSddTask(model, options.taskId);
1538
+ const runId = options.runId;
1539
+ const state = await readRunState(projectRoot, runId);
1540
+ const reviewArtifact = options.reviewArtifact ?? artifactPathForAgent(state, options.taskId, 'reviewer');
1541
+ const validationArtifact = options.validationArtifact ?? artifactPathForAgent(state, options.taskId, 'validator');
1542
+ const gaps = [...inspected.gaps];
1543
+ const acceptanceCoverage = [];
1544
+ const acceptedArtifacts = [];
1545
+ let reviewStatus = null;
1546
+ let validationStatus = null;
1547
+ await appendEvent(projectRoot, runId, {
1548
+ event: 'phase_started',
1549
+ runId,
1550
+ summary: `Phase 1.9 goal-level verify started for ${options.taskId}`,
1551
+ data: { phase: 'verify', branch, task: options.taskId }
1552
+ });
1553
+ if (!inspected.task) {
1554
+ gaps.push(taskGap(options.taskId, 'task', `Task ${options.taskId} was not found for goal-level verification.`, 'Create the task or choose an existing task id before verify.'));
1555
+ }
1556
+ if (!reviewArtifact) {
1557
+ gaps.push(taskGap(options.taskId, 'review_artifact', 'No reviewer artifact was supplied or found in run state.', 'Run review first or pass --review-artifact artifacts/<file>.'));
1558
+ }
1559
+ else {
1560
+ const reviewReport = await validateSddResultArtifact(projectRoot, runId, reviewArtifact, { expectedTask: options.taskId, expectedAgent: 'reviewer' });
1561
+ if (!reviewReport.valid || !reviewReport.result) {
1562
+ gaps.push(taskGap(options.taskId, 'review_artifact', `Reviewer artifact ${reviewArtifact} is invalid: ${reviewReport.issues.map((issue) => issue.message).join('; ')}`, 'Fix reviewer artifact contract before goal-level verify.'));
1563
+ }
1564
+ else {
1565
+ reviewStatus = reviewReport.result.status;
1566
+ acceptedArtifacts.push(reviewArtifact);
1567
+ if (reviewReport.result.status !== 'PASS') {
1568
+ gaps.push(taskGap(options.taskId, 'review_status', `Reviewer status is ${reviewReport.result.status}, not PASS.`, 'Resolve review findings before marking verification PASS.'));
1569
+ }
1570
+ }
1571
+ }
1572
+ if (!validationArtifact) {
1573
+ gaps.push(taskGap(options.taskId, 'validation_artifact', 'No validator artifact was supplied or found in run state.', 'Run validation first or pass --validation-artifact artifacts/<file>.'));
1574
+ }
1575
+ else {
1576
+ const validationReport = await validateSddResultArtifact(projectRoot, runId, validationArtifact, { expectedTask: options.taskId, expectedAgent: 'validator' });
1577
+ if (!validationReport.valid || !validationReport.result) {
1578
+ gaps.push(taskGap(options.taskId, 'validation_artifact', `Validator artifact ${validationArtifact} is invalid: ${validationReport.issues.map((issue) => issue.message).join('; ')}`, 'Fix validator artifact contract before goal-level verify.'));
1579
+ }
1580
+ else {
1581
+ validationStatus = validationReport.result.status;
1582
+ acceptedArtifacts.push(validationArtifact);
1583
+ if (validationReport.result.status === 'FAIL' || validationReport.result.status === 'BLOCKED') {
1584
+ gaps.push(taskGap(options.taskId, 'validation_status', `Validator status is ${validationReport.result.status}.`, 'Do not mark task completed; inspect validation gaps and recovery proposal.'));
1585
+ }
1586
+ }
1587
+ }
1588
+ if (inspected.task) {
1589
+ const validationRaw = validationArtifact ? await readArtifactIfExists(projectRoot, runId, validationArtifact) : '';
1590
+ for (const acceptance of inspected.task.acceptance) {
1591
+ const covered = validationRaw.toLowerCase().includes(acceptance.toLowerCase());
1592
+ acceptanceCoverage.push({
1593
+ acceptance,
1594
+ status: covered ? statusFromValidation(validationStatus) : 'GAP',
1595
+ evidence: covered ? `Mentioned in ${validationArtifact}.` : 'No matching acceptance evidence found in validator artifact.'
1596
+ });
1597
+ if (!covered) {
1598
+ gaps.push(taskGap(options.taskId, 'acceptance_coverage', `Acceptance item is not covered by validator evidence: ${acceptance}`, 'Update the validator artifact so it includes the exact Acceptance text, preferably under ## Acceptance Mapping; use sdd artifact template to generate the mapping skeleton.'));
1599
+ }
1600
+ }
1601
+ }
1602
+ const status = deriveGoalVerifyStatus(reviewStatus, validationStatus, gaps);
1603
+ const coverageArtifact = await writeArtifact(projectRoot, runId, `acceptance-coverage-${options.taskId}.md`, renderAcceptanceCoverageArtifact(options.taskId, status, inspected.task, reviewArtifact, validationArtifact, acceptanceCoverage, gaps));
1604
+ const allArtifacts = [...acceptedArtifacts, coverageArtifact.runRelativePath];
1605
+ const proposal = await writeSyncBackProposal(projectRoot, runId, options.taskId, status === 'PASS' ? 'verified' : 'blocked', allArtifacts, gaps, status === 'PASS' ? 'Goal-level verify mapped validator evidence to all acceptance items.' : 'Goal-level verify found gaps; sync-back is a verification gap proposal, not task completion.');
1606
+ await persistVerifyState(projectRoot, runId, {
1607
+ status,
1608
+ taskId: options.taskId,
1609
+ taskState: { status: status === 'PASS' ? 'verified' : 'blocked', verifyStatus: status, gaps, artifacts: allArtifacts, acceptanceCoverage },
1610
+ commands: inspected.task?.validation ?? [],
1611
+ evidence: allArtifacts,
1612
+ syncBackProposalPath: proposal.runRelativePath,
1613
+ artifacts: allArtifacts.map((artifactPath) => ({ path: artifactPath, kind: artifactKind(artifactPath), task: options.taskId, agent: agentFromArtifactPath(artifactPath) }))
1614
+ });
1615
+ await appendEvent(projectRoot, runId, {
1616
+ event: status === 'PASS' ? 'validation_passed' : 'validation_failed',
1617
+ runId,
1618
+ summary: `Phase 1.9 goal-level verify ${status} for ${options.taskId}`,
1619
+ data: { task: options.taskId, status, coverageArtifact: coverageArtifact.runRelativePath, gaps }
1620
+ });
1621
+ await appendEvent(projectRoot, runId, {
1622
+ event: 'sync_back_proposed',
1623
+ runId,
1624
+ summary: `Verify sync-back proposal created for ${options.taskId}`,
1625
+ data: { proposal: proposal.runRelativePath, status }
1626
+ });
1627
+ return {
1628
+ runId,
1629
+ taskId: options.taskId,
1630
+ status,
1631
+ task: inspected.task,
1632
+ reviewArtifact,
1633
+ validationArtifact,
1634
+ coverageArtifactPath: coverageArtifact.runRelativePath,
1635
+ syncBackProposalPath: proposal.runRelativePath,
1636
+ acceptanceCoverage,
1637
+ gaps,
1638
+ commands: inspected.task?.validation ?? [],
1639
+ message: status === 'PASS' ? 'Goal-level verify passed with explicit acceptance coverage.' : 'Goal-level verify found gaps; inspect coverage artifact and sync-back proposal.'
1640
+ };
1641
+ }
1642
+ export function renderGoalVerifyResult(result) {
1643
+ return JSON.stringify({
1644
+ runId: result.runId,
1645
+ taskId: result.taskId,
1646
+ status: result.status,
1647
+ message: result.message,
1648
+ reviewArtifact: result.reviewArtifact,
1649
+ validationArtifact: result.validationArtifact,
1650
+ coverageArtifactPath: result.coverageArtifactPath,
1651
+ syncBackProposalPath: result.syncBackProposalPath,
1652
+ commands: result.commands,
1653
+ acceptanceCoverage: result.acceptanceCoverage,
1654
+ gaps: result.gaps
1655
+ }, null, 2);
1656
+ }
1657
+ export function renderSingleTaskLoopResult(result) {
1658
+ return JSON.stringify({
1659
+ runId: result.runId,
1660
+ taskId: result.taskId,
1661
+ status: result.status,
1662
+ message: result.message,
1663
+ acceptedArtifacts: result.acceptedArtifacts,
1664
+ requiredArtifacts: result.requiredArtifacts,
1665
+ syncBackProposalPath: result.syncBackProposalPath,
1666
+ gaps: result.gaps
1667
+ }, null, 2);
1668
+ }
1669
+ export function renderLifecycleDecisionGate(result) {
1670
+ const decision = result.record.decision;
1671
+ const lines = [
1672
+ 'Lifecycle Decision Gate',
1673
+ `profile=${decision.profile ?? 'unknown'}`,
1674
+ `confidence=${decision.confidence ?? 'unknown'}`,
1675
+ `checkpoint_required=${decision.human_checkpoint_required}`,
1676
+ `hard_gates=${decision.hard_gate_hits.join(',') || 'none'}`,
1677
+ `required_stages=${decision.required_stages.join(' -> ') || 'none'}`,
1678
+ `skipped_stages=${decision.skipped_stages.join(',') || 'none'}`,
1679
+ 'Reasons:'
1680
+ ];
1681
+ for (const reason of result.record.reasons) {
1682
+ lines.push(`- ${reason}`);
1683
+ }
1684
+ lines.push('Escalation triggers:');
1685
+ for (const trigger of result.record.escalation_triggers) {
1686
+ lines.push(`- ${trigger}`);
1687
+ }
1688
+ lines.push('Command boundaries:');
1689
+ for (const boundary of result.boundaries) {
1690
+ lines.push(`- ${boundary}`);
1691
+ }
1692
+ return lines.join('\n');
1693
+ }
1694
+ export function inspectSddTask(model, taskId) {
1695
+ const matchingTasks = model.tasks.filter((candidate) => candidate.id === taskId);
1696
+ if (matchingTasks.length > 1) {
1697
+ return {
1698
+ task: null,
1699
+ gaps: [
1700
+ ...model.gaps.filter((gap) => gap.taskId === taskId),
1701
+ taskGap(taskId, 'id', `Task id ${taskId} is ambiguous; ${matchingTasks.length} tasks share this id: ${matchingTasks.map(taskSourceEvidence).join('; ')}.`, 'Inspect by a unique task id, or rename duplicate task ids before implementation.')
1702
+ ]
1703
+ };
1704
+ }
1705
+ const task = matchingTasks[0] ?? null;
1706
+ return {
1707
+ task,
1708
+ gaps: model.gaps.filter((gap) => gap.taskId === taskId || (task === null && gap.taskId === null))
1709
+ };
1710
+ }
1711
+ export function renderTaskList(model) {
1712
+ const lines = [`SDD tasks for ${model.branch}`];
1713
+ for (const task of model.tasks) {
1714
+ lines.push(`${task.id}\t${task.status}\twave=${task.wave ?? 'n/a'}\tdeps=${task.dependsOn.join(',') || 'none'}\t${task.title ?? ''}`.trim());
1715
+ }
1716
+ lines.push(`gaps=${model.gaps.length}`);
1717
+ return lines.join('\n');
1718
+ }
1719
+ export function renderTaskInspect(task, gaps = []) {
1720
+ return JSON.stringify({ task, gaps }, null, 2);
1721
+ }
1722
+ export function renderTaskGapReport(model) {
1723
+ if (model.gaps.length === 0) {
1724
+ return 'PASS\nNo task gaps detected.';
1725
+ }
1726
+ const lines = ['BLOCKED', 'Task Gap Report'];
1727
+ for (const gap of model.gaps) {
1728
+ lines.push(`- [${gap.severity}] ${gap.type} ${gap.taskId ?? 'document'} ${gap.field}: ${gap.message} Recommendation: ${gap.recommendation}`);
1729
+ }
1730
+ return lines.join('\n');
1731
+ }
1732
+ export async function inspectTaskGraph(projectRoot, options = {}) {
1733
+ const branch = options.branch ?? 'master';
1734
+ const model = await parseSddBranch(projectRoot, branch);
1735
+ const nodes = model.tasks.map((task) => ({
1736
+ taskId: task.id,
1737
+ title: task.title,
1738
+ status: task.status,
1739
+ wave: task.wave,
1740
+ dependsOn: task.dependsOn,
1741
+ affectedFiles: task.affectedFiles,
1742
+ risk: task.risk,
1743
+ validation: task.validation,
1744
+ source: task.source
1745
+ }));
1746
+ const diagnostics = model.gaps.map((gap) => ({
1747
+ severity: gap.severity,
1748
+ taskId: gap.taskId,
1749
+ field: gap.field,
1750
+ message: gap.message,
1751
+ recommendation: gap.recommendation
1752
+ }));
1753
+ diagnostics.push(...detectTaskGraphCycles(model.tasks));
1754
+ const taskCounts = new Map();
1755
+ for (const task of model.tasks) {
1756
+ taskCounts.set(task.id, (taskCounts.get(task.id) ?? 0) + 1);
1757
+ }
1758
+ const dependencyEdges = model.tasks.flatMap((task) => task.dependsOn
1759
+ .filter((dependency) => taskCounts.get(dependency) === 1)
1760
+ .map((dependency) => ({ from: dependency, to: task.id, type: 'depends_on', files: [] })));
1761
+ const fileOverlapEdges = [];
1762
+ for (let leftIndex = 0; leftIndex < model.tasks.length; leftIndex += 1) {
1763
+ for (let rightIndex = leftIndex + 1; rightIndex < model.tasks.length; rightIndex += 1) {
1764
+ const left = model.tasks[leftIndex];
1765
+ const right = model.tasks[rightIndex];
1766
+ const files = overlappingFiles(left.affectedFiles, right.affectedFiles);
1767
+ if (files.length > 0) {
1768
+ fileOverlapEdges.push({ from: left.id, to: right.id, type: 'file_overlap', files });
1769
+ }
1770
+ }
1771
+ }
1772
+ const validationCommands = [...new Set(model.tasks.flatMap((task) => task.validation))].sort();
1773
+ const highRiskTasks = model.tasks
1774
+ .filter((task) => task.risk.length > 0)
1775
+ .map((task) => task.id)
1776
+ .sort();
1777
+ return {
1778
+ version: TASK_GRAPH_PLANNER_CONTRACT_VERSION,
1779
+ branch,
1780
+ valid: diagnostics.every((diagnostic) => diagnostic.severity !== 'blocking'),
1781
+ nodes,
1782
+ dependencyEdges,
1783
+ fileOverlapEdges,
1784
+ diagnostics,
1785
+ summary: {
1786
+ tasks: nodes.length,
1787
+ dependencies: dependencyEdges.length,
1788
+ fileOverlaps: fileOverlapEdges.length,
1789
+ highRiskTasks,
1790
+ validationCommands
1791
+ }
1792
+ };
1793
+ }
1794
+ export async function inspectWavePlan(projectRoot, options = {}) {
1795
+ const branch = options.branch ?? 'master';
1796
+ const capabilityId = options.capabilityId ?? 'native-file-edit';
1797
+ const graph = await inspectTaskGraph(projectRoot, { branch });
1798
+ const taskIds = new Set(graph.nodes.map((node) => node.taskId));
1799
+ const blockingDiagnostics = graph.diagnostics.filter((diagnostic) => diagnostic.severity === 'blocking');
1800
+ const globalBlocking = blockingDiagnostics.filter((diagnostic) => diagnostic.taskId === null);
1801
+ const blockingByTask = new Map();
1802
+ for (const diagnostic of blockingDiagnostics.filter((candidate) => candidate.taskId !== null)) {
1803
+ const reasons = blockingByTask.get(diagnostic.taskId ?? '') ?? [];
1804
+ reasons.push(diagnostic.message);
1805
+ blockingByTask.set(diagnostic.taskId ?? '', reasons);
1806
+ }
1807
+ const decisions = new Map();
1808
+ await Promise.all(graph.nodes.map(async (node) => {
1809
+ decisions.set(node.taskId, await inspectWorktreeIsolation(projectRoot, { branch, taskId: node.taskId, capabilityId }));
1810
+ }));
1811
+ const manualGates = [];
1812
+ const blockedTasks = [];
1813
+ const blockedTaskIds = new Set();
1814
+ const manualTaskIds = new Set();
1815
+ const candidates = new Map();
1816
+ for (const node of graph.nodes) {
1817
+ const decision = decisions.get(node.taskId);
1818
+ const diagnosticReasons = blockingByTask.get(node.taskId) ?? [];
1819
+ if (globalBlocking.length > 0) {
1820
+ blockedTasks.push({ taskId: node.taskId, gate: 'blocked', reasons: globalBlocking.map((diagnostic) => diagnostic.message) });
1821
+ blockedTaskIds.add(node.taskId);
1822
+ }
1823
+ else if (diagnosticReasons.length > 0) {
1824
+ blockedTasks.push({ taskId: node.taskId, gate: 'blocked', reasons: diagnosticReasons });
1825
+ blockedTaskIds.add(node.taskId);
1826
+ }
1827
+ else if (!decision || decision.mode === 'blocked') {
1828
+ blockedTasks.push({ taskId: node.taskId, gate: 'blocked', reasons: decision?.reasons ?? [`Task ${node.taskId} cannot be inspected for isolation.`] });
1829
+ blockedTaskIds.add(node.taskId);
1830
+ }
1831
+ else if (decision.mode === 'manual') {
1832
+ manualGates.push({ taskId: node.taskId, gate: 'manual', reasons: decision.reasons });
1833
+ manualTaskIds.add(node.taskId);
1834
+ }
1835
+ else {
1836
+ candidates.set(node.taskId, node);
1837
+ }
1838
+ }
1839
+ let changed = true;
1840
+ while (changed) {
1841
+ changed = false;
1842
+ for (const [taskId, node] of [...candidates]) {
1843
+ const blockedDependencies = node.dependsOn.filter((dependency) => !taskIds.has(dependency) || blockedTaskIds.has(dependency) || manualTaskIds.has(dependency));
1844
+ if (blockedDependencies.length > 0) {
1845
+ blockedTasks.push({
1846
+ taskId,
1847
+ gate: 'blocked',
1848
+ reasons: [`Task ${taskId} depends on non-plannable task(s): ${blockedDependencies.join(', ')}.`]
1849
+ });
1850
+ blockedTaskIds.add(taskId);
1851
+ candidates.delete(taskId);
1852
+ changed = true;
1853
+ }
1854
+ }
1855
+ }
1856
+ const waves = [];
1857
+ const completed = new Set();
1858
+ const remaining = new Map(candidates);
1859
+ while (remaining.size > 0) {
1860
+ const ready = [...remaining.values()]
1861
+ .filter((node) => node.dependsOn.every((dependency) => completed.has(dependency)))
1862
+ .sort((left, right) => left.taskId.localeCompare(right.taskId));
1863
+ if (ready.length === 0) {
1864
+ for (const taskId of remaining.keys()) {
1865
+ blockedTasks.push({ taskId, gate: 'blocked', reasons: [`Task ${taskId} cannot be placed in a dependency wave.`] });
1866
+ blockedTaskIds.add(taskId);
1867
+ }
1868
+ break;
1869
+ }
1870
+ const waveNodes = [];
1871
+ for (const node of ready) {
1872
+ if (!waveNodes.some((candidate) => graphTasksOverlap(graph, candidate.taskId, node.taskId))) {
1873
+ waveNodes.push(node);
1874
+ }
1875
+ }
1876
+ const tasks = waveNodes.map((node) => ({
1877
+ taskId: node.taskId,
1878
+ isolationMode: decisions.get(node.taskId)?.mode ?? 'blocked',
1879
+ reasons: decisions.get(node.taskId)?.reasons ?? []
1880
+ }));
1881
+ waves.push({ index: waves.length + 1, tasks });
1882
+ for (const node of waveNodes) {
1883
+ remaining.delete(node.taskId);
1884
+ completed.add(node.taskId);
1885
+ }
1886
+ }
1887
+ const plannedTasks = waves.reduce((count, wave) => count + wave.tasks.length, 0);
1888
+ return {
1889
+ version: WAVE_PLANNER_CONTRACT_VERSION,
1890
+ branch,
1891
+ valid: graph.valid && blockedTasks.length === 0,
1892
+ waves,
1893
+ manualGates,
1894
+ blockedTasks,
1895
+ diagnostics: graph.diagnostics,
1896
+ summary: {
1897
+ tasks: graph.nodes.length,
1898
+ waves: waves.length,
1899
+ plannedTasks,
1900
+ manualTasks: manualGates.length,
1901
+ blockedTasks: blockedTasks.length
1902
+ }
1903
+ };
1904
+ }
1905
+ export async function runBackgroundExecutor(projectRoot, options) {
1906
+ const branch = options.branch ?? 'master';
1907
+ const agent = options.agent ?? 'implementer';
1908
+ const workerAdapterId = options.workerAdapterId ?? 'sdd-cli-task-worker';
1909
+ const runState = options.runId ? await readRunState(projectRoot, options.runId) : await createRun(projectRoot);
1910
+ const runId = runState.runId;
1911
+ const worker = await inspectWorkerAdapterContract(projectRoot, workerAdapterId);
1912
+ const issues = [];
1913
+ if (!worker) {
1914
+ issues.push(contractIssue('workerAdapterId', `Worker adapter ${workerAdapterId} is not declared.`, 'Use a worker adapter declared by the Phase 3.5 worker adapter contract.'));
1915
+ }
1916
+ if (worker?.kind === 'manual_handoff') {
1917
+ issues.push(contractIssue('workerAdapterId', `Worker adapter ${workerAdapterId} is manual handoff only.`, 'Use a runnable worker adapter for background executor claim/run/ingest.'));
1918
+ }
1919
+ const inspected = inspectSddTask(await parseSddBranch(projectRoot, branch), options.taskId);
1920
+ if (!inspected.task || inspected.gaps.some((gap) => gap.severity === 'blocking')) {
1921
+ issues.push(...inspected.gaps.map((gap) => contractIssue(gap.field, gap.message, gap.recommendation)));
1922
+ }
1923
+ const decision = await inspectWorktreeIsolation(projectRoot, { branch, taskId: options.taskId, capabilityId: worker?.capabilityId ?? 'sdd-cli' });
1924
+ if (decision.mode === 'blocked' || decision.mode === 'manual') {
1925
+ for (const reason of decision.reasons) {
1926
+ issues.push(contractIssue('isolation', reason, 'Resolve isolation gates or use explicit worktree/manual routing before running the background executor.'));
1927
+ }
1928
+ }
1929
+ const delegationId = options.delegationId ?? `B-${options.taskId}-${agent}-001`;
1930
+ const expectedArtifact = options.artifactPath ? getRunRelativeArtifactPath(toArtifactRootRelativePath(options.artifactPath)) : `artifacts/${agent}-${options.taskId}.md`;
1931
+ const existingDelegation = runState.delegations[delegationId];
1932
+ if (existingDelegation && isDelegationTerminal(existingDelegation.status)) {
1933
+ issues.push(contractIssue('delegationId', `Delegation ${delegationId} is already terminal.`, 'Create a new delegation id for retry instead of reopening a terminal delegation.'));
1934
+ }
1935
+ const governance = await evaluateGovernancePolicy(projectRoot, {
1936
+ operation: 'background_executor',
1937
+ runId,
1938
+ taskId: options.taskId,
1939
+ workerAdapterId,
1940
+ riskTags: inspected.task?.risk ?? [],
1941
+ excludeQueueItemId: `${runId}:${delegationId}`
1942
+ });
1943
+ if (!governance.allowed) {
1944
+ issues.push(...governance.issues);
1945
+ await appendEvent(projectRoot, runId, { event: 'governance_policy_blocked', runId, summary: `Governance policy blocked background executor for ${options.taskId}`, data: { taskId: options.taskId, delegationId, decision: governance } });
1946
+ }
1947
+ if (issues.length > 0) {
1948
+ await appendEvent(projectRoot, runId, { event: 'background_executor_blocked', runId, summary: `Background executor blocked for ${options.taskId}`, data: { taskId: options.taskId, delegationId, issues } });
1949
+ return {
1950
+ version: BACKGROUND_EXECUTOR_CONTRACT_VERSION,
1951
+ runId,
1952
+ taskId: options.taskId,
1953
+ delegationId: null,
1954
+ queueItemId: null,
1955
+ workerAdapterId,
1956
+ status: 'blocked',
1957
+ artifactPath: options.artifactPath ?? null,
1958
+ ingestion: null,
1959
+ issues,
1960
+ message: 'Background executor blocked before delegation claim.'
1961
+ };
1962
+ }
1963
+ const delegation = existingDelegation ?? createDelegationRecord({
1964
+ delegationId,
1965
+ task: options.taskId,
1966
+ agent,
1967
+ runMode: 'background',
1968
+ blocking: true,
1969
+ requiredForPhaseExit: true,
1970
+ expectedArtifact,
1971
+ timeoutSeconds: options.timeoutSeconds
1972
+ });
1973
+ await persistDelegation(projectRoot, runId, delegation);
1974
+ const claimedState = await readRunState(projectRoot, runId);
1975
+ await writeRunState(projectRoot, {
1976
+ ...claimedState,
1977
+ status: 'running',
1978
+ phase: 'background',
1979
+ currentTask: options.taskId
1980
+ });
1981
+ await appendEvent(projectRoot, runId, {
1982
+ event: existingDelegation ? 'background_executor_resumed' : 'delegation_started',
1983
+ runId,
1984
+ summary: `Background executor claimed ${delegationId} for ${options.taskId}`,
1985
+ data: { delegationId, taskId: options.taskId, agent, workerAdapterId, expectedArtifact, queueItemId: `${runId}:${delegationId}` }
1986
+ });
1987
+ if (!options.artifactPath) {
1988
+ return {
1989
+ version: BACKGROUND_EXECUTOR_CONTRACT_VERSION,
1990
+ runId,
1991
+ taskId: options.taskId,
1992
+ delegationId,
1993
+ queueItemId: `${runId}:${delegationId}`,
1994
+ workerAdapterId,
1995
+ status: 'claimed',
1996
+ artifactPath: null,
1997
+ ingestion: null,
1998
+ issues: [],
1999
+ message: `Background executor claimed ${delegationId}; provide ${expectedArtifact} and rerun with --artifact to ingest terminal evidence.`
2000
+ };
2001
+ }
2002
+ const ingestion = await ingestArtifactResult(projectRoot, runId, { delegationId, artifactPath: options.artifactPath });
2003
+ const executorStatus = ingestion.valid ? ingestion.record.delegationStatus === 'COMPLETED' ? 'completed' : 'failed' : 'blocked';
2004
+ const ingestedState = await readRunState(projectRoot, runId);
2005
+ await writeRunState(projectRoot, {
2006
+ ...ingestedState,
2007
+ status: executorStatus === 'completed' ? 'completed' : executorStatus === 'failed' ? 'failed' : 'blocked',
2008
+ phase: 'background',
2009
+ currentTask: options.taskId
2010
+ });
2011
+ return {
2012
+ version: BACKGROUND_EXECUTOR_CONTRACT_VERSION,
2013
+ runId,
2014
+ taskId: options.taskId,
2015
+ delegationId,
2016
+ queueItemId: `${runId}:${delegationId}`,
2017
+ workerAdapterId,
2018
+ status: executorStatus,
2019
+ artifactPath: ingestion.record.artifactPath,
2020
+ ingestion: ingestion.record,
2021
+ issues: ingestion.record.issues,
2022
+ message: ingestion.valid ? `Background executor ingested terminal artifact for ${delegationId}.` : `Background executor artifact ingestion blocked for ${delegationId}.`
2023
+ };
2024
+ }
2025
+ export async function inspectBackgroundExecutor(projectRoot, runId) {
2026
+ const [snapshot, ingestionInspection] = await Promise.all([
2027
+ listDelegationQueueItems(projectRoot, { runId }),
2028
+ inspectArtifactResultIngestions(projectRoot, runId)
2029
+ ]);
2030
+ const issues = [...ingestionInspection.issues];
2031
+ return {
2032
+ version: BACKGROUND_EXECUTOR_CONTRACT_VERSION,
2033
+ runId,
2034
+ delegations: snapshot.items,
2035
+ artifactIngestions: ingestionInspection.records,
2036
+ runningDelegations: snapshot.items.filter((item) => item.status === 'RUNNING').length,
2037
+ terminalDelegations: snapshot.items.filter((item) => isDelegationTerminal(item.status)).length,
2038
+ valid: issues.length === 0,
2039
+ issues
2040
+ };
2041
+ }
2042
+ export async function runWaveExecutor(projectRoot, options = {}) {
2043
+ const branch = options.branch ?? 'master';
2044
+ const strategy = options.strategy ?? 'fast-stop';
2045
+ const agent = options.agent ?? 'implementer';
2046
+ const workerAdapterId = options.workerAdapterId ?? 'sdd-cli-task-worker';
2047
+ const runState = options.runId ? await readRunState(projectRoot, options.runId) : await createRun(projectRoot);
2048
+ const runId = runState.runId;
2049
+ const plan = await inspectWavePlan(projectRoot, { branch, capabilityId: options.capabilityId ?? 'native-file-edit' });
2050
+ const issues = [];
2051
+ const taskResults = [];
2052
+ const governance = await evaluateGovernancePolicy(projectRoot, {
2053
+ operation: 'wave_executor',
2054
+ runId,
2055
+ workerAdapterId,
2056
+ riskTags: plan.manualGates.flatMap((gate) => gate.reasons)
2057
+ });
2058
+ if (!governance.allowed) {
2059
+ await appendEvent(projectRoot, runId, { event: 'governance_policy_blocked', runId, summary: `Governance policy blocked wave executor for ${branch}`, data: { branch, strategy, decision: governance } });
2060
+ await writeRunState(projectRoot, { ...runState, status: 'blocked', phase: 'wave', currentTask: null });
2061
+ return {
2062
+ version: WAVE_EXECUTOR_CONTRACT_VERSION,
2063
+ runId,
2064
+ branch,
2065
+ strategy,
2066
+ status: 'blocked',
2067
+ plannedWaves: plan.waves.length,
2068
+ executedWaves: 0,
2069
+ taskResults,
2070
+ manualGates: plan.manualGates,
2071
+ blockedTasks: plan.blockedTasks,
2072
+ issues: governance.issues,
2073
+ message: 'Wave executor blocked by governance policy.'
2074
+ };
2075
+ }
2076
+ await writeRunState(projectRoot, {
2077
+ ...runState,
2078
+ status: 'running',
2079
+ phase: 'wave',
2080
+ currentTask: null
2081
+ });
2082
+ await appendEvent(projectRoot, runId, {
2083
+ event: 'wave_executor_started',
2084
+ runId,
2085
+ summary: `Wave executor started for ${branch}`,
2086
+ data: { branch, strategy, plannedWaves: plan.waves.length }
2087
+ });
2088
+ if (!plan.valid || plan.manualGates.length > 0 || plan.blockedTasks.length > 0) {
2089
+ for (const gate of [...plan.manualGates, ...plan.blockedTasks]) {
2090
+ issues.push(contractIssue(`task:${gate.taskId}`, gate.reasons.join(' | '), 'Resolve manual or blocked wave gates before running the wave executor.'));
2091
+ }
2092
+ await appendEvent(projectRoot, runId, {
2093
+ event: 'wave_executor_blocked',
2094
+ runId,
2095
+ summary: `Wave executor blocked for ${branch}`,
2096
+ data: { branch, manualGates: plan.manualGates, blockedTasks: plan.blockedTasks, issues }
2097
+ });
2098
+ const blockedState = await readRunState(projectRoot, runId);
2099
+ await writeRunState(projectRoot, { ...blockedState, status: 'blocked', phase: 'wave', currentTask: null });
2100
+ return {
2101
+ version: WAVE_EXECUTOR_CONTRACT_VERSION,
2102
+ runId,
2103
+ branch,
2104
+ strategy,
2105
+ status: 'blocked',
2106
+ plannedWaves: plan.waves.length,
2107
+ executedWaves: 0,
2108
+ taskResults,
2109
+ manualGates: plan.manualGates,
2110
+ blockedTasks: plan.blockedTasks,
2111
+ issues,
2112
+ message: 'Wave executor blocked before executing planned tasks.'
2113
+ };
2114
+ }
2115
+ let executedWaves = 0;
2116
+ let stopAfterWave = false;
2117
+ for (const wave of plan.waves) {
2118
+ executedWaves += 1;
2119
+ await appendEvent(projectRoot, runId, {
2120
+ event: 'wave_executor_wave_started',
2121
+ runId,
2122
+ summary: `Wave ${wave.index} started`,
2123
+ data: { branch, waveIndex: wave.index, taskIds: wave.tasks.map((task) => task.taskId) }
2124
+ });
2125
+ let waveTerminalCompleted = true;
2126
+ for (const task of wave.tasks) {
2127
+ const result = await runBackgroundExecutor(projectRoot, {
2128
+ branch,
2129
+ runId,
2130
+ taskId: task.taskId,
2131
+ agent,
2132
+ workerAdapterId,
2133
+ artifactPath: options.artifactPaths?.[task.taskId],
2134
+ delegationId: `W${wave.index}-${task.taskId}-${agent}-001`
2135
+ });
2136
+ taskResults.push({ waveIndex: wave.index, taskId: task.taskId, result });
2137
+ issues.push(...result.issues);
2138
+ if (result.status !== 'completed') {
2139
+ waveTerminalCompleted = false;
2140
+ stopAfterWave = true;
2141
+ if (strategy === 'fast-stop') {
2142
+ break;
2143
+ }
2144
+ }
2145
+ }
2146
+ await appendEvent(projectRoot, runId, {
2147
+ event: 'wave_executor_wave_completed',
2148
+ runId,
2149
+ summary: `Wave ${wave.index} ${waveTerminalCompleted ? 'completed' : 'stopped'}`,
2150
+ data: { branch, waveIndex: wave.index, completed: waveTerminalCompleted }
2151
+ });
2152
+ if (stopAfterWave) {
2153
+ break;
2154
+ }
2155
+ }
2156
+ const statuses = taskResults.map((task) => task.result.status);
2157
+ const status = statuses.includes('blocked')
2158
+ ? 'blocked'
2159
+ : statuses.includes('failed')
2160
+ ? 'failed'
2161
+ : statuses.includes('claimed') || taskResults.length < plan.summary.plannedTasks
2162
+ ? 'claimed'
2163
+ : 'completed';
2164
+ const completedState = await readRunState(projectRoot, runId);
2165
+ await writeRunState(projectRoot, {
2166
+ ...completedState,
2167
+ status: status === 'completed' ? 'completed' : status === 'failed' ? 'failed' : status === 'blocked' ? 'blocked' : 'running',
2168
+ phase: 'wave',
2169
+ currentTask: null
2170
+ });
2171
+ await appendEvent(projectRoot, runId, {
2172
+ event: status === 'completed' ? 'wave_executor_completed' : 'wave_executor_stopped',
2173
+ runId,
2174
+ summary: `Wave executor ${status} for ${branch}`,
2175
+ data: { branch, strategy, status, executedWaves, taskResults: taskResults.map((task) => ({ waveIndex: task.waveIndex, taskId: task.taskId, status: task.result.status })) }
2176
+ });
2177
+ return {
2178
+ version: WAVE_EXECUTOR_CONTRACT_VERSION,
2179
+ runId,
2180
+ branch,
2181
+ strategy,
2182
+ status,
2183
+ plannedWaves: plan.waves.length,
2184
+ executedWaves,
2185
+ taskResults,
2186
+ manualGates: [],
2187
+ blockedTasks: [],
2188
+ issues,
2189
+ message: `Wave executor ${status} after ${executedWaves} wave(s).`
2190
+ };
2191
+ }
2192
+ export async function inspectWaveExecutor(projectRoot, runId) {
2193
+ const [background, events] = await Promise.all([
2194
+ inspectBackgroundExecutor(projectRoot, runId),
2195
+ readRunEvents(projectRoot, runId)
2196
+ ]);
2197
+ const waveEvents = events.filter((event) => event.event.startsWith('wave_executor_'));
2198
+ const issues = [...background.issues];
2199
+ if (waveEvents.length === 0) {
2200
+ issues.push(contractIssue('wave_executor', `Run ${runId} has no wave executor events.`, 'Run sdd wave run before inspecting wave executor evidence.'));
2201
+ }
2202
+ return {
2203
+ version: WAVE_EXECUTOR_CONTRACT_VERSION,
2204
+ runId,
2205
+ background,
2206
+ waveEvents,
2207
+ valid: issues.length === 0,
2208
+ issues
2209
+ };
2210
+ }
2211
+ function graphTasksOverlap(graph, leftTaskId, rightTaskId) {
2212
+ return graph.fileOverlapEdges.some((edge) => (edge.from === leftTaskId && edge.to === rightTaskId) || (edge.from === rightTaskId && edge.to === leftTaskId));
2213
+ }
2214
+ export function renderDoctorReport(report) {
2215
+ const lines = [`${report.status}`];
2216
+ for (const check of report.checks) {
2217
+ const action = check.action ? ` Action: ${check.action}` : '';
2218
+ lines.push(`[${check.level}] ${check.check}: ${check.message}${action}`);
2219
+ }
2220
+ return lines.join('\n');
2221
+ }
2222
+ function buildLoopSteps(taskId, options) {
2223
+ const steps = [
2224
+ { agent: 'implementer', suppliedArtifact: options.implementArtifact, expectedArtifact: `artifacts/implement-${taskId}.md`, required: false },
2225
+ { agent: 'reviewer', suppliedArtifact: options.reviewArtifact, expectedArtifact: `artifacts/review-${taskId}.md`, required: true }
2226
+ ];
2227
+ if (options.debugArtifact) {
2228
+ steps.push({ agent: 'debugger', suppliedArtifact: options.debugArtifact, expectedArtifact: `artifacts/debug-${taskId}.md`, required: false });
2229
+ }
2230
+ steps.push({ agent: 'validator', suppliedArtifact: options.validationArtifact, expectedArtifact: `artifacts/validation-${taskId}.md`, required: true });
2231
+ return steps;
2232
+ }
2233
+ async function persistDelegation(projectRoot, runId, delegation) {
2234
+ const state = await readRunState(projectRoot, runId);
2235
+ await writeRunState(projectRoot, {
2236
+ ...state,
2237
+ status: delegation.status === 'RUNNING' ? 'running' : state.status,
2238
+ delegations: {
2239
+ ...state.delegations,
2240
+ [delegation.delegationId]: delegation
2241
+ }
2242
+ });
2243
+ }
2244
+ async function persistLoopState(projectRoot, runId, input) {
2245
+ const state = await readRunState(projectRoot, runId);
2246
+ const now = new Date().toISOString();
2247
+ const knownArtifactPaths = new Set(state.artifacts.map((artifact) => artifact.path));
2248
+ const newArtifacts = input.artifacts
2249
+ .filter((artifact) => !knownArtifactPaths.has(artifact.path))
2250
+ .map((artifact) => ({ ...artifact, createdAt: now }));
2251
+ await writeRunState(projectRoot, {
2252
+ ...state,
2253
+ status: input.status,
2254
+ phase: input.phase,
2255
+ currentTask: input.taskId,
2256
+ tasks: {
2257
+ ...state.tasks,
2258
+ [input.taskId]: input.taskState
2259
+ },
2260
+ artifacts: [...state.artifacts, ...newArtifacts],
2261
+ validation: {
2262
+ ...state.validation,
2263
+ status: input.validationStatus,
2264
+ evidence: input.artifacts.filter((artifact) => artifact.kind === 'validation').map((artifact) => artifact.path)
2265
+ },
2266
+ syncBack: {
2267
+ mode: 'proposal',
2268
+ proposalPath: input.syncBackProposalPath,
2269
+ status: 'proposed'
2270
+ }
2271
+ });
2272
+ }
2273
+ async function persistVerifyState(projectRoot, runId, input) {
2274
+ const state = await readRunState(projectRoot, runId);
2275
+ const now = new Date().toISOString();
2276
+ const knownArtifactPaths = new Set(state.artifacts.map((artifact) => artifact.path));
2277
+ const newArtifacts = input.artifacts
2278
+ .filter((artifact) => !knownArtifactPaths.has(artifact.path))
2279
+ .map((artifact) => ({ ...artifact, createdAt: now }));
2280
+ await writeRunState(projectRoot, {
2281
+ ...state,
2282
+ status: input.status === 'PASS' ? 'completed' : 'blocked',
2283
+ phase: 'verify',
2284
+ currentTask: input.taskId,
2285
+ tasks: {
2286
+ ...state.tasks,
2287
+ [input.taskId]: input.taskState
2288
+ },
2289
+ artifacts: [...state.artifacts, ...newArtifacts],
2290
+ validation: {
2291
+ status: input.status === 'PASS' ? 'pass' : input.status === 'PASS_WITH_GAPS' ? 'pass_with_gaps' : input.status === 'BLOCKED' ? 'blocked' : 'fail',
2292
+ commands: input.commands,
2293
+ evidence: input.evidence
2294
+ },
2295
+ syncBack: {
2296
+ mode: 'proposal',
2297
+ proposalPath: input.syncBackProposalPath,
2298
+ status: 'proposed'
2299
+ }
2300
+ });
2301
+ }
2302
+ async function writeSyncBackProposal(projectRoot, runId, taskId, status, artifacts, gaps, summary) {
2303
+ const content = `# Sync-back Proposal\n\n## ${taskId}\n\n- status: ${status}\n- summary: ${summary}\n- artifacts:\n${artifacts.length > 0 ? artifacts.map((artifact) => ` - ${artifact}`).join('\n') : ' - none'}\n- gaps:\n${gaps.length > 0 ? gaps.map((gap) => ` - [${gap.severity}] ${gap.type} ${gap.field}: ${gap.message}`).join('\n') : ' - none'}\n\n## Boundaries\n\n- Proposal only; tasks.md/spec.md/plan.md were not modified by runtime.\n- Runtime modeled agent/verify steps through supplied artifacts and contract validation; no external agent API was invoked.\n`;
2304
+ return writeArtifact(projectRoot, runId, 'sync-back-proposal.md', content);
2305
+ }
2306
+ function renderLoopGapReport(taskId, gaps) {
2307
+ return `# Gap Report ${taskId}\n\n\`\`\`sdd-result\ncontract: ${SDD_RESULT_CONTRACT}\nversion: ${SDD_RESULT_VERSION}\nagent: runtime\ntask: ${taskId}\nstatus: BLOCKED\nartifacts:\n - artifacts/gap-report-${taskId}.md\n\`\`\`\n\n## Gaps\n\n${gaps.length > 0 ? gaps.map((gap) => `- [${gap.severity}] ${gap.type} ${gap.field}: ${gap.message} Recommendation: ${gap.recommendation}`).join('\n') : '- No structured gaps were provided; inspect task selection and supplied artifacts.'}\n`;
2308
+ }
2309
+ function renderAcceptanceCoverageArtifact(taskId, status, task, reviewArtifact, validationArtifact, coverage, gaps) {
2310
+ return `# Acceptance Coverage ${taskId}\n\n\`\`\`sdd-result\ncontract: ${SDD_RESULT_CONTRACT}\nversion: ${SDD_RESULT_VERSION}\nagent: validator\ntask: ${taskId}\nstatus: ${status}\nartifacts:\n - artifacts/acceptance-coverage-${taskId}.md\n\`\`\`\n\n## Source Evidence\n\n- review_artifact: ${reviewArtifact ?? 'missing'}\n- validation_artifact: ${validationArtifact ?? 'missing'}\n- task_source: ${task ? sourceLocationEvidence(task.source) : 'missing'}\n\n## Commands Declared\n\n${task && task.validation.length > 0 ? task.validation.map((command) => `- ${command}`).join('\n') : '- none'}\n\n## Acceptance Mapping\n\n${coverage.length > 0 ? coverage.map((item) => `- [${item.status}] ${item.acceptance} Evidence: ${item.evidence}`).join('\n') : '- No acceptance items available.'}\n\n## Gaps\n\n${gaps.length > 0 ? gaps.map((gap) => `- [${gap.severity}] ${gap.type} ${gap.field}: ${gap.message} Recommendation: ${gap.recommendation}`).join('\n') : '- none'}\n`;
2311
+ }
2312
+ function statusFromValidation(status) {
2313
+ if (status === 'PASS') {
2314
+ return 'PASS';
2315
+ }
2316
+ if (status === 'PASS_WITH_GAPS') {
2317
+ return 'PASS_WITH_GAPS';
2318
+ }
2319
+ if (status === 'FAIL') {
2320
+ return 'FAIL';
2321
+ }
2322
+ if (status === 'BLOCKED' || status === 'TIMED_OUT' || status === 'CANCELLED') {
2323
+ return 'BLOCKED';
2324
+ }
2325
+ return 'GAP';
2326
+ }
2327
+ function deriveGoalVerifyStatus(reviewStatus, validationStatus, gaps) {
2328
+ if (gaps.length > 0) {
2329
+ return validationStatus === 'PASS_WITH_GAPS' ? 'PASS_WITH_GAPS' : 'BLOCKED';
2330
+ }
2331
+ if (reviewStatus !== 'PASS' || !validationStatus) {
2332
+ return 'BLOCKED';
2333
+ }
2334
+ if (validationStatus === 'PASS') {
2335
+ return 'PASS';
2336
+ }
2337
+ if (validationStatus === 'PASS_WITH_GAPS') {
2338
+ return 'PASS_WITH_GAPS';
2339
+ }
2340
+ return validationStatus === 'FAIL' ? 'FAIL' : 'BLOCKED';
2341
+ }
2342
+ async function readArtifactIfExists(projectRoot, runId, runRelativeArtifactPath) {
2343
+ try {
2344
+ return await readArtifact(projectRoot, runId, toArtifactRootRelativePath(runRelativeArtifactPath));
2345
+ }
2346
+ catch {
2347
+ return '';
2348
+ }
2349
+ }
2350
+ function artifactPathForAgent(state, taskId, agent) {
2351
+ const delegation = Object.values(state.delegations).find((candidate) => candidate.task === taskId && candidate.agent === agent && candidate.status === 'COMPLETED');
2352
+ if (delegation) {
2353
+ return delegation.expectedArtifact;
2354
+ }
2355
+ const artifact = state.artifacts.find((candidate) => candidate.task === taskId && candidate.agent === agent);
2356
+ return artifact?.path ?? null;
2357
+ }
2358
+ function artifactOptionName(agent) {
2359
+ if (agent === 'implementer') {
2360
+ return '--implement-artifact';
2361
+ }
2362
+ if (agent === 'reviewer') {
2363
+ return '--review-artifact';
2364
+ }
2365
+ if (agent === 'debugger') {
2366
+ return '--debug-artifact';
2367
+ }
2368
+ if (agent === 'validator') {
2369
+ return '--validation-artifact';
2370
+ }
2371
+ return '--artifact';
2372
+ }
2373
+ function artifactIngestionKey(delegationId, artifactPath) {
2374
+ return `${delegationId}:${artifactPath}`;
2375
+ }
2376
+ function delegationStatusFromResultStatus(status) {
2377
+ if (status === 'PASS' || status === 'PASS_WITH_GAPS') {
2378
+ return 'COMPLETED';
2379
+ }
2380
+ if (status === 'TIMED_OUT') {
2381
+ return 'TIMED_OUT';
2382
+ }
2383
+ if (status === 'CANCELLED') {
2384
+ return 'CANCELLED';
2385
+ }
2386
+ return 'FAILED';
2387
+ }
2388
+ function terminalEventForDelegationStatus(status) {
2389
+ if (status === 'COMPLETED') {
2390
+ return 'delegation_completed';
2391
+ }
2392
+ if (status === 'TIMED_OUT') {
2393
+ return 'delegation_timeout';
2394
+ }
2395
+ if (status === 'CANCELLED') {
2396
+ return 'delegation_cancelled';
2397
+ }
2398
+ return 'delegation_failed';
2399
+ }
2400
+ function artifactIngestionGaps(delegation, status) {
2401
+ if (status === 'PASS') {
2402
+ return [];
2403
+ }
2404
+ return [taskGap(delegation.task, delegation.agent, `Artifact ingestion returned ${status} for ${delegation.delegationId}.`, 'Inspect the ingested artifact evidence before verify or sync-back apply.')];
2405
+ }
2406
+ function overlappingFiles(left, right) {
2407
+ const rightSet = new Set(right.map(normalizeComparablePath));
2408
+ return left.filter((file) => rightSet.has(normalizeComparablePath(file)));
2409
+ }
2410
+ function normalizeComparablePath(filePath) {
2411
+ return filePath.replace(/\\/g, '/').replace(/^\.\//, '');
2412
+ }
2413
+ function artifactKind(artifactPath) {
2414
+ const fileName = path.posix.basename(artifactPath.replace(/\\/g, '/'));
2415
+ if (fileName.startsWith('implement-')) {
2416
+ return 'implement';
2417
+ }
2418
+ if (fileName.startsWith('review-')) {
2419
+ return 'review';
2420
+ }
2421
+ if (fileName.startsWith('debug-')) {
2422
+ return 'debug';
2423
+ }
2424
+ if (fileName.startsWith('validation-')) {
2425
+ return 'validation';
2426
+ }
2427
+ if (fileName.startsWith('gap-report-')) {
2428
+ return 'gap-report';
2429
+ }
2430
+ if (fileName === 'sync-back-proposal.md') {
2431
+ return 'sync-back-proposal';
2432
+ }
2433
+ return 'artifact';
2434
+ }
2435
+ function agentFromArtifactPath(artifactPath) {
2436
+ const kind = artifactKind(artifactPath);
2437
+ if (kind === 'implement') {
2438
+ return 'implementer';
2439
+ }
2440
+ if (kind === 'review' || kind === 'validation' || kind === 'debug') {
2441
+ return kind === 'debug' ? 'debugger' : kind === 'review' ? 'reviewer' : 'validator';
2442
+ }
2443
+ if (kind === 'gap-report' || kind === 'sync-back-proposal') {
2444
+ return 'runtime';
2445
+ }
2446
+ return 'unknown';
2447
+ }
2448
+ async function detectProjectConfig(projectRoot, projectName) {
2449
+ const config = defaultProjectConfig(projectName);
2450
+ const detection = await detectProject(projectRoot);
2451
+ config.project.language = detection.primary.language;
2452
+ config.project.framework = detection.primary.framework;
2453
+ config.validation.default = detection.primary.validationDefault;
2454
+ config.detection = {
2455
+ confidence: detection.primary.confidence,
2456
+ mixed_stack: detection.mixed_stack,
2457
+ primary: detection.primary.id,
2458
+ candidates: detection.candidates.map((candidate) => ({
2459
+ id: candidate.id,
2460
+ confidence: candidate.confidence,
2461
+ score: candidate.score
2462
+ }))
2463
+ };
2464
+ return config;
2465
+ }
2466
+ const PROJECT_DETECTORS = [
2467
+ {
2468
+ id: 'java-ssm-maven-multimodule',
2469
+ async detect(projectRoot, rootEntries) {
2470
+ const hasPom = rootEntries.includes('pom.xml');
2471
+ const pom = hasPom ? await readFile(path.join(projectRoot, 'pom.xml'), 'utf8') : '';
2472
+ const javaFiles = await countFiles(projectRoot, (filePath) => filePath.endsWith('.java'), 100);
2473
+ const springXmlFiles = await countFiles(projectRoot, (filePath) => filePath.endsWith('.xml') && filePath.includes('/src/main/') && /applicationContext|spring|dubbo|mybatis/i.test(path.basename(filePath)), 50);
2474
+ const mybatisMapperFiles = await countFiles(projectRoot, (filePath) => filePath.endsWith('Mapper.xml'), 50);
2475
+ const mavenMultimodule = /<packaging>\s*pom\s*<\/packaging>/i.test(pom) || /<modules>\s*<module>/is.test(pom);
2476
+ const ssmScore = springXmlFiles + mybatisMapperFiles + (/spring|mybatis|dubbo/i.test(pom) ? 3 : 0);
2477
+ const evidence = detectionEvidence([
2478
+ { kind: 'pom.xml', detail: hasPom ? 'root pom.xml present' : '', weight: hasPom ? 6 : 0 },
2479
+ { kind: 'maven_multimodule', detail: mavenMultimodule ? 'packaging pom or modules detected' : '', weight: mavenMultimodule ? 3 : 0 },
2480
+ { kind: 'java_sources', detail: `${javaFiles} Java source file(s)`, weight: Math.min(javaFiles, 10) },
2481
+ { kind: 'ssm_evidence', detail: `${ssmScore} Spring/MyBatis evidence point(s)`, weight: Math.min(ssmScore, 10) }
2482
+ ]);
2483
+ return detectionCandidate('java-ssm-maven-multimodule', 'java', mavenMultimodule || ssmScore > 0 ? 'ssm-maven-multimodule' : 'maven', evidence, ['mvn compile']);
2484
+ }
2485
+ },
2486
+ {
2487
+ id: 'typescript-node',
2488
+ async detect(projectRoot, rootEntries) {
2489
+ const hasPackageJson = rootEntries.includes('package.json');
2490
+ const packageJson = hasPackageJson ? await readFile(path.join(projectRoot, 'package.json'), 'utf8') : '';
2491
+ const tsFiles = await countFiles(projectRoot, (filePath) => filePath.endsWith('.ts') || filePath.endsWith('.tsx'), 100);
2492
+ const nodeSourceDirs = rootEntries.filter((entry) => ['src', 'app', 'pages'].includes(entry)).length;
2493
+ const typescriptEvidence = /typescript|tsx|ts-node|vite|next|nuxt/i.test(packageJson);
2494
+ const evidence = detectionEvidence([
2495
+ { kind: 'package.json', detail: hasPackageJson ? 'root package.json present' : '', weight: hasPackageJson ? 4 : 0 },
2496
+ { kind: 'typescript_sources', detail: `${tsFiles} TypeScript source file(s)`, weight: Math.min(tsFiles, 10) },
2497
+ { kind: 'typescript_package', detail: typescriptEvidence ? 'TypeScript-related package metadata detected' : '', weight: typescriptEvidence ? 3 : 0 },
2498
+ { kind: 'node_source_dirs', detail: `${nodeSourceDirs} common Node source dir(s)`, weight: nodeSourceDirs }
2499
+ ]);
2500
+ return detectionCandidate('typescript-node', 'typescript', typescriptEvidence || tsFiles > 0 ? 'typescript-node' : 'node', evidence, ['npm run typecheck']);
2501
+ }
2502
+ }
2503
+ ];
2504
+ async function detectProject(projectRoot) {
2505
+ const rootEntries = await safeReadDir(projectRoot);
2506
+ const detected = await Promise.all(PROJECT_DETECTORS.map((detector) => detector.detect(projectRoot, rootEntries)));
2507
+ const candidates = detected.filter((candidate) => candidate.score > 0).sort((left, right) => right.score - left.score);
2508
+ const primary = candidates[0] ?? detectionCandidate('typescript-node', 'typescript', 'node', [], ['npm run typecheck']);
2509
+ return {
2510
+ primary,
2511
+ candidates: candidates.length > 0 ? candidates : [primary],
2512
+ mixed_stack: candidates.length > 1
2513
+ };
2514
+ }
2515
+ function detectionEvidence(items) {
2516
+ return items.filter((item) => item.weight > 0);
2517
+ }
2518
+ function detectionCandidate(id, language, framework, evidence, validationDefault) {
2519
+ const score = evidence.reduce((total, item) => total + item.weight, 0);
2520
+ return {
2521
+ id,
2522
+ language,
2523
+ framework,
2524
+ score,
2525
+ confidence: detectionConfidence(score),
2526
+ evidence,
2527
+ validationDefault
2528
+ };
2529
+ }
2530
+ function detectionConfidence(score) {
2531
+ if (score >= 10) {
2532
+ return 'high';
2533
+ }
2534
+ if (score >= 5) {
2535
+ return 'medium';
2536
+ }
2537
+ return 'low';
2538
+ }
2539
+ async function safeReadDir(directory) {
2540
+ try {
2541
+ return await readdir(directory);
2542
+ }
2543
+ catch {
2544
+ return [];
2545
+ }
2546
+ }
2547
+ async function countFiles(root, predicate, limit) {
2548
+ let count = 0;
2549
+ const pending = [root];
2550
+ while (pending.length > 0 && count < limit) {
2551
+ const current = pending.pop();
2552
+ for (const entry of await safeReadDir(current)) {
2553
+ if (['.git', 'node_modules', 'target', 'dist', '.sdd'].includes(entry)) {
2554
+ continue;
2555
+ }
2556
+ const fullPath = path.join(current, entry);
2557
+ const entryStat = await stat(fullPath).catch(() => null);
2558
+ if (!entryStat) {
2559
+ continue;
2560
+ }
2561
+ if (entryStat.isDirectory()) {
2562
+ pending.push(fullPath);
2563
+ }
2564
+ else if (predicate(fullPath.replace(/\\/g, '/'))) {
2565
+ count += 1;
2566
+ if (count >= limit) {
2567
+ return count;
2568
+ }
2569
+ }
2570
+ }
2571
+ }
2572
+ return count;
2573
+ }
2574
+ export function defaultProjectConfig(projectName) {
2575
+ return {
2576
+ contract: PROJECT_CONFIG_CONTRACT,
2577
+ project: {
2578
+ name: projectName,
2579
+ language: 'typescript',
2580
+ framework: 'node'
2581
+ },
2582
+ sdd: {
2583
+ spec_dir: 'specs/<branch>',
2584
+ docs_language: 'zh-CN',
2585
+ compatible_with: 'spec-kit'
2586
+ },
2587
+ validation: {
2588
+ default: ['npm run typecheck']
2589
+ },
2590
+ editing: {
2591
+ prefer_hashline: true,
2592
+ native_edit_fallback: true
2593
+ },
2594
+ runtime: {
2595
+ background_write: false,
2596
+ worktree_isolation: false,
2597
+ sync_back_mode: 'proposal'
2598
+ },
2599
+ lifecycle: {
2600
+ decision_required: true,
2601
+ profiles: ['direct', 'compact', 'full', 'research']
2602
+ }
2603
+ };
2604
+ }
2605
+ export const TOOL_CAPABILITY_REGISTRY_VERSION = 'phase-3.1-tool-capability-registry-v1';
2606
+ export const TOOL_PLUGIN_CONTRACT_REGISTRY_VERSION = 'phase-3.2-tool-plugin-loading-contract-v1';
2607
+ export const DELEGATION_QUEUE_CONTRACT_VERSION = 'phase-3.3-delegation-queue-contract-v1';
2608
+ export const DELEGATION_STATE_MACHINE_VERSION = 'phase-3.4-delegation-state-machine-v1';
2609
+ export const WORKER_ADAPTER_CONTRACT_REGISTRY_VERSION = 'phase-3.5-worker-adapter-contract-v1';
2610
+ export const ARTIFACT_RESULT_INGESTION_REGISTRY_VERSION = ARTIFACT_RESULT_INGESTION_CONTRACT_VERSION;
2611
+ export const WORKTREE_ISOLATION_CONTRACT_VERSION = 'phase-3.7-worktree-isolation-contract-v1';
2612
+ export const WORKTREE_LIFECYCLE_CONTRACT_VERSION = 'phase-3.8-worktree-lifecycle-v1';
2613
+ const DELEGATION_STATUSES = ['PENDING', 'RUNNING', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'RECOVERABLE', 'STALE'];
2614
+ const TERMINAL_DELEGATION_STATUSES = ['COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED'];
2615
+ const DELEGATION_STATE_TRANSITIONS = [
2616
+ { from: 'PENDING', to: 'RUNNING', event: 'delegation_started', terminal: false },
2617
+ { from: 'PENDING', to: 'CANCELLED', event: 'delegation_cancelled', terminal: true },
2618
+ { from: 'RUNNING', to: 'COMPLETED', event: 'delegation_completed', terminal: true },
2619
+ { from: 'RUNNING', to: 'FAILED', event: 'delegation_failed', terminal: true },
2620
+ { from: 'RUNNING', to: 'TIMED_OUT', event: 'delegation_timeout', terminal: true },
2621
+ { from: 'RUNNING', to: 'CANCELLED', event: 'delegation_cancelled', terminal: true },
2622
+ { from: 'RUNNING', to: 'RECOVERABLE', event: 'artifact_invalid', terminal: false },
2623
+ { from: 'RUNNING', to: 'STALE', event: 'delegation_stale', terminal: false },
2624
+ { from: 'RECOVERABLE', to: 'RUNNING', event: 'delegation_retry_started', terminal: false },
2625
+ { from: 'RECOVERABLE', to: 'FAILED', event: 'delegation_failed', terminal: true },
2626
+ { from: 'RECOVERABLE', to: 'CANCELLED', event: 'delegation_cancelled', terminal: true },
2627
+ { from: 'STALE', to: 'RUNNING', event: 'delegation_heartbeat', terminal: false },
2628
+ { from: 'STALE', to: 'TIMED_OUT', event: 'delegation_timeout', terminal: true },
2629
+ { from: 'STALE', to: 'FAILED', event: 'delegation_failed', terminal: true },
2630
+ { from: 'STALE', to: 'CANCELLED', event: 'delegation_cancelled', terminal: true }
2631
+ ];
2632
+ const BASELINE_WORKER_ADAPTER_IDS = [
2633
+ 'claude-code-subagent-worker',
2634
+ 'sdd-cli-task-worker',
2635
+ 'manual-handoff-worker'
2636
+ ];
2637
+ const BASELINE_GOVERNANCE_POLICY_OPERATIONS = [
2638
+ 'background_executor',
2639
+ 'wave_executor',
2640
+ 'sync_back_apply',
2641
+ 'destructive_git',
2642
+ 'external_interaction',
2643
+ 'cleanup'
2644
+ ];
2645
+ const BASELINE_TOOL_CAPABILITY_IDS = [
2646
+ 'sdd-cli',
2647
+ 'hashline-edit',
2648
+ 'native-file-edit',
2649
+ 'git-local',
2650
+ 'validation-command',
2651
+ 'artifact-run-hygiene',
2652
+ 'browser-ui-check',
2653
+ 'governance-policy'
2654
+ ];
2655
+ const BASELINE_TOOL_PLUGIN_CONTRACT_IDS = [
2656
+ 'sdd-cli-runtime',
2657
+ 'hashline-edit-adapter',
2658
+ 'native-file-edit-adapter',
2659
+ 'git-local-inspection',
2660
+ 'validation-command-runner',
2661
+ 'artifact-run-hygiene-tools',
2662
+ 'browser-ui-check-adapter'
2663
+ ];
2664
+ const BUILT_IN_TOOL_CAPABILITIES = [
2665
+ {
2666
+ id: 'artifact-run-hygiene',
2667
+ title: 'Artifact and run hygiene',
2668
+ category: 'artifact',
2669
+ summary: 'Generate and validate sdd-result artifacts, archive exploratory runs, and scope doctor run evidence checks.',
2670
+ sideEffect: 'local_write',
2671
+ defaultAvailable: true,
2672
+ allowedStages: ['do', 'verify', 'doctor'],
2673
+ requiredEvidence: ['sdd-result artifact', 'run event log', 'doctor report'],
2674
+ forbiddenUses: ['delete run evidence', 'auto apply sync-back', 'mark acceptance without validator evidence']
2675
+ },
2676
+ {
2677
+ id: 'browser-ui-check',
2678
+ title: 'Browser UI check',
2679
+ category: 'browser',
2680
+ summary: 'Use a browser to inspect frontend behavior when UI changes need manual verification.',
2681
+ sideEffect: 'external_interaction',
2682
+ defaultAvailable: true,
2683
+ allowedStages: ['validation', 'review'],
2684
+ requiredEvidence: ['manual UI observation', 'console/network findings when relevant'],
2685
+ forbiddenUses: ['bypass authentication policy', 'perform destructive production actions', 'publish sensitive data to third-party tools']
2686
+ },
2687
+ {
2688
+ id: 'git-local',
2689
+ title: 'Local Git inspection',
2690
+ category: 'git',
2691
+ summary: 'Inspect local repository status, diffs, and history for coordination and safety checks.',
2692
+ sideEffect: 'read_only',
2693
+ defaultAvailable: true,
2694
+ allowedStages: ['status', 'review', 'doctor'],
2695
+ requiredEvidence: ['git status or diff summary when used for decisions'],
2696
+ forbiddenUses: ['force push', 'destructive reset', 'delete branches without explicit approval']
2697
+ },
2698
+ {
2699
+ id: 'hashline-edit',
2700
+ title: 'Hashline UTF-8 text editing',
2701
+ category: 'editing',
2702
+ summary: 'Edit UTF-8 text files through stable line anchors for safer targeted modifications.',
2703
+ sideEffect: 'local_write',
2704
+ defaultAvailable: true,
2705
+ allowedStages: ['implementation', 'docs'],
2706
+ requiredEvidence: ['read anchors before edit', 'diff or readback after important edits'],
2707
+ forbiddenUses: ['edit binary files', 'retry stale anchors without rereading', 'overwrite unrelated user changes']
2708
+ },
2709
+ {
2710
+ id: 'native-file-edit',
2711
+ title: 'Native file read/edit/write fallback',
2712
+ category: 'editing',
2713
+ summary: 'Use native file tools for reads and targeted edits when hashline editing is unsuitable.',
2714
+ sideEffect: 'local_write',
2715
+ defaultAvailable: true,
2716
+ allowedStages: ['implementation', 'docs'],
2717
+ requiredEvidence: ['read before write', 'diff or readback after important edits'],
2718
+ forbiddenUses: ['create unsolicited docs', 'overwrite existing files without reading', 'write secrets']
2719
+ },
2720
+ {
2721
+ id: 'sdd-cli',
2722
+ title: 'SDD local CLI/runtime',
2723
+ category: 'runtime',
2724
+ summary: 'Read and update local SDD runtime state, semantic docs, artifacts, and generated entries through explicit commands.',
2725
+ sideEffect: 'command_execution',
2726
+ defaultAvailable: true,
2727
+ allowedStages: ['status', 'do', 'verify', 'sync-back', 'doctor'],
2728
+ requiredEvidence: ['command output', 'state/event/artifact path when runtime changes'],
2729
+ forbiddenUses: ['unapproved complex sync-back apply', 'automatic commit or push', 'background write orchestration']
2730
+ },
2731
+ {
2732
+ id: 'validation-command',
2733
+ title: 'Project validation commands',
2734
+ category: 'validation',
2735
+ summary: 'Run project-specific checks such as typecheck, tests, build, lint, or smoke commands.',
2736
+ sideEffect: 'command_execution',
2737
+ defaultAvailable: true,
2738
+ allowedStages: ['validation', 'verify', 'doctor'],
2739
+ requiredEvidence: ['command name', 'pass/fail status', 'relevant output excerpt'],
2740
+ forbiddenUses: ['skip failing checks without explanation', 'run destructive commands as validation', 'hide hook failures']
2741
+ },
2742
+ {
2743
+ id: 'governance-policy',
2744
+ title: 'Governance policy gate',
2745
+ category: 'governance',
2746
+ summary: 'Evaluate concurrency, confirmation, retry, cleanup, and risky-operation gates before runtime execution.',
2747
+ sideEffect: 'read_only',
2748
+ defaultAvailable: true,
2749
+ allowedStages: ['status', 'do', 'verify', 'doctor'],
2750
+ requiredEvidence: ['policy decision', 'blocked or confirmed operation reason', 'runtime event when execution is gated'],
2751
+ forbiddenUses: ['auto approve destructive operations', 'bypass permission prompts', 'delete run history']
2752
+ }
2753
+ ];
2754
+ export async function listToolCapabilities(projectRoot) {
2755
+ await readProjectConfig(projectRoot);
2756
+ return {
2757
+ version: TOOL_CAPABILITY_REGISTRY_VERSION,
2758
+ capabilities: [...BUILT_IN_TOOL_CAPABILITIES].sort((left, right) => left.id.localeCompare(right.id))
2759
+ };
2760
+ }
2761
+ export async function inspectToolCapability(projectRoot, capabilityId) {
2762
+ const registry = await listToolCapabilities(projectRoot);
2763
+ return registry.capabilities.find((capability) => capability.id === capabilityId) ?? null;
2764
+ }
2765
+ export async function inspectWorktreeIsolation(projectRoot, options) {
2766
+ const branch = options.branch ?? 'master';
2767
+ const [model, capabilityRegistry] = await Promise.all([parseSddBranch(projectRoot, branch), listToolCapabilities(projectRoot)]);
2768
+ const inspected = inspectSddTask(model, options.taskId);
2769
+ const capabilityId = options.capabilityId ?? 'native-file-edit';
2770
+ const capability = capabilityRegistry.capabilities.find((candidate) => candidate.id === capabilityId);
2771
+ const task = inspected.task;
2772
+ const affectedFiles = task?.affectedFiles ?? [];
2773
+ const risk = task?.risk ?? [];
2774
+ const peers = (options.peerTaskIds ?? []).map((peerTaskId) => {
2775
+ const peer = inspectSddTask(model, peerTaskId).task;
2776
+ return { taskId: peerTaskId, affectedFiles: peer?.affectedFiles ?? [], risk: peer?.risk ?? [] };
2777
+ });
2778
+ const sideEffect = capability?.sideEffect ?? 'local_write';
2779
+ const overlaps = peers
2780
+ .map((peer) => ({ peerTaskId: peer.taskId, files: overlappingFiles(affectedFiles, peer.affectedFiles) }))
2781
+ .filter((overlap) => overlap.files.length > 0);
2782
+ const highRisk = risk.some((item) => ['database', 'schema', 'security', 'state-machine', 'ci'].includes(item));
2783
+ const manualRisk = risk.some((item) => ['database', 'security'].includes(item));
2784
+ const unsafeOverlap = overlaps.length > 0 && sideEffect !== 'read_only';
2785
+ const reasons = [];
2786
+ const gates = [];
2787
+ if (!task) {
2788
+ reasons.push(`Task ${options.taskId} is missing or ambiguous in specs/${branch}/tasks.md.`);
2789
+ }
2790
+ if (!capability) {
2791
+ reasons.push(`Capability ${capabilityId} is not declared in the Phase 3.1 capability registry.`);
2792
+ }
2793
+ if (unsafeOverlap) {
2794
+ reasons.push(`Writable task overlaps peer affected file(s): ${overlaps.map((overlap) => `${overlap.peerTaskId}:${overlap.files.join(',')}`).join('; ')}.`);
2795
+ }
2796
+ if (manualRisk) {
2797
+ reasons.push('Database/security risk requires manual isolation gate before worktree lifecycle automation.');
2798
+ }
2799
+ else if (highRisk && sideEffect !== 'read_only') {
2800
+ reasons.push('High-risk writable task requires worktree isolation.');
2801
+ }
2802
+ else if (sideEffect === 'read_only') {
2803
+ reasons.push('Read-only capability does not require worktree isolation.');
2804
+ }
2805
+ else if (sideEffect === 'local_write' || sideEffect === 'command_execution') {
2806
+ reasons.push('Writable or command-executing capability requires worktree isolation unless blocked by overlap.');
2807
+ }
2808
+ gates.push({ name: 'task_found', passed: task !== null, message: task ? `Task ${options.taskId} found.` : `Task ${options.taskId} missing or ambiguous.` }, { name: 'capability_declared', passed: capability !== undefined, message: capability ? `Capability ${capabilityId} side_effect=${sideEffect}.` : `Capability ${capabilityId} missing.` }, { name: 'files_overlap', passed: overlaps.length === 0, message: overlaps.length === 0 ? 'No peer affected_files overlap.' : `Overlaps: ${overlaps.map((overlap) => `${overlap.peerTaskId}:${overlap.files.join(',')}`).join('; ')}` }, { name: 'unsafe_concurrency', passed: !unsafeOverlap, message: unsafeOverlap ? 'Writable overlapping tasks are not safe to run concurrently.' : 'No unsafe writable overlap detected.' }, { name: 'read_only', passed: sideEffect === 'read_only', message: sideEffect === 'read_only' ? 'Read-only task can run without worktree.' : 'Task may mutate local state or execute commands.' });
2809
+ const mode = !task || !capability || unsafeOverlap
2810
+ ? 'blocked'
2811
+ : manualRisk || sideEffect === 'external_interaction'
2812
+ ? 'manual'
2813
+ : sideEffect === 'read_only'
2814
+ ? 'none'
2815
+ : 'required';
2816
+ return {
2817
+ version: WORKTREE_ISOLATION_CONTRACT_VERSION,
2818
+ taskId: options.taskId,
2819
+ mode,
2820
+ safeConcurrency: mode !== 'blocked',
2821
+ capabilityId,
2822
+ capabilitySideEffect: sideEffect,
2823
+ affectedFiles,
2824
+ risk,
2825
+ peers,
2826
+ overlaps,
2827
+ gates,
2828
+ reasons
2829
+ };
2830
+ }
2831
+ export async function createWorktreeLifecycle(projectRoot, runId, options) {
2832
+ await readProjectConfig(projectRoot);
2833
+ const state = await readRunState(projectRoot, runId);
2834
+ const decision = await inspectWorktreeIsolation(projectRoot, { taskId: options.taskId });
2835
+ if (decision.mode === 'blocked' || decision.mode === 'none') {
2836
+ throw new Error(`Cannot create worktree for ${options.taskId}: isolation mode is ${decision.mode}.`);
2837
+ }
2838
+ const gitRoot = await getGitRoot(projectRoot);
2839
+ if (!gitRoot) {
2840
+ throw new Error('Cannot create worktree outside a Git repository.');
2841
+ }
2842
+ const worktreeId = options.worktreeId ?? defaultWorktreeId(runId, options.taskId);
2843
+ assertSafePathSegment(worktreeId, 'worktreeId');
2844
+ const currentWorktrees = state.worktrees ?? {};
2845
+ const existing = currentWorktrees[worktreeId];
2846
+ if (existing && existing.status !== 'removed') {
2847
+ throw new Error(`Worktree ${worktreeId} already exists for run ${runId}.`);
2848
+ }
2849
+ const baseRef = options.baseRef ?? 'HEAD';
2850
+ const branchName = `sdd-${worktreeId}`;
2851
+ const worktreePath = path.join(getWorktreesDir(projectRoot), worktreeId);
2852
+ await mkdir(path.dirname(worktreePath), { recursive: true });
2853
+ await execFileAsync('git', ['-C', projectRoot, 'worktree', 'add', '-b', branchName, worktreePath, baseRef]);
2854
+ const now = new Date().toISOString();
2855
+ const record = {
2856
+ contract: WORKTREE_LIFECYCLE_CONTRACT_VERSION,
2857
+ runId,
2858
+ taskId: options.taskId,
2859
+ worktreeId,
2860
+ status: 'created',
2861
+ branchName,
2862
+ worktreePath: path.relative(projectRoot, worktreePath),
2863
+ baseRef,
2864
+ createdAt: now,
2865
+ updatedAt: now,
2866
+ removedAt: null,
2867
+ keepReason: null,
2868
+ dirty: false
2869
+ };
2870
+ await writeRunState(projectRoot, { ...state, worktrees: { ...currentWorktrees, [worktreeId]: record } });
2871
+ await appendEvent(projectRoot, runId, {
2872
+ event: 'worktree_created',
2873
+ runId,
2874
+ summary: `Worktree ${worktreeId} created for ${options.taskId}.`,
2875
+ data: { worktreeId, taskId: options.taskId, path: record.worktreePath, branchName, baseRef }
2876
+ });
2877
+ return record;
2878
+ }
2879
+ export async function inspectWorktreeLifecycle(projectRoot, runId) {
2880
+ const state = await readRunState(projectRoot, runId);
2881
+ const records = await Promise.all(Object.values(state.worktrees ?? {}).map(async (record) => ({
2882
+ ...record,
2883
+ dirty: record.status === 'removed' ? false : await isGitWorktreeDirty(projectRoot, record.worktreePath)
2884
+ })));
2885
+ const issues = [];
2886
+ const activePaths = new Set(records.filter((record) => record.status !== 'removed').map((record) => normalizeComparablePath(record.worktreePath)));
2887
+ const registeredPaths = await listGitWorktreePaths(projectRoot);
2888
+ for (const record of records) {
2889
+ const absolutePath = path.resolve(projectRoot, record.worktreePath);
2890
+ const comparablePath = normalizeComparablePath(record.worktreePath);
2891
+ const pathExists = await exists(absolutePath);
2892
+ if (record.contract !== WORKTREE_LIFECYCLE_CONTRACT_VERSION) {
2893
+ issues.push(contractIssue('contract', `${record.worktreeId} uses ${record.contract}.`, `Use ${WORKTREE_LIFECYCLE_CONTRACT_VERSION}.`));
2894
+ }
2895
+ if (record.status !== 'removed' && !pathExists) {
2896
+ issues.push(contractIssue('worktreePath', `${record.worktreeId} points to missing worktree path ${record.worktreePath}.`, 'Inspect worktree state and mark removed only through lifecycle remove.'));
2897
+ }
2898
+ if (record.status !== 'removed' && pathExists && !registeredPaths.has(normalizeComparablePath(absolutePath))) {
2899
+ issues.push(contractIssue('worktreePath', `${record.worktreeId} path is not registered in git worktree list.`, 'Inspect git worktree list and reconcile lifecycle state.'));
2900
+ }
2901
+ if (record.status === 'removed' && pathExists) {
2902
+ issues.push(contractIssue('status', `${record.worktreeId} is removed in state but path still exists.`, 'Inspect the path before deleting anything manually.'));
2903
+ }
2904
+ if (record.status !== 'removed' && record.dirty) {
2905
+ issues.push(contractIssue('dirty', `${record.worktreeId} has uncommitted changes.`, 'Keep the worktree or resolve changes before lifecycle remove.'));
2906
+ }
2907
+ if (record.status !== 'removed' && activePaths.has(comparablePath) && Array.from(activePaths).filter((item) => item === comparablePath).length > 1) {
2908
+ issues.push(contractIssue('worktreePath', `${record.worktreeId} shares a path with another active worktree.`, 'Use one lifecycle record per worktree path.'));
2909
+ }
2910
+ }
2911
+ for (const orphanPath of await listOrphanWorktreeDirs(projectRoot, activePaths)) {
2912
+ issues.push(contractIssue('orphan', `${orphanPath} exists without active lifecycle state.`, 'Inspect the directory before removing it or recreate lifecycle state.'));
2913
+ }
2914
+ return {
2915
+ runId,
2916
+ contract: WORKTREE_LIFECYCLE_CONTRACT_VERSION,
2917
+ records,
2918
+ valid: issues.length === 0,
2919
+ issues
2920
+ };
2921
+ }
2922
+ export async function keepWorktreeLifecycle(projectRoot, runId, worktreeId, options = {}) {
2923
+ assertSafePathSegment(worktreeId, 'worktreeId');
2924
+ const state = await readRunState(projectRoot, runId);
2925
+ const record = (state.worktrees ?? {})[worktreeId];
2926
+ if (!record) {
2927
+ throw new Error(`Unknown worktree ${worktreeId} for run ${runId}.`);
2928
+ }
2929
+ const now = new Date().toISOString();
2930
+ const nextRecord = {
2931
+ ...record,
2932
+ status: 'kept',
2933
+ updatedAt: now,
2934
+ keepReason: options.reason ?? 'kept for later inspection',
2935
+ dirty: await isGitWorktreeDirty(projectRoot, record.worktreePath)
2936
+ };
2937
+ await writeRunState(projectRoot, { ...state, worktrees: { ...(state.worktrees ?? {}), [worktreeId]: nextRecord } });
2938
+ await appendEvent(projectRoot, runId, {
2939
+ event: 'worktree_kept',
2940
+ runId,
2941
+ summary: `Worktree ${worktreeId} kept.`,
2942
+ data: { worktreeId, reason: nextRecord.keepReason, dirty: nextRecord.dirty }
2943
+ });
2944
+ return nextRecord;
2945
+ }
2946
+ export async function removeWorktreeLifecycle(projectRoot, runId, worktreeId) {
2947
+ assertSafePathSegment(worktreeId, 'worktreeId');
2948
+ const state = await readRunState(projectRoot, runId);
2949
+ const record = (state.worktrees ?? {})[worktreeId];
2950
+ if (!record) {
2951
+ throw new Error(`Unknown worktree ${worktreeId} for run ${runId}.`);
2952
+ }
2953
+ if (record.status === 'removed') {
2954
+ return record;
2955
+ }
2956
+ const dirty = await isGitWorktreeDirty(projectRoot, record.worktreePath);
2957
+ if (dirty) {
2958
+ throw new Error(`Refusing to remove dirty worktree ${worktreeId}. Commit, stash, or keep it first.`);
2959
+ }
2960
+ await execFileAsync('git', ['-C', projectRoot, 'worktree', 'remove', path.resolve(projectRoot, record.worktreePath)]);
2961
+ const now = new Date().toISOString();
2962
+ const nextRecord = {
2963
+ ...record,
2964
+ status: 'removed',
2965
+ updatedAt: now,
2966
+ removedAt: now,
2967
+ dirty: false
2968
+ };
2969
+ await writeRunState(projectRoot, { ...state, worktrees: { ...(state.worktrees ?? {}), [worktreeId]: nextRecord } });
2970
+ await appendEvent(projectRoot, runId, {
2971
+ event: 'worktree_removed',
2972
+ runId,
2973
+ summary: `Worktree ${worktreeId} removed.`,
2974
+ data: { worktreeId, path: record.worktreePath }
2975
+ });
2976
+ return nextRecord;
2977
+ }
2978
+ const BUILT_IN_TOOL_PLUGIN_CONTRACTS = [
2979
+ {
2980
+ id: 'artifact-run-hygiene-tools',
2981
+ title: 'Artifact/run hygiene tools contract',
2982
+ version: '1.0.0',
2983
+ capabilityId: 'artifact-run-hygiene',
2984
+ entryKind: 'command',
2985
+ assetPath: 'packages/core/src/index.ts#artifact-run-hygiene',
2986
+ loadMode: 'static_manifest',
2987
+ checksum: null,
2988
+ requiredEvidence: ['sdd-result artifact', 'run event log', 'doctor report'],
2989
+ forbiddenUses: ['dynamic plugin execution', 'delete run evidence', 'auto apply sync-back', 'background write orchestration']
2990
+ },
2991
+ {
2992
+ id: 'browser-ui-check-adapter',
2993
+ title: 'Browser UI check adapter contract',
2994
+ version: '1.0.0',
2995
+ capabilityId: 'browser-ui-check',
2996
+ entryKind: 'manual',
2997
+ assetPath: 'claude-code/browser-tools',
2998
+ loadMode: 'readonly_asset',
2999
+ checksum: null,
3000
+ requiredEvidence: ['manual UI observation', 'console/network findings when relevant'],
3001
+ forbiddenUses: ['dynamic browser automation plugin execution', 'destructive production actions', 'publish sensitive data to third-party tools']
3002
+ },
3003
+ {
3004
+ id: 'git-local-inspection',
3005
+ title: 'Local git inspection contract',
3006
+ version: '1.0.0',
3007
+ capabilityId: 'git-local',
3008
+ entryKind: 'cli',
3009
+ assetPath: 'git',
3010
+ loadMode: 'readonly_asset',
3011
+ checksum: null,
3012
+ requiredEvidence: ['git status or diff summary when used for decisions'],
3013
+ forbiddenUses: ['force push', 'destructive reset', 'delete branches without explicit approval', 'background write orchestration']
3014
+ },
3015
+ {
3016
+ id: 'hashline-edit-adapter',
3017
+ title: 'Hashline edit adapter contract',
3018
+ version: '1.0.0',
3019
+ capabilityId: 'hashline-edit',
3020
+ entryKind: 'adapter',
3021
+ assetPath: 'mcp:hashline-edit',
3022
+ loadMode: 'readonly_asset',
3023
+ checksum: null,
3024
+ requiredEvidence: ['read anchors before edit', 'diff or readback after important edits'],
3025
+ forbiddenUses: ['dynamic external plugin scan', 'retry stale anchors without rereading', 'overwrite unrelated user changes']
3026
+ },
3027
+ {
3028
+ id: 'native-file-edit-adapter',
3029
+ title: 'Native file edit adapter contract',
3030
+ version: '1.0.0',
3031
+ capabilityId: 'native-file-edit',
3032
+ entryKind: 'adapter',
3033
+ assetPath: 'claude-code:file-tools',
3034
+ loadMode: 'readonly_asset',
3035
+ checksum: null,
3036
+ requiredEvidence: ['read before write', 'diff or readback after important edits'],
3037
+ forbiddenUses: ['permission injection', 'overwrite existing files without reading', 'write secrets']
3038
+ },
3039
+ {
3040
+ id: 'sdd-cli-runtime',
3041
+ title: 'SDD CLI/runtime contract',
3042
+ version: '1.0.0',
3043
+ capabilityId: 'sdd-cli',
3044
+ entryKind: 'cli',
3045
+ assetPath: 'dist/packages/cli/src/main.js',
3046
+ loadMode: 'static_manifest',
3047
+ checksum: null,
3048
+ requiredEvidence: ['command output', 'state/event/artifact path when runtime changes'],
3049
+ forbiddenUses: ['dynamic plugin execution', 'unapproved complex sync-back apply', 'automatic commit or push', 'background write orchestration']
3050
+ },
3051
+ {
3052
+ id: 'validation-command-runner',
3053
+ title: 'Validation command runner contract',
3054
+ version: '1.0.0',
3055
+ capabilityId: 'validation-command',
3056
+ entryKind: 'command',
3057
+ assetPath: '.sdd/project.yml#validation.default',
3058
+ loadMode: 'static_manifest',
3059
+ checksum: null,
3060
+ requiredEvidence: ['command name', 'pass/fail status', 'relevant output excerpt'],
3061
+ forbiddenUses: ['dynamic plugin execution', 'run destructive commands as validation', 'hide hook failures']
3062
+ }
3063
+ ];
3064
+ export async function listToolPluginContracts(projectRoot) {
3065
+ await readProjectConfig(projectRoot);
3066
+ return {
3067
+ version: TOOL_PLUGIN_CONTRACT_REGISTRY_VERSION,
3068
+ contracts: [...BUILT_IN_TOOL_PLUGIN_CONTRACTS].sort((left, right) => left.id.localeCompare(right.id))
3069
+ };
3070
+ }
3071
+ export async function inspectToolPluginContract(projectRoot, pluginId) {
3072
+ const registry = await listToolPluginContracts(projectRoot);
3073
+ return registry.contracts.find((contract) => contract.id === pluginId) ?? null;
3074
+ }
3075
+ const BUILT_IN_WORKER_ADAPTER_CONTRACTS = [
3076
+ {
3077
+ id: 'claude-code-subagent-worker',
3078
+ title: 'Claude Code subagent worker adapter',
3079
+ version: '1.0.0',
3080
+ kind: 'claude_code_subagent',
3081
+ capabilityId: 'sdd-cli',
3082
+ pluginContractId: 'sdd-cli-runtime',
3083
+ input: {
3084
+ queueItemId: '<run_id>:<delegation_id>',
3085
+ runId: '<run_id>',
3086
+ taskId: '<task_id>',
3087
+ delegationId: '<delegation_id>',
3088
+ stateMachineVersion: DELEGATION_STATE_MACHINE_VERSION
3089
+ },
3090
+ output: {
3091
+ artifactReference: 'artifacts/<agent>-<task_id>.md',
3092
+ terminalStatus: ['COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED'],
3093
+ exitStatuses: ['completed', 'failed', 'timed_out', 'cancelled', 'blocked'],
3094
+ requiredEvents: ['delegation_started', 'delegation_completed', 'delegation_failed', 'delegation_timeout', 'delegation_cancelled']
3095
+ },
3096
+ sideEffect: 'command_execution',
3097
+ permissionPrompt: 'Run a Claude Code/subagent task for one queued delegation and persist only declared run events/artifact references.',
3098
+ requiredEvidence: ['queue item id', 'delegation state transition event', 'sdd-result artifact reference'],
3099
+ forbiddenUses: ['bypass Claude Code permission prompts', 'execute undeclared wave scheduling', 'write outside .sdd/runs or declared artifacts', 'reopen terminal delegation ids']
3100
+ },
3101
+ {
3102
+ id: 'manual-handoff-worker',
3103
+ title: 'Manual handoff worker adapter',
3104
+ version: '1.0.0',
3105
+ kind: 'manual_handoff',
3106
+ capabilityId: 'sdd-cli',
3107
+ pluginContractId: 'sdd-cli-runtime',
3108
+ input: {
3109
+ queueItemId: '<run_id>:<delegation_id>',
3110
+ runId: '<run_id>',
3111
+ taskId: '<task_id>',
3112
+ delegationId: '<delegation_id>',
3113
+ stateMachineVersion: DELEGATION_STATE_MACHINE_VERSION
3114
+ },
3115
+ output: {
3116
+ artifactReference: 'artifacts/<agent>-<task_id>.md',
3117
+ terminalStatus: ['COMPLETED', 'FAILED', 'CANCELLED'],
3118
+ exitStatuses: ['completed', 'failed', 'cancelled', 'blocked'],
3119
+ requiredEvents: ['delegation_started', 'delegation_completed', 'delegation_failed', 'delegation_cancelled']
3120
+ },
3121
+ sideEffect: 'read_only',
3122
+ permissionPrompt: 'Prepare a manual delegation handoff without starting a background process.',
3123
+ requiredEvidence: ['queue item id', 'manual handoff instructions', 'expected artifact reference'],
3124
+ forbiddenUses: ['start background worker', 'claim queue item', 'mark delegation completed without artifact evidence']
3125
+ },
3126
+ {
3127
+ id: 'sdd-cli-task-worker',
3128
+ title: 'SDD CLI task worker adapter',
3129
+ version: '1.0.0',
3130
+ kind: 'sdd_cli_task',
3131
+ capabilityId: 'artifact-run-hygiene',
3132
+ pluginContractId: 'artifact-run-hygiene-tools',
3133
+ input: {
3134
+ queueItemId: '<run_id>:<delegation_id>',
3135
+ runId: '<run_id>',
3136
+ taskId: '<task_id>',
3137
+ delegationId: '<delegation_id>',
3138
+ stateMachineVersion: DELEGATION_STATE_MACHINE_VERSION
3139
+ },
3140
+ output: {
3141
+ artifactReference: 'artifacts/<agent>-<task_id>.md',
3142
+ terminalStatus: ['COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED'],
3143
+ exitStatuses: ['completed', 'failed', 'timed_out', 'cancelled', 'blocked'],
3144
+ requiredEvents: ['delegation_started', 'delegation_completed', 'delegation_failed', 'delegation_timeout', 'delegation_cancelled']
3145
+ },
3146
+ sideEffect: 'local_write',
3147
+ permissionPrompt: 'Run a bounded SDD CLI task adapter and write only declared artifact/run evidence.',
3148
+ requiredEvidence: ['queue item id', 'command output', 'sdd-result artifact reference'],
3149
+ forbiddenUses: ['unapproved complex sync-back apply', 'dynamic plugin execution', 'background wave execution', 'write undeclared artifacts']
3150
+ }
3151
+ ];
3152
+ export async function listWorkerAdapterContracts(projectRoot) {
3153
+ await readProjectConfig(projectRoot);
3154
+ return {
3155
+ version: WORKER_ADAPTER_CONTRACT_REGISTRY_VERSION,
3156
+ adapters: [...BUILT_IN_WORKER_ADAPTER_CONTRACTS].sort((left, right) => left.id.localeCompare(right.id))
3157
+ };
3158
+ }
3159
+ export async function inspectWorkerAdapterContract(projectRoot, adapterId) {
3160
+ const registry = await listWorkerAdapterContracts(projectRoot);
3161
+ return registry.adapters.find((adapter) => adapter.id === adapterId) ?? null;
3162
+ }
3163
+ const BUILT_IN_GOVERNANCE_POLICY = {
3164
+ version: GOVERNANCE_POLICY_CONTRACT_VERSION,
3165
+ concurrency: {
3166
+ maxBackgroundDelegations: 4,
3167
+ maxWaveExecutors: 1
3168
+ },
3169
+ manualConfirmation: {
3170
+ operations: ['sync_back_apply', 'destructive_git', 'external_interaction', 'cleanup'],
3171
+ workerAdapters: ['manual-handoff-worker'],
3172
+ riskTags: ['database', 'security', 'permission', 'external', 'destructive-git', 'data-loss']
3173
+ },
3174
+ cleanup: {
3175
+ archiveOnly: true,
3176
+ deleteRunHistory: false
3177
+ },
3178
+ retry: {
3179
+ reopenTerminalDelegation: false,
3180
+ maxAttemptsPerDelegation: 1
3181
+ },
3182
+ stopConditions: ['manual_confirmation_required', 'concurrency_limit_reached', 'terminal_delegation_reopen', 'planner_manual_gate', 'invalid_artifact_evidence'],
3183
+ audit: {
3184
+ requiredEvents: ['governance_policy_blocked', 'governance_policy_confirmed'],
3185
+ requiredEvidence: ['policy version', 'operation', 'decision status', 'reason']
3186
+ }
3187
+ };
3188
+ export async function inspectGovernancePolicy(projectRoot) {
3189
+ await readProjectConfig(projectRoot);
3190
+ return {
3191
+ ...BUILT_IN_GOVERNANCE_POLICY,
3192
+ concurrency: { ...BUILT_IN_GOVERNANCE_POLICY.concurrency },
3193
+ manualConfirmation: {
3194
+ operations: [...BUILT_IN_GOVERNANCE_POLICY.manualConfirmation.operations],
3195
+ workerAdapters: [...BUILT_IN_GOVERNANCE_POLICY.manualConfirmation.workerAdapters],
3196
+ riskTags: [...BUILT_IN_GOVERNANCE_POLICY.manualConfirmation.riskTags]
3197
+ },
3198
+ cleanup: { ...BUILT_IN_GOVERNANCE_POLICY.cleanup },
3199
+ retry: { ...BUILT_IN_GOVERNANCE_POLICY.retry },
3200
+ stopConditions: [...BUILT_IN_GOVERNANCE_POLICY.stopConditions],
3201
+ audit: {
3202
+ requiredEvents: [...BUILT_IN_GOVERNANCE_POLICY.audit.requiredEvents],
3203
+ requiredEvidence: [...BUILT_IN_GOVERNANCE_POLICY.audit.requiredEvidence]
3204
+ }
3205
+ };
3206
+ }
3207
+ export async function evaluateGovernancePolicy(projectRoot, input) {
3208
+ const policy = await inspectGovernancePolicy(projectRoot);
3209
+ const issues = [];
3210
+ const reasons = [];
3211
+ const queue = await listDelegationQueueItems(projectRoot);
3212
+ const runningDelegations = queue.items.filter((item) => item.status === 'RUNNING' && item.id !== input.excludeQueueItemId).length;
3213
+ const runningWaveExecutors = (await readAllRunStates(projectRoot)).filter((state) => state.status === 'running' && state.phase === 'wave').length;
3214
+ if ((input.operation === 'background_executor' || input.operation === 'wave_executor') && runningDelegations >= policy.concurrency.maxBackgroundDelegations) {
3215
+ const reason = `Running delegation count ${runningDelegations} reached governance limit ${policy.concurrency.maxBackgroundDelegations}.`;
3216
+ reasons.push(reason);
3217
+ issues.push(contractIssue('governance.concurrency', reason, 'Wait for existing delegations to finish, archive stale exploratory runs, or inspect governance policy before starting more background work.'));
3218
+ }
3219
+ if (input.operation === 'wave_executor' && runningWaveExecutors >= policy.concurrency.maxWaveExecutors) {
3220
+ const reason = `Running wave executor count ${runningWaveExecutors} reached governance limit ${policy.concurrency.maxWaveExecutors}.`;
3221
+ reasons.push(reason);
3222
+ issues.push(contractIssue('governance.wave_concurrency', reason, 'Wait for the running wave executor to finish or archive the stale run before starting another wave.'));
3223
+ }
3224
+ const confirmationReasons = [];
3225
+ if (policy.manualConfirmation.operations.includes(input.operation)) {
3226
+ confirmationReasons.push(`Operation ${input.operation} requires explicit confirmation.`);
3227
+ }
3228
+ if (input.workerAdapterId && policy.manualConfirmation.workerAdapters.includes(input.workerAdapterId)) {
3229
+ confirmationReasons.push(`Worker adapter ${input.workerAdapterId} requires manual confirmation.`);
3230
+ }
3231
+ const riskHits = (input.riskTags ?? []).filter((tag) => policy.manualConfirmation.riskTags.includes(tag));
3232
+ if (riskHits.length > 0) {
3233
+ confirmationReasons.push(`Risk tag(s) require confirmation: ${riskHits.join(', ')}.`);
3234
+ }
3235
+ if (confirmationReasons.length > 0 && !input.approved) {
3236
+ reasons.push(...confirmationReasons);
3237
+ for (const reason of confirmationReasons) {
3238
+ issues.push(contractIssue('governance.confirmation', reason, 'Get explicit user confirmation before continuing this governed operation.'));
3239
+ }
3240
+ }
3241
+ if (confirmationReasons.length > 0 && input.approved) {
3242
+ reasons.push(...confirmationReasons.map((reason) => `${reason} Approval recorded.`));
3243
+ }
3244
+ const status = issues.length > 0
3245
+ ? confirmationReasons.length > 0 && issues.every((issue) => issue.field === 'governance.confirmation')
3246
+ ? 'confirm'
3247
+ : 'block'
3248
+ : 'allow';
3249
+ return {
3250
+ version: GOVERNANCE_POLICY_CONTRACT_VERSION,
3251
+ operation: input.operation,
3252
+ status,
3253
+ allowed: status === 'allow',
3254
+ reasons: reasons.length > 0 ? reasons : [`Operation ${input.operation} is allowed by governance policy.`],
3255
+ issues,
3256
+ policy
3257
+ };
3258
+ }
3259
+ export async function listDelegationQueueItems(projectRoot, options = {}) {
3260
+ await readProjectConfig(projectRoot);
3261
+ const states = options.runId
3262
+ ? [await readRunState(projectRoot, options.runId)]
3263
+ : await readAllRunStates(projectRoot);
3264
+ const items = states
3265
+ .filter((state) => state.status !== 'archived')
3266
+ .flatMap((state) => Object.values(state.delegations).map((delegation) => delegationQueueItemFromRunState(state, delegation)))
3267
+ .sort((left, right) => left.id.localeCompare(right.id));
3268
+ return {
3269
+ version: DELEGATION_QUEUE_CONTRACT_VERSION,
3270
+ items
3271
+ };
3272
+ }
3273
+ export async function inspectDelegationQueueItem(projectRoot, queueItemId) {
3274
+ const snapshot = await listDelegationQueueItems(projectRoot);
3275
+ return snapshot.items.find((item) => item.id === queueItemId) ?? null;
3276
+ }
3277
+ export function getDelegationStateMachine() {
3278
+ return {
3279
+ version: DELEGATION_STATE_MACHINE_VERSION,
3280
+ statuses: [...DELEGATION_STATUSES],
3281
+ terminalStatuses: [...TERMINAL_DELEGATION_STATUSES],
3282
+ transitions: DELEGATION_STATE_TRANSITIONS.map((transition) => ({ ...transition }))
3283
+ };
3284
+ }
3285
+ export function validateDelegationStateTransition(from, to, event = null) {
3286
+ const issues = [];
3287
+ if (!DELEGATION_STATUSES.includes(from)) {
3288
+ issues.push(contractIssue('from', `Unsupported delegation status ${from}.`, 'Use a status declared by the Phase 3.4 delegation state machine.'));
3289
+ }
3290
+ if (!DELEGATION_STATUSES.includes(to)) {
3291
+ issues.push(contractIssue('to', `Unsupported delegation status ${to}.`, 'Use a status declared by the Phase 3.4 delegation state machine.'));
3292
+ }
3293
+ if (TERMINAL_DELEGATION_STATUSES.includes(from)) {
3294
+ issues.push(contractIssue('from', `Terminal delegation status ${from} cannot transition to ${to}.`, 'Create a new delegation id for retry instead of reopening a terminal delegation.'));
3295
+ }
3296
+ const transition = DELEGATION_STATE_TRANSITIONS.find((candidate) => candidate.from === from && candidate.to === to && (event === null || candidate.event === event));
3297
+ if (!transition) {
3298
+ const eventText = event === null ? '' : ` on ${event}`;
3299
+ issues.push(contractIssue('transition', `Transition ${from} -> ${to}${eventText} is not allowed.`, 'Use one of the declared Phase 3.4 delegation state machine transitions.'));
3300
+ }
3301
+ return {
3302
+ valid: issues.length === 0,
3303
+ from,
3304
+ to,
3305
+ event,
3306
+ issues
3307
+ };
3308
+ }
3309
+ export function emptyLifecycleDecisionRecord() {
3310
+ return {
3311
+ contract: LIFECYCLE_DECISION_CONTRACT,
3312
+ version: LIFECYCLE_DECISION_VERSION,
3313
+ model_version: 'phase1.0-final',
3314
+ input_summary: {},
3315
+ decision: {
3316
+ profile: null,
3317
+ confidence: null,
3318
+ hard_gate_hits: [],
3319
+ required_stages: [],
3320
+ skipped_stages: [],
3321
+ human_checkpoint_required: false
3322
+ },
3323
+ reasons: ['Phase 1.2 records the lifecycle decision contract; Phase 1.7 command gate will populate decision values.'],
3324
+ escalation_triggers: [],
3325
+ downgrade_reason: null,
3326
+ audit: {
3327
+ decided_at: null,
3328
+ decided_by: null,
3329
+ policy_version: 'phase1.0-final',
3330
+ source_artifacts: ['docs/architecture/lifecycle-decision-model.md']
3331
+ }
3332
+ };
3333
+ }
3334
+ function renderProjectConfig(config) {
3335
+ const detection = config.detection ? `detection:\n confidence: ${config.detection.confidence}\n mixed_stack: ${config.detection.mixed_stack}\n primary: ${config.detection.primary}\n candidates:\n${config.detection.candidates.map((candidate) => ` - id: ${candidate.id}\n confidence: ${candidate.confidence}\n score: ${candidate.score}`).join('\n')}\n` : '';
3336
+ return `contract: ${config.contract}\nproject:\n name: ${config.project.name}\n language: ${config.project.language}\n framework: ${config.project.framework}\n${detection}sdd:\n spec_dir: ${config.sdd.spec_dir}\n docs_language: ${config.sdd.docs_language}\n compatible_with: ${config.sdd.compatible_with}\nvalidation:\n default:\n${config.validation.default.map((command) => ` - ${command}`).join('\n')}\nediting:\n prefer_hashline: ${config.editing.prefer_hashline}\n native_edit_fallback: ${config.editing.native_edit_fallback}\nruntime:\n background_write: ${config.runtime.background_write}\n worktree_isolation: ${config.runtime.worktree_isolation}\n sync_back_mode: ${config.runtime.sync_back_mode}\nlifecycle:\n decision_required: ${config.lifecycle.decision_required}\n profiles:\n${config.lifecycle.profiles.map((profile) => ` - ${profile}`).join('\n')}\n`;
3337
+ }
3338
+ function parseProjectConfig(raw, configPath) {
3339
+ const requiredSnippets = [
3340
+ 'contract: phase-1.2-project-contract',
3341
+ 'project:',
3342
+ 'sdd:',
3343
+ 'validation:',
3344
+ 'runtime:',
3345
+ 'lifecycle:'
3346
+ ];
3347
+ for (const snippet of requiredSnippets) {
3348
+ if (!raw.includes(snippet)) {
3349
+ throw new Error(`${configPath} missing required snippet: ${snippet}`);
3350
+ }
3351
+ }
3352
+ const projectName = readScalar(raw, 'name') ?? path.basename(path.dirname(path.dirname(configPath)));
3353
+ const language = readScalar(raw, 'language') ?? 'unknown';
3354
+ const framework = readScalar(raw, 'framework') ?? 'unknown';
3355
+ const specDir = readScalar(raw, 'spec_dir') ?? 'specs/<branch>';
3356
+ const docsLanguage = readScalar(raw, 'docs_language') ?? 'zh-CN';
3357
+ const compatibleWith = readScalar(raw, 'compatible_with') ?? 'spec-kit';
3358
+ const defaultCommands = readListInSection(raw, 'validation', 'default');
3359
+ const profiles = readListInSection(raw, 'lifecycle', 'profiles');
3360
+ return {
3361
+ contract: PROJECT_CONFIG_CONTRACT,
3362
+ project: {
3363
+ name: projectName,
3364
+ language,
3365
+ framework
3366
+ },
3367
+ detection: parseDetection(raw),
3368
+ sdd: {
3369
+ spec_dir: specDir,
3370
+ docs_language: docsLanguage,
3371
+ compatible_with: compatibleWith
3372
+ },
3373
+ validation: {
3374
+ default: defaultCommands
3375
+ },
3376
+ editing: {
3377
+ prefer_hashline: readBoolean(raw, 'prefer_hashline', true),
3378
+ native_edit_fallback: readBoolean(raw, 'native_edit_fallback', true)
3379
+ },
3380
+ runtime: {
3381
+ background_write: readBoolean(raw, 'background_write', false),
3382
+ worktree_isolation: readBoolean(raw, 'worktree_isolation', false),
3383
+ sync_back_mode: 'proposal'
3384
+ },
3385
+ lifecycle: {
3386
+ decision_required: readBoolean(raw, 'decision_required', true),
3387
+ profiles: profiles.length > 0 ? profiles : ['direct', 'compact', 'full', 'research']
3388
+ }
3389
+ };
3390
+ }
3391
+ function parseDetection(raw) {
3392
+ const primary = readScalar(raw, 'primary');
3393
+ const confidence = readDetectionConfidence(readScalar(raw, 'confidence'));
3394
+ if (!primary || !confidence) {
3395
+ return undefined;
3396
+ }
3397
+ const candidateIds = readListFieldObjects(raw, 'candidates', 'id');
3398
+ return {
3399
+ confidence,
3400
+ mixed_stack: readBoolean(raw, 'mixed_stack', false),
3401
+ primary,
3402
+ candidates: candidateIds.map((id) => ({
3403
+ id,
3404
+ confidence,
3405
+ score: 0
3406
+ }))
3407
+ };
3408
+ }
3409
+ function readDetectionConfidence(value) {
3410
+ return value === 'high' || value === 'medium' || value === 'low' ? value : null;
3411
+ }
3412
+ function readListFieldObjects(raw, section, field) {
3413
+ const lines = raw.split(/\r?\n/);
3414
+ const sectionIndex = lines.findIndex((line) => line.trim() === `${section}:`);
3415
+ if (sectionIndex < 0) {
3416
+ return [];
3417
+ }
3418
+ const values = [];
3419
+ for (let index = sectionIndex + 1; index < lines.length; index += 1) {
3420
+ const line = lines[index];
3421
+ if (line.trim().length === 0) {
3422
+ continue;
3423
+ }
3424
+ if (!line.startsWith(' ') && !line.startsWith(' - ')) {
3425
+ break;
3426
+ }
3427
+ const match = line.match(new RegExp(`^-?\\s*${field}:\\s*(.+?)\\s*$`));
3428
+ const indentedMatch = line.trim().match(new RegExp(`^-?\\s*${field}:\\s*(.+?)\\s*$`));
3429
+ const value = match?.[1] ?? indentedMatch?.[1];
3430
+ if (value) {
3431
+ values.push(value.trim());
3432
+ }
3433
+ }
3434
+ return values;
3435
+ }
3436
+ function readScalar(raw, key) {
3437
+ const match = raw.match(new RegExp(`^\\s*${key}:\\s*(.+?)\\s*$`, 'm'));
3438
+ return match?.[1]?.trim() ?? null;
3439
+ }
3440
+ function readBoolean(raw, key, defaultValue) {
3441
+ const value = readScalar(raw, key);
3442
+ if (value === 'true') {
3443
+ return true;
3444
+ }
3445
+ if (value === 'false') {
3446
+ return false;
3447
+ }
3448
+ return defaultValue;
3449
+ }
3450
+ function readListInSection(raw, section, key) {
3451
+ const lines = raw.split(/\r?\n/);
3452
+ const sectionIndex = lines.findIndex((line) => line.trim() === `${section}:`);
3453
+ if (sectionIndex < 0) {
3454
+ return [];
3455
+ }
3456
+ const keyIndex = lines.findIndex((line, index) => index > sectionIndex && line.trim() === `${key}:`);
3457
+ if (keyIndex < 0) {
3458
+ return [];
3459
+ }
3460
+ const items = [];
3461
+ for (let index = keyIndex + 1; index < lines.length; index += 1) {
3462
+ const line = lines[index];
3463
+ if (!line.startsWith(' - ')) {
3464
+ break;
3465
+ }
3466
+ items.push(line.slice(' - '.length).trim());
3467
+ }
3468
+ return items;
3469
+ }
3470
+ async function parseRetainedPhaseTasks(specBranchDir) {
3471
+ const entries = await readdir(specBranchDir, { withFileTypes: true });
3472
+ const taskFiles = entries
3473
+ .filter((entry) => entry.isFile() && /^phase\d+\.\d+-tasks\.md$/.test(entry.name))
3474
+ .map((entry) => path.join(specBranchDir, entry.name))
3475
+ .sort();
3476
+ const tasks = [];
3477
+ const gaps = [];
3478
+ for (const taskFile of taskFiles) {
3479
+ const raw = await readFile(taskFile, 'utf8');
3480
+ const parsed = parseSddTasksMarkdown(raw, { tasksPath: taskFile, validateDependencies: false });
3481
+ tasks.push(...parsed.tasks);
3482
+ gaps.push(...parsed.gaps);
3483
+ }
3484
+ gaps.push(...validateAggregateTaskSet(tasks));
3485
+ return { tasks, gaps };
3486
+ }
3487
+ function documentGap(field, message, recommendation) {
3488
+ return {
3489
+ type: 'Document Gap',
3490
+ severity: 'blocking',
3491
+ taskId: null,
3492
+ field,
3493
+ message,
3494
+ recommendation
3495
+ };
3496
+ }
3497
+ function parseTaskStatus(value) {
3498
+ if (value === 'pending' || value === 'in_progress' || value === 'completed' || value === 'blocked' || value === 'deferred') {
3499
+ return value;
3500
+ }
3501
+ return 'unknown';
3502
+ }
3503
+ function parseWave(value) {
3504
+ if (!value) {
3505
+ return null;
3506
+ }
3507
+ const parsed = Number(value);
3508
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
3509
+ }
3510
+ function parseSimpleYamlBlock(raw) {
3511
+ const result = {};
3512
+ const lines = raw.split(/\r?\n/);
3513
+ let currentListKey = null;
3514
+ for (const line of lines) {
3515
+ const trimmed = line.trim();
3516
+ if (!trimmed || trimmed.startsWith('#')) {
3517
+ continue;
3518
+ }
3519
+ if (currentListKey && /^-\s+/.test(trimmed)) {
3520
+ const current = result[currentListKey];
3521
+ const items = Array.isArray(current) ? current : [];
3522
+ items.push(trimmed.slice(2).trim());
3523
+ result[currentListKey] = items;
3524
+ continue;
3525
+ }
3526
+ const scalarMatch = trimmed.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
3527
+ if (!scalarMatch) {
3528
+ currentListKey = null;
3529
+ continue;
3530
+ }
3531
+ const key = scalarMatch[1];
3532
+ const value = scalarMatch[2].trim();
3533
+ if (value === '') {
3534
+ result[key] = [];
3535
+ currentListKey = key;
3536
+ }
3537
+ else if (value === '[]') {
3538
+ result[key] = [];
3539
+ currentListKey = null;
3540
+ }
3541
+ else if (value.startsWith('[') && value.endsWith(']')) {
3542
+ result[key] = value.slice(1, -1).split(',').map((item) => item.trim()).filter(Boolean);
3543
+ currentListKey = null;
3544
+ }
3545
+ else {
3546
+ result[key] = value;
3547
+ currentListKey = null;
3548
+ }
3549
+ }
3550
+ return result;
3551
+ }
3552
+ function scalarValue(value) {
3553
+ return typeof value === 'string' && value.length > 0 ? value : null;
3554
+ }
3555
+ function listValue(value) {
3556
+ if (Array.isArray(value)) {
3557
+ return value.filter(Boolean);
3558
+ }
3559
+ if (!value || value === '[]') {
3560
+ return [];
3561
+ }
3562
+ return [value];
3563
+ }
3564
+ function lineNumberAt(raw, offset) {
3565
+ return raw.slice(0, offset).split(/\r?\n/).length;
3566
+ }
3567
+ function nearestTaskHeading(prefix) {
3568
+ const matches = Array.from(prefix.matchAll(/^###\s+(.+)$/gm));
3569
+ const last = matches.at(-1);
3570
+ if (!last) {
3571
+ return null;
3572
+ }
3573
+ const raw = last[1].trim();
3574
+ const parsed = raw.match(/^([^::\s]+)\s*[::]\s*(.+)$/);
3575
+ return {
3576
+ raw,
3577
+ id: parsed?.[1]?.trim() ?? null,
3578
+ title: parsed?.[2]?.trim() ?? raw
3579
+ };
3580
+ }
3581
+ function nextTaskStart(raw, offset) {
3582
+ const next = raw.slice(offset).search(/^###\s+/m);
3583
+ return next < 0 ? raw.length : offset + next;
3584
+ }
3585
+ function parseTaskCompanionSections(raw) {
3586
+ return {
3587
+ boundary: sectionText(raw, 'Boundary'),
3588
+ acceptance: sectionBullets(raw, 'Acceptance'),
3589
+ implementationNotes: sectionText(raw, 'Implementation Notes')
3590
+ };
3591
+ }
3592
+ function sectionText(raw, title) {
3593
+ const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3594
+ const sectionPattern = `^####\\s+${escaped}\\s*$([\\s\\S]*?)(?=^####\\s+|^###\\s+|$(?![\\s\\S]))`;
3595
+ const match = raw.match(new RegExp(sectionPattern, 'im'));
3596
+ const text = match?.[1]?.trim() ?? '';
3597
+ return text.length > 0 ? text : null;
3598
+ }
3599
+ function sectionBullets(raw, title) {
3600
+ const text = sectionText(raw, title);
3601
+ if (!text) {
3602
+ return [];
3603
+ }
3604
+ return text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith('- ')).map((line) => line.slice(2).trim()).filter(Boolean);
3605
+ }
3606
+ function validateTask(task) {
3607
+ const gaps = [];
3608
+ const requiredLists = [
3609
+ ['affectedFiles', 'affected_files'],
3610
+ ['validation', 'validation']
3611
+ ];
3612
+ if (task.status === 'unknown') {
3613
+ gaps.push(taskGap(task.id, 'status', 'Task status is missing or unsupported.', 'Use one of pending, in_progress, completed, blocked, deferred.'));
3614
+ }
3615
+ if (task.wave === null) {
3616
+ gaps.push(taskGap(task.id, 'wave', 'Task wave is missing or invalid.', 'Add a positive integer wave value.'));
3617
+ }
3618
+ for (const [property, field] of requiredLists) {
3619
+ if (task[property].length === 0) {
3620
+ gaps.push(taskGap(task.id, field, `Task ${task.id} has no ${field}.`, `Declare ${field} in the sdd-task block before implementation.`));
3621
+ }
3622
+ }
3623
+ if (!task.boundary) {
3624
+ gaps.push(taskGap(task.id, 'Boundary', `Task ${task.id} has no Boundary section.`, 'Add a #### Boundary section describing allowed and forbidden scope.'));
3625
+ }
3626
+ if (task.acceptance.length === 0) {
3627
+ gaps.push(taskGap(task.id, 'Acceptance', `Task ${task.id} has no acceptance items.`, 'Add verifiable bullets under #### Acceptance.'));
3628
+ }
3629
+ return gaps;
3630
+ }
3631
+ function taskGap(taskId, field, message, recommendation) {
3632
+ return {
3633
+ type: 'Task Gap',
3634
+ severity: 'blocking',
3635
+ taskId,
3636
+ field,
3637
+ message,
3638
+ recommendation
3639
+ };
3640
+ }
3641
+ function validateAggregateTaskSet(tasks) {
3642
+ const gaps = [];
3643
+ const tasksById = new Map();
3644
+ for (const task of tasks) {
3645
+ const matchingTasks = tasksById.get(task.id) ?? [];
3646
+ matchingTasks.push(task);
3647
+ tasksById.set(task.id, matchingTasks);
3648
+ }
3649
+ for (const [taskId, matchingTasks] of tasksById) {
3650
+ if (matchingTasks.length > 1) {
3651
+ gaps.push(taskGap(taskId, 'id', `Duplicate task id ${taskId} across parsed task files: ${matchingTasks.map(taskSourceEvidence).join('; ')}.`, 'Rename duplicate task ids or add deterministic source disambiguation before implementation.'));
3652
+ }
3653
+ }
3654
+ for (const task of tasks) {
3655
+ for (const dependency of task.dependsOn) {
3656
+ const matchingDependencies = tasksById.get(dependency) ?? [];
3657
+ if (matchingDependencies.length === 0) {
3658
+ gaps.push({
3659
+ type: 'Dependency Gap',
3660
+ severity: 'blocking',
3661
+ taskId: task.id,
3662
+ field: 'depends_on',
3663
+ message: `Task ${task.id} depends on unknown task ${dependency}.`,
3664
+ recommendation: 'Fix depends_on to reference an existing task id, or add the missing task.'
3665
+ });
3666
+ }
3667
+ else if (matchingDependencies.length > 1) {
3668
+ gaps.push({
3669
+ type: 'Dependency Gap',
3670
+ severity: 'blocking',
3671
+ taskId: task.id,
3672
+ field: 'depends_on',
3673
+ message: `Task ${task.id} depends on ambiguous duplicate task id ${dependency}: ${matchingDependencies.map(taskSourceEvidence).join('; ')}.`,
3674
+ recommendation: 'Rename duplicate task ids so dependencies resolve to one task.'
3675
+ });
3676
+ }
3677
+ }
3678
+ }
3679
+ return gaps;
3680
+ }
3681
+ function detectTaskGraphCycles(tasks) {
3682
+ const uniqueTaskIds = new Set();
3683
+ const duplicateTaskIds = new Set();
3684
+ for (const task of tasks) {
3685
+ if (uniqueTaskIds.has(task.id)) {
3686
+ duplicateTaskIds.add(task.id);
3687
+ }
3688
+ uniqueTaskIds.add(task.id);
3689
+ }
3690
+ const graph = new Map();
3691
+ for (const task of tasks.filter((candidate) => !duplicateTaskIds.has(candidate.id))) {
3692
+ graph.set(task.id, task.dependsOn.filter((dependency) => uniqueTaskIds.has(dependency) && !duplicateTaskIds.has(dependency)));
3693
+ }
3694
+ const diagnostics = [];
3695
+ const visited = new Set();
3696
+ const visiting = new Set();
3697
+ const stack = [];
3698
+ const reportedCycles = new Set();
3699
+ const visit = (taskId) => {
3700
+ if (visiting.has(taskId)) {
3701
+ const cycleStart = stack.indexOf(taskId);
3702
+ const cycle = [...stack.slice(cycleStart), taskId];
3703
+ const key = cycle.join('->');
3704
+ if (!reportedCycles.has(key)) {
3705
+ reportedCycles.add(key);
3706
+ diagnostics.push({
3707
+ severity: 'blocking',
3708
+ taskId,
3709
+ field: 'depends_on',
3710
+ message: `Task dependency cycle detected: ${cycle.join(' -> ')}.`,
3711
+ recommendation: 'Break the cycle before graph planning or wave planning.'
3712
+ });
3713
+ }
3714
+ return;
3715
+ }
3716
+ if (visited.has(taskId)) {
3717
+ return;
3718
+ }
3719
+ visiting.add(taskId);
3720
+ stack.push(taskId);
3721
+ for (const dependency of graph.get(taskId) ?? []) {
3722
+ visit(dependency);
3723
+ }
3724
+ stack.pop();
3725
+ visiting.delete(taskId);
3726
+ visited.add(taskId);
3727
+ };
3728
+ for (const taskId of graph.keys()) {
3729
+ visit(taskId);
3730
+ }
3731
+ return diagnostics;
3732
+ }
3733
+ function taskSourceEvidence(task) {
3734
+ return `${task.id} at ${sourceLocationEvidence(task.source)}`;
3735
+ }
3736
+ function sourceLocationEvidence(source) {
3737
+ return `${source.filePath}:${source.lineStart}-${source.lineEnd}`;
3738
+ }
3739
+ function buildSddResult(metadata) {
3740
+ const contract = scalarValue(metadata.contract);
3741
+ const version = scalarValue(metadata.version);
3742
+ const agent = scalarValue(metadata.agent);
3743
+ const task = scalarValue(metadata.task);
3744
+ const status = scalarValue(metadata.status);
3745
+ const artifacts = listValue(metadata.artifacts);
3746
+ if (contract !== SDD_RESULT_CONTRACT || version !== SDD_RESULT_VERSION || !agent || !task || !isSddResultStatus(status) || artifacts.length === 0) {
3747
+ return null;
3748
+ }
3749
+ return {
3750
+ contract: SDD_RESULT_CONTRACT,
3751
+ version: SDD_RESULT_VERSION,
3752
+ agent,
3753
+ task,
3754
+ status,
3755
+ artifacts,
3756
+ rawMetadata: metadata
3757
+ };
3758
+ }
3759
+ function validateSddResultMetadata(metadata) {
3760
+ const issues = [];
3761
+ const contract = scalarValue(metadata.contract);
3762
+ const version = scalarValue(metadata.version);
3763
+ const agent = scalarValue(metadata.agent);
3764
+ const task = scalarValue(metadata.task);
3765
+ const status = scalarValue(metadata.status);
3766
+ const artifacts = listValue(metadata.artifacts);
3767
+ if (contract !== SDD_RESULT_CONTRACT) {
3768
+ issues.push(contractIssue('contract', `Expected ${SDD_RESULT_CONTRACT}, got ${contract ?? 'missing'}.`, 'Use contract: sdd-result-v1.'));
3769
+ }
3770
+ if (version !== SDD_RESULT_VERSION) {
3771
+ issues.push(contractIssue('version', `Expected ${SDD_RESULT_VERSION}, got ${version ?? 'missing'}.`, 'Use version: 1.3.0 until a new contract version is introduced.'));
3772
+ }
3773
+ if (!agent) {
3774
+ issues.push(contractIssue('agent', 'sdd-result agent is required.', 'Set agent to the producing agent name.'));
3775
+ }
3776
+ if (!task) {
3777
+ issues.push(contractIssue('task', 'sdd-result task is required.', 'Set task to the delegated task id.'));
3778
+ }
3779
+ if (!isSddResultStatus(status)) {
3780
+ issues.push(contractIssue('status', `Unsupported sdd-result status ${status ?? 'missing'}.`, 'Use PASS, PASS_WITH_GAPS, FAIL, BLOCKED, TIMED_OUT, or CANCELLED.'));
3781
+ }
3782
+ if (artifacts.length === 0) {
3783
+ issues.push(contractIssue('artifacts', 'sdd-result artifacts must contain at least one path.', 'Add the current run-relative artifact path, for example artifacts/<file>. Source/test files belong in ## Evidence.'));
3784
+ }
3785
+ for (const artifactPath of artifacts) {
3786
+ validateRunRelativeArtifactReference(artifactPath, issues);
3787
+ if (!artifactPath.replace(/\\/g, '/').startsWith('artifacts/')) {
3788
+ issues.push(contractIssue('artifacts', `Source/test path ${artifactPath} is not a run artifact reference.`, 'Move source/test file citations to ## Evidence; keep only run-relative artifacts/<file> paths in sdd-result.artifacts.'));
3789
+ }
3790
+ }
3791
+ return issues;
3792
+ }
3793
+ async function readAllRunStates(projectRoot) {
3794
+ const runsDir = getRunsDir(projectRoot);
3795
+ if (!await exists(runsDir)) {
3796
+ return [];
3797
+ }
3798
+ const entries = await readdir(runsDir, { withFileTypes: true });
3799
+ const states = [];
3800
+ for (const entry of entries.filter((candidate) => candidate.isDirectory())) {
3801
+ try {
3802
+ states.push(await readRunState(projectRoot, entry.name));
3803
+ }
3804
+ catch {
3805
+ continue;
3806
+ }
3807
+ }
3808
+ return states;
3809
+ }
3810
+ function delegationQueueItemFromRunState(state, delegation) {
3811
+ return {
3812
+ id: `${state.runId}:${delegation.delegationId}`,
3813
+ runId: state.runId,
3814
+ delegationId: delegation.delegationId,
3815
+ taskId: delegation.task,
3816
+ agent: delegation.agent,
3817
+ requestedCapabilityId: 'sdd-cli',
3818
+ dedupeKey: `${state.runId}:${delegation.task}:${delegation.agent}`,
3819
+ status: delegation.status,
3820
+ statusSource: 'run_state_delegation',
3821
+ runMode: delegation.runMode,
3822
+ expectedArtifact: delegation.expectedArtifact,
3823
+ requiredEvidence: [delegation.expectedArtifact, state.eventLogPath],
3824
+ createdAt: delegation.startedAt,
3825
+ updatedAt: delegation.terminalEventAt ?? delegation.lastHeartbeatAt ?? state.updatedAt
3826
+ };
3827
+ }
3828
+ function summarizeRunState(state) {
3829
+ return {
3830
+ runId: state.runId,
3831
+ status: state.status,
3832
+ phase: state.phase,
3833
+ currentTask: state.currentTask,
3834
+ createdAt: state.createdAt,
3835
+ updatedAt: state.updatedAt,
3836
+ validationStatus: state.validation.status,
3837
+ syncBackStatus: state.syncBack.status,
3838
+ taskIds: Object.keys(state.tasks).sort(),
3839
+ artifactCount: state.artifacts.length
3840
+ };
3841
+ }
3842
+ function runtimeTaskStatus(value) {
3843
+ if (!isRecord(value)) {
3844
+ return null;
3845
+ }
3846
+ const status = value.status;
3847
+ const verifyStatus = value.verifyStatus;
3848
+ if (typeof verifyStatus === 'string') {
3849
+ return verifyStatus;
3850
+ }
3851
+ return typeof status === 'string' ? status : null;
3852
+ }
3853
+ function runtimeTaskGaps(value) {
3854
+ if (!isRecord(value) || !Array.isArray(value.gaps)) {
3855
+ return [];
3856
+ }
3857
+ return value.gaps.filter(isTaskGap);
3858
+ }
3859
+ function applySyncBackToTasksMarkdown(raw, task, note) {
3860
+ const range = locateTaskBlockRange(raw, task);
3861
+ const block = raw.slice(range.start, range.end);
3862
+ const nextBlock = setTaskBlockStatus(block, 'completed');
3863
+ const sectionEnd = nextTaskStart(raw, range.end);
3864
+ const section = raw.slice(range.end, sectionEnd);
3865
+ const nextSection = appendSyncBackImplementationNote(section, note);
3866
+ return `${raw.slice(0, range.start)}${nextBlock}${nextSection}${raw.slice(sectionEnd)}`;
3867
+ }
3868
+ function locateTaskBlockRange(raw, task) {
3869
+ const matches = Array.from(raw.matchAll(/^\s*```sdd-task\s*\r?\n([\s\S]*?)\r?^\s*```\s*$/gm));
3870
+ const matching = matches.filter((match) => {
3871
+ const metadata = parseSimpleYamlBlock(match[1] ?? '');
3872
+ const id = scalarValue(metadata.id);
3873
+ const start = match.index ?? 0;
3874
+ return id === task.id && lineNumberAt(raw, start) === task.source.lineStart;
3875
+ });
3876
+ const fallback = matches.filter((match) => scalarValue(parseSimpleYamlBlock(match[1] ?? '').id) === task.id);
3877
+ const selected = matching.length === 1 ? matching[0] : fallback.length === 1 ? fallback[0] : null;
3878
+ if (!selected || selected.index === undefined) {
3879
+ throw new Error(`Cannot locate a unique sdd-task block for ${task.id}.`);
3880
+ }
3881
+ return {
3882
+ start: selected.index,
3883
+ end: selected.index + selected[0].length
3884
+ };
3885
+ }
3886
+ function setTaskBlockStatus(block, status) {
3887
+ if (/^\s*status:\s*[^\r\n]*$/m.test(block)) {
3888
+ return block.replace(/^(\s*status:\s*)[^\r\n]*$/m, `$1${status}`);
3889
+ }
3890
+ const eol = block.includes('\r\n') ? '\r\n' : '\n';
3891
+ if (/^\s*id:\s*[^\r\n]*$/m.test(block)) {
3892
+ return block.replace(/^(\s*id:\s*[^\r\n]*)$/m, `$1${eol}status: ${status}`);
3893
+ }
3894
+ throw new Error('Cannot update task status because the sdd-task block has no id line.');
3895
+ }
3896
+ function appendSyncBackImplementationNote(section, note) {
3897
+ const runMatch = note.match(/run `([^`]+)`/);
3898
+ if (runMatch && section.includes(`run \`${runMatch[1]}\``)) {
3899
+ return section;
3900
+ }
3901
+ const heading = section.match(/^####\s+Implementation Notes\s*$/im);
3902
+ if (!heading || heading.index === undefined) {
3903
+ const separator = section.length === 0 || section.endsWith('\n') ? '' : '\n';
3904
+ return `${section}${separator}\n#### Implementation Notes\n\n${note}\n`;
3905
+ }
3906
+ const contentStart = heading.index + heading[0].length;
3907
+ const remainder = section.slice(contentStart);
3908
+ const nextHeadingOffset = remainder.search(/\n####\s+|\n###\s+/);
3909
+ const insertAt = nextHeadingOffset < 0 ? section.length : contentStart + nextHeadingOffset;
3910
+ const before = section.slice(0, insertAt).trimEnd();
3911
+ const after = section.slice(insertAt);
3912
+ return `${before}\n${note}${after}`;
3913
+ }
3914
+ function syncBackImplementationNote(state, inspection) {
3915
+ const artifacts = inspection.artifacts.length > 0
3916
+ ? inspection.artifacts.map((artifact) => `\`${artifact}\``).join(', ')
3917
+ : 'none';
3918
+ return `- Sync-back applied from run \`${state.runId}\` (${state.updatedAt}); proposal: \`${inspection.proposalPath ?? 'none'}\`; artifacts: ${artifacts}.`;
3919
+ }
3920
+ function isTaskGap(value) {
3921
+ if (!isRecord(value)) {
3922
+ return false;
3923
+ }
3924
+ return (value.type === 'Task Gap' || value.type === 'Document Gap' || value.type === 'Dependency Gap')
3925
+ && (value.severity === 'blocking' || value.severity === 'warning')
3926
+ && (typeof value.taskId === 'string' || value.taskId === null)
3927
+ && typeof value.field === 'string'
3928
+ && typeof value.message === 'string'
3929
+ && typeof value.recommendation === 'string';
3930
+ }
3931
+ function isRecord(value) {
3932
+ return typeof value === 'object' && value !== null;
3933
+ }
3934
+ async function inspectRunEvidence(projectRoot, options = {}) {
3935
+ const runsDir = getRunsDir(projectRoot);
3936
+ const entries = await readdir(runsDir, { withFileTypes: true });
3937
+ const runDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
3938
+ const checks = [];
3939
+ const states = [];
3940
+ const unreadableRunIds = [];
3941
+ let issueCount = 0;
3942
+ for (const runId of runDirs) {
3943
+ try {
3944
+ states.push({ runId, state: await readRunState(projectRoot, runId) });
3945
+ }
3946
+ catch {
3947
+ unreadableRunIds.push(runId);
3948
+ }
3949
+ }
3950
+ const nonArchived = states.filter((entry) => entry.state.status !== 'archived');
3951
+ let inspected = options.allRuns ? states : nonArchived;
3952
+ if (!options.allRuns && options.latestOnly && inspected.length > 0) {
3953
+ inspected = [inspected.slice().sort((left, right) => Date.parse(right.state.updatedAt) - Date.parse(left.state.updatedAt))[0]];
3954
+ }
3955
+ const inspectedRunIds = new Set(inspected.map((entry) => entry.runId));
3956
+ const skippedArchived = states.length - nonArchived.length;
3957
+ const skippedByScope = states.filter((entry) => !inspectedRunIds.has(entry.runId) && entry.state.status !== 'archived').length;
3958
+ if (skippedArchived > 0 && !options.allRuns) {
3959
+ checks.push({ level: 'PASS', check: 'run_evidence_scope', message: `Skipped ${skippedArchived} archived run(s); use sdd doctor --all-runs for historical audit.` });
3960
+ }
3961
+ if (options.latestOnly && !options.allRuns && skippedByScope > 0) {
3962
+ checks.push({ level: 'PASS', check: 'run_evidence_scope', message: `Latest-only doctor inspected 1 run and skipped ${skippedByScope} older non-archived run(s).` });
3963
+ }
3964
+ if (options.allRuns && skippedArchived > 0) {
3965
+ checks.push({ level: 'PASS', check: 'run_evidence_scope', message: `All-runs doctor includes ${skippedArchived} archived run(s).` });
3966
+ }
3967
+ for (const { runId } of inspected) {
3968
+ try {
3969
+ const state = await readRunState(projectRoot, runId);
3970
+ const events = await readRunEvents(projectRoot, runId);
3971
+ const terminalDelegationIds = terminalDelegationIdsFromEvents(events);
3972
+ const transitionChecks = inspectRuntimeDelegationTransitions(runId, events);
3973
+ issueCount += transitionChecks.length;
3974
+ checks.push(...transitionChecks);
3975
+ const ingestionInspection = await inspectArtifactResultIngestions(projectRoot, runId);
3976
+ for (const issue of ingestionInspection.issues) {
3977
+ issueCount += 1;
3978
+ checks.push({ level: 'FAIL', check: 'artifact_result_ingestion', message: `${runId}: ${issue.message}`, action: issue.recommendation });
3979
+ }
3980
+ const worktreeInspection = await inspectWorktreeLifecycle(projectRoot, runId);
3981
+ for (const issue of worktreeInspection.issues) {
3982
+ issueCount += 1;
3983
+ checks.push({ level: 'FAIL', check: 'worktree_lifecycle', message: `${runId}: ${issue.message}`, action: issue.recommendation });
3984
+ }
3985
+ for (const delegation of Object.values(state.delegations)) {
3986
+ const report = await validateDelegationRecord(projectRoot, runId, delegation);
3987
+ if (report.stale) {
3988
+ issueCount += 1;
3989
+ checks.push({ level: delegation.blocking ? 'FAIL' : 'WARN', check: 'stale_delegation', message: `${runId}/${delegation.delegationId} is RUNNING past timeout.`, action: 'Record a recovery proposal; do not auto-fix or mark completed.' });
3990
+ }
3991
+ if (delegation.terminalEventRequired && isDelegationTerminal(delegation.status) && !terminalDelegationIds.has(delegation.delegationId)) {
3992
+ issueCount += 1;
3993
+ checks.push({ level: delegation.requiredForPhaseExit ? 'FAIL' : 'WARN', check: 'terminal_event_missing', message: `${runId}/${delegation.delegationId} is ${delegation.status} but has no terminal delegation event.`, action: 'Append correct terminal event through runtime or inspect the run manually.' });
3994
+ }
3995
+ for (const issue of report.issues) {
3996
+ if (issue.field === 'status' && !report.stale) {
3997
+ issueCount += 1;
3998
+ checks.push({ level: delegation.requiredForPhaseExit ? 'FAIL' : 'WARN', check: 'delegation_state_machine', message: `${runId}/${delegation.delegationId}: ${issue.message}`, action: issue.recommendation });
3999
+ }
4000
+ else if (issue.field !== 'status' && issue.field !== 'terminalEventAt') {
4001
+ issueCount += 1;
4002
+ checks.push({ level: delegation.requiredForPhaseExit ? 'FAIL' : 'WARN', check: 'artifact_invalid', message: `${runId}/${delegation.delegationId}: ${issue.message}`, action: issue.recommendation });
4003
+ }
4004
+ }
4005
+ }
4006
+ for (const event of events.filter((candidate) => candidate.event === 'delegation_started')) {
4007
+ const delegationId = String(event.data?.delegationId ?? '');
4008
+ if (delegationId && !terminalDelegationIds.has(delegationId)) {
4009
+ const delegation = state.delegations[delegationId];
4010
+ if (!delegation || !isDelegationTerminal(delegation.status)) {
4011
+ issueCount += 1;
4012
+ checks.push({ level: delegation?.blocking === false ? 'WARN' : 'FAIL', check: 'terminal_event_missing', message: `${runId}/${delegationId} has delegation_started without terminal event.`, action: 'Record delegation_completed/delegation_failed/delegation_timeout/delegation_cancelled before phase exit.' });
4013
+ }
4014
+ }
4015
+ }
4016
+ }
4017
+ catch (error) {
4018
+ issueCount += 1;
4019
+ checks.push({ level: 'FAIL', check: 'run_state', message: `Cannot inspect run ${runId}: ${messageFromError(error)}`, action: 'Inspect state.json/events.jsonl manually; doctor does not auto-fix.' });
4020
+ }
4021
+ }
4022
+ for (const runId of unreadableRunIds) {
4023
+ if (options.allRuns || inspectedRunIds.has(runId)) {
4024
+ issueCount += 1;
4025
+ checks.push({ level: 'FAIL', check: 'run_state', message: `Cannot inspect run ${runId}.`, action: 'Inspect state.json/events.jsonl manually; doctor does not auto-fix.' });
4026
+ }
4027
+ }
4028
+ if (runDirs.length === 0) {
4029
+ checks.push({ level: 'WARN', check: 'run_evidence', message: 'No runs found under .sdd/runs.', action: 'Create a run before /sdd-do or /sdd-verify.' });
4030
+ }
4031
+ else if (inspected.length === 0 && issueCount === 0) {
4032
+ checks.push({ level: 'WARN', check: 'run_evidence', message: 'No non-archived runs were inspected.', action: 'Use sdd doctor --all-runs to audit archived history or create a new run.' });
4033
+ }
4034
+ else if (issueCount === 0) {
4035
+ checks.push({ level: 'PASS', check: 'run_evidence', message: `Inspected ${inspected.length} run(s); no stale delegation, invalid artifact, or terminal event gap found.` });
4036
+ }
4037
+ return checks;
4038
+ }
4039
+ async function inspectLocalRunIndexEvidence(projectRoot) {
4040
+ const inspection = await inspectLocalRunIndex(projectRoot);
4041
+ if (!inspection.exists) {
4042
+ return [{
4043
+ level: 'WARN',
4044
+ check: 'local_run_index',
4045
+ message: 'Local run index is missing; .sdd/runs remains the source of truth.',
4046
+ action: 'Run sdd run index rebuild to create the derived index.'
4047
+ }];
4048
+ }
4049
+ if (!inspection.valid) {
4050
+ return inspection.issues.map((issue) => ({
4051
+ level: 'WARN',
4052
+ check: 'local_run_index',
4053
+ message: issue.message,
4054
+ action: issue.recommendation
4055
+ }));
4056
+ }
4057
+ return [{
4058
+ level: 'PASS',
4059
+ check: 'local_run_index',
4060
+ message: `Local run index is current with ${inspection.index?.runs.length ?? 0} run(s), ${inspection.index?.delegations.length ?? 0} delegation(s), and ${inspection.index?.artifacts.length ?? 0} artifact(s).`
4061
+ }];
4062
+ }
4063
+ async function inspectAiToolEntryEvidence(projectRoot) {
4064
+ const results = await checkAiToolEntryDrift(projectRoot);
4065
+ const checks = results.flatMap((result) => result.entries.map((entry) => {
4066
+ const check = `ai_entry_${entry.id}`;
4067
+ const message = `${entry.relativePath}: ${entry.message}`;
4068
+ if (entry.status === 'unchanged') {
4069
+ return { level: 'PASS', check, message };
4070
+ }
4071
+ if (entry.status === 'missing' || entry.status === 'drifted') {
4072
+ return { level: 'FAIL', check, message, action: entry.action ?? 'Run sdd update.' };
4073
+ }
4074
+ if (entry.status === 'foreign' || entry.status === 'conflict') {
4075
+ return { level: 'FAIL', check, message, action: entry.action ?? 'Review the target file before running sdd update.' };
4076
+ }
4077
+ return { level: 'WARN', check, message, action: entry.action };
4078
+ }));
4079
+ if (checks.length === 0) {
4080
+ checks.push({ level: 'WARN', check: 'ai_entries', message: 'No AI tool adapters selected for drift inspection.', action: 'Run sdd init --ai claude-code if Claude Code entries are required.' });
4081
+ }
4082
+ return checks;
4083
+ }
4084
+ async function inspectCapabilityRegistry(projectRoot) {
4085
+ try {
4086
+ const registry = await listToolCapabilities(projectRoot);
4087
+ const present = new Set(registry.capabilities.map((capability) => capability.id));
4088
+ const missing = BASELINE_TOOL_CAPABILITY_IDS.filter((capabilityId) => !present.has(capabilityId));
4089
+ if (missing.length > 0) {
4090
+ return [{
4091
+ level: 'FAIL',
4092
+ check: 'capability_registry',
4093
+ message: `Capability registry ${registry.version} is missing baseline capability id(s): ${missing.join(', ')}.`,
4094
+ action: 'Restore the built-in Phase 3.1 capability registry.'
4095
+ }];
4096
+ }
4097
+ return [{
4098
+ level: 'PASS',
4099
+ check: 'capability_registry',
4100
+ message: `Capability registry ${registry.version} exposes ${registry.capabilities.length} baseline capability declaration(s).`
4101
+ }];
4102
+ }
4103
+ catch (error) {
4104
+ return [{
4105
+ level: 'FAIL',
4106
+ check: 'capability_registry',
4107
+ message: `Cannot inspect capability registry: ${messageFromError(error)}`,
4108
+ action: 'Run sdd init or fix .sdd/project.yml before inspecting capabilities.'
4109
+ }];
4110
+ }
4111
+ }
4112
+ async function inspectToolPluginContracts(projectRoot) {
4113
+ try {
4114
+ const [capabilityRegistry, pluginRegistry] = await Promise.all([
4115
+ listToolCapabilities(projectRoot),
4116
+ listToolPluginContracts(projectRoot)
4117
+ ]);
4118
+ const capabilityIds = new Set(capabilityRegistry.capabilities.map((capability) => capability.id));
4119
+ const pluginIds = new Set(pluginRegistry.contracts.map((contract) => contract.id));
4120
+ const missingPlugins = BASELINE_TOOL_PLUGIN_CONTRACT_IDS.filter((pluginId) => !pluginIds.has(pluginId));
4121
+ const missingCapabilities = pluginRegistry.contracts
4122
+ .filter((contract) => !capabilityIds.has(contract.capabilityId))
4123
+ .map((contract) => `${contract.id}->${contract.capabilityId}`);
4124
+ if (missingPlugins.length > 0 || missingCapabilities.length > 0) {
4125
+ const problems = [
4126
+ missingPlugins.length > 0 ? `missing baseline plugin id(s): ${missingPlugins.join(', ')}` : null,
4127
+ missingCapabilities.length > 0 ? `unknown capability reference(s): ${missingCapabilities.join(', ')}` : null
4128
+ ].filter((problem) => problem !== null);
4129
+ return [{
4130
+ level: 'FAIL',
4131
+ check: 'plugin_loading_contract',
4132
+ message: `Plugin loading contract ${pluginRegistry.version} has compatibility issue(s): ${problems.join('; ')}.`,
4133
+ action: 'Restore the built-in Phase 3.2 plugin loading contract registry.'
4134
+ }];
4135
+ }
4136
+ return [{
4137
+ level: 'PASS',
4138
+ check: 'plugin_loading_contract',
4139
+ message: `Plugin loading contract ${pluginRegistry.version} exposes ${pluginRegistry.contracts.length} baseline plugin contract declaration(s) compatible with capability registry ${capabilityRegistry.version}.`
4140
+ }];
4141
+ }
4142
+ catch (error) {
4143
+ return [{
4144
+ level: 'FAIL',
4145
+ check: 'plugin_loading_contract',
4146
+ message: `Cannot inspect plugin loading contracts: ${messageFromError(error)}`,
4147
+ action: 'Run sdd init or fix .sdd/project.yml before inspecting plugin contracts.'
4148
+ }];
4149
+ }
4150
+ }
4151
+ async function inspectDelegationQueueContract(projectRoot) {
4152
+ try {
4153
+ const [capabilityRegistry, snapshot] = await Promise.all([
4154
+ listToolCapabilities(projectRoot),
4155
+ listDelegationQueueItems(projectRoot)
4156
+ ]);
4157
+ const capabilityIds = new Set(capabilityRegistry.capabilities.map((capability) => capability.id));
4158
+ const invalidItems = snapshot.items
4159
+ .filter((item) => !item.id || !item.runId || !item.delegationId || !item.taskId || !item.agent || !item.dedupeKey || !capabilityIds.has(item.requestedCapabilityId))
4160
+ .map((item) => item.id || '<missing-id>');
4161
+ if (invalidItems.length > 0) {
4162
+ return [{
4163
+ level: 'FAIL',
4164
+ check: 'delegation_queue_contract',
4165
+ message: `Delegation queue contract ${snapshot.version} has invalid queue item(s): ${invalidItems.join(', ')}.`,
4166
+ action: 'Restore delegation run-state records and ensure requested capabilities reference the Phase 3.1 registry.'
4167
+ }];
4168
+ }
4169
+ return [{
4170
+ level: 'PASS',
4171
+ check: 'delegation_queue_contract',
4172
+ message: `Delegation queue contract ${snapshot.version} derived ${snapshot.items.length} queue item(s) from run-state delegations.`
4173
+ }];
4174
+ }
4175
+ catch (error) {
4176
+ return [{
4177
+ level: 'FAIL',
4178
+ check: 'delegation_queue_contract',
4179
+ message: `Cannot inspect delegation queue contract: ${messageFromError(error)}`,
4180
+ action: 'Run sdd init or fix .sdd/project.yml before inspecting delegation queue items.'
4181
+ }];
4182
+ }
4183
+ }
4184
+ async function inspectDelegationStateMachineContract(projectRoot) {
4185
+ try {
4186
+ await readProjectConfig(projectRoot);
4187
+ const machine = getDelegationStateMachine();
4188
+ const statuses = new Set(machine.statuses);
4189
+ const terminalStatuses = new Set(machine.terminalStatuses);
4190
+ const missingStatuses = DELEGATION_STATUSES.filter((status) => !statuses.has(status));
4191
+ const invalidTerminalStatuses = machine.terminalStatuses.filter((status) => !TERMINAL_DELEGATION_STATUSES.includes(status));
4192
+ const invalidTransitions = machine.transitions.filter((transition) => !statuses.has(transition.from) ||
4193
+ !statuses.has(transition.to) ||
4194
+ terminalStatuses.has(transition.from) ||
4195
+ transition.terminal !== terminalStatuses.has(transition.to));
4196
+ if (missingStatuses.length > 0 || invalidTerminalStatuses.length > 0 || invalidTransitions.length > 0) {
4197
+ const problems = [
4198
+ missingStatuses.length > 0 ? `missing status(es): ${missingStatuses.join(', ')}` : null,
4199
+ invalidTerminalStatuses.length > 0 ? `invalid terminal status(es): ${invalidTerminalStatuses.join(', ')}` : null,
4200
+ invalidTransitions.length > 0 ? `invalid transition(s): ${invalidTransitions.map((transition) => `${transition.from}->${transition.to}`).join(', ')}` : null
4201
+ ].filter((problem) => problem !== null);
4202
+ return [{
4203
+ level: 'FAIL',
4204
+ check: 'delegation_state_machine',
4205
+ message: `Delegation state machine ${machine.version} has compatibility issue(s): ${problems.join('; ')}.`,
4206
+ action: 'Restore the built-in Phase 3.4 delegation state machine contract.'
4207
+ }];
4208
+ }
4209
+ return [{
4210
+ level: 'PASS',
4211
+ check: 'delegation_state_machine',
4212
+ message: `Delegation state machine ${machine.version} declares ${machine.statuses.length} status(es), ${machine.terminalStatuses.length} terminal status(es), and ${machine.transitions.length} transition(s).`
4213
+ }];
4214
+ }
4215
+ catch (error) {
4216
+ return [{
4217
+ level: 'FAIL',
4218
+ check: 'delegation_state_machine',
4219
+ message: `Cannot inspect delegation state machine: ${messageFromError(error)}`,
4220
+ action: 'Run sdd init or fix .sdd/project.yml before inspecting the delegation state machine.'
4221
+ }];
4222
+ }
4223
+ }
4224
+ async function inspectWorkerAdapterContracts(projectRoot) {
4225
+ try {
4226
+ const [capabilityRegistry, pluginRegistry, adapterRegistry] = await Promise.all([
4227
+ listToolCapabilities(projectRoot),
4228
+ listToolPluginContracts(projectRoot),
4229
+ listWorkerAdapterContracts(projectRoot)
4230
+ ]);
4231
+ const capabilityIds = new Set(capabilityRegistry.capabilities.map((capability) => capability.id));
4232
+ const pluginIds = new Set(pluginRegistry.contracts.map((contract) => contract.id));
4233
+ const adapterIds = new Set(adapterRegistry.adapters.map((adapter) => adapter.id));
4234
+ const missingAdapters = BASELINE_WORKER_ADAPTER_IDS.filter((adapterId) => !adapterIds.has(adapterId));
4235
+ const missingCapabilities = adapterRegistry.adapters
4236
+ .filter((adapter) => !capabilityIds.has(adapter.capabilityId))
4237
+ .map((adapter) => `${adapter.id}->${adapter.capabilityId}`);
4238
+ const missingPlugins = adapterRegistry.adapters
4239
+ .filter((adapter) => !pluginIds.has(adapter.pluginContractId))
4240
+ .map((adapter) => `${adapter.id}->${adapter.pluginContractId}`);
4241
+ const invalidStateMachineRefs = adapterRegistry.adapters
4242
+ .filter((adapter) => adapter.input.stateMachineVersion !== DELEGATION_STATE_MACHINE_VERSION)
4243
+ .map((adapter) => adapter.id);
4244
+ const invalidTerminalStatuses = adapterRegistry.adapters
4245
+ .filter((adapter) => adapter.output.terminalStatus.some((status) => !TERMINAL_DELEGATION_STATUSES.includes(status)))
4246
+ .map((adapter) => adapter.id);
4247
+ if (missingAdapters.length > 0 || missingCapabilities.length > 0 || missingPlugins.length > 0 || invalidStateMachineRefs.length > 0 || invalidTerminalStatuses.length > 0) {
4248
+ const problems = [
4249
+ missingAdapters.length > 0 ? `missing baseline adapter id(s): ${missingAdapters.join(', ')}` : null,
4250
+ missingCapabilities.length > 0 ? `unknown capability reference(s): ${missingCapabilities.join(', ')}` : null,
4251
+ missingPlugins.length > 0 ? `unknown plugin contract reference(s): ${missingPlugins.join(', ')}` : null,
4252
+ invalidStateMachineRefs.length > 0 ? `invalid state machine reference(s): ${invalidStateMachineRefs.join(', ')}` : null,
4253
+ invalidTerminalStatuses.length > 0 ? `invalid terminal status output(s): ${invalidTerminalStatuses.join(', ')}` : null
4254
+ ].filter((problem) => problem !== null);
4255
+ return [{
4256
+ level: 'FAIL',
4257
+ check: 'worker_adapter_contract',
4258
+ message: `Worker adapter contract ${adapterRegistry.version} has compatibility issue(s): ${problems.join('; ')}.`,
4259
+ action: 'Restore the built-in Phase 3.5 worker adapter contract registry.'
4260
+ }];
4261
+ }
4262
+ return [{
4263
+ level: 'PASS',
4264
+ check: 'worker_adapter_contract',
4265
+ message: `Worker adapter contract ${adapterRegistry.version} exposes ${adapterRegistry.adapters.length} adapter manifest(s) compatible with capability, plugin, and state machine contracts.`
4266
+ }];
4267
+ }
4268
+ catch (error) {
4269
+ return [{
4270
+ level: 'FAIL',
4271
+ check: 'worker_adapter_contract',
4272
+ message: `Cannot inspect worker adapter contracts: ${messageFromError(error)}`,
4273
+ action: 'Run sdd init or fix .sdd/project.yml before inspecting worker adapter contracts.'
4274
+ }];
4275
+ }
4276
+ }
4277
+ async function inspectWorktreeIsolationContract(projectRoot) {
4278
+ try {
4279
+ await readProjectConfig(projectRoot);
4280
+ const modes = ['none', 'required', 'blocked', 'manual'];
4281
+ const requiredGates = ['task_found', 'capability_declared', 'files_overlap', 'unsafe_concurrency', 'read_only'];
4282
+ return [{
4283
+ level: 'PASS',
4284
+ check: 'worktree_isolation_contract',
4285
+ message: `Worktree isolation contract ${WORKTREE_ISOLATION_CONTRACT_VERSION} declares ${modes.length} mode(s) and ${requiredGates.length} dry-run gate(s).`
4286
+ }];
4287
+ }
4288
+ catch (error) {
4289
+ return [{
4290
+ level: 'FAIL',
4291
+ check: 'worktree_isolation_contract',
4292
+ message: `Cannot inspect worktree isolation contract: ${messageFromError(error)}`,
4293
+ action: 'Run sdd init or fix .sdd/project.yml before inspecting worktree isolation contract.'
4294
+ }];
4295
+ }
4296
+ }
4297
+ async function inspectWorktreeLifecycleContract(projectRoot) {
4298
+ try {
4299
+ await readProjectConfig(projectRoot);
4300
+ const statuses = ['created', 'kept', 'removed'];
4301
+ return [{
4302
+ level: 'PASS',
4303
+ check: 'worktree_lifecycle_contract',
4304
+ message: `Worktree lifecycle contract ${WORKTREE_LIFECYCLE_CONTRACT_VERSION} declares ${statuses.length} status(es) and safe create/keep/remove operations.`
4305
+ }];
4306
+ }
4307
+ catch (error) {
4308
+ return [{
4309
+ level: 'FAIL',
4310
+ check: 'worktree_lifecycle_contract',
4311
+ message: `Cannot inspect worktree lifecycle contract: ${messageFromError(error)}`,
4312
+ action: 'Run sdd init or fix .sdd/project.yml before inspecting worktree lifecycle contract.'
4313
+ }];
4314
+ }
4315
+ }
4316
+ async function inspectTaskGraphPlannerContract(projectRoot) {
4317
+ try {
4318
+ await readProjectConfig(projectRoot);
4319
+ const edgeTypes = ['depends_on', 'file_overlap'];
4320
+ return [{
4321
+ level: 'PASS',
4322
+ check: 'task_graph_planner_contract',
4323
+ message: `Task graph planner contract ${TASK_GRAPH_PLANNER_CONTRACT_VERSION} declares ${edgeTypes.length} edge type(s), graph diagnostics, and read-only inspect output.`
4324
+ }];
4325
+ }
4326
+ catch (error) {
4327
+ return [{
4328
+ level: 'FAIL',
4329
+ check: 'task_graph_planner_contract',
4330
+ message: `Cannot inspect task graph planner contract: ${messageFromError(error)}`,
4331
+ action: 'Run sdd init or fix .sdd/project.yml before inspecting task graph planner contract.'
4332
+ }];
4333
+ }
4334
+ }
4335
+ async function inspectWavePlannerContract(projectRoot) {
4336
+ try {
4337
+ await readProjectConfig(projectRoot);
4338
+ const gates = ['manual', 'blocked'];
4339
+ return [{
4340
+ level: 'PASS',
4341
+ check: 'wave_planner_contract',
4342
+ message: `Wave planner contract ${WAVE_PLANNER_CONTRACT_VERSION} declares dependency waves and ${gates.length} gate type(s) without execution side effects.`
4343
+ }];
4344
+ }
4345
+ catch (error) {
4346
+ return [{
4347
+ level: 'FAIL',
4348
+ check: 'wave_planner_contract',
4349
+ message: `Cannot inspect wave planner contract: ${messageFromError(error)}`,
4350
+ action: 'Run sdd init or fix .sdd/project.yml before inspecting wave planner contract.'
4351
+ }];
4352
+ }
4353
+ }
4354
+ async function inspectBackgroundExecutorContract(projectRoot) {
4355
+ try {
4356
+ await readProjectConfig(projectRoot);
4357
+ const statuses = ['claimed', 'completed', 'failed', 'blocked'];
4358
+ return [{
4359
+ level: 'PASS',
4360
+ check: 'background_executor_contract',
4361
+ message: `Background executor contract ${BACKGROUND_EXECUTOR_CONTRACT_VERSION} declares ${statuses.length} result status(es), single-delegation claim/run/ingest, and no wave execution side effects.`
4362
+ }];
4363
+ }
4364
+ catch (error) {
4365
+ return [{
4366
+ level: 'FAIL',
4367
+ check: 'background_executor_contract',
4368
+ message: `Cannot inspect background executor contract: ${messageFromError(error)}`,
4369
+ action: 'Run sdd init or fix .sdd/project.yml before inspecting background executor contract.'
4370
+ }];
4371
+ }
4372
+ }
4373
+ async function inspectWaveExecutorContract(projectRoot) {
4374
+ try {
4375
+ await readProjectConfig(projectRoot);
4376
+ const strategies = ['fast-stop', 'safe-continue'];
4377
+ return [{
4378
+ level: 'PASS',
4379
+ check: 'wave_executor_contract',
4380
+ message: `Wave executor contract ${WAVE_EXECUTOR_CONTRACT_VERSION} declares ${strategies.length} strategy option(s), planner-driven execution, and no sync-back apply side effects.`
4381
+ }];
4382
+ }
4383
+ catch (error) {
4384
+ return [{
4385
+ level: 'FAIL',
4386
+ check: 'wave_executor_contract',
4387
+ message: `Cannot inspect wave executor contract: ${messageFromError(error)}`,
4388
+ action: 'Run sdd init or fix .sdd/project.yml before inspecting wave executor contract.'
4389
+ }];
4390
+ }
4391
+ }
4392
+ async function inspectLocalRunIndexContract(projectRoot) {
4393
+ try {
4394
+ await readProjectConfig(projectRoot);
4395
+ return [{
4396
+ level: 'PASS',
4397
+ check: 'local_run_index_contract',
4398
+ message: `Local run index contract ${LOCAL_RUN_INDEX_CONTRACT_VERSION} declares rebuildable derived run, task, delegation, artifact, and wave summary indexes.`
4399
+ }];
4400
+ }
4401
+ catch (error) {
4402
+ return [{
4403
+ level: 'FAIL',
4404
+ check: 'local_run_index_contract',
4405
+ message: `Cannot inspect local run index contract: ${messageFromError(error)}`,
4406
+ action: 'Run sdd init or fix .sdd/project.yml before inspecting local run index contract.'
4407
+ }];
4408
+ }
4409
+ }
4410
+ async function inspectGovernancePolicyContract(projectRoot) {
4411
+ try {
4412
+ const policy = await inspectGovernancePolicy(projectRoot);
4413
+ const missingOperations = BASELINE_GOVERNANCE_POLICY_OPERATIONS.filter((operation) => !policy.manualConfirmation.operations.includes(operation) && operation !== 'background_executor' && operation !== 'wave_executor');
4414
+ if (missingOperations.length > 0 || policy.cleanup.deleteRunHistory || !policy.cleanup.archiveOnly || policy.retry.reopenTerminalDelegation) {
4415
+ return [{
4416
+ level: 'FAIL',
4417
+ check: 'governance_policy_contract',
4418
+ message: `Governance policy ${policy.version} has unsafe compatibility issue(s).`,
4419
+ action: 'Restore the built-in Phase 3.14 governance policy contract.'
4420
+ }];
4421
+ }
4422
+ return [{
4423
+ level: 'PASS',
4424
+ check: 'governance_policy_contract',
4425
+ message: `Governance policy ${policy.version} gates ${BASELINE_GOVERNANCE_POLICY_OPERATIONS.length} operation type(s), max ${policy.concurrency.maxBackgroundDelegations} background delegation(s), and explicit confirmation for risky shared-state actions.`
4426
+ }];
4427
+ }
4428
+ catch (error) {
4429
+ return [{
4430
+ level: 'FAIL',
4431
+ check: 'governance_policy_contract',
4432
+ message: `Cannot inspect governance policy contract: ${messageFromError(error)}`,
4433
+ action: 'Run sdd init or fix .sdd/project.yml before inspecting governance policy contract.'
4434
+ }];
4435
+ }
4436
+ }
4437
+ export async function readRunEvents(projectRoot, runId) {
4438
+ const eventPath = path.join(getRunDir(projectRoot, runId), 'events.jsonl');
4439
+ if (!await exists(eventPath)) {
4440
+ return [];
4441
+ }
4442
+ const raw = await readFile(eventPath, 'utf8');
4443
+ return raw.split(/\r?\n/).filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
4444
+ }
4445
+ function terminalDelegationIdsFromEvents(events) {
4446
+ const terminalEvents = new Set(['delegation_completed', 'delegation_failed', 'delegation_timeout', 'delegation_cancelled']);
4447
+ const ids = new Set();
4448
+ for (const event of events) {
4449
+ if (terminalEvents.has(event.event)) {
4450
+ const delegationId = event.data?.delegationId;
4451
+ if (typeof delegationId === 'string' && delegationId.length > 0) {
4452
+ ids.add(delegationId);
4453
+ }
4454
+ }
4455
+ }
4456
+ return ids;
4457
+ }
4458
+ function inspectRuntimeDelegationTransitions(runId, events) {
4459
+ const statusByDelegation = new Map();
4460
+ const checks = [];
4461
+ for (const event of events) {
4462
+ const delegationId = event.data?.delegationId;
4463
+ if (typeof delegationId !== 'string' || delegationId.length === 0) {
4464
+ continue;
4465
+ }
4466
+ const nextStatus = delegationStatusFromRuntimeEvent(event.event);
4467
+ if (!nextStatus) {
4468
+ continue;
4469
+ }
4470
+ const currentStatus = statusByDelegation.get(delegationId) ?? 'PENDING';
4471
+ const validation = validateDelegationStateTransition(currentStatus, nextStatus, event.event);
4472
+ if (!validation.valid) {
4473
+ checks.push({
4474
+ level: 'FAIL',
4475
+ check: 'delegation_state_transition',
4476
+ message: `${runId}/${delegationId} cannot transition ${currentStatus} -> ${nextStatus} on ${event.event}.`,
4477
+ action: validation.issues[0]?.recommendation ?? 'Use a declared Phase 3.4 delegation state transition.'
4478
+ });
4479
+ continue;
4480
+ }
4481
+ statusByDelegation.set(delegationId, nextStatus);
4482
+ }
4483
+ return checks;
4484
+ }
4485
+ function delegationStatusFromRuntimeEvent(event) {
4486
+ if (event === 'delegation_started' || event === 'delegation_retry_started' || event === 'delegation_heartbeat') {
4487
+ return 'RUNNING';
4488
+ }
4489
+ if (event === 'delegation_completed') {
4490
+ return 'COMPLETED';
4491
+ }
4492
+ if (event === 'delegation_failed') {
4493
+ return 'FAILED';
4494
+ }
4495
+ if (event === 'delegation_timeout') {
4496
+ return 'TIMED_OUT';
4497
+ }
4498
+ if (event === 'delegation_cancelled') {
4499
+ return 'CANCELLED';
4500
+ }
4501
+ if (event === 'artifact_invalid') {
4502
+ return 'RECOVERABLE';
4503
+ }
4504
+ if (event === 'delegation_stale') {
4505
+ return 'STALE';
4506
+ }
4507
+ return null;
4508
+ }
4509
+ function validateDelegationShape(delegation) {
4510
+ const issues = [];
4511
+ if (delegation.contract !== DELEGATION_LIVENESS_CONTRACT) {
4512
+ issues.push(contractIssue('contract', `Expected ${DELEGATION_LIVENESS_CONTRACT}, got ${delegation.contract}.`, 'Use the delegation liveness contract id.'));
4513
+ }
4514
+ if (delegation.version !== DELEGATION_LIVENESS_VERSION) {
4515
+ issues.push(contractIssue('version', `Expected ${DELEGATION_LIVENESS_VERSION}, got ${delegation.version}.`, 'Use version: 1.3.0 until a new contract version is introduced.'));
4516
+ }
4517
+ if (!delegation.delegationId) {
4518
+ issues.push(contractIssue('delegationId', 'delegationId is required.', 'Persist a stable delegation id.'));
4519
+ }
4520
+ if (!delegation.task) {
4521
+ issues.push(contractIssue('task', 'delegation task is required.', 'Persist the delegated task id.'));
4522
+ }
4523
+ if (!delegation.agent) {
4524
+ issues.push(contractIssue('agent', 'delegation agent is required.', 'Persist the delegated agent name.'));
4525
+ }
4526
+ if (delegation.runMode !== 'foreground' && delegation.runMode !== 'background') {
4527
+ issues.push(contractIssue('runMode', `Unsupported runMode ${delegation.runMode}.`, 'Use foreground or background.'));
4528
+ }
4529
+ if (!isDelegationStatus(delegation.status)) {
4530
+ issues.push(contractIssue('status', `Unsupported delegation status ${delegation.status}.`, 'Use a status from the delegation liveness contract.'));
4531
+ }
4532
+ if (!Number.isInteger(delegation.timeoutSeconds) || delegation.timeoutSeconds <= 0) {
4533
+ issues.push(contractIssue('timeoutSeconds', 'timeoutSeconds must be a positive integer.', 'Set an explicit positive timeout.'));
4534
+ }
4535
+ validateRunRelativeArtifactReference(delegation.expectedArtifact, issues, 'expectedArtifact');
4536
+ return issues;
4537
+ }
4538
+ function validateRunRelativeArtifactReference(value, issues, field = 'artifacts') {
4539
+ try {
4540
+ toArtifactRootRelativePath(value);
4541
+ }
4542
+ catch (error) {
4543
+ issues.push(contractIssue(field, messageFromError(error), 'Use run-relative artifacts/<file> paths that stay under the run artifact directory.'));
4544
+ }
4545
+ }
4546
+ function isSddResultStatus(value) {
4547
+ return value === 'PASS' || value === 'PASS_WITH_GAPS' || value === 'FAIL' || value === 'BLOCKED' || value === 'TIMED_OUT' || value === 'CANCELLED';
4548
+ }
4549
+ function isDelegationStatus(value) {
4550
+ return value === 'PENDING' || value === 'RUNNING' || value === 'COMPLETED' || value === 'FAILED' || value === 'TIMED_OUT' || value === 'CANCELLED' || value === 'RECOVERABLE' || value === 'STALE';
4551
+ }
4552
+ function contractIssue(field, message, recommendation) {
4553
+ return { field, message, recommendation };
4554
+ }
4555
+ function normalizeArtifactRootRelativePath(value) {
4556
+ const normalized = normalizePortablePath(value);
4557
+ if (!normalized || normalized === '.' || normalized.startsWith('../') || normalized === '..' || path.isAbsolute(value)) {
4558
+ throw new Error(`Artifact path must be relative and stay under artifacts/: ${value}`);
4559
+ }
4560
+ if (normalized.startsWith('artifacts/')) {
4561
+ throw new Error(`Artifact helper paths must be artifact-root-relative, not run-relative: ${value}`);
4562
+ }
4563
+ return normalized;
4564
+ }
4565
+ function normalizePortablePath(value) {
4566
+ return path.posix.normalize(value.replace(/\\/g, '/'));
4567
+ }
4568
+ const FULL_PROFILE_HARD_GATES = [
4569
+ 'security_auth',
4570
+ 'database_or_data_loss',
4571
+ 'api_schema_contract',
4572
+ 'state_machine_concurrency_liveness',
4573
+ 'ci_dependency_build_release'
4574
+ ];
4575
+ function normalizeLifecycleSignals(input) {
4576
+ return {
4577
+ intent_clarity: input.intent_clarity ?? 'medium',
4578
+ acceptance_clarity: input.acceptance_clarity ?? 'medium',
4579
+ estimated_change_size: input.estimated_change_size ?? 'small',
4580
+ task_count_estimate: input.task_count_estimate ?? 1,
4581
+ file_count_estimate: input.file_count_estimate ?? 1,
4582
+ affected_layers: input.affected_layers ?? [],
4583
+ affected_contracts: input.affected_contracts ?? [],
4584
+ dependency_fanout: input.dependency_fanout ?? 'local',
4585
+ impact_confidence: input.impact_confidence ?? 'medium',
4586
+ risk_tags: uniqueStrings(input.risk_tags ?? []),
4587
+ reversibility: input.reversibility ?? 'unknown',
4588
+ validation_clarity: input.validation_clarity ?? 'partial',
4589
+ validation_available: input.validation_available ?? false,
4590
+ validation_cost: input.validation_cost ?? 'unknown',
4591
+ policy_hits: uniqueStrings(input.policy_hits ?? []),
4592
+ permission_required: uniqueStrings(input.permission_required ?? []),
4593
+ requires_agents: input.requires_agents ?? false,
4594
+ handoff_count: input.handoff_count ?? 0,
4595
+ artifact_dependency: input.artifact_dependency ?? false,
4596
+ runtime_recovery_need: input.runtime_recovery_need ?? false,
4597
+ orchestration_uncertainty: input.orchestration_uncertainty ?? 'medium',
4598
+ human_checkpoint_required: input.human_checkpoint_required ?? false,
4599
+ approval_reason: input.approval_reason ?? [],
4600
+ source_artifacts: input.source_artifacts ?? [],
4601
+ can_scout_impact: input.can_scout_impact ?? true,
4602
+ architecture_decision_required: input.architecture_decision_required ?? false,
4603
+ external_unknown: input.external_unknown ?? false
4604
+ };
4605
+ }
4606
+ function evaluateLifecycleHardGates(signals) {
4607
+ const hits = [];
4608
+ const riskText = signals.risk_tags.map((tag) => tag.toLowerCase());
4609
+ if (signals.external_unknown) {
4610
+ hits.push('external_unknown');
4611
+ }
4612
+ if (signals.architecture_decision_required) {
4613
+ hits.push('architecture_decision_required');
4614
+ }
4615
+ if (containsAny(riskText, ['security', 'auth', 'permission', 'credential', 'data_leak', 'privacy'])) {
4616
+ hits.push('security_auth');
4617
+ }
4618
+ if (containsAny(riskText, ['database', 'migration', 'data_loss', 'irreversible', 'schema-data']) || signals.reversibility === 'irreversible') {
4619
+ hits.push('database_or_data_loss');
4620
+ }
4621
+ if (signals.affected_contracts.length > 0 || containsAny(riskText, ['api', 'schema', 'contract'])) {
4622
+ hits.push('api_schema_contract');
4623
+ }
4624
+ if (containsAny(riskText, ['state_machine', 'state-machine', 'concurrency', 'liveness', 'recovery'])) {
4625
+ hits.push('state_machine_concurrency_liveness');
4626
+ }
4627
+ if (containsAny(riskText, ['ci', 'cd', 'dependency', 'build', 'release', 'publish'])) {
4628
+ hits.push('ci_dependency_build_release');
4629
+ }
4630
+ if (signals.impact_confidence === 'low') {
4631
+ hits.push(signals.can_scout_impact ? 'low_impact_confidence_scoutable' : 'low_impact_confidence_unscoutable');
4632
+ }
4633
+ if (signals.acceptance_clarity === 'low' || signals.validation_clarity === 'unclear') {
4634
+ hits.push('unclear_acceptance_or_validation');
4635
+ }
4636
+ if (signals.policy_hits.length > 0 || signals.permission_required.length > 0) {
4637
+ hits.push('policy_or_permission_checkpoint');
4638
+ }
4639
+ return uniqueStrings(hits);
4640
+ }
4641
+ function matchesDirectWhitelist(signals, hardGateHits) {
4642
+ return signals.intent_clarity === 'high'
4643
+ && signals.acceptance_clarity === 'high'
4644
+ && signals.validation_clarity === 'clear'
4645
+ && signals.validation_available
4646
+ && signals.validation_cost === 'cheap'
4647
+ && signals.impact_confidence === 'high'
4648
+ && signals.risk_tags.length === 0
4649
+ && hardGateHits.length === 0
4650
+ && signals.reversibility === 'reversible'
4651
+ && !signals.requires_agents
4652
+ && signals.handoff_count === 0
4653
+ && !signals.artifact_dependency
4654
+ && !signals.runtime_recovery_need
4655
+ && signals.orchestration_uncertainty === 'low'
4656
+ && signals.file_count_estimate <= 2
4657
+ && signals.task_count_estimate <= 1;
4658
+ }
4659
+ function isBoundedCompactChange(signals) {
4660
+ return signals.estimated_change_size !== 'large'
4661
+ && signals.task_count_estimate <= 2
4662
+ && signals.file_count_estimate <= 5
4663
+ && signals.dependency_fanout !== 'multi_component'
4664
+ && signals.dependency_fanout !== 'unknown'
4665
+ && signals.impact_confidence !== 'low'
4666
+ && signals.validation_clarity !== 'unclear'
4667
+ && signals.orchestration_uncertainty !== 'high';
4668
+ }
4669
+ function estimateLifecycleConfidence(signals, hardGateHits) {
4670
+ if (signals.intent_clarity === 'low' || signals.impact_confidence === 'low' || signals.orchestration_uncertainty === 'high' || signals.external_unknown || signals.architecture_decision_required) {
4671
+ return 'low';
4672
+ }
4673
+ if (signals.intent_clarity === 'high' && signals.acceptance_clarity === 'high' && signals.impact_confidence === 'high' && signals.validation_clarity === 'clear' && hardGateHits.length === 0) {
4674
+ return 'high';
4675
+ }
4676
+ return 'medium';
4677
+ }
4678
+ function requiredStagesForProfile(profile) {
4679
+ if (profile === 'direct') {
4680
+ return ['intent', 'implement', 'minimal-validation'];
4681
+ }
4682
+ if (profile === 'compact') {
4683
+ return ['intent-or-mini-spec', 'task-boundary', 'implement', 'validation'];
4684
+ }
4685
+ if (profile === 'full') {
4686
+ return ['spec', 'plan', 'tasks', 'do', 'verify', 'sync-back-proposal'];
4687
+ }
4688
+ return ['research', 'options', 'decision', 'architecture-artifact', 'implementation-spec'];
4689
+ }
4690
+ function skippedStagesForProfile(profile) {
4691
+ if (profile === 'direct') {
4692
+ return ['full-spec', 'full-plan', 'full-tasks', 'agent-workflow'];
4693
+ }
4694
+ if (profile === 'compact') {
4695
+ return ['full-spec', 'full-plan'];
4696
+ }
4697
+ if (profile === 'full') {
4698
+ return ['research'];
4699
+ }
4700
+ return ['direct-implementation'];
4701
+ }
4702
+ function defaultEscalationTriggers() {
4703
+ return [
4704
+ 'actual impact exceeds initial estimate',
4705
+ 'API/database/security/state-machine/concurrency/build risk appears',
4706
+ 'acceptance or validation becomes unclear',
4707
+ 'validation fails for non-obvious reasons',
4708
+ 'Spec/Plan/Task/Scope/Validation/Environment gap is detected',
4709
+ 'required artifact, delegation terminal event, or liveness evidence is missing'
4710
+ ];
4711
+ }
4712
+ function commandIntegrationBoundaries(profile) {
4713
+ const boundaries = [
4714
+ 'Command gate may decide and record lifecycle profile only.',
4715
+ 'Command gate must not execute Phase 1.8 task implementation loop.',
4716
+ 'Command gate must not launch agents or workflows in Phase 1.7.',
4717
+ 'Phase transition still requires checkpoint confirmation.'
4718
+ ];
4719
+ if (profile === 'direct' || profile === 'compact') {
4720
+ boundaries.push('Short path is allowed only within the recorded lifecycle decision; escalation triggers remain active.');
4721
+ }
4722
+ return boundaries;
4723
+ }
4724
+ function containsAny(values, needles) {
4725
+ return values.some((value) => needles.some((needle) => value.includes(needle)));
4726
+ }
4727
+ function uniqueStrings(values) {
4728
+ return Array.from(new Set(values.filter((value) => value.length > 0)));
4729
+ }
4730
+ async function createUniqueRunId(projectRoot) {
4731
+ const base = formatDateForRunId(new Date());
4732
+ for (let sequence = 1; sequence <= 999; sequence += 1) {
4733
+ const runId = `${base}-${String(sequence).padStart(3, '0')}`;
4734
+ if (!await exists(getRunDir(projectRoot, runId))) {
4735
+ return runId;
4736
+ }
4737
+ }
4738
+ throw new Error(`Cannot allocate run id for ${base}; sequence exhausted.`);
4739
+ }
4740
+ function formatDateForRunId(date) {
4741
+ const year = date.getFullYear();
4742
+ const month = String(date.getMonth() + 1).padStart(2, '0');
4743
+ const day = String(date.getDate()).padStart(2, '0');
4744
+ return `${year}${month}${day}`;
4745
+ }
4746
+ function defaultWorktreeId(runId, taskId) {
4747
+ return `wt-${runId}-${taskId}`.replace(/[^A-Za-z0-9._-]/g, '-');
4748
+ }
4749
+ async function isGitWorktreeDirty(projectRoot, worktreePath) {
4750
+ const absolutePath = path.resolve(projectRoot, worktreePath);
4751
+ if (!await exists(absolutePath)) {
4752
+ return false;
4753
+ }
4754
+ try {
4755
+ const result = await execFileAsync('git', ['-C', absolutePath, 'status', '--porcelain']);
4756
+ return result.stdout.trim().length > 0;
4757
+ }
4758
+ catch {
4759
+ return true;
4760
+ }
4761
+ }
4762
+ async function listGitWorktreePaths(projectRoot) {
4763
+ try {
4764
+ const result = await execFileAsync('git', ['-C', projectRoot, 'worktree', 'list', '--porcelain']);
4765
+ return new Set(result.stdout.split(/\r?\n/).filter((line) => line.startsWith('worktree ')).map((line) => normalizeComparablePath(path.resolve(line.slice('worktree '.length)))));
4766
+ }
4767
+ catch {
4768
+ return new Set();
4769
+ }
4770
+ }
4771
+ async function listOrphanWorktreeDirs(projectRoot, activePaths) {
4772
+ const worktreesDir = getWorktreesDir(projectRoot);
4773
+ if (!await exists(worktreesDir)) {
4774
+ return [];
4775
+ }
4776
+ const entries = await readdir(worktreesDir, { withFileTypes: true });
4777
+ const orphans = [];
4778
+ for (const entry of entries.filter((candidate) => candidate.isDirectory())) {
4779
+ const relativePath = path.relative(projectRoot, path.join(worktreesDir, entry.name));
4780
+ if (!activePaths.has(normalizeComparablePath(relativePath))) {
4781
+ orphans.push(relativePath);
4782
+ }
4783
+ }
4784
+ return orphans;
4785
+ }
4786
+ async function getGitRoot(projectRoot) {
4787
+ try {
4788
+ const result = await execFileAsync('git', ['-C', projectRoot, 'rev-parse', '--show-toplevel']);
4789
+ return result.stdout.trim();
4790
+ }
4791
+ catch {
4792
+ return null;
4793
+ }
4794
+ }
4795
+ async function exists(filePath) {
4796
+ try {
4797
+ await stat(filePath);
4798
+ return true;
4799
+ }
4800
+ catch {
4801
+ return false;
4802
+ }
4803
+ }
4804
+ function summarizeDoctorStatus(checks) {
4805
+ if (checks.some((check) => check.level === 'FAIL')) {
4806
+ return 'FAIL';
4807
+ }
4808
+ if (checks.some((check) => check.level === 'WARN')) {
4809
+ return 'WARN';
4810
+ }
4811
+ return 'PASS';
4812
+ }
4813
+ function assertSafePathSegment(value, field) {
4814
+ if (value === '.' || value === '..') {
4815
+ throw new Error(`${field} cannot be . or ...`);
4816
+ }
4817
+ if (!/^[A-Za-z0-9._-]+$/.test(value)) {
4818
+ throw new Error(`${field} must contain only letters, digits, dot, underscore, or dash.`);
4819
+ }
4820
+ }
4821
+ function messageFromError(error) {
4822
+ return error instanceof Error ? error.message : String(error);
4823
+ }
4824
+ //# sourceMappingURL=index.js.map