sdd-agent-platform 0.2.0 → 0.3.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.
- package/dist/packages/cli/src/main.js +167 -9
- package/dist/packages/cli/src/main.js.map +1 -1
- package/dist/packages/core/src/ai-tools.d.ts +1 -1
- package/dist/packages/core/src/ai-tools.js +3 -2
- package/dist/packages/core/src/ai-tools.js.map +1 -1
- package/dist/packages/core/src/index.d.ts +237 -2
- package/dist/packages/core/src/index.js +1379 -61
- package/dist/packages/core/src/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -50,6 +50,29 @@ export const EVIDENCE_INGESTION_CONTRACT_VERSION = 'phase-6.0-evidence-ingestion
|
|
|
50
50
|
export const AGENT_EXECUTION_RECORD_CONTRACT_VERSION = 'phase-6.0-agent-execution-record-v1';
|
|
51
51
|
export const TEAM_SESSION_RECORD_CONTRACT_VERSION = 'phase-6.0-team-session-record-v1';
|
|
52
52
|
export const RESIDENT_WORKER_RUNTIME_CONTRACT_VERSION = 'phase-6.1-resident-worker-runtime-v1';
|
|
53
|
+
export const SDD_EVIDENCE_CONTRACT = 'sdd-evidence-v1';
|
|
54
|
+
export const SDD_EVIDENCE_VERSION = '1.0.0';
|
|
55
|
+
export const ACCEPTANCE_POLICY_RULESET_VERSION = 'acceptance-policy-v1';
|
|
56
|
+
export const INVOCATION_LEDGER_CONTRACT_VERSION = 'phase-6.9-invocation-ledger-v1';
|
|
57
|
+
export const ROUTE_CACHE_CONTRACT_VERSION = 'phase-6.9-route-cache-v1';
|
|
58
|
+
export const RUNTIME_PROFILE_CONTRACT_VERSION = 'phase-6.9-runtime-profile-v1';
|
|
59
|
+
export const CONTEXT_BUDGET_CONTRACT_VERSION = 'phase-6.10-context-budget-v1';
|
|
60
|
+
export const COMMAND_OUTPUT_SUMMARY_CONTRACT_VERSION = 'sdd-command-output-summary-v1';
|
|
61
|
+
export const EVIDENCE_SUMMARY_CONTRACT_VERSION = 'sdd-evidence-summary-v1';
|
|
62
|
+
export const CONTEXT_PACKAGE_CONTRACT_VERSION = 'sdd-context-package-v1';
|
|
63
|
+
export const LOG_WORKER_SUMMARY_CONTRACT_VERSION = 'sdd-log-worker-summary-v1';
|
|
64
|
+
const RUNTIME_STORE_SCHEMA_VERSION = 1;
|
|
65
|
+
const RUNTIME_STORE_CONTRACT_VERSION = 'phase-6.11-runtime-store-v1';
|
|
66
|
+
class RuntimeStoreError extends Error {
|
|
67
|
+
code;
|
|
68
|
+
constructor(code, message, options) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.code = code;
|
|
71
|
+
this.name = 'RuntimeStoreError';
|
|
72
|
+
this.cause = options?.cause;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
let runtimeStoreConstructorPromise = null;
|
|
53
76
|
const DEFAULT_RESIDENT_WORKER_LEASE_SECONDS = 900;
|
|
54
77
|
export function getWorktreesDir(projectRoot) {
|
|
55
78
|
return path.join(getSddDir(projectRoot), 'worktrees');
|
|
@@ -66,6 +89,9 @@ export function getRunsDir(projectRoot) {
|
|
|
66
89
|
export function getLocalRunIndexPath(projectRoot) {
|
|
67
90
|
return path.join(getSddDir(projectRoot), 'run-index.json');
|
|
68
91
|
}
|
|
92
|
+
export function getRuntimeStorePath(projectRoot) {
|
|
93
|
+
return path.join(getSddDir(projectRoot), 'runtime.sqlite');
|
|
94
|
+
}
|
|
69
95
|
export function getRunDir(projectRoot, runId) {
|
|
70
96
|
assertSafePathSegment(runId, 'runId');
|
|
71
97
|
return path.join(getRunsDir(projectRoot), runId);
|
|
@@ -82,6 +108,305 @@ export function getTeamSessionsDir(projectRoot, runId) {
|
|
|
82
108
|
export function getWorkerRuntimesDir(projectRoot, runId) {
|
|
83
109
|
return path.join(getRunDir(projectRoot, runId), 'worker-runtimes');
|
|
84
110
|
}
|
|
111
|
+
export function getInvocationLedgerPath(projectRoot, runId) {
|
|
112
|
+
return path.join(getRunDir(projectRoot, runId), 'invocations.jsonl');
|
|
113
|
+
}
|
|
114
|
+
export function getRouteCacheDir(projectRoot) {
|
|
115
|
+
return path.join(getSddDir(projectRoot), 'cache', 'routes');
|
|
116
|
+
}
|
|
117
|
+
function getRouteCachePath(projectRoot, key) {
|
|
118
|
+
assertSafePathSegment(key, 'routeCacheKey');
|
|
119
|
+
return path.join(getRouteCacheDir(projectRoot), `${key}.json`);
|
|
120
|
+
}
|
|
121
|
+
async function loadRuntimeStoreConstructor() {
|
|
122
|
+
runtimeStoreConstructorPromise ??= importNodeSqlite()
|
|
123
|
+
.then((sqlite) => sqlite.DatabaseSync)
|
|
124
|
+
.catch((error) => {
|
|
125
|
+
throw new RuntimeStoreError('STORE_UNAVAILABLE', `node:sqlite is unavailable: ${messageFromError(error)}`, { cause: error });
|
|
126
|
+
});
|
|
127
|
+
return runtimeStoreConstructorPromise;
|
|
128
|
+
}
|
|
129
|
+
async function importNodeSqlite() {
|
|
130
|
+
const emitWarning = process.emitWarning;
|
|
131
|
+
const forwardWarning = emitWarning;
|
|
132
|
+
process.emitWarning = ((warning, ...args) => {
|
|
133
|
+
const message = warning instanceof Error ? warning.message : warning;
|
|
134
|
+
if (typeof message === 'string' && message.includes('SQLite is an experimental feature')) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
return forwardWarning.call(process, warning, ...args);
|
|
138
|
+
});
|
|
139
|
+
try {
|
|
140
|
+
return await import('node:sqlite');
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
process.emitWarning = emitWarning;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function openRuntimeStore(projectRoot) {
|
|
147
|
+
await mkdir(getSddDir(projectRoot), { recursive: true });
|
|
148
|
+
const DatabaseSync = await loadRuntimeStoreConstructor();
|
|
149
|
+
const storePath = getRuntimeStorePath(projectRoot);
|
|
150
|
+
let db;
|
|
151
|
+
try {
|
|
152
|
+
db = new DatabaseSync(storePath);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
throw new RuntimeStoreError('STORE_UNAVAILABLE', `Cannot open runtime store ${storePath}: ${messageFromError(error)}`, { cause: error });
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
159
|
+
db.exec('PRAGMA busy_timeout = 5000');
|
|
160
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
161
|
+
initializeRuntimeStoreSchema(db);
|
|
162
|
+
return { path: storePath, db };
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
db.close();
|
|
166
|
+
throw new RuntimeStoreError('SCHEMA_MISMATCH', `Cannot initialize runtime store ${storePath}: ${messageFromError(error)}`, { cause: error });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async function withRuntimeStore(projectRoot, fn) {
|
|
170
|
+
const store = await openRuntimeStore(projectRoot);
|
|
171
|
+
try {
|
|
172
|
+
return await fn(store);
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
store.db.close();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function initializeRuntimeStoreSchema(db) {
|
|
179
|
+
const currentVersion = readRuntimeStoreSchemaVersion(db);
|
|
180
|
+
if (currentVersion > RUNTIME_STORE_SCHEMA_VERSION) {
|
|
181
|
+
throw new RuntimeStoreError('SCHEMA_MISMATCH', `Runtime store schema ${currentVersion} is newer than supported schema ${RUNTIME_STORE_SCHEMA_VERSION}.`);
|
|
182
|
+
}
|
|
183
|
+
db.exec(`
|
|
184
|
+
CREATE TABLE IF NOT EXISTS runtime_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
185
|
+
CREATE TABLE IF NOT EXISTS runs (run_id TEXT PRIMARY KEY, status TEXT NOT NULL, phase TEXT, current_task TEXT, partition TEXT, git_branch TEXT, task_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, state_json TEXT NOT NULL, state_hash TEXT NOT NULL);
|
|
186
|
+
CREATE TABLE IF NOT EXISTS events (event_id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, event_time TEXT NOT NULL, event_name TEXT NOT NULL, event_hash TEXT NOT NULL, event_json TEXT NOT NULL, source TEXT NOT NULL DEFAULT 'runtime', FOREIGN KEY(run_id) REFERENCES runs(run_id) ON DELETE CASCADE, UNIQUE(run_id, event_hash));
|
|
187
|
+
CREATE TABLE IF NOT EXISTS attempts (attempt_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, task_id TEXT, status TEXT, payload_json TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(run_id) REFERENCES runs(run_id) ON DELETE CASCADE);
|
|
188
|
+
CREATE TABLE IF NOT EXISTS activities (activity_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, task_id TEXT, branch TEXT, kind TEXT NOT NULL, ref TEXT NOT NULL, status TEXT, created_at TEXT NOT NULL, payload_json TEXT NOT NULL, FOREIGN KEY(run_id) REFERENCES runs(run_id) ON DELETE CASCADE);
|
|
189
|
+
CREATE TABLE IF NOT EXISTS artifacts (artifact_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, path TEXT NOT NULL, kind TEXT NOT NULL, task_id TEXT, agent TEXT, content_hash TEXT NOT NULL, bytes INTEGER NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, payload_json TEXT NOT NULL, FOREIGN KEY(run_id) REFERENCES runs(run_id) ON DELETE CASCADE, UNIQUE(run_id, path, content_hash));
|
|
190
|
+
CREATE TABLE IF NOT EXISTS artifact_ingestions (ingestion_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, delegation_id TEXT NOT NULL, task_id TEXT NOT NULL, agent TEXT NOT NULL, artifact_path TEXT NOT NULL, status TEXT NOT NULL, result_status TEXT, ingested_at TEXT NOT NULL, payload_json TEXT NOT NULL, FOREIGN KEY(run_id) REFERENCES runs(run_id) ON DELETE CASCADE);
|
|
191
|
+
CREATE TABLE IF NOT EXISTS policy_decisions (decision_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, task_id TEXT, acceptance_id TEXT, status TEXT NOT NULL, issue_codes TEXT NOT NULL, created_at TEXT NOT NULL, payload_json TEXT NOT NULL, FOREIGN KEY(run_id) REFERENCES runs(run_id) ON DELETE CASCADE);
|
|
192
|
+
CREATE TABLE IF NOT EXISTS evidence_claims (claim_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, partition TEXT, task_id TEXT NOT NULL, acceptance_id TEXT NOT NULL, coverage_status TEXT NOT NULL, source_artifact TEXT NOT NULL, is_derived INTEGER NOT NULL, created_at TEXT NOT NULL, payload_json TEXT NOT NULL, FOREIGN KEY(run_id) REFERENCES runs(run_id) ON DELETE CASCADE);
|
|
193
|
+
CREATE TABLE IF NOT EXISTS gaps (gap_id TEXT PRIMARY KEY, run_id TEXT, task_id TEXT, severity TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL);
|
|
194
|
+
CREATE TABLE IF NOT EXISTS recovery_actions (action_id TEXT PRIMARY KEY, run_id TEXT, task_id TEXT, status TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL);
|
|
195
|
+
CREATE TABLE IF NOT EXISTS source_snapshots (snapshot_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, partition TEXT, spec_hash TEXT, plan_hash TEXT, tasks_hash TEXT, created_at TEXT NOT NULL, payload_json TEXT NOT NULL, FOREIGN KEY(run_id) REFERENCES runs(run_id) ON DELETE CASCADE);
|
|
196
|
+
CREATE TABLE IF NOT EXISTS projections (projection_id TEXT PRIMARY KEY, projection_type TEXT NOT NULL, scope_key TEXT NOT NULL, generated_at TEXT NOT NULL, payload_json TEXT NOT NULL, UNIQUE(projection_type, scope_key));
|
|
197
|
+
CREATE TABLE IF NOT EXISTS legacy_imports (import_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, entity_type TEXT NOT NULL, content_hash TEXT NOT NULL, imported_at TEXT NOT NULL, status TEXT NOT NULL, issue TEXT, UNIQUE(run_id, entity_type));
|
|
198
|
+
CREATE INDEX IF NOT EXISTS idx_runs_partition_task_updated ON runs(partition, task_id, updated_at);
|
|
199
|
+
CREATE INDEX IF NOT EXISTS idx_events_run_time ON events(run_id, event_time, event_id);
|
|
200
|
+
CREATE INDEX IF NOT EXISTS idx_artifacts_run_path ON artifacts(run_id, path);
|
|
201
|
+
CREATE INDEX IF NOT EXISTS idx_evidence_claims_run_task ON evidence_claims(run_id, task_id, acceptance_id);
|
|
202
|
+
`);
|
|
203
|
+
db.exec(`PRAGMA user_version = ${RUNTIME_STORE_SCHEMA_VERSION}`);
|
|
204
|
+
const now = new Date().toISOString();
|
|
205
|
+
db.prepare('INSERT OR REPLACE INTO runtime_meta (key, value, updated_at) VALUES (?, ?, ?)').run('contract', RUNTIME_STORE_CONTRACT_VERSION, now);
|
|
206
|
+
db.prepare('INSERT OR REPLACE INTO runtime_meta (key, value, updated_at) VALUES (?, ?, ?)').run('schema_version', String(RUNTIME_STORE_SCHEMA_VERSION), now);
|
|
207
|
+
}
|
|
208
|
+
function readRuntimeStoreSchemaVersion(db) {
|
|
209
|
+
const row = db.prepare('PRAGMA user_version').get();
|
|
210
|
+
return typeof row?.user_version === 'number' ? row.user_version : 0;
|
|
211
|
+
}
|
|
212
|
+
async function upsertRuntimeRunState(projectRoot, state, serializedState) {
|
|
213
|
+
await withRuntimeStore(projectRoot, ({ db }) => {
|
|
214
|
+
db.prepare(`INSERT INTO runs (run_id, status, phase, current_task, partition, git_branch, task_id, created_at, updated_at, state_json, state_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(run_id) DO UPDATE SET status=excluded.status, phase=excluded.phase, current_task=excluded.current_task, partition=excluded.partition, git_branch=excluded.git_branch, task_id=excluded.task_id, updated_at=excluded.updated_at, state_json=excluded.state_json, state_hash=excluded.state_hash`)
|
|
215
|
+
.run(state.runId, state.status, state.phase, state.currentTask, state.partition, state.gitBranch, state.taskId, state.createdAt, state.updatedAt, serializedState, hashDocumentContent(serializedState));
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
async function readRuntimeRunState(projectRoot, runId) {
|
|
219
|
+
return withRuntimeStore(projectRoot, ({ db }) => {
|
|
220
|
+
const row = db.prepare('SELECT state_json FROM runs WHERE run_id = ?').get(runId);
|
|
221
|
+
return row?.state_json ? normalizeRunState(JSON.parse(row.state_json)) : null;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
async function importLegacyRunStateIfNeeded(projectRoot, runId, statePath) {
|
|
225
|
+
if (!await exists(statePath)) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const raw = await readFile(statePath, 'utf8');
|
|
230
|
+
const contentHash = hashDocumentContent(raw);
|
|
231
|
+
return await withRuntimeStore(projectRoot, ({ db }) => {
|
|
232
|
+
const legacy = db.prepare('SELECT content_hash FROM legacy_imports WHERE run_id = ? AND entity_type = ?').get(runId, 'state');
|
|
233
|
+
const row = db.prepare('SELECT state_json, state_hash FROM runs WHERE run_id = ?').get(runId);
|
|
234
|
+
if (legacy?.content_hash === contentHash && row?.state_json && row.state_hash === contentHash) {
|
|
235
|
+
return normalizeRunState(JSON.parse(row.state_json));
|
|
236
|
+
}
|
|
237
|
+
const state = normalizeRunState(JSON.parse(raw));
|
|
238
|
+
db.prepare(`INSERT INTO runs (run_id, status, phase, current_task, partition, git_branch, task_id, created_at, updated_at, state_json, state_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(run_id) DO UPDATE SET status=excluded.status, phase=excluded.phase, current_task=excluded.current_task, partition=excluded.partition, git_branch=excluded.git_branch, task_id=excluded.task_id, created_at=excluded.created_at, updated_at=excluded.updated_at, state_json=excluded.state_json, state_hash=excluded.state_hash`)
|
|
239
|
+
.run(state.runId, state.status, state.phase, state.currentTask, state.partition, state.gitBranch, state.taskId, state.createdAt, state.updatedAt, raw, contentHash);
|
|
240
|
+
for (const artifact of state.artifacts) {
|
|
241
|
+
const payload = JSON.stringify({ ...artifact, status: 'legacy_imported' });
|
|
242
|
+
db.prepare('INSERT OR REPLACE INTO artifacts (artifact_id, run_id, path, kind, task_id, agent, content_hash, bytes, status, created_at, payload_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
243
|
+
.run(runtimeScopedId(state.runId, artifact.path, hashDocumentContent(payload)), state.runId, artifact.path, artifact.kind, artifact.task, artifact.agent, hashDocumentContent(payload), Buffer.byteLength(payload, 'utf8'), 'legacy_imported', artifact.createdAt, payload);
|
|
244
|
+
}
|
|
245
|
+
for (const record of Object.values(state.artifactIngestions ?? {})) {
|
|
246
|
+
db.prepare('INSERT OR REPLACE INTO artifact_ingestions (ingestion_id, run_id, delegation_id, task_id, agent, artifact_path, status, result_status, ingested_at, payload_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
247
|
+
.run(runtimeScopedId(record.runId, record.delegationId, record.artifactPath), record.runId, record.delegationId, record.task, record.agent, record.artifactPath, record.status, record.resultStatus, record.ingestedAt, JSON.stringify(record));
|
|
248
|
+
}
|
|
249
|
+
db.prepare('INSERT OR REPLACE INTO legacy_imports (import_id, run_id, entity_type, content_hash, imported_at, status, issue) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
250
|
+
.run(legacyImportId(runId, 'state'), runId, 'state', contentHash, new Date().toISOString(), 'imported', null);
|
|
251
|
+
return state;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
await recordLegacyImportFailure(projectRoot, runId, 'state', error);
|
|
256
|
+
throw new RuntimeStoreError('LEGACY_IMPORT_FAILED', `Cannot import legacy state for ${runId}: ${messageFromError(error)}`, { cause: error });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async function recordRuntimeEvent(projectRoot, event, source = 'runtime') {
|
|
260
|
+
const eventJson = JSON.stringify(event);
|
|
261
|
+
await withRuntimeStore(projectRoot, ({ db }) => {
|
|
262
|
+
db.prepare('INSERT OR IGNORE INTO events (run_id, event_time, event_name, event_hash, event_json, source) VALUES (?, ?, ?, ?, ?, ?)')
|
|
263
|
+
.run(event.runId, event.time, event.event, hashDocumentContent(eventJson), eventJson, source);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
async function importLegacyRunEventsIfNeeded(projectRoot, runId, eventPath) {
|
|
267
|
+
if (!await exists(eventPath)) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
const raw = await readFile(eventPath, 'utf8');
|
|
272
|
+
const contentHash = hashDocumentContent(raw);
|
|
273
|
+
await withRuntimeStore(projectRoot, ({ db }) => {
|
|
274
|
+
const legacy = db.prepare('SELECT content_hash FROM legacy_imports WHERE run_id = ? AND entity_type = ?').get(runId, 'events');
|
|
275
|
+
if (legacy?.content_hash === contentHash) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
for (const line of raw.split(/\r?\n/).filter((item) => item.trim().length > 0)) {
|
|
279
|
+
const event = JSON.parse(line);
|
|
280
|
+
const eventJson = JSON.stringify(event);
|
|
281
|
+
db.prepare('INSERT OR IGNORE INTO events (run_id, event_time, event_name, event_hash, event_json, source) VALUES (?, ?, ?, ?, ?, ?)')
|
|
282
|
+
.run(event.runId, event.time, event.event, hashDocumentContent(eventJson), eventJson, 'legacy');
|
|
283
|
+
}
|
|
284
|
+
db.prepare('INSERT OR REPLACE INTO legacy_imports (import_id, run_id, entity_type, content_hash, imported_at, status, issue) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
285
|
+
.run(legacyImportId(runId, 'events'), runId, 'events', contentHash, new Date().toISOString(), 'imported', null);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
await recordLegacyImportFailure(projectRoot, runId, 'events', error);
|
|
290
|
+
throw new RuntimeStoreError('LEGACY_IMPORT_FAILED', `Cannot import legacy events for ${runId}: ${messageFromError(error)}`, { cause: error });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
async function readRuntimeRunEvents(projectRoot, runId) {
|
|
294
|
+
return withRuntimeStore(projectRoot, ({ db }) => {
|
|
295
|
+
const rows = db.prepare('SELECT event_json FROM events WHERE run_id = ? ORDER BY event_time ASC, event_id ASC').all(runId);
|
|
296
|
+
return rows.map((row) => JSON.parse(row.event_json));
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
async function recordRuntimeArtifact(projectRoot, input) {
|
|
300
|
+
const contentHash = hashDocumentContent(input.content);
|
|
301
|
+
const now = new Date().toISOString();
|
|
302
|
+
const payload = JSON.stringify({ path: input.path, status: input.status, taskId: input.taskId ?? null, agent: input.agent ?? null });
|
|
303
|
+
await withRuntimeStore(projectRoot, ({ db }) => {
|
|
304
|
+
db.prepare('INSERT OR REPLACE INTO artifacts (artifact_id, run_id, path, kind, task_id, agent, content_hash, bytes, status, created_at, payload_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
305
|
+
.run(runtimeScopedId(input.runId, input.path, contentHash), input.runId, input.path, artifactKind(input.path), input.taskId ?? null, input.agent ?? null, contentHash, Buffer.byteLength(input.content, 'utf8'), input.status, now, payload);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
async function recordRuntimeActivity(projectRoot, entry) {
|
|
309
|
+
await withRuntimeStore(projectRoot, ({ db }) => {
|
|
310
|
+
db.prepare('INSERT OR REPLACE INTO activities (activity_id, run_id, task_id, branch, kind, ref, status, created_at, payload_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
311
|
+
.run(entry.entryId, entry.runId, entry.taskId ?? null, entry.branch ?? null, entry.kind, entry.ref, entry.status ?? null, entry.timestamp, JSON.stringify(entry));
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
async function recordRuntimeArtifactIngestion(projectRoot, record) {
|
|
315
|
+
await withRuntimeStore(projectRoot, ({ db }) => {
|
|
316
|
+
db.prepare('INSERT OR REPLACE INTO artifact_ingestions (ingestion_id, run_id, delegation_id, task_id, agent, artifact_path, status, result_status, ingested_at, payload_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
317
|
+
.run(runtimeScopedId(record.runId, record.delegationId, record.artifactPath), record.runId, record.delegationId, record.task, record.agent, record.artifactPath, record.status, record.resultStatus, record.ingestedAt, JSON.stringify(record));
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
async function recordRuntimeEvidenceAdmission(projectRoot, state, ingestion, trust) {
|
|
321
|
+
const now = new Date().toISOString();
|
|
322
|
+
await withRuntimeStore(projectRoot, ({ db }) => {
|
|
323
|
+
for (const claim of trust?.claims ?? []) {
|
|
324
|
+
db.prepare('INSERT OR REPLACE INTO evidence_claims (claim_id, run_id, partition, task_id, acceptance_id, coverage_status, source_artifact, is_derived, created_at, payload_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
325
|
+
.run(runtimeScopedId(ingestion.runId, claim.task, claim.acceptance, claim.sourceArtifact), ingestion.runId, state.partition, claim.task, claim.acceptance, claim.status, claim.sourceArtifact, isDerivedEvidenceRef(claim.sourceArtifact) ? 1 : 0, now, JSON.stringify(claim));
|
|
326
|
+
}
|
|
327
|
+
const issueCodes = uniqueEvidenceIssueCodes(trust?.issues ?? ingestion.issues);
|
|
328
|
+
if ((trust?.issues.length ?? ingestion.issues.length) > 0 || ingestion.status === 'rejected') {
|
|
329
|
+
db.prepare('INSERT OR REPLACE INTO policy_decisions (decision_id, run_id, task_id, acceptance_id, status, issue_codes, created_at, payload_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
|
330
|
+
.run(runtimeScopedId(ingestion.runId, ingestion.delegationId, ingestion.artifactPath, 'admission'), ingestion.runId, ingestion.task, null, ingestion.status, issueCodes.join(','), now, JSON.stringify({ ingestion, trust: trust ?? null }));
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
async function readRuntimeEvidenceClaims(projectRoot, runId, taskId) {
|
|
335
|
+
return withRuntimeStore(projectRoot, ({ db }) => {
|
|
336
|
+
const rows = taskId
|
|
337
|
+
? db.prepare('SELECT c.payload_json FROM evidence_claims c JOIN runs r ON r.run_id = c.run_id WHERE c.run_id = ? AND c.task_id = ? AND (c.partition IS NULL OR r.partition IS NULL OR c.partition = r.partition) ORDER BY c.created_at ASC, c.claim_id ASC').all(runId, taskId)
|
|
338
|
+
: db.prepare('SELECT c.payload_json FROM evidence_claims c JOIN runs r ON r.run_id = c.run_id WHERE c.run_id = ? AND (c.partition IS NULL OR r.partition IS NULL OR c.partition = r.partition) ORDER BY c.created_at ASC, c.claim_id ASC').all(runId);
|
|
339
|
+
return rows.map((row) => JSON.parse(row.payload_json));
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
async function hasRuntimeEvidenceScopeViolation(projectRoot, runId, taskId) {
|
|
343
|
+
return withRuntimeStore(projectRoot, ({ db }) => {
|
|
344
|
+
const row = taskId
|
|
345
|
+
? db.prepare('SELECT COUNT(*) AS count FROM evidence_claims c JOIN runs r ON r.run_id = c.run_id WHERE c.run_id = ? AND c.task_id = ? AND c.partition IS NOT NULL AND r.partition IS NOT NULL AND c.partition <> r.partition').get(runId, taskId)
|
|
346
|
+
: db.prepare('SELECT COUNT(*) AS count FROM evidence_claims c JOIN runs r ON r.run_id = c.run_id WHERE c.run_id = ? AND c.partition IS NOT NULL AND r.partition IS NOT NULL AND c.partition <> r.partition').get(runId);
|
|
347
|
+
return (row?.count ?? 0) > 0;
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
async function recordRuntimeProjection(projectRoot, projectionType, scopeKey, payload) {
|
|
351
|
+
const now = new Date().toISOString();
|
|
352
|
+
await withRuntimeStore(projectRoot, ({ db }) => {
|
|
353
|
+
db.prepare('INSERT OR REPLACE INTO projections (projection_id, projection_type, scope_key, generated_at, payload_json) VALUES (?, ?, ?, ?, ?)')
|
|
354
|
+
.run(runtimeScopedId(projectionType, scopeKey), projectionType, scopeKey, now, JSON.stringify(payload));
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
async function recordLegacyImportFailure(projectRoot, runId, entityType, error) {
|
|
358
|
+
try {
|
|
359
|
+
await withRuntimeStore(projectRoot, ({ db }) => {
|
|
360
|
+
db.prepare('INSERT OR REPLACE INTO legacy_imports (import_id, run_id, entity_type, content_hash, imported_at, status, issue) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
361
|
+
.run(legacyImportId(runId, entityType), runId, entityType, 'unavailable', new Date().toISOString(), 'failed', messageFromError(error));
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function runtimeScopedId(...parts) {
|
|
369
|
+
return createHash('sha256').update(parts.join('\0'), 'utf8').digest('hex').slice(0, 32);
|
|
370
|
+
}
|
|
371
|
+
function legacyImportId(runId, entityType) {
|
|
372
|
+
return runtimeScopedId(runId, entityType);
|
|
373
|
+
}
|
|
374
|
+
async function inspectRuntimeStoreEvidence(projectRoot) {
|
|
375
|
+
try {
|
|
376
|
+
return await withRuntimeStore(projectRoot, ({ path: storePath, db }) => {
|
|
377
|
+
const versionRow = db.prepare('PRAGMA user_version').get();
|
|
378
|
+
const integrityRow = db.prepare('PRAGMA integrity_check').get();
|
|
379
|
+
const schemaVersion = versionRow?.user_version ?? 0;
|
|
380
|
+
if (schemaVersion !== RUNTIME_STORE_SCHEMA_VERSION) {
|
|
381
|
+
return [{ level: 'FAIL', check: 'runtime_store', message: `Runtime store schema version ${schemaVersion} does not match expected ${RUNTIME_STORE_SCHEMA_VERSION}.`, action: 'Run a compatible sdd version or rebuild the runtime store from legacy .sdd/runs.' }];
|
|
382
|
+
}
|
|
383
|
+
if (integrityRow?.integrity_check !== 'ok') {
|
|
384
|
+
return [{ level: 'FAIL', check: 'runtime_store', message: `Runtime store integrity check failed: ${integrityRow?.integrity_check ?? 'unknown'}.`, action: 'Rebuild runtime projections from legacy .sdd/runs after preserving the database for debugging.' }];
|
|
385
|
+
}
|
|
386
|
+
const checks = [{ level: 'PASS', check: 'runtime_store', message: `${RUNTIME_STORE_CONTRACT_VERSION} is available at ${storePath} with schema ${schemaVersion}.` }];
|
|
387
|
+
const scopeLeak = db.prepare('SELECT COUNT(*) AS count FROM evidence_claims c JOIN runs r ON r.run_id = c.run_id WHERE c.partition IS NOT NULL AND r.partition IS NOT NULL AND c.partition <> r.partition').get();
|
|
388
|
+
checks.push(((scopeLeak?.count ?? 0) > 0)
|
|
389
|
+
? { level: 'FAIL', check: 'runtime_partition_scope', message: `Runtime store has ${scopeLeak?.count ?? 0} cross-partition evidence claim(s).`, action: 'Reingest validator artifacts for the correct run partition; mismatched claims cannot satisfy PASS evidence.' }
|
|
390
|
+
: { level: 'PASS', check: 'runtime_partition_scope', message: 'Runtime evidence claims match their run partition scope.' });
|
|
391
|
+
const freshness = db.prepare('SELECT (SELECT MAX(updated_at) FROM runs) AS latest_run, (SELECT MAX(generated_at) FROM projections) AS latest_projection').get();
|
|
392
|
+
checks.push(freshness?.latest_run && freshness?.latest_projection && freshness.latest_projection < freshness.latest_run
|
|
393
|
+
? { level: 'PASS', check: 'runtime_projection_freshness', message: `Latest runtime projection ${freshness.latest_projection} is older than latest run update ${freshness.latest_run}; views can be refreshed on demand.` }
|
|
394
|
+
: { level: 'PASS', check: 'runtime_projection_freshness', message: freshness?.latest_projection ? 'Runtime projections are current with known run updates.' : 'No runtime projections have been materialized yet.' });
|
|
395
|
+
const legacy = db.prepare("SELECT status, COUNT(*) AS count FROM legacy_imports GROUP BY status").all();
|
|
396
|
+
const failedLegacy = legacy.find((row) => row.status === 'failed')?.count ?? 0;
|
|
397
|
+
const importedLegacy = legacy.find((row) => row.status === 'imported')?.count ?? 0;
|
|
398
|
+
checks.push(failedLegacy > 0
|
|
399
|
+
? { level: 'FAIL', check: 'runtime_legacy_import', message: `${failedLegacy} legacy import record(s) failed.`, action: 'Inspect legacy .sdd/runs files and repair malformed state/events/invocation evidence before verify or sync-back.' }
|
|
400
|
+
: importedLegacy > 0
|
|
401
|
+
? { level: 'PASS', check: 'runtime_legacy_import', message: `${importedLegacy} legacy runtime record(s) imported non-destructively; trust debt was preserved, not upgraded.` }
|
|
402
|
+
: { level: 'PASS', check: 'runtime_legacy_import', message: 'No legacy runtime import debt detected.' });
|
|
403
|
+
return checks;
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
return [{ level: 'FAIL', check: 'runtime_store', message: `Runtime store unavailable: ${messageFromError(error)}`, action: 'Use a Node runtime with node:sqlite support and writable .sdd/runtime.sqlite.' }];
|
|
408
|
+
}
|
|
409
|
+
}
|
|
85
410
|
export function getWorkerRuntimeRecordPath(projectRoot, runId, runtimeId) {
|
|
86
411
|
assertSafePathSegment(runtimeId, 'runtimeId');
|
|
87
412
|
return path.join(getWorkerRuntimesDir(projectRoot, runId), `${runtimeId}.json`);
|
|
@@ -117,11 +442,116 @@ export async function writeArtifact(projectRoot, runId, artifactRootRelativePath
|
|
|
117
442
|
const absolutePath = getArtifactPath(projectRoot, runId, normalized);
|
|
118
443
|
await mkdir(path.dirname(absolutePath), { recursive: true });
|
|
119
444
|
await writeFile(absolutePath, content, 'utf8');
|
|
120
|
-
|
|
445
|
+
const runRelativePath = getRunRelativeArtifactPath(normalized);
|
|
446
|
+
await appendArtifactHashLedgerEntry(projectRoot, {
|
|
447
|
+
runId,
|
|
448
|
+
artifactPath: runRelativePath,
|
|
449
|
+
content,
|
|
450
|
+
status: 'written'
|
|
451
|
+
});
|
|
452
|
+
await recordRuntimeArtifact(projectRoot, { runId, path: runRelativePath, content, status: 'written' });
|
|
453
|
+
return { absolutePath, runRelativePath };
|
|
121
454
|
}
|
|
122
455
|
export async function readArtifact(projectRoot, runId, artifactRootRelativePath) {
|
|
123
456
|
return readFile(getArtifactPath(projectRoot, runId, normalizeArtifactRootRelativePath(artifactRootRelativePath)), 'utf8');
|
|
124
457
|
}
|
|
458
|
+
export async function appendInvocationLedgerEntry(projectRoot, input) {
|
|
459
|
+
const timestamp = new Date().toISOString();
|
|
460
|
+
const entry = {
|
|
461
|
+
contract: 'sdd-invocation-ledger-v1',
|
|
462
|
+
version: INVOCATION_LEDGER_CONTRACT_VERSION,
|
|
463
|
+
entryId: createHash('sha256').update(`${input.runId}:${input.kind}:${input.ref}:${timestamp}`).digest('hex').slice(0, 16),
|
|
464
|
+
timestamp,
|
|
465
|
+
...input
|
|
466
|
+
};
|
|
467
|
+
await appendFile(getInvocationLedgerPath(projectRoot, input.runId), `${JSON.stringify(entry)}\n`, 'utf8');
|
|
468
|
+
await recordRuntimeActivity(projectRoot, entry);
|
|
469
|
+
return entry;
|
|
470
|
+
}
|
|
471
|
+
async function importLegacyInvocationLedgerIfNeeded(projectRoot, runId, ledgerPath) {
|
|
472
|
+
if (!await exists(ledgerPath)) {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
const raw = await readFile(ledgerPath, 'utf8');
|
|
477
|
+
const contentHash = hashDocumentContent(raw);
|
|
478
|
+
await withRuntimeStore(projectRoot, ({ db }) => {
|
|
479
|
+
const legacy = db.prepare('SELECT content_hash FROM legacy_imports WHERE run_id = ? AND entity_type = ?').get(runId, 'invocations');
|
|
480
|
+
if (legacy?.content_hash === contentHash) {
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
for (const line of raw.split(/\r?\n/).filter(Boolean)) {
|
|
484
|
+
const entry = JSON.parse(line);
|
|
485
|
+
db.prepare('INSERT OR REPLACE INTO activities (activity_id, run_id, task_id, branch, kind, ref, status, created_at, payload_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
486
|
+
.run(entry.entryId, entry.runId, entry.taskId ?? null, entry.branch ?? null, entry.kind, entry.ref, entry.status ?? null, entry.timestamp, JSON.stringify(entry));
|
|
487
|
+
}
|
|
488
|
+
db.prepare('INSERT OR REPLACE INTO legacy_imports (import_id, run_id, entity_type, content_hash, imported_at, status, issue) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
489
|
+
.run(legacyImportId(runId, 'invocations'), runId, 'invocations', contentHash, new Date().toISOString(), 'imported', null);
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
await recordLegacyImportFailure(projectRoot, runId, 'invocations', error);
|
|
494
|
+
throw new RuntimeStoreError('LEGACY_IMPORT_FAILED', `Cannot import legacy invocation ledger for ${runId}: ${messageFromError(error)}`, { cause: error });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
export async function listInvocationLedgerEntries(projectRoot, runId) {
|
|
498
|
+
const ledgerPath = getInvocationLedgerPath(projectRoot, runId);
|
|
499
|
+
if (!await exists(ledgerPath)) {
|
|
500
|
+
return [];
|
|
501
|
+
}
|
|
502
|
+
await importLegacyInvocationLedgerIfNeeded(projectRoot, runId, ledgerPath);
|
|
503
|
+
const raw = await readFile(ledgerPath, 'utf8');
|
|
504
|
+
return raw.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line));
|
|
505
|
+
}
|
|
506
|
+
async function appendArtifactHashLedgerEntry(projectRoot, input) {
|
|
507
|
+
return appendUniqueInvocationLedgerEntry(projectRoot, {
|
|
508
|
+
runId: input.runId,
|
|
509
|
+
taskId: input.taskId ?? null,
|
|
510
|
+
branch: input.branch ?? null,
|
|
511
|
+
kind: 'artifact_hash',
|
|
512
|
+
ref: input.artifactPath,
|
|
513
|
+
status: input.status ?? 'observed',
|
|
514
|
+
artifactPath: input.artifactPath,
|
|
515
|
+
outputHash: hashDocumentContent(input.content),
|
|
516
|
+
materialRefs: [],
|
|
517
|
+
metadata: { bytes: Buffer.byteLength(input.content, 'utf8') }
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
async function appendUniqueInvocationLedgerEntry(projectRoot, input) {
|
|
521
|
+
const existing = await findMatchingInvocationLedgerEntry(projectRoot, input);
|
|
522
|
+
if (existing) {
|
|
523
|
+
return existing;
|
|
524
|
+
}
|
|
525
|
+
return appendInvocationLedgerEntry(projectRoot, input);
|
|
526
|
+
}
|
|
527
|
+
async function findMatchingInvocationLedgerEntry(projectRoot, input) {
|
|
528
|
+
const ledgerPath = getInvocationLedgerPath(projectRoot, input.runId);
|
|
529
|
+
if (!await exists(ledgerPath)) {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
const raw = await readFile(ledgerPath, 'utf8');
|
|
533
|
+
for (const line of raw.split(/\r?\n/).filter(Boolean)) {
|
|
534
|
+
const entry = JSON.parse(line);
|
|
535
|
+
if (entry.kind === input.kind && entry.ref === input.ref && entry.status === input.status && entry.outputHash === input.outputHash) {
|
|
536
|
+
return entry;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
async function appendDeclaredCommandLedgerEntries(projectRoot, input) {
|
|
542
|
+
for (const command of input.commands) {
|
|
543
|
+
await appendUniqueInvocationLedgerEntry(projectRoot, {
|
|
544
|
+
runId: input.runId,
|
|
545
|
+
taskId: input.taskId,
|
|
546
|
+
branch: input.branch,
|
|
547
|
+
kind: 'command',
|
|
548
|
+
ref: command,
|
|
549
|
+
status: 'declared',
|
|
550
|
+
materialRefs: [],
|
|
551
|
+
metadata: { source: 'task.validation' }
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
125
555
|
export async function writeAgentExecutionRecord(projectRoot, record) {
|
|
126
556
|
await mkdir(getAgentExecutionsDir(projectRoot, record.runId), { recursive: true });
|
|
127
557
|
await writeFile(getAgentExecutionRecordPath(projectRoot, record.runId, record.executionId), `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
|
@@ -744,8 +1174,15 @@ export async function createRun(projectRoot, options = {}) {
|
|
|
744
1174
|
}
|
|
745
1175
|
export async function readRunState(projectRoot, runId) {
|
|
746
1176
|
const statePath = path.join(getRunDir(projectRoot, runId), 'state.json');
|
|
747
|
-
const
|
|
748
|
-
|
|
1177
|
+
const legacyState = await importLegacyRunStateIfNeeded(projectRoot, runId, statePath);
|
|
1178
|
+
if (legacyState) {
|
|
1179
|
+
return legacyState;
|
|
1180
|
+
}
|
|
1181
|
+
const storedState = await readRuntimeRunState(projectRoot, runId);
|
|
1182
|
+
if (storedState) {
|
|
1183
|
+
return storedState;
|
|
1184
|
+
}
|
|
1185
|
+
throw new Error(`Run state not found for ${runId}.`);
|
|
749
1186
|
}
|
|
750
1187
|
export async function writeRunState(projectRoot, state) {
|
|
751
1188
|
const nextState = normalizeRunState({
|
|
@@ -753,7 +1190,9 @@ export async function writeRunState(projectRoot, state) {
|
|
|
753
1190
|
updatedAt: new Date().toISOString()
|
|
754
1191
|
});
|
|
755
1192
|
const statePath = path.join(getRunDir(projectRoot, state.runId), 'state.json');
|
|
756
|
-
|
|
1193
|
+
const serializedState = `${JSON.stringify(nextState, null, 2)}\n`;
|
|
1194
|
+
await writeFile(statePath, serializedState, 'utf8');
|
|
1195
|
+
await upsertRuntimeRunState(projectRoot, nextState, serializedState);
|
|
757
1196
|
}
|
|
758
1197
|
function normalizeRunState(state) {
|
|
759
1198
|
return {
|
|
@@ -799,6 +1238,7 @@ export async function appendEvent(projectRoot, runId, event) {
|
|
|
799
1238
|
};
|
|
800
1239
|
const eventPath = path.join(getRunDir(projectRoot, runId), 'events.jsonl');
|
|
801
1240
|
await appendFile(eventPath, `${JSON.stringify(nextEvent)}\n`, 'utf8');
|
|
1241
|
+
await recordRuntimeEvent(projectRoot, nextEvent);
|
|
802
1242
|
return nextEvent;
|
|
803
1243
|
}
|
|
804
1244
|
export async function archiveRun(projectRoot, runId, options = {}) {
|
|
@@ -854,6 +1294,7 @@ export async function listRuns(projectRoot) {
|
|
|
854
1294
|
export async function rebuildLocalRunIndex(projectRoot) {
|
|
855
1295
|
const index = await buildLocalRunIndexSnapshot(projectRoot);
|
|
856
1296
|
await writeFile(getLocalRunIndexPath(projectRoot), `${JSON.stringify(index, null, 2)}\n`, 'utf8');
|
|
1297
|
+
await recordRuntimeProjection(projectRoot, 'local_run_index', 'all', index);
|
|
857
1298
|
return index;
|
|
858
1299
|
}
|
|
859
1300
|
export async function readLocalRunIndex(projectRoot) {
|
|
@@ -868,7 +1309,7 @@ export async function queryLocalRunIndex(projectRoot, query = {}) {
|
|
|
868
1309
|
.filter((run) => !query.taskId || run.taskIds.includes(query.taskId))
|
|
869
1310
|
.filter((run) => !query.artifact || index.artifacts.some((artifact) => artifact.runId === run.runId && artifact.path === query.artifact))
|
|
870
1311
|
.map((run) => run.runId));
|
|
871
|
-
|
|
1312
|
+
const result = {
|
|
872
1313
|
...index,
|
|
873
1314
|
runs: index.runs.filter((run) => runIds.has(run.runId)),
|
|
874
1315
|
tasks: index.tasks.filter((task) => runIds.has(task.runId) && (!query.taskId || task.taskId === query.taskId) && (!query.partition || task.partition === query.partition)),
|
|
@@ -878,6 +1319,8 @@ export async function queryLocalRunIndex(projectRoot, query = {}) {
|
|
|
878
1319
|
latestByPartitionTask: index.latestByPartitionTask.filter((entry) => runIds.has(entry.runId) && (!query.partition || entry.partition === query.partition) && (!query.taskId || entry.taskId === query.taskId)),
|
|
879
1320
|
activeByAffectedFile: index.activeByAffectedFile.filter((entry) => runIds.has(entry.runId) && (!query.partition || entry.partition === query.partition) && (!query.taskId || entry.taskId === query.taskId))
|
|
880
1321
|
};
|
|
1322
|
+
await recordRuntimeProjection(projectRoot, 'local_run_index_query', hashDocumentContent(JSON.stringify(query)), result);
|
|
1323
|
+
return result;
|
|
881
1324
|
}
|
|
882
1325
|
export async function inspectLocalRunIndex(projectRoot) {
|
|
883
1326
|
const indexPath = getLocalRunIndexPath(projectRoot);
|
|
@@ -1050,11 +1493,12 @@ function isActiveRunForAffectedFile(state) {
|
|
|
1050
1493
|
}
|
|
1051
1494
|
export async function inspectRun(projectRoot, runId) {
|
|
1052
1495
|
const state = await readRunState(projectRoot, runId);
|
|
1053
|
-
const [events, agentExecutions, teamSessions, workerRuntimes] = await Promise.all([
|
|
1496
|
+
const [events, agentExecutions, teamSessions, workerRuntimes, invocationLedger] = await Promise.all([
|
|
1054
1497
|
readRunEvents(projectRoot, runId),
|
|
1055
1498
|
listAgentExecutionRecords(projectRoot, runId),
|
|
1056
1499
|
listTeamSessionRecords(projectRoot, runId),
|
|
1057
|
-
listResidentWorkerRuntimeRecords(projectRoot, runId)
|
|
1500
|
+
listResidentWorkerRuntimeRecords(projectRoot, runId),
|
|
1501
|
+
listInvocationLedgerEntries(projectRoot, runId)
|
|
1058
1502
|
]);
|
|
1059
1503
|
return {
|
|
1060
1504
|
summary: summarizeRunState(state),
|
|
@@ -1067,14 +1511,15 @@ export async function inspectRun(projectRoot, runId) {
|
|
|
1067
1511
|
worktrees: Object.values(state.worktrees ?? {}),
|
|
1068
1512
|
validation: state.validation,
|
|
1069
1513
|
syncBack: state.syncBack,
|
|
1070
|
-
taskRunEvidence: buildTaskRunEvidence(state, events, agentExecutions, teamSessions, workerRuntimes),
|
|
1514
|
+
taskRunEvidence: buildTaskRunEvidence(state, events, agentExecutions, teamSessions, workerRuntimes, invocationLedger),
|
|
1071
1515
|
tasks: state.tasks,
|
|
1072
1516
|
agentExecutions,
|
|
1073
1517
|
teamSessions,
|
|
1074
|
-
workerRuntimes
|
|
1518
|
+
workerRuntimes,
|
|
1519
|
+
invocationLedger
|
|
1075
1520
|
};
|
|
1076
1521
|
}
|
|
1077
|
-
function buildTaskRunEvidence(state, events, agentExecutions = [], teamSessions = [], workerRuntimes = []) {
|
|
1522
|
+
function buildTaskRunEvidence(state, events, agentExecutions = [], teamSessions = [], workerRuntimes = [], invocationLedger = []) {
|
|
1078
1523
|
return {
|
|
1079
1524
|
version: TASK_RUN_EVIDENCE_CONTRACT_VERSION,
|
|
1080
1525
|
runId: state.runId,
|
|
@@ -1094,8 +1539,250 @@ function buildTaskRunEvidence(state, events, agentExecutions = [], teamSessions
|
|
|
1094
1539
|
syncBackProposal: state.syncBack.proposalPath,
|
|
1095
1540
|
agentExecutions,
|
|
1096
1541
|
teamSessions,
|
|
1097
|
-
workerRuntimes
|
|
1542
|
+
workerRuntimes,
|
|
1543
|
+
invocationLedger
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
export function parseContextProfile(value) {
|
|
1547
|
+
if (!value || value === 'brief') {
|
|
1548
|
+
return 'brief';
|
|
1549
|
+
}
|
|
1550
|
+
if (value === 'normal' || value === 'forensic') {
|
|
1551
|
+
return value;
|
|
1552
|
+
}
|
|
1553
|
+
throw new Error(`Unsupported context profile: ${value}`);
|
|
1554
|
+
}
|
|
1555
|
+
export function contextBudgetForProfile(profile) {
|
|
1556
|
+
if (profile === 'forensic') {
|
|
1557
|
+
return { contract: CONTEXT_BUDGET_CONTRACT_VERSION, profile, maxBytes: 64 * 1024, preserve: ['blockers', 'failures', 'source_refs', 'complete_evidence'] };
|
|
1558
|
+
}
|
|
1559
|
+
if (profile === 'normal') {
|
|
1560
|
+
return { contract: CONTEXT_BUDGET_CONTRACT_VERSION, profile, maxBytes: 12 * 1024, preserve: ['blockers', 'failures', 'source_refs', 'next_action'] };
|
|
1561
|
+
}
|
|
1562
|
+
return { contract: CONTEXT_BUDGET_CONTRACT_VERSION, profile, maxBytes: 2 * 1024, preserve: ['blockers', 'current_task', 'next_action', 'source_refs'] };
|
|
1563
|
+
}
|
|
1564
|
+
export function summarizeCommandOutput(rawOutput, source, profile = 'brief') {
|
|
1565
|
+
const lines = rawOutput.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
1566
|
+
const failureLines = lines.filter((line) => /\b(fail(?:ed|ure)?|error|blocked|exception|traceback|timed out|timeout)\b/i.test(line));
|
|
1567
|
+
const successLines = lines.filter((line) => /\b(pass(?:ed)?|success|completed|ok)\b/i.test(line));
|
|
1568
|
+
let maxHighlights = 5;
|
|
1569
|
+
if (profile === 'normal') {
|
|
1570
|
+
maxHighlights = 12;
|
|
1571
|
+
}
|
|
1572
|
+
else if (profile === 'forensic') {
|
|
1573
|
+
maxHighlights = lines.length;
|
|
1574
|
+
}
|
|
1575
|
+
const selected = [...failureLines, ...successLines, ...lines].filter((line, index, all) => all.indexOf(line) === index).slice(0, maxHighlights);
|
|
1576
|
+
let status = 'UNKNOWN';
|
|
1577
|
+
if (failureLines.some((line) => /\bblocked\b/i.test(line))) {
|
|
1578
|
+
status = 'BLOCKED';
|
|
1579
|
+
}
|
|
1580
|
+
else if (failureLines.length > 0) {
|
|
1581
|
+
status = 'FAIL';
|
|
1582
|
+
}
|
|
1583
|
+
else if (successLines.length > 0) {
|
|
1584
|
+
status = 'PASS';
|
|
1585
|
+
}
|
|
1586
|
+
return {
|
|
1587
|
+
contract: COMMAND_OUTPUT_SUMMARY_CONTRACT_VERSION,
|
|
1588
|
+
authoritative: false,
|
|
1589
|
+
usableForPass: false,
|
|
1590
|
+
source,
|
|
1591
|
+
status,
|
|
1592
|
+
highlights: selected,
|
|
1593
|
+
omittedLines: Math.max(0, lines.length - selected.length)
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
export async function buildEvidenceSummaryProjection(projectRoot, options) {
|
|
1597
|
+
const state = await readRunState(projectRoot, options.runId);
|
|
1598
|
+
const [events, invocationLedger] = await Promise.all([
|
|
1599
|
+
readRunEvents(projectRoot, options.runId),
|
|
1600
|
+
listInvocationLedgerEntries(projectRoot, options.runId)
|
|
1601
|
+
]);
|
|
1602
|
+
const taskId = options.taskId ?? state.currentTask ?? state.taskId;
|
|
1603
|
+
const admittedClaims = await readRuntimeEvidenceClaims(projectRoot, state.runId, taskId ?? null);
|
|
1604
|
+
const artifactIngestions = Object.values(state.artifactIngestions ?? {}).filter((record) => !taskId || record.task === taskId);
|
|
1605
|
+
const artifacts = state.artifacts.filter((artifact) => !taskId || artifact.task === taskId);
|
|
1606
|
+
const ledgerArtifactRefs = invocationLedger
|
|
1607
|
+
.filter((entry) => entry.kind === 'artifact_hash' && entry.artifactPath)
|
|
1608
|
+
.map((entry) => ({ path: entry.artifactPath, kind: 'artifact' }));
|
|
1609
|
+
const artifactSourceRefs = [
|
|
1610
|
+
...artifacts.map((artifact) => ({ path: `.sdd/runs/${state.runId}/${artifact.path}`, kind: 'artifact' })),
|
|
1611
|
+
...ledgerArtifactRefs.map((artifact) => ({ path: `.sdd/runs/${state.runId}/${artifact.path}`, kind: artifact.kind }))
|
|
1612
|
+
];
|
|
1613
|
+
const sources = await uniqueContextSourceRefs([
|
|
1614
|
+
await contextSourceRefForProjectPath(projectRoot, `.sdd/runs/${state.runId}/state.json`, 'run_state'),
|
|
1615
|
+
await contextSourceRefForProjectPath(projectRoot, `.sdd/runs/${state.runId}/events.jsonl`, 'command_output'),
|
|
1616
|
+
await contextSourceRefForProjectPath(projectRoot, `.sdd/runs/${state.runId}/invocations.jsonl`, 'ledger'),
|
|
1617
|
+
...(await Promise.all(artifactSourceRefs.map((artifact) => contextSourceRefForProjectPath(projectRoot, artifact.path, artifact.kind))))
|
|
1618
|
+
]);
|
|
1619
|
+
const issueCodes = uniqueEvidenceIssueCodes(artifactIngestions.flatMap((record) => record.issues));
|
|
1620
|
+
const passCount = Math.max(admittedClaims.filter((claim) => claim.status === 'PASS').length, artifactIngestions.filter((record) => record.status === 'accepted' && (record.resultStatus === 'PASS' || record.resultStatus === 'PASS_WITH_GAPS')).length);
|
|
1621
|
+
const failCount = Math.max(admittedClaims.filter((claim) => claim.status === 'FAIL').length, artifactIngestions.filter((record) => record.status === 'rejected' || record.resultStatus === 'FAIL').length);
|
|
1622
|
+
const blockedCount = Math.max(admittedClaims.filter((claim) => claim.status === 'BLOCKED').length, artifactIngestions.filter((record) => record.resultStatus === 'BLOCKED' || record.resultStatus === 'TIMED_OUT' || record.resultStatus === 'CANCELLED' || record.gaps.length > 0).length);
|
|
1623
|
+
const highlights = [
|
|
1624
|
+
`run=${state.runId} status=${state.status} phase=${state.phase ?? 'none'}`,
|
|
1625
|
+
`task=${taskId ?? 'none'} validation=${state.validation.status} sync_back=${state.syncBack.status}`,
|
|
1626
|
+
`artifacts=${artifacts.length} ingestions=${artifactIngestions.length} admitted_claims=${admittedClaims.length} events=${events.length} ledger=${invocationLedger.length}`,
|
|
1627
|
+
`pass=${passCount} blocked=${blockedCount} fail=${failCount}`
|
|
1628
|
+
];
|
|
1629
|
+
if (issueCodes.length > 0) {
|
|
1630
|
+
highlights.push(`issues=${issueCodes.join(',')}`);
|
|
1631
|
+
}
|
|
1632
|
+
const projection = {
|
|
1633
|
+
contract: EVIDENCE_SUMMARY_CONTRACT_VERSION,
|
|
1634
|
+
authoritative: false,
|
|
1635
|
+
usableForPass: false,
|
|
1636
|
+
runId: state.runId,
|
|
1637
|
+
taskId: taskId ?? null,
|
|
1638
|
+
sources,
|
|
1639
|
+
passCount,
|
|
1640
|
+
blockedCount,
|
|
1641
|
+
failCount,
|
|
1642
|
+
issueCodes,
|
|
1643
|
+
policyRefs: [
|
|
1644
|
+
`${ACCEPTANCE_POLICY_RULESET_VERSION}:require-source-evidence`,
|
|
1645
|
+
`${ACCEPTANCE_POLICY_RULESET_VERSION}:require-provenance`,
|
|
1646
|
+
`${ACCEPTANCE_POLICY_RULESET_VERSION}:reject-derived-source-evidence`
|
|
1647
|
+
],
|
|
1648
|
+
highlights
|
|
1649
|
+
};
|
|
1650
|
+
await recordRuntimeProjection(projectRoot, 'evidence_summary', `${state.runId}:${taskId ?? 'all'}`, projection);
|
|
1651
|
+
return projection;
|
|
1652
|
+
}
|
|
1653
|
+
export async function buildContextBuildPackage(projectRoot, options) {
|
|
1654
|
+
const profile = options.profile ?? 'brief';
|
|
1655
|
+
const branch = options.branch ?? (await resolveSddContext(projectRoot)).partition;
|
|
1656
|
+
const model = await parseSddBranch(projectRoot, branch);
|
|
1657
|
+
const inspected = inspectSddTask(model, options.taskId);
|
|
1658
|
+
if (!inspected.task) {
|
|
1659
|
+
throw new Error(`Task not found: ${options.taskId}`);
|
|
1660
|
+
}
|
|
1661
|
+
const task = inspected.task;
|
|
1662
|
+
const docRefs = await Promise.all([
|
|
1663
|
+
contextSourceRefForAbsolutePath(projectRoot, model.tasksPath, 'document'),
|
|
1664
|
+
contextSourceRefForAbsolutePath(projectRoot, model.planPath, 'document'),
|
|
1665
|
+
contextSourceRefForAbsolutePath(projectRoot, model.specPath, 'document')
|
|
1666
|
+
]);
|
|
1667
|
+
const affectedRefs = await Promise.all(task.affectedFiles.map((file) => contextSourceRefForProjectPath(projectRoot, file, 'document')));
|
|
1668
|
+
const artifactRefs = await Promise.all(task.requiredArtifacts.map((artifact) => contextSourceRefForProjectPath(projectRoot, artifact, 'artifact')));
|
|
1669
|
+
const routeRef = await contextSourceRefForProjectPath(projectRoot, `.sdd/cache/routes`, 'derived');
|
|
1670
|
+
const runIndexRef = await contextSourceRefForProjectPath(projectRoot, `.sdd/run-index.json`, 'derived');
|
|
1671
|
+
const mustRead = uniqueContextSourceRefs(contextMustReadRefs(options.mode, docRefs, affectedRefs, artifactRefs));
|
|
1672
|
+
const optionalRead = uniqueContextSourceRefs(contextOptionalRefs(options.mode, docRefs, affectedRefs, artifactRefs, routeRef, runIndexRef, options.agent ?? null));
|
|
1673
|
+
const doNotReadUnlessNeeded = uniqueContextSourceRefs(contextDeferredRefs(options.mode, docRefs, affectedRefs, artifactRefs, routeRef, runIndexRef));
|
|
1674
|
+
const contextPackage = {
|
|
1675
|
+
contract: CONTEXT_PACKAGE_CONTRACT_VERSION,
|
|
1676
|
+
profile,
|
|
1677
|
+
mode: options.mode,
|
|
1678
|
+
agent: options.agent ?? null,
|
|
1679
|
+
authoritative: false,
|
|
1680
|
+
usableForPass: false,
|
|
1681
|
+
taskId: task.id,
|
|
1682
|
+
branch,
|
|
1683
|
+
mustRead,
|
|
1684
|
+
optionalRead,
|
|
1685
|
+
doNotReadUnlessNeeded,
|
|
1686
|
+
nextCommands: contextNextCommands(task.id, branch, options.mode, options.agent),
|
|
1687
|
+
warnings: [
|
|
1688
|
+
'Context package is derived guidance only and cannot satisfy PASS evidence.',
|
|
1689
|
+
...inspected.gaps.map((gap) => `${gap.field}: ${gap.message}`)
|
|
1690
|
+
]
|
|
1098
1691
|
};
|
|
1692
|
+
await recordRuntimeProjection(projectRoot, 'context_build', `${branch}:${task.id}:${options.mode}:${options.agent ?? 'none'}:${profile}`, contextPackage);
|
|
1693
|
+
return contextPackage;
|
|
1694
|
+
}
|
|
1695
|
+
export function validateLogWorkerSummary(summary) {
|
|
1696
|
+
const issues = [];
|
|
1697
|
+
const candidate = summary;
|
|
1698
|
+
if (candidate.contract !== LOG_WORKER_SUMMARY_CONTRACT_VERSION) {
|
|
1699
|
+
issues.push(contractIssue('contract', 'Log worker summary contract is invalid.', `Use ${LOG_WORKER_SUMMARY_CONTRACT_VERSION}.`));
|
|
1700
|
+
}
|
|
1701
|
+
if (candidate.authoritative !== false) {
|
|
1702
|
+
issues.push(contractIssue('authoritative', 'Log worker summary must be non-authoritative.', 'Set authoritative=false and keep workflow decisions in core runtime.'));
|
|
1703
|
+
}
|
|
1704
|
+
if (candidate.usableForPass !== false) {
|
|
1705
|
+
issues.push(contractIssue('usableForPass', 'Log worker summary cannot be used for PASS evidence.', 'Set usableForPass=false and reference source artifacts for PASS evidence.'));
|
|
1706
|
+
}
|
|
1707
|
+
const forbiddenAuthority = Array.isArray(candidate.forbiddenAuthority) ? candidate.forbiddenAuthority.filter((item) => typeof item === 'string') : [];
|
|
1708
|
+
if (forbiddenAuthority.length > 0) {
|
|
1709
|
+
issues.push(contractIssue('forbiddenAuthority', `Log worker summary claims forbidden workflow authority: ${forbiddenAuthority.join(', ')}.`, 'Remove PASS/BLOCKED/route/doctor/sync-back decisions from worker summaries.'));
|
|
1710
|
+
}
|
|
1711
|
+
for (const field of ['status', 'verdict', 'routeDecision', 'doctorVerdict', 'syncBackReady']) {
|
|
1712
|
+
if (field in candidate) {
|
|
1713
|
+
issues.push(contractIssue(field, `Log worker summary must not expose workflow decision field ${field}.`, 'Keep decision fields in core runtime outputs only.'));
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
return { valid: issues.length === 0, issues };
|
|
1717
|
+
}
|
|
1718
|
+
async function contextSourceRefForProjectPath(projectRoot, relativePath, kind) {
|
|
1719
|
+
const normalized = normalizePortablePath(relativePath);
|
|
1720
|
+
return contextSourceRefForAbsolutePath(projectRoot, path.join(projectRoot, normalized), kind, normalized);
|
|
1721
|
+
}
|
|
1722
|
+
async function contextSourceRefForAbsolutePath(projectRoot, absolutePath, kind, displayPath) {
|
|
1723
|
+
const relativePath = displayPath ?? normalizePortablePath(path.relative(projectRoot, absolutePath));
|
|
1724
|
+
if (!relativePath || relativePath.startsWith('../') || path.isAbsolute(relativePath)) {
|
|
1725
|
+
return { path: normalizePortablePath(absolutePath), hash: hashDocumentContent(`external:${absolutePath}`), kind };
|
|
1726
|
+
}
|
|
1727
|
+
if (!await exists(absolutePath)) {
|
|
1728
|
+
return { path: relativePath, hash: hashDocumentContent(`missing:${relativePath}`), kind };
|
|
1729
|
+
}
|
|
1730
|
+
const fileStat = await stat(absolutePath);
|
|
1731
|
+
if (fileStat.isDirectory()) {
|
|
1732
|
+
return { path: relativePath, hash: hashDocumentContent(`directory:${relativePath}`), kind };
|
|
1733
|
+
}
|
|
1734
|
+
return { path: relativePath, hash: hashDocumentContent(await readFile(absolutePath, 'utf8')), kind };
|
|
1735
|
+
}
|
|
1736
|
+
function uniqueContextSourceRefs(refs) {
|
|
1737
|
+
return [...new Map(refs.map((ref) => [`${ref.kind}:${ref.path}`, ref])).values()];
|
|
1738
|
+
}
|
|
1739
|
+
function uniqueEvidenceIssueCodes(issues) {
|
|
1740
|
+
const known = ['EMPTY_EVIDENCE', 'TODO_PLACEHOLDER', 'TEMPLATE_TEXT', 'MENTION_ONLY', 'UNSOURCED_PASS', 'MISSING_COMMAND_OUTPUT', 'MISSING_ARTIFACT_REFERENCE', 'MISSING_MATERIAL_REFERENCE', 'PROVENANCE_GAP', 'POLICY_RULE_FAILED', 'DERIVED_SOURCE_EVIDENCE', 'PARTITION_SCOPE_VIOLATION'];
|
|
1741
|
+
return known.filter((code) => issues.some((issue) => issue.message.includes(code)));
|
|
1742
|
+
}
|
|
1743
|
+
function contextMustReadRefs(mode, docs, affected, artifacts) {
|
|
1744
|
+
if (mode === 'do') {
|
|
1745
|
+
return [docs[0], docs[1], ...affected];
|
|
1746
|
+
}
|
|
1747
|
+
if (mode === 'verify') {
|
|
1748
|
+
return [docs[0], ...artifacts];
|
|
1749
|
+
}
|
|
1750
|
+
if (mode === 'sync-back') {
|
|
1751
|
+
return [docs[0], ...affected];
|
|
1752
|
+
}
|
|
1753
|
+
return [docs[0]];
|
|
1754
|
+
}
|
|
1755
|
+
function contextOptionalRefs(mode, docs, affected, artifacts, routeRef, runIndexRef, agent) {
|
|
1756
|
+
const refs = mode === 'doctor' ? [docs[1], docs[2], runIndexRef, routeRef, ...artifacts] : [docs[2], ...artifacts];
|
|
1757
|
+
if (agent === 'implementer') {
|
|
1758
|
+
refs.push(...affected);
|
|
1759
|
+
}
|
|
1760
|
+
if (agent === 'reviewer' || agent === 'validator') {
|
|
1761
|
+
refs.push(runIndexRef);
|
|
1762
|
+
}
|
|
1763
|
+
return refs;
|
|
1764
|
+
}
|
|
1765
|
+
function contextDeferredRefs(mode, docs, affected, artifacts, routeRef, runIndexRef) {
|
|
1766
|
+
if (mode === 'doctor') {
|
|
1767
|
+
return [...affected];
|
|
1768
|
+
}
|
|
1769
|
+
if (mode === 'verify') {
|
|
1770
|
+
return [...affected, routeRef];
|
|
1771
|
+
}
|
|
1772
|
+
return [runIndexRef, ...artifacts, docs[2]];
|
|
1773
|
+
}
|
|
1774
|
+
function contextNextCommands(taskId, branch, mode, agent) {
|
|
1775
|
+
if (mode === 'do') {
|
|
1776
|
+
return [`sdd do task ${taskId} --branch ${branch}`];
|
|
1777
|
+
}
|
|
1778
|
+
if (mode === 'verify') {
|
|
1779
|
+
return [`sdd verify task ${taskId} --branch ${branch}`, `sdd evidence summary <run_id> --task ${taskId} --json`];
|
|
1780
|
+
}
|
|
1781
|
+
if (mode === 'sync-back') {
|
|
1782
|
+
return [`sdd sync-back inspect --branch ${branch} --task ${taskId}`];
|
|
1783
|
+
}
|
|
1784
|
+
const agentSuffix = agent ? ` --agent ${agent}` : '';
|
|
1785
|
+
return [`sdd doctor --latest-only --branch ${branch}`, `sdd context build --task ${taskId} --branch ${branch} --mode verify${agentSuffix} --json`];
|
|
1099
1786
|
}
|
|
1100
1787
|
function stringData(data, key) {
|
|
1101
1788
|
const value = data?.[key];
|
|
@@ -1454,13 +2141,22 @@ export async function inspectSyncBack(projectRoot, options) {
|
|
|
1454
2141
|
reasons.push(`Affected file ${conflict.file} is active in run ${conflict.runId} for ${conflict.partition}/${conflict.taskId}.`);
|
|
1455
2142
|
}
|
|
1456
2143
|
const proposalPath = state.syncBack.proposalPath;
|
|
2144
|
+
const expectedProposalDigest = state.syncBack.proposalDigest ?? null;
|
|
1457
2145
|
let proposal = null;
|
|
2146
|
+
let proposalDigestValid = null;
|
|
1458
2147
|
if (!proposalPath) {
|
|
1459
2148
|
reasons.push('Run has no sync-back proposal.');
|
|
1460
2149
|
}
|
|
1461
2150
|
else {
|
|
1462
2151
|
try {
|
|
1463
2152
|
proposal = await readArtifact(projectRoot, state.runId, toArtifactRootRelativePath(proposalPath));
|
|
2153
|
+
if (expectedProposalDigest) {
|
|
2154
|
+
const actualProposalDigest = hashDocumentContent(proposal);
|
|
2155
|
+
proposalDigestValid = actualProposalDigest === expectedProposalDigest;
|
|
2156
|
+
if (!proposalDigestValid) {
|
|
2157
|
+
reasons.push(`Sync-back proposal ${proposalPath} digest changed from ${expectedProposalDigest} to ${actualProposalDigest}.`);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
1464
2160
|
}
|
|
1465
2161
|
catch (error) {
|
|
1466
2162
|
reasons.push(`Cannot read sync-back proposal ${proposalPath}: ${messageFromError(error)}`);
|
|
@@ -1489,6 +2185,8 @@ export async function inspectSyncBack(projectRoot, options) {
|
|
|
1489
2185
|
reasons,
|
|
1490
2186
|
proposalPath,
|
|
1491
2187
|
proposal,
|
|
2188
|
+
proposalDigest: expectedProposalDigest,
|
|
2189
|
+
proposalDigestValid,
|
|
1492
2190
|
runTaskStatus: runtimeTaskStatus(state.tasks[taskId]),
|
|
1493
2191
|
markdownTask,
|
|
1494
2192
|
markdownStatus: markdownTask?.status ?? null,
|
|
@@ -1584,6 +2282,7 @@ export async function doctor(projectRoot, options = {}) {
|
|
|
1584
2282
|
else {
|
|
1585
2283
|
checks.push({ level: 'FAIL', check: 'project_config', message: '.sdd/project.yml is missing.', action: 'Run sdd init.' });
|
|
1586
2284
|
}
|
|
2285
|
+
checks.push(...await inspectRuntimeStoreEvidence(projectRoot));
|
|
1587
2286
|
const runsDir = getRunsDir(projectRoot);
|
|
1588
2287
|
if (await exists(runsDir)) {
|
|
1589
2288
|
try {
|
|
@@ -1603,7 +2302,7 @@ export async function doctor(projectRoot, options = {}) {
|
|
|
1603
2302
|
checks.push(await exists(specsDir)
|
|
1604
2303
|
? { level: 'PASS', check: 'specs_dir', message: 'specs directory exists.' }
|
|
1605
2304
|
: { level: 'WARN', check: 'specs_dir', message: 'specs directory is missing.', action: 'Create specs/<branch>/ documents before full SDD execution.' });
|
|
1606
|
-
checks.push(...await inspectDocumentChainEvidence(projectRoot));
|
|
2305
|
+
checks.push(...await inspectDocumentChainEvidence(projectRoot, options.branch ?? undefined));
|
|
1607
2306
|
if (await exists(configPath)) {
|
|
1608
2307
|
checks.push(...await inspectAiToolEntryEvidence(projectRoot));
|
|
1609
2308
|
}
|
|
@@ -1817,7 +2516,8 @@ export function parseSddTasksMarkdown(raw, options = {}) {
|
|
|
1817
2516
|
return { tasks, gaps };
|
|
1818
2517
|
}
|
|
1819
2518
|
export async function renderSddResultArtifactTemplate(projectRoot, options) {
|
|
1820
|
-
const
|
|
2519
|
+
const runState = options.runId ? await readRunState(projectRoot, options.runId).catch(() => null) : null;
|
|
2520
|
+
const branch = options.branch ?? runState?.partition ?? runState?.gitBranch ?? (await resolveSddContext(projectRoot)).branch;
|
|
1821
2521
|
const status = options.status ?? 'PASS';
|
|
1822
2522
|
if (!isSddResultStatus(status)) {
|
|
1823
2523
|
throw new Error(`Unsupported sdd-result status ${status}.`);
|
|
@@ -1892,10 +2592,15 @@ export async function validateSddResultArtifact(projectRoot, runId, runRelativeA
|
|
|
1892
2592
|
}
|
|
1893
2593
|
const parsed = parseSddResultMarkdown(raw);
|
|
1894
2594
|
issues.push(...parsed.issues);
|
|
2595
|
+
let trust;
|
|
1895
2596
|
if (parsed.result) {
|
|
1896
2597
|
issues.push(...validateSddResult(parsed.result, { ...options, runRelativeArtifactPath }));
|
|
2598
|
+
trust = validateArtifactTrust(raw, parsed.result, runRelativeArtifactPath, options);
|
|
2599
|
+
if (!trust.valid) {
|
|
2600
|
+
issues.push(...trust.issues);
|
|
2601
|
+
}
|
|
1897
2602
|
}
|
|
1898
|
-
return { valid: issues.length === 0 && parsed.result !== null, result: parsed.result, issues };
|
|
2603
|
+
return { valid: issues.length === 0 && parsed.result !== null, result: parsed.result, issues, trust };
|
|
1899
2604
|
}
|
|
1900
2605
|
export function parseSddResultMarkdown(raw) {
|
|
1901
2606
|
const matches = Array.from(raw.matchAll(/^\s*```sdd-result\s*\r?\n([\s\S]*?)\r?^\s*```\s*$/gm));
|
|
@@ -1924,6 +2629,177 @@ export function validateSddResult(result, options = {}) {
|
|
|
1924
2629
|
}
|
|
1925
2630
|
return issues;
|
|
1926
2631
|
}
|
|
2632
|
+
export function parseSddEvidenceMarkdown(raw, options = {}) {
|
|
2633
|
+
const matches = Array.from(raw.matchAll(/^\s*```sdd-evidence\s*\r?\n([\s\S]*?)\r?^\s*```\s*$/gm));
|
|
2634
|
+
const claims = [];
|
|
2635
|
+
const issues = [];
|
|
2636
|
+
for (const match of matches) {
|
|
2637
|
+
const metadata = parseSimpleYamlBlock(match[1] ?? '');
|
|
2638
|
+
const built = buildEvidenceClaim(metadata, options.sourceArtifact);
|
|
2639
|
+
issues.push(...built.issues);
|
|
2640
|
+
if (built.claim) {
|
|
2641
|
+
claims.push(built.claim);
|
|
2642
|
+
issues.push(...validateEvidenceClaim(built.claim, options));
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
return { valid: claims.length > 0 && issues.length === 0, claims, issues };
|
|
2646
|
+
}
|
|
2647
|
+
function validateArtifactTrust(raw, result, runRelativeArtifactPath, options = {}) {
|
|
2648
|
+
const expectedAgent = options.expectedAgent ?? result.agent;
|
|
2649
|
+
const expectedTask = options.expectedTask ?? result.task;
|
|
2650
|
+
const requiresTrust = expectedAgent === 'validator' && (result.status === 'PASS' || result.status === 'PASS_WITH_GAPS');
|
|
2651
|
+
const parsed = parseSddEvidenceMarkdown(raw, { expectedTask, sourceArtifact: runRelativeArtifactPath });
|
|
2652
|
+
const issues = requiresTrust || parsed.claims.length > 0 ? [...parsed.issues] : [];
|
|
2653
|
+
if (requiresTrust) {
|
|
2654
|
+
issues.push(...validateArtifactBodyTrust(raw));
|
|
2655
|
+
if (parsed.claims.length === 0) {
|
|
2656
|
+
issues.push(evidenceIssue('UNSOURCED_PASS', 'sdd-evidence', `Validator ${result.status} artifact ${runRelativeArtifactPath} has no structured ${SDD_EVIDENCE_CONTRACT} evidence block.`, 'Add policy-backed sdd-evidence with acceptance, claim, source artifact, evidence refs, provenance refs, and policy refs.'));
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
return { valid: issues.length === 0 && (!requiresTrust || parsed.claims.length > 0), claims: parsed.claims, issues };
|
|
2660
|
+
}
|
|
2661
|
+
function buildEvidenceClaim(metadata, sourceArtifact) {
|
|
2662
|
+
const issues = [];
|
|
2663
|
+
const contract = scalarValue(metadata.contract);
|
|
2664
|
+
const version = scalarValue(metadata.version);
|
|
2665
|
+
const task = scalarValue(metadata.task);
|
|
2666
|
+
const acceptance = scalarValue(metadata.acceptance);
|
|
2667
|
+
const status = scalarValue(metadata.status);
|
|
2668
|
+
const claimText = scalarValue(metadata.claim);
|
|
2669
|
+
const claimSourceArtifact = scalarValue(metadata.source_artifact) ?? scalarValue(metadata.sourceArtifact) ?? sourceArtifact ?? null;
|
|
2670
|
+
if (!contract) {
|
|
2671
|
+
issues.push(evidenceIssue('POLICY_RULE_FAILED', 'sdd-evidence.contract', 'sdd-evidence contract is missing.', `Set contract: ${SDD_EVIDENCE_CONTRACT}.`));
|
|
2672
|
+
}
|
|
2673
|
+
if (!version) {
|
|
2674
|
+
issues.push(evidenceIssue('POLICY_RULE_FAILED', 'sdd-evidence.version', 'sdd-evidence version is missing.', `Set version: ${SDD_EVIDENCE_VERSION}.`));
|
|
2675
|
+
}
|
|
2676
|
+
if (!task) {
|
|
2677
|
+
issues.push(evidenceIssue('POLICY_RULE_FAILED', 'sdd-evidence.task', 'sdd-evidence task is missing.', 'Set task to the delegated task id.'));
|
|
2678
|
+
}
|
|
2679
|
+
if (!acceptance) {
|
|
2680
|
+
issues.push(evidenceIssue('POLICY_RULE_FAILED', 'sdd-evidence.acceptance', 'sdd-evidence acceptance is missing.', 'Set acceptance to the AC id or acceptance text being proven.'));
|
|
2681
|
+
}
|
|
2682
|
+
if (!status || !isEvidenceCoverageStatus(status)) {
|
|
2683
|
+
issues.push(evidenceIssue('POLICY_RULE_FAILED', 'sdd-evidence.status', `sdd-evidence status ${status ?? 'missing'} is not supported.`, 'Use PASS, FAIL, BLOCKED, REFERENCED_ONLY, or MISSING.'));
|
|
2684
|
+
}
|
|
2685
|
+
if (!claimText) {
|
|
2686
|
+
issues.push(evidenceIssue('UNSOURCED_PASS', 'sdd-evidence.claim', 'sdd-evidence claim is missing.', 'Add a concise claim that states what acceptance is proven.'));
|
|
2687
|
+
}
|
|
2688
|
+
if (!claimSourceArtifact) {
|
|
2689
|
+
issues.push(evidenceIssue('MISSING_ARTIFACT_REFERENCE', 'sdd-evidence.source_artifact', 'sdd-evidence source artifact is missing.', 'Set source_artifact to the run-relative artifact path.'));
|
|
2690
|
+
}
|
|
2691
|
+
if (!task || !acceptance || !status || !isEvidenceCoverageStatus(status) || !claimText || !claimSourceArtifact || contract !== SDD_EVIDENCE_CONTRACT || version !== SDD_EVIDENCE_VERSION) {
|
|
2692
|
+
return { claim: null, issues };
|
|
2693
|
+
}
|
|
2694
|
+
const evidence = listValue(metadata.evidence_refs ?? metadata.evidence_ref ?? metadata.evidence).map(parseEvidenceItem);
|
|
2695
|
+
const provenance = listValue(metadata.provenance_refs ?? metadata.provenance_ref ?? metadata.provenance);
|
|
2696
|
+
const policy = listValue(metadata.policy_refs ?? metadata.policy_ref ?? metadata.policy);
|
|
2697
|
+
return {
|
|
2698
|
+
claim: {
|
|
2699
|
+
contract: SDD_EVIDENCE_CONTRACT,
|
|
2700
|
+
version: SDD_EVIDENCE_VERSION,
|
|
2701
|
+
task,
|
|
2702
|
+
acceptance,
|
|
2703
|
+
status,
|
|
2704
|
+
claim: claimText,
|
|
2705
|
+
sourceArtifact: claimSourceArtifact,
|
|
2706
|
+
evidence,
|
|
2707
|
+
provenance,
|
|
2708
|
+
policy,
|
|
2709
|
+
rawMetadata: metadata
|
|
2710
|
+
},
|
|
2711
|
+
issues
|
|
2712
|
+
};
|
|
2713
|
+
}
|
|
2714
|
+
function validateEvidenceClaim(claim, options) {
|
|
2715
|
+
const issues = [];
|
|
2716
|
+
if (options.expectedTask && claim.task !== options.expectedTask) {
|
|
2717
|
+
issues.push(evidenceIssue('POLICY_RULE_FAILED', 'sdd-evidence.task', `sdd-evidence task ${claim.task} does not match expected task ${options.expectedTask}.`, 'Use the same task id as the delegated artifact.'));
|
|
2718
|
+
}
|
|
2719
|
+
if (containsTemplatePlaceholder(claim.claim)) {
|
|
2720
|
+
issues.push(evidenceIssue('TODO_PLACEHOLDER', 'sdd-evidence.claim', 'sdd-evidence claim contains placeholder text.', 'Replace TODO/template text with actual validation evidence.'));
|
|
2721
|
+
}
|
|
2722
|
+
if (isDerivedEvidenceRef(claim.sourceArtifact)) {
|
|
2723
|
+
issues.push(evidenceIssue('DERIVED_SOURCE_EVIDENCE', 'sdd-evidence.source_artifact', `sdd-evidence source artifact ${claim.sourceArtifact} is derived output, not source evidence.`, 'Reference the validator/reviewer/source artifact that contains the evidence, not coverage, cache, proposal, or summary output.'));
|
|
2724
|
+
}
|
|
2725
|
+
if (claim.status === 'PASS') {
|
|
2726
|
+
if (claim.evidence.length === 0) {
|
|
2727
|
+
issues.push(evidenceIssue('UNSOURCED_PASS', 'sdd-evidence.evidence', `PASS claim ${claim.acceptance} has no evidence refs.`, 'Add at least one command, artifact, file, test, or review evidence ref.'));
|
|
2728
|
+
}
|
|
2729
|
+
if (claim.provenance.length === 0) {
|
|
2730
|
+
issues.push(evidenceIssue('PROVENANCE_GAP', 'sdd-evidence.provenance', `PASS claim ${claim.acceptance} has no provenance refs.`, 'Add provenance refs for the artifact, command, run state, or material used by this claim.'));
|
|
2731
|
+
}
|
|
2732
|
+
if (claim.policy.length === 0) {
|
|
2733
|
+
issues.push(evidenceIssue('POLICY_RULE_FAILED', 'sdd-evidence.policy', `PASS claim ${claim.acceptance} has no policy refs.`, `Add ${ACCEPTANCE_POLICY_RULESET_VERSION}:<rule-id> policy refs.`));
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
for (const item of claim.evidence) {
|
|
2737
|
+
if (!item.ref) {
|
|
2738
|
+
issues.push(evidenceIssue('MISSING_ARTIFACT_REFERENCE', 'sdd-evidence.evidence', `Evidence item for ${claim.acceptance} has an empty ref.`, `Provide a non-empty evidence ref.`));
|
|
2739
|
+
}
|
|
2740
|
+
if (containsTemplatePlaceholder(item.ref) || containsTemplatePlaceholder(item.summary ?? '')) {
|
|
2741
|
+
issues.push(evidenceIssue('TODO_PLACEHOLDER', 'sdd-evidence.evidence', `Evidence item for ${claim.acceptance} contains placeholder text.`, `Replace TODO/template text with real evidence.`));
|
|
2742
|
+
}
|
|
2743
|
+
if (isDerivedEvidenceRef(item.ref)) {
|
|
2744
|
+
issues.push(evidenceIssue('DERIVED_SOURCE_EVIDENCE', 'sdd-evidence.evidence', `Evidence ref ${item.ref} is derived output, not source evidence.`, `Use source artifacts, commands, files, tests, or material refs.`));
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
for (const ref of claim.provenance) {
|
|
2748
|
+
if (isDerivedEvidenceRef(ref)) {
|
|
2749
|
+
issues.push(evidenceIssue('DERIVED_SOURCE_EVIDENCE', 'sdd-evidence.provenance', `Provenance ref ${ref} is derived output, not source evidence.`, `Use source artifact, command, run-state, or material provenance refs.`));
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
return issues;
|
|
2753
|
+
}
|
|
2754
|
+
function validateArtifactBodyTrust(raw) {
|
|
2755
|
+
const issues = [];
|
|
2756
|
+
if (raw.trim().length === 0) {
|
|
2757
|
+
issues.push(evidenceIssue('EMPTY_EVIDENCE', 'evidence', 'Artifact body is empty.', 'Write non-empty evidence and an sdd-result block.'));
|
|
2758
|
+
}
|
|
2759
|
+
if (containsTemplatePlaceholder(raw)) {
|
|
2760
|
+
issues.push(evidenceIssue('TODO_PLACEHOLDER', 'evidence', 'Artifact still contains TODO/template placeholder text.', 'Replace scaffold text with real evidence before claiming PASS.'));
|
|
2761
|
+
}
|
|
2762
|
+
if (/^\s*-\s*\[PASS\]\s*(?:Acceptance\s+)?(?:AC[-\w.]+|[^\r\n]+)\s*$/im.test(raw) && !/^\s*```sdd-evidence\s*$/im.test(raw)) {
|
|
2763
|
+
issues.push(evidenceIssue('MENTION_ONLY', 'acceptance_coverage', 'Validator PASS artifact only mentions an acceptance target without structured source evidence.', `Add ${SDD_EVIDENCE_CONTRACT} claim/evidence/provenance/policy records.`));
|
|
2764
|
+
}
|
|
2765
|
+
if (/Mentioned in artifacts\//i.test(raw)) {
|
|
2766
|
+
issues.push(evidenceIssue('MENTION_ONLY', 'acceptance_coverage', 'Artifact cites generated mention-only coverage text.', 'Use source evidence, not generated coverage summaries.'));
|
|
2767
|
+
}
|
|
2768
|
+
return issues;
|
|
2769
|
+
}
|
|
2770
|
+
function parseEvidenceItem(value) {
|
|
2771
|
+
const separator = value.indexOf(':');
|
|
2772
|
+
if (separator > 0) {
|
|
2773
|
+
const kind = value.slice(0, separator).trim();
|
|
2774
|
+
const ref = value.slice(separator + 1).trim();
|
|
2775
|
+
if (/^[A-Za-z0-9_-]+$/.test(kind)) {
|
|
2776
|
+
return { kind, ref, summary: null };
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
return { kind: value.startsWith('artifacts/') ? 'artifact' : 'text', ref: value, summary: null };
|
|
2780
|
+
}
|
|
2781
|
+
function isEvidenceCoverageStatus(value) {
|
|
2782
|
+
return value === 'PASS' || value === 'FAIL' || value === 'BLOCKED' || value === 'REFERENCED_ONLY' || value === 'MISSING';
|
|
2783
|
+
}
|
|
2784
|
+
function containsTemplatePlaceholder(value) {
|
|
2785
|
+
return /\bTODO\b|template placeholder|TODO\.|Add validation evidence|TODO run validation command|TODO cite files/i.test(value);
|
|
2786
|
+
}
|
|
2787
|
+
function isDerivedEvidenceRef(value) {
|
|
2788
|
+
const normalized = normalizePortablePath(value).toLowerCase();
|
|
2789
|
+
return normalized.includes('acceptance-coverage-')
|
|
2790
|
+
|| normalized.endsWith('sync-back-proposal.md')
|
|
2791
|
+
|| normalized.includes('/cache/')
|
|
2792
|
+
|| normalized.includes('/profile')
|
|
2793
|
+
|| normalized.endsWith('run-index.json')
|
|
2794
|
+
|| normalized.includes('command-output-summary')
|
|
2795
|
+
|| normalized.includes('evidence-summary')
|
|
2796
|
+
|| normalized.includes('context-package')
|
|
2797
|
+
|| normalized.includes('context-build')
|
|
2798
|
+
|| normalized.includes('log-worker-summary');
|
|
2799
|
+
}
|
|
2800
|
+
function evidenceIssue(code, field, message, recommendation) {
|
|
2801
|
+
return contractIssue(field, `${code}: ${message}`, recommendation);
|
|
2802
|
+
}
|
|
1927
2803
|
export function createDelegationRecord(input) {
|
|
1928
2804
|
return {
|
|
1929
2805
|
contract: DELEGATION_LIVENESS_CONTRACT,
|
|
@@ -2034,6 +2910,8 @@ export async function ingestArtifactResult(projectRoot, runId, input) {
|
|
|
2034
2910
|
[key]: record
|
|
2035
2911
|
}
|
|
2036
2912
|
});
|
|
2913
|
+
await recordRuntimeArtifactIngestion(projectRoot, record);
|
|
2914
|
+
await recordRuntimeEvidenceAdmission(projectRoot, state, record, report.trust);
|
|
2037
2915
|
if (!accepted && delegation.status === 'RUNNING' && !report.valid) {
|
|
2038
2916
|
await appendEvent(projectRoot, runId, { event: 'artifact_invalid', runId, summary: `Artifact ingestion rejected for ${delegation.delegationId}`, data: { delegationId: delegation.delegationId, artifact: artifactPath, issues } });
|
|
2039
2917
|
}
|
|
@@ -2318,6 +3196,7 @@ export async function runSingleTaskLoop(projectRoot, options) {
|
|
|
2318
3196
|
taskState: { status: 'blocked', gaps: allGaps, artifacts: [gapArtifact.runRelativePath] },
|
|
2319
3197
|
validationStatus: 'blocked',
|
|
2320
3198
|
syncBackProposalPath: proposal.runRelativePath,
|
|
3199
|
+
syncBackProposalDigest: proposal.digest,
|
|
2321
3200
|
artifacts: [{ path: gapArtifact.runRelativePath, kind: 'gap-report', task: options.taskId, agent: 'runtime' }]
|
|
2322
3201
|
});
|
|
2323
3202
|
await appendEvent(projectRoot, runId, {
|
|
@@ -2361,6 +3240,7 @@ export async function runSingleTaskLoop(projectRoot, options) {
|
|
|
2361
3240
|
let terminalStatus = 'completed';
|
|
2362
3241
|
let validationStatus = 'pass';
|
|
2363
3242
|
for (const step of steps) {
|
|
3243
|
+
const stepRouteDecision = await routeSddTask(projectRoot, { taskId: options.taskId, branch, agent: step.agent, teamModeEnabled: options.teamModeEnabled, teamModeActivation: options.teamModeActivation });
|
|
2364
3244
|
if (!step.suppliedArtifact) {
|
|
2365
3245
|
if (!step.required) {
|
|
2366
3246
|
await appendEvent(projectRoot, runId, {
|
|
@@ -2373,7 +3253,7 @@ export async function runSingleTaskLoop(projectRoot, options) {
|
|
|
2373
3253
|
runId,
|
|
2374
3254
|
taskId: options.taskId,
|
|
2375
3255
|
agent: step.agent,
|
|
2376
|
-
route:
|
|
3256
|
+
route: stepRouteDecision,
|
|
2377
3257
|
status: 'skipped',
|
|
2378
3258
|
delegationId: `B-${options.taskId}-${step.agent}-001`,
|
|
2379
3259
|
evidenceSummary: `${step.agent} artifact was not supplied and the step is optional.`
|
|
@@ -2392,7 +3272,7 @@ export async function runSingleTaskLoop(projectRoot, options) {
|
|
|
2392
3272
|
runId,
|
|
2393
3273
|
taskId: options.taskId,
|
|
2394
3274
|
agent: step.agent,
|
|
2395
|
-
route:
|
|
3275
|
+
route: stepRouteDecision,
|
|
2396
3276
|
status: 'blocked',
|
|
2397
3277
|
delegationId: `B-${options.taskId}-${step.agent}-001`,
|
|
2398
3278
|
evidenceSummary: `${step.agent} artifact was not supplied; execution is blocked before host invocation.`
|
|
@@ -2471,6 +3351,7 @@ export async function runSingleTaskLoop(projectRoot, options) {
|
|
|
2471
3351
|
taskState: { status: terminalStatus, gaps, artifacts: acceptedArtifacts },
|
|
2472
3352
|
validationStatus,
|
|
2473
3353
|
syncBackProposalPath: proposal.runRelativePath,
|
|
3354
|
+
syncBackProposalDigest: proposal.digest,
|
|
2474
3355
|
artifacts: acceptedArtifacts.map((artifactPath) => ({ path: artifactPath, kind: artifactKind(artifactPath), task: options.taskId, agent: agentFromArtifactPath(artifactPath) }))
|
|
2475
3356
|
});
|
|
2476
3357
|
await appendEvent(projectRoot, runId, {
|
|
@@ -2528,6 +3409,7 @@ export async function runGoalVerify(projectRoot, options) {
|
|
|
2528
3409
|
const acceptedArtifacts = [];
|
|
2529
3410
|
let reviewStatus = null;
|
|
2530
3411
|
let validationStatus = null;
|
|
3412
|
+
let validationTrust = null;
|
|
2531
3413
|
await appendEvent(projectRoot, runId, {
|
|
2532
3414
|
event: 'phase_started',
|
|
2533
3415
|
runId,
|
|
@@ -2558,6 +3440,7 @@ export async function runGoalVerify(projectRoot, options) {
|
|
|
2558
3440
|
}
|
|
2559
3441
|
else {
|
|
2560
3442
|
const validationReport = await validateSddResultArtifact(projectRoot, runId, validationArtifact, { expectedTask: options.taskId, expectedAgent: 'validator' });
|
|
3443
|
+
validationTrust = validationReport.trust ?? null;
|
|
2561
3444
|
if (!validationReport.valid || !validationReport.result) {
|
|
2562
3445
|
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.'));
|
|
2563
3446
|
}
|
|
@@ -2571,15 +3454,50 @@ export async function runGoalVerify(projectRoot, options) {
|
|
|
2571
3454
|
}
|
|
2572
3455
|
if (inspected.task) {
|
|
2573
3456
|
const validationRaw = validationArtifact ? await readArtifactIfExists(projectRoot, runId, validationArtifact) : '';
|
|
3457
|
+
const reviewRaw = reviewArtifact ? await readArtifactIfExists(projectRoot, runId, reviewArtifact) : '';
|
|
3458
|
+
if (reviewArtifact && reviewRaw.length > 0) {
|
|
3459
|
+
await appendArtifactHashLedgerEntry(projectRoot, { runId, taskId: options.taskId, branch, artifactPath: reviewArtifact, content: reviewRaw });
|
|
3460
|
+
}
|
|
3461
|
+
if (validationArtifact && validationRaw.length > 0) {
|
|
3462
|
+
await appendArtifactHashLedgerEntry(projectRoot, { runId, taskId: options.taskId, branch, artifactPath: validationArtifact, content: validationRaw });
|
|
3463
|
+
}
|
|
3464
|
+
await appendDeclaredCommandLedgerEntries(projectRoot, { runId, taskId: options.taskId, branch, commands: inspected.task.validation });
|
|
3465
|
+
const invocationLedger = await listInvocationLedgerEntries(projectRoot, runId);
|
|
3466
|
+
const admittedClaims = await readRuntimeEvidenceClaims(projectRoot, runId, options.taskId);
|
|
3467
|
+
const scopeViolation = await hasRuntimeEvidenceScopeViolation(projectRoot, runId, options.taskId);
|
|
3468
|
+
if (scopeViolation) {
|
|
3469
|
+
gaps.push(taskGap(options.taskId, 'runtime_scope', 'PARTITION_SCOPE_VIOLATION: Runtime evidence claims do not match the run partition.', 'Reingest validator evidence in the correct branch/partition before verify.'));
|
|
3470
|
+
}
|
|
2574
3471
|
for (const target of taskAcceptanceCoverageTargets(inspected.task)) {
|
|
2575
|
-
const
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
3472
|
+
const coverage = scopeViolation
|
|
3473
|
+
? acceptanceCoverageDecision(target.label, 'BLOCKED', 'Runtime evidence scope violation blocks acceptance coverage.', ['PARTITION_SCOPE_VIOLATION'], [], ['require-partition-scope'])
|
|
3474
|
+
: evaluateAcceptanceCoverageTarget(target, {
|
|
3475
|
+
taskId: options.taskId,
|
|
3476
|
+
validationArtifact,
|
|
3477
|
+
validationRaw,
|
|
3478
|
+
claims: admittedClaims.length > 0 ? admittedClaims : validationTrust?.claims ?? [],
|
|
3479
|
+
validationStatus,
|
|
3480
|
+
invocationLedger
|
|
3481
|
+
});
|
|
3482
|
+
await appendInvocationLedgerEntry(projectRoot, {
|
|
3483
|
+
runId,
|
|
3484
|
+
taskId: options.taskId,
|
|
3485
|
+
branch,
|
|
3486
|
+
kind: 'policy_evaluation',
|
|
3487
|
+
ref: `${ACCEPTANCE_POLICY_RULESET_VERSION}:${target.label}`,
|
|
3488
|
+
status: coverage.status,
|
|
3489
|
+
artifactPath: validationArtifact,
|
|
3490
|
+
inputHash: validationRaw.length > 0 ? hashDocumentContent(validationRaw) : null,
|
|
3491
|
+
materialRefs: [],
|
|
3492
|
+
metadata: {
|
|
3493
|
+
passedRules: coverage.policyDecision?.passedRules.join(',') ?? '',
|
|
3494
|
+
failedRules: coverage.policyDecision?.failedRules.join(',') ?? '',
|
|
3495
|
+
issueCodes: coverage.issueCodes?.join(',') ?? ''
|
|
3496
|
+
}
|
|
2580
3497
|
});
|
|
2581
|
-
|
|
2582
|
-
|
|
3498
|
+
acceptanceCoverage.push(coverage);
|
|
3499
|
+
if (coverage.status !== 'PASS') {
|
|
3500
|
+
gaps.push(taskGap(options.taskId, 'acceptance_coverage', `Acceptance target ${target.label} is ${coverage.status}: ${coverage.evidence}`, `Add ${SDD_EVIDENCE_CONTRACT} claim/evidence/provenance/policy records for ${target.label}; mention-only acceptance refs cannot pass.`));
|
|
2583
3501
|
}
|
|
2584
3502
|
}
|
|
2585
3503
|
}
|
|
@@ -2595,6 +3513,7 @@ export async function runGoalVerify(projectRoot, options) {
|
|
|
2595
3513
|
commands: inspected.task?.validation ?? [],
|
|
2596
3514
|
evidence: allArtifacts,
|
|
2597
3515
|
syncBackProposalPath: proposal.runRelativePath,
|
|
3516
|
+
syncBackProposalDigest: proposal.digest,
|
|
2598
3517
|
artifacts: allArtifacts.map((artifactPath) => ({ path: artifactPath, kind: artifactKind(artifactPath), task: options.taskId, agent: agentFromArtifactPath(artifactPath) }))
|
|
2599
3518
|
});
|
|
2600
3519
|
await appendEvent(projectRoot, runId, {
|
|
@@ -3045,7 +3964,7 @@ export async function runBackgroundExecutor(projectRoot, options) {
|
|
|
3045
3964
|
if (worker?.kind === 'manual_handoff') {
|
|
3046
3965
|
issues.push(contractIssue('workerAdapterId', `Worker adapter ${workerAdapterId} is manual handoff only.`, 'Use a runnable worker adapter for background executor claim/run/ingest.'));
|
|
3047
3966
|
}
|
|
3048
|
-
const route = await routeSddTask(projectRoot, { taskId: options.taskId, branch });
|
|
3967
|
+
const route = await routeSddTask(projectRoot, { taskId: options.taskId, branch, agent });
|
|
3049
3968
|
if (!inspected.task || inspected.gaps.some((gap) => gap.severity === 'blocking')) {
|
|
3050
3969
|
issues.push(...inspected.gaps.map((gap) => contractIssue(gap.field, gap.message, gap.recommendation)));
|
|
3051
3970
|
}
|
|
@@ -3745,7 +4664,9 @@ async function persistLoopState(projectRoot, runId, input) {
|
|
|
3745
4664
|
syncBack: {
|
|
3746
4665
|
mode: 'proposal',
|
|
3747
4666
|
proposalPath: input.syncBackProposalPath,
|
|
3748
|
-
|
|
4667
|
+
proposalDigest: input.syncBackProposalDigest,
|
|
4668
|
+
sourceVerifyStatus: input.status,
|
|
4669
|
+
status: state.syncBack.status === 'applied' ? 'applied' : 'proposed'
|
|
3749
4670
|
}
|
|
3750
4671
|
});
|
|
3751
4672
|
}
|
|
@@ -3774,13 +4695,16 @@ async function persistVerifyState(projectRoot, runId, input) {
|
|
|
3774
4695
|
syncBack: {
|
|
3775
4696
|
mode: 'proposal',
|
|
3776
4697
|
proposalPath: input.syncBackProposalPath,
|
|
3777
|
-
|
|
4698
|
+
proposalDigest: input.syncBackProposalDigest,
|
|
4699
|
+
sourceVerifyStatus: input.status,
|
|
4700
|
+
status: state.syncBack.status === 'applied' ? 'applied' : 'proposed'
|
|
3778
4701
|
}
|
|
3779
4702
|
});
|
|
3780
4703
|
}
|
|
3781
4704
|
async function writeSyncBackProposal(projectRoot, runId, taskId, status, artifacts, gaps, summary) {
|
|
3782
4705
|
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`;
|
|
3783
|
-
|
|
4706
|
+
const written = await writeArtifact(projectRoot, runId, 'sync-back-proposal.md', content);
|
|
4707
|
+
return { ...written, digest: hashDocumentContent(content) };
|
|
3784
4708
|
}
|
|
3785
4709
|
function toSafeRecordId(value) {
|
|
3786
4710
|
const sanitized = value.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
@@ -3796,6 +4720,22 @@ function routeRecordSnapshot(route) {
|
|
|
3796
4720
|
blockedReason: route.blockedReason
|
|
3797
4721
|
};
|
|
3798
4722
|
}
|
|
4723
|
+
function routeRecordId(route, profile) {
|
|
4724
|
+
const content = JSON.stringify({
|
|
4725
|
+
version: route.version,
|
|
4726
|
+
taskId: route.taskId,
|
|
4727
|
+
branch: route.branch,
|
|
4728
|
+
category: route.category,
|
|
4729
|
+
profile,
|
|
4730
|
+
recommendedProfile: route.recommendedProfile,
|
|
4731
|
+
autonomyCeiling: route.autonomyCeiling,
|
|
4732
|
+
requiredCapabilities: route.requiredCapabilities,
|
|
4733
|
+
toolPermissionProfile: route.toolPermission?.profile ?? null,
|
|
4734
|
+
toolPermissionPolicy: route.toolPermission?.policy ?? null,
|
|
4735
|
+
blockedReason: route.blockedReason
|
|
4736
|
+
});
|
|
4737
|
+
return createHash('sha256').update(content).digest('hex').slice(0, 16);
|
|
4738
|
+
}
|
|
3799
4739
|
function sourceAttributionForCapabilities(capabilityIds, route) {
|
|
3800
4740
|
return capabilityIds.map((capabilityId) => {
|
|
3801
4741
|
const routeSource = route?.registrySources?.find((source) => source.kind === 'skill_capability' && source.id === capabilityId);
|
|
@@ -3833,6 +4773,7 @@ function buildAgentExecutionRecord(input) {
|
|
|
3833
4773
|
queueItemId: input.queueItemId ?? null,
|
|
3834
4774
|
ingestionStatus: input.ingestion?.status ?? null,
|
|
3835
4775
|
resultStatus: input.ingestion?.resultStatus ?? null,
|
|
4776
|
+
routeId: routeRecordId(input.route, profile),
|
|
3836
4777
|
routeDecision: routeRecordSnapshot(input.route),
|
|
3837
4778
|
evidenceSummary: input.evidenceSummary,
|
|
3838
4779
|
createdAt: now,
|
|
@@ -3871,7 +4812,170 @@ function renderLoopGapReport(taskId, gaps) {
|
|
|
3871
4812
|
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`;
|
|
3872
4813
|
}
|
|
3873
4814
|
function renderAcceptanceCoverageArtifact(taskId, status, task, reviewArtifact, validationArtifact, coverage, gaps) {
|
|
3874
|
-
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`;
|
|
4815
|
+
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## Policy Decisions\n\n${coverage.length > 0 ? coverage.map((item) => `- ${item.acceptance}: status=${item.policyDecision?.status ?? item.status}; ruleset=${item.policyDecision?.ruleSet.id ?? ACCEPTANCE_POLICY_RULESET_VERSION}; passed=${item.policyDecision?.passedRules.join(',') || 'none'}; failed=${item.policyDecision?.failedRules.join(',') || 'none'}; issues=${item.issueCodes?.join(',') || 'none'}`).join('\n') : '- none'}\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`;
|
|
4816
|
+
}
|
|
4817
|
+
function evaluateAcceptanceCoverageTarget(target, input) {
|
|
4818
|
+
const matchingClaims = input.claims.filter((claim) => claim.task === input.taskId && acceptanceMatchesTarget(target, claim.acceptance));
|
|
4819
|
+
const issueCodes = [];
|
|
4820
|
+
const failedRules = [];
|
|
4821
|
+
if (matchingClaims.length === 0) {
|
|
4822
|
+
if (target.matchTexts.some((text) => text.length > 0 && input.validationRaw.toLowerCase().includes(text.toLowerCase()))) {
|
|
4823
|
+
issueCodes.push('MENTION_ONLY');
|
|
4824
|
+
failedRules.push('require-structured-evidence');
|
|
4825
|
+
return acceptanceCoverageDecision(target.label, 'REFERENCED_ONLY', `Referenced ${target.label} in ${input.validationArtifact ?? 'validator artifact'} without policy-backed evidence.`, issueCodes, [], failedRules);
|
|
4826
|
+
}
|
|
4827
|
+
issueCodes.push('MISSING_ARTIFACT_REFERENCE');
|
|
4828
|
+
failedRules.push('require-acceptance-claim');
|
|
4829
|
+
return acceptanceCoverageDecision(target.label, 'MISSING', 'No policy-backed acceptance evidence found in validator artifact.', issueCodes, [], failedRules);
|
|
4830
|
+
}
|
|
4831
|
+
let best = matchingClaims[0];
|
|
4832
|
+
for (const claim of matchingClaims.slice(1)) {
|
|
4833
|
+
if (coverageRank(claim.status) > coverageRank(best.status)) {
|
|
4834
|
+
best = claim;
|
|
4835
|
+
}
|
|
4836
|
+
}
|
|
4837
|
+
if (best.status === 'PASS') {
|
|
4838
|
+
if (best.evidence.length === 0) {
|
|
4839
|
+
issueCodes.push('UNSOURCED_PASS');
|
|
4840
|
+
failedRules.push('require-source-evidence');
|
|
4841
|
+
}
|
|
4842
|
+
if (best.provenance.length === 0) {
|
|
4843
|
+
issueCodes.push('PROVENANCE_GAP');
|
|
4844
|
+
failedRules.push('require-provenance');
|
|
4845
|
+
}
|
|
4846
|
+
if (best.policy.length === 0) {
|
|
4847
|
+
issueCodes.push('POLICY_RULE_FAILED');
|
|
4848
|
+
failedRules.push('require-policy-rule');
|
|
4849
|
+
}
|
|
4850
|
+
if (input.validationStatus !== 'PASS') {
|
|
4851
|
+
issueCodes.push('POLICY_RULE_FAILED');
|
|
4852
|
+
failedRules.push('require-validator-pass');
|
|
4853
|
+
}
|
|
4854
|
+
const ledgerDecision = evaluateClaimLedgerCorroboration(best, input.invocationLedger);
|
|
4855
|
+
issueCodes.push(...ledgerDecision.issueCodes);
|
|
4856
|
+
failedRules.push(...ledgerDecision.failedRules);
|
|
4857
|
+
if (issueCodes.length === 0) {
|
|
4858
|
+
return acceptanceCoverageDecision(target.label, 'PASS', `Policy-proven by ${SDD_EVIDENCE_CONTRACT} claim for ${best.acceptance} in ${best.sourceArtifact}; evidence=${best.evidence.map((item) => `${item.kind}:${item.ref}`).join(', ')}; provenance=${best.provenance.join(', ')}; policy=${best.policy.join(', ')}.`, [], ['require-source-evidence', 'require-provenance', 'require-policy-rule', 'require-validator-pass', 'require-ledger-corroboration'], []);
|
|
4859
|
+
}
|
|
4860
|
+
return acceptanceCoverageDecision(target.label, 'BLOCKED', `PASS claim for ${best.acceptance} is missing required policy/provenance corroboration.`, issueCodes, [], failedRules);
|
|
4861
|
+
}
|
|
4862
|
+
if (best.status === 'FAIL') {
|
|
4863
|
+
return acceptanceCoverageDecision(target.label, 'FAIL', `Explicit FAIL claim for ${best.acceptance}: ${best.claim}`, [], ['explicit-fail-overrides-pass'], []);
|
|
4864
|
+
}
|
|
4865
|
+
if (best.status === 'BLOCKED') {
|
|
4866
|
+
return acceptanceCoverageDecision(target.label, 'BLOCKED', `Explicit BLOCKED claim for ${best.acceptance}: ${best.claim}`, [], ['explicit-blocked-overrides-pass'], []);
|
|
4867
|
+
}
|
|
4868
|
+
if (best.status === 'REFERENCED_ONLY') {
|
|
4869
|
+
return acceptanceCoverageDecision(target.label, 'REFERENCED_ONLY', `Structured evidence references ${best.acceptance} but does not prove PASS.`, ['MENTION_ONLY'], [], ['require-pass-claim']);
|
|
4870
|
+
}
|
|
4871
|
+
return acceptanceCoverageDecision(target.label, 'MISSING', `Structured evidence marks ${best.acceptance} missing.`, ['MISSING_ARTIFACT_REFERENCE'], [], ['require-pass-claim']);
|
|
4872
|
+
}
|
|
4873
|
+
function evaluateClaimLedgerCorroboration(claim, entries) {
|
|
4874
|
+
const issueCodes = [];
|
|
4875
|
+
const failedRules = [];
|
|
4876
|
+
const commandRefs = invocationLedgerRefs(entries, 'command');
|
|
4877
|
+
const artifactRefs = invocationLedgerRefs(entries, 'artifact_hash');
|
|
4878
|
+
const materialRefs = ledgerMaterialRefs(entries);
|
|
4879
|
+
const addIssue = (code, rule) => {
|
|
4880
|
+
if (!issueCodes.includes(code)) {
|
|
4881
|
+
issueCodes.push(code);
|
|
4882
|
+
}
|
|
4883
|
+
if (!failedRules.includes(rule)) {
|
|
4884
|
+
failedRules.push(rule);
|
|
4885
|
+
}
|
|
4886
|
+
};
|
|
4887
|
+
if (!artifactRefs.has(claim.sourceArtifact)) {
|
|
4888
|
+
addIssue('MISSING_ARTIFACT_REFERENCE', 'require-source-artifact-hash');
|
|
4889
|
+
}
|
|
4890
|
+
for (const item of claim.evidence) {
|
|
4891
|
+
if (item.kind === 'command' && !commandRefs.has(item.ref)) {
|
|
4892
|
+
addIssue('MISSING_COMMAND_OUTPUT', 'require-command-ledger');
|
|
4893
|
+
}
|
|
4894
|
+
if (item.kind === 'artifact' && !artifactRefs.has(item.ref)) {
|
|
4895
|
+
addIssue('MISSING_ARTIFACT_REFERENCE', 'require-artifact-hash');
|
|
4896
|
+
}
|
|
4897
|
+
if (item.kind === 'material' && !materialRefs.has(item.ref)) {
|
|
4898
|
+
addIssue('MISSING_MATERIAL_REFERENCE', 'require-material-ledger');
|
|
4899
|
+
}
|
|
4900
|
+
}
|
|
4901
|
+
for (const ref of claim.provenance) {
|
|
4902
|
+
const typed = parseTypedLedgerRef(ref);
|
|
4903
|
+
if (!typed) {
|
|
4904
|
+
continue;
|
|
4905
|
+
}
|
|
4906
|
+
if (typed.kind === 'command' && !commandRefs.has(typed.ref)) {
|
|
4907
|
+
addIssue('PROVENANCE_GAP', 'require-command-provenance-ledger');
|
|
4908
|
+
}
|
|
4909
|
+
if (typed.kind === 'artifact' && !artifactRefs.has(typed.ref)) {
|
|
4910
|
+
addIssue('PROVENANCE_GAP', 'require-artifact-provenance-ledger');
|
|
4911
|
+
}
|
|
4912
|
+
if (typed.kind === 'material' && !materialRefs.has(typed.ref)) {
|
|
4913
|
+
addIssue('PROVENANCE_GAP', 'require-material-provenance-ledger');
|
|
4914
|
+
}
|
|
4915
|
+
}
|
|
4916
|
+
return { issueCodes, failedRules };
|
|
4917
|
+
}
|
|
4918
|
+
function invocationLedgerRefs(entries, kind) {
|
|
4919
|
+
const refs = new Set();
|
|
4920
|
+
for (const entry of entries) {
|
|
4921
|
+
if (entry.kind === kind) {
|
|
4922
|
+
refs.add(entry.ref);
|
|
4923
|
+
if (entry.artifactPath) {
|
|
4924
|
+
refs.add(entry.artifactPath);
|
|
4925
|
+
}
|
|
4926
|
+
}
|
|
4927
|
+
}
|
|
4928
|
+
return refs;
|
|
4929
|
+
}
|
|
4930
|
+
function parseTypedLedgerRef(value) {
|
|
4931
|
+
const separator = value.indexOf(':');
|
|
4932
|
+
if (separator <= 0) {
|
|
4933
|
+
return null;
|
|
4934
|
+
}
|
|
4935
|
+
const kind = value.slice(0, separator).trim();
|
|
4936
|
+
const ref = value.slice(separator + 1).trim();
|
|
4937
|
+
if (!/^[A-Za-z0-9_-]+$/.test(kind) || !ref) {
|
|
4938
|
+
return null;
|
|
4939
|
+
}
|
|
4940
|
+
return { kind, ref };
|
|
4941
|
+
}
|
|
4942
|
+
function acceptanceMatchesTarget(target, acceptance) {
|
|
4943
|
+
const normalizedAcceptance = acceptance.toLowerCase();
|
|
4944
|
+
return target.matchTexts.some((text) => text.toLowerCase() === normalizedAcceptance) || target.label.toLowerCase() === normalizedAcceptance;
|
|
4945
|
+
}
|
|
4946
|
+
function coverageRank(status) {
|
|
4947
|
+
if (status === 'FAIL') {
|
|
4948
|
+
return 5;
|
|
4949
|
+
}
|
|
4950
|
+
if (status === 'BLOCKED') {
|
|
4951
|
+
return 4;
|
|
4952
|
+
}
|
|
4953
|
+
if (status === 'PASS') {
|
|
4954
|
+
return 3;
|
|
4955
|
+
}
|
|
4956
|
+
if (status === 'REFERENCED_ONLY') {
|
|
4957
|
+
return 2;
|
|
4958
|
+
}
|
|
4959
|
+
return 1;
|
|
4960
|
+
}
|
|
4961
|
+
function acceptanceCoverageDecision(acceptance, status, evidence, issueCodes, passedRules, failedRules) {
|
|
4962
|
+
return {
|
|
4963
|
+
acceptance,
|
|
4964
|
+
status,
|
|
4965
|
+
evidence,
|
|
4966
|
+
issueCodes,
|
|
4967
|
+
policyDecision: {
|
|
4968
|
+
status,
|
|
4969
|
+
ruleSet: {
|
|
4970
|
+
id: ACCEPTANCE_POLICY_RULESET_VERSION,
|
|
4971
|
+
version: SDD_EVIDENCE_VERSION,
|
|
4972
|
+
ruleIds: ['require-structured-evidence', 'require-source-evidence', 'require-provenance', 'require-policy-rule']
|
|
4973
|
+
},
|
|
4974
|
+
passedRules,
|
|
4975
|
+
failedRules,
|
|
4976
|
+
issueCodes
|
|
4977
|
+
}
|
|
4978
|
+
};
|
|
3875
4979
|
}
|
|
3876
4980
|
function taskAcceptanceCoverageTargets(task) {
|
|
3877
4981
|
if (task.acceptanceRefs.length > 0) {
|
|
@@ -3890,21 +4994,6 @@ function taskAcceptanceCoverageTargets(task) {
|
|
|
3890
4994
|
matchTexts: [acceptance]
|
|
3891
4995
|
}));
|
|
3892
4996
|
}
|
|
3893
|
-
function statusFromValidation(status) {
|
|
3894
|
-
if (status === 'PASS') {
|
|
3895
|
-
return 'PASS';
|
|
3896
|
-
}
|
|
3897
|
-
if (status === 'PASS_WITH_GAPS') {
|
|
3898
|
-
return 'PASS_WITH_GAPS';
|
|
3899
|
-
}
|
|
3900
|
-
if (status === 'FAIL') {
|
|
3901
|
-
return 'FAIL';
|
|
3902
|
-
}
|
|
3903
|
-
if (status === 'BLOCKED' || status === 'TIMED_OUT' || status === 'CANCELLED') {
|
|
3904
|
-
return 'BLOCKED';
|
|
3905
|
-
}
|
|
3906
|
-
return 'GAP';
|
|
3907
|
-
}
|
|
3908
4997
|
function deriveGoalVerifyStatus(reviewStatus, validationStatus, gaps) {
|
|
3909
4998
|
if (gaps.length > 0) {
|
|
3910
4999
|
return validationStatus === 'PASS_WITH_GAPS' ? 'PASS_WITH_GAPS' : 'BLOCKED';
|
|
@@ -5316,17 +6405,35 @@ export async function inspectTeamModePolicy(projectRoot, options = {}) {
|
|
|
5316
6405
|
const autonomyCeiling = task ? taskAutonomyCeiling(task) : 'research_before_implementation';
|
|
5317
6406
|
return buildTeamModePolicy({ activation, task, category, risk: task?.risk ?? [], autonomyCeiling, blockedReason: blockingGap?.message ?? null });
|
|
5318
6407
|
}
|
|
5319
|
-
export async function routeSddTask(projectRoot, options) {
|
|
6408
|
+
export async function routeSddTask(projectRoot, options = { taskId: '' }) {
|
|
6409
|
+
const profileSpans = [];
|
|
6410
|
+
const routeStart = Date.now();
|
|
6411
|
+
const routeStartedAt = new Date(routeStart).toISOString();
|
|
5320
6412
|
const registry = await buildAgentSkillRuntimeRegistry(projectRoot);
|
|
6413
|
+
profileSpans.push(runtimeProfileSpan('agent_runtime_registry', routeStart));
|
|
6414
|
+
const branchStart = Date.now();
|
|
5321
6415
|
const branch = options.branch ?? (await resolveSddContext(projectRoot)).branch;
|
|
5322
6416
|
const model = await parseSddBranch(projectRoot, branch);
|
|
6417
|
+
profileSpans.push(runtimeProfileSpan('document_parse', branchStart));
|
|
6418
|
+
const cacheKey = routeCacheKey({ taskId: options.taskId, branch, agent: options.agent ?? null, teamModeActivation: resolveTeamModeActivation(options, 'auto'), documents: model.documents });
|
|
6419
|
+
const cachedDecision = options.cache ? await readRouteCache(projectRoot, cacheKey) : null;
|
|
6420
|
+
if (cachedDecision) {
|
|
6421
|
+
return {
|
|
6422
|
+
...cachedDecision,
|
|
6423
|
+
cache: { contract: ROUTE_CACHE_CONTRACT_VERSION, key: cacheKey, status: 'hit', source: 'content_addressed_derived_route', authoritative: false },
|
|
6424
|
+
profile: options.profile ? [...(cachedDecision.profile ?? []), runtimeProfileSpan('route_total', routeStart, routeStartedAt)] : undefined
|
|
6425
|
+
};
|
|
6426
|
+
}
|
|
6427
|
+
const computeStart = Date.now();
|
|
5323
6428
|
const inspected = inspectSddTask(model, options.taskId);
|
|
5324
6429
|
const task = inspected.task;
|
|
5325
6430
|
const blockingGap = inspected.gaps.find((gap) => gap.severity === 'blocking');
|
|
5326
6431
|
const matchedRules = task && !blockingGap ? matchRoutingRules(task, registry) : [];
|
|
5327
6432
|
const profileSelection = task && !blockingGap ? deriveAllowedProfiles(task, registry, matchedRules) : { profiles: [], resolvedAliases: [] };
|
|
5328
|
-
const
|
|
5329
|
-
const
|
|
6433
|
+
const delegatedProfile = task && !blockingGap && options.agent ? toAgentProfileId(options.agent, registry) : null;
|
|
6434
|
+
const allowedProfiles = delegatedProfile && !profileSelection.profiles.includes(delegatedProfile) ? [...profileSelection.profiles, delegatedProfile] : profileSelection.profiles;
|
|
6435
|
+
const taskRecommendedProfile = task && !blockingGap ? chooseRecommendedProfile(task, allowedProfiles, registry, matchedRules) : null;
|
|
6436
|
+
const recommendedProfile = delegatedProfile ?? taskRecommendedProfile;
|
|
5330
6437
|
const category = task ? routeCategory(task, blockingGap, allowedProfiles, matchedRules) : 'blocked';
|
|
5331
6438
|
const autonomyCeiling = task ? taskAutonomyCeiling(task) : 'research_before_implementation';
|
|
5332
6439
|
const requiredCapabilities = task && recommendedProfile ? selectRequiredSkillCapabilities(task, recommendedProfile, registry, matchedRules) : [];
|
|
@@ -5336,7 +6443,7 @@ export async function routeSddTask(projectRoot, options) {
|
|
|
5336
6443
|
const routedCategory = blockedReason ? 'blocked' : category;
|
|
5337
6444
|
const registrySources = routeRegistrySources(registry, recommendedProfile, requiredCapabilities);
|
|
5338
6445
|
const adapterMapping = recommendedProfile ? adapterMappingForProfile(registry, recommendedProfile) : null;
|
|
5339
|
-
|
|
6446
|
+
const decision = {
|
|
5340
6447
|
version: AGENT_ROUTER_CONTRACT_VERSION,
|
|
5341
6448
|
taskId: options.taskId,
|
|
5342
6449
|
branch,
|
|
@@ -5365,8 +6472,66 @@ export async function routeSddTask(projectRoot, options) {
|
|
|
5365
6472
|
resolvedAliases: profileSelection.resolvedAliases,
|
|
5366
6473
|
routingRuleHits: matchedRules.map((rule) => rule.id),
|
|
5367
6474
|
quarantineWarnings: quarantineWarningsForSources(registrySources),
|
|
5368
|
-
adapterMapping
|
|
6475
|
+
adapterMapping,
|
|
6476
|
+
cache: options.cache ? { contract: ROUTE_CACHE_CONTRACT_VERSION, key: cacheKey, status: 'miss', source: 'content_addressed_derived_route', authoritative: false } : undefined
|
|
5369
6477
|
};
|
|
6478
|
+
profileSpans.push(runtimeProfileSpan('route_compute', computeStart));
|
|
6479
|
+
if (options.cache) {
|
|
6480
|
+
await writeRouteCache(projectRoot, cacheKey, decision);
|
|
6481
|
+
decision.cache = { contract: ROUTE_CACHE_CONTRACT_VERSION, key: cacheKey, status: 'stored', source: 'content_addressed_derived_route', authoritative: false };
|
|
6482
|
+
}
|
|
6483
|
+
if (options.profile) {
|
|
6484
|
+
decision.profile = [...profileSpans, runtimeProfileSpan('route_total', routeStart, routeStartedAt)];
|
|
6485
|
+
}
|
|
6486
|
+
return decision;
|
|
6487
|
+
}
|
|
6488
|
+
function runtimeProfileSpan(name, startedAtMs, startedAt = new Date(startedAtMs).toISOString()) {
|
|
6489
|
+
const endedAtMs = Date.now();
|
|
6490
|
+
return {
|
|
6491
|
+
contract: RUNTIME_PROFILE_CONTRACT_VERSION,
|
|
6492
|
+
name,
|
|
6493
|
+
startedAt,
|
|
6494
|
+
endedAt: new Date(endedAtMs).toISOString(),
|
|
6495
|
+
durationMs: Math.max(0, endedAtMs - startedAtMs)
|
|
6496
|
+
};
|
|
6497
|
+
}
|
|
6498
|
+
function routeCacheKey(input) {
|
|
6499
|
+
return createHash('sha256')
|
|
6500
|
+
.update(JSON.stringify({
|
|
6501
|
+
contract: ROUTE_CACHE_CONTRACT_VERSION,
|
|
6502
|
+
router: AGENT_ROUTER_CONTRACT_VERSION,
|
|
6503
|
+
teamMode: TEAM_MODE_POLICY_VERSION,
|
|
6504
|
+
policy: ACCEPTANCE_POLICY_RULESET_VERSION,
|
|
6505
|
+
runtime: AGENT_SKILL_TEAM_RUNTIME_CONTRACT_VERSION,
|
|
6506
|
+
capabilities: TOOL_CAPABILITY_REGISTRY_VERSION,
|
|
6507
|
+
plugins: TOOL_PLUGIN_CONTRACT_REGISTRY_VERSION,
|
|
6508
|
+
workers: WORKER_ADAPTER_CONTRACT_REGISTRY_VERSION,
|
|
6509
|
+
taskId: input.taskId,
|
|
6510
|
+
branch: input.branch,
|
|
6511
|
+
agent: input.agent,
|
|
6512
|
+
teamModeActivation: input.teamModeActivation,
|
|
6513
|
+
specHash: input.documents.specHash ?? null,
|
|
6514
|
+
planHash: input.documents.planHash ?? null,
|
|
6515
|
+
tasksHash: input.documents.tasksHash ?? null,
|
|
6516
|
+
planBasedOnSpecHash: input.documents.planBasedOnSpecHash ?? null,
|
|
6517
|
+
tasksBasedOnPlanHash: input.documents.tasksBasedOnPlanHash ?? null
|
|
6518
|
+
}))
|
|
6519
|
+
.digest('hex')
|
|
6520
|
+
.slice(0, 32);
|
|
6521
|
+
}
|
|
6522
|
+
async function readRouteCache(projectRoot, key) {
|
|
6523
|
+
const cachePath = getRouteCachePath(projectRoot, key);
|
|
6524
|
+
if (!await exists(cachePath)) {
|
|
6525
|
+
return null;
|
|
6526
|
+
}
|
|
6527
|
+
const parsed = JSON.parse(await readFile(cachePath, 'utf8'));
|
|
6528
|
+
return parsed.version === AGENT_ROUTER_CONTRACT_VERSION ? parsed : null;
|
|
6529
|
+
}
|
|
6530
|
+
async function writeRouteCache(projectRoot, key, decision) {
|
|
6531
|
+
const cachePath = getRouteCachePath(projectRoot, key);
|
|
6532
|
+
await mkdir(path.dirname(cachePath), { recursive: true });
|
|
6533
|
+
const { cache: _cache, profile: _profile, ...cacheable } = decision;
|
|
6534
|
+
await writeFile(cachePath, `${JSON.stringify(cacheable, null, 2)}\n`, 'utf8');
|
|
5370
6535
|
}
|
|
5371
6536
|
export async function inspectQueryStatusContract(projectRoot) {
|
|
5372
6537
|
await readProjectConfig(projectRoot);
|
|
@@ -5993,6 +7158,9 @@ function baseTeamModePolicy(input) {
|
|
|
5993
7158
|
activation: input.activation,
|
|
5994
7159
|
costClass: input.costClass,
|
|
5995
7160
|
reason: input.reason,
|
|
7161
|
+
costRoute: input.costRoute ?? 'not_applicable',
|
|
7162
|
+
downgradeReason: input.downgradeReason ?? null,
|
|
7163
|
+
trustPolicyEnforced: input.trustPolicyEnforced ?? true,
|
|
5996
7164
|
chiefProfile: 'orchestrator',
|
|
5997
7165
|
memberProfiles,
|
|
5998
7166
|
allowedWaves,
|
|
@@ -6018,7 +7186,9 @@ function buildTeamModePolicy(options) {
|
|
|
6018
7186
|
decision: 'blocked',
|
|
6019
7187
|
costClass: 'none',
|
|
6020
7188
|
reason: options.blockedReason,
|
|
6021
|
-
blockedReason: options.blockedReason
|
|
7189
|
+
blockedReason: options.blockedReason,
|
|
7190
|
+
costRoute: 'blocked',
|
|
7191
|
+
trustPolicyEnforced: true
|
|
6022
7192
|
});
|
|
6023
7193
|
}
|
|
6024
7194
|
if (activation === 'off') {
|
|
@@ -6028,7 +7198,9 @@ function buildTeamModePolicy(options) {
|
|
|
6028
7198
|
enabled: false,
|
|
6029
7199
|
decision: 'disabled',
|
|
6030
7200
|
costClass: 'none',
|
|
6031
|
-
reason: 'Team-mode automation disabled for this route.'
|
|
7201
|
+
reason: 'Team-mode automation disabled for this route.',
|
|
7202
|
+
costRoute: 'not_applicable',
|
|
7203
|
+
trustPolicyEnforced: true
|
|
6032
7204
|
});
|
|
6033
7205
|
}
|
|
6034
7206
|
const task = options.task ?? null;
|
|
@@ -6045,6 +7217,8 @@ function buildTeamModePolicy(options) {
|
|
|
6045
7217
|
decision: 'enabled',
|
|
6046
7218
|
costClass: 'high',
|
|
6047
7219
|
reason: 'Security-sensitive task automatically requires bounded security-research team evidence.',
|
|
7220
|
+
costRoute: 'no_downgrade',
|
|
7221
|
+
trustPolicyEnforced: true,
|
|
6048
7222
|
allowedWaves: selectTeamWaves(['security_research', 'validation']),
|
|
6049
7223
|
memberProfiles: ['security', 'reviewer', 'validator'],
|
|
6050
7224
|
maxMembers: 3
|
|
@@ -6058,6 +7232,8 @@ function buildTeamModePolicy(options) {
|
|
|
6058
7232
|
decision: 'enabled',
|
|
6059
7233
|
costClass: highRisk ? 'high' : 'medium',
|
|
6060
7234
|
reason: 'High-risk or research-before-implementation task automatically requires adversarial planning/review evidence.',
|
|
7235
|
+
costRoute: 'no_downgrade',
|
|
7236
|
+
trustPolicyEnforced: true,
|
|
6061
7237
|
allowedWaves: selectTeamWaves(['hyperplan', 'implementation_review', 'validation']),
|
|
6062
7238
|
memberProfiles: ['architect', 'reviewer', 'security', 'validator'],
|
|
6063
7239
|
maxMembers: 4
|
|
@@ -6071,6 +7247,9 @@ function buildTeamModePolicy(options) {
|
|
|
6071
7247
|
decision: 'enabled',
|
|
6072
7248
|
costClass: 'low',
|
|
6073
7249
|
reason: activation === 'force' ? 'Team-mode was forced, so router selects the lowest-cost review-lite team.' : 'Task metadata indicates review or validation evidence is useful, so router selects review-lite.',
|
|
7250
|
+
costRoute: activation === 'force' ? 'no_downgrade' : 'downgraded',
|
|
7251
|
+
downgradeReason: activation === 'force' ? null : 'Low-cost review-lite route keeps reviewer/validator artifacts and policy-backed verify mandatory.',
|
|
7252
|
+
trustPolicyEnforced: true,
|
|
6074
7253
|
allowedWaves: selectTeamWaves(['implementation_review', 'validation']),
|
|
6075
7254
|
memberProfiles: ['reviewer', 'validator'],
|
|
6076
7255
|
maxMembers: 2
|
|
@@ -6082,7 +7261,10 @@ function buildTeamModePolicy(options) {
|
|
|
6082
7261
|
enabled: false,
|
|
6083
7262
|
decision: 'disabled',
|
|
6084
7263
|
costClass: 'none',
|
|
6085
|
-
reason: 'Low-risk task does not need an agent team.'
|
|
7264
|
+
reason: 'Low-risk task does not need an agent team.',
|
|
7265
|
+
costRoute: 'downgraded',
|
|
7266
|
+
downgradeReason: 'Low-risk task uses no team automation, but artifact trust policy and policy-backed verify remain mandatory.',
|
|
7267
|
+
trustPolicyEnforced: true
|
|
6086
7268
|
});
|
|
6087
7269
|
}
|
|
6088
7270
|
function modelPolicyForProfile(profile, registry) {
|
|
@@ -7151,9 +8333,9 @@ function isTaskGap(value) {
|
|
|
7151
8333
|
function isRecord(value) {
|
|
7152
8334
|
return typeof value === 'object' && value !== null;
|
|
7153
8335
|
}
|
|
7154
|
-
async function inspectDocumentChainEvidence(projectRoot) {
|
|
8336
|
+
async function inspectDocumentChainEvidence(projectRoot, branch) {
|
|
7155
8337
|
try {
|
|
7156
|
-
const context = await resolveSddContext(projectRoot);
|
|
8338
|
+
const context = await resolveSddContext(projectRoot, branch ? { branch, branchSource: 'cli_option' } : {});
|
|
7157
8339
|
const model = await parseSddBranch(projectRoot, context.branch);
|
|
7158
8340
|
if (!model.documents.specExists || !model.documents.tasksExists) {
|
|
7159
8341
|
return [{
|
|
@@ -7307,6 +8489,119 @@ function routePreflightNeedsTeamSession(event) {
|
|
|
7307
8489
|
const teamMode = decision.teamMode;
|
|
7308
8490
|
return isRecord(teamMode) && (teamMode.decision === 'enabled' || teamMode.decision === 'blocked');
|
|
7309
8491
|
}
|
|
8492
|
+
function acceptanceCoverageEntries(taskState) {
|
|
8493
|
+
if (!isRecord(taskState) || !Array.isArray(taskState.acceptanceCoverage)) {
|
|
8494
|
+
return [];
|
|
8495
|
+
}
|
|
8496
|
+
return taskState.acceptanceCoverage.filter(isRecord);
|
|
8497
|
+
}
|
|
8498
|
+
function ledgerMaterialRefs(entries) {
|
|
8499
|
+
const refs = new Set();
|
|
8500
|
+
for (const entry of entries) {
|
|
8501
|
+
if (entry.kind === 'material') {
|
|
8502
|
+
refs.add(entry.ref);
|
|
8503
|
+
}
|
|
8504
|
+
for (const ref of entry.materialRefs) {
|
|
8505
|
+
refs.add(ref);
|
|
8506
|
+
}
|
|
8507
|
+
}
|
|
8508
|
+
return refs;
|
|
8509
|
+
}
|
|
8510
|
+
function claimMaterialRefs(claim) {
|
|
8511
|
+
const refs = [];
|
|
8512
|
+
for (const item of claim.evidence) {
|
|
8513
|
+
if (item.kind === 'material') {
|
|
8514
|
+
refs.push(item.ref);
|
|
8515
|
+
}
|
|
8516
|
+
}
|
|
8517
|
+
for (const ref of claim.provenance) {
|
|
8518
|
+
if (ref.startsWith('material:')) {
|
|
8519
|
+
refs.push(ref.slice('material:'.length));
|
|
8520
|
+
}
|
|
8521
|
+
}
|
|
8522
|
+
return refs;
|
|
8523
|
+
}
|
|
8524
|
+
async function inspectRunTrustEvidence(projectRoot, state, invocationLedger) {
|
|
8525
|
+
const checks = [];
|
|
8526
|
+
const runId = state.runId;
|
|
8527
|
+
const materials = ledgerMaterialRefs(invocationLedger);
|
|
8528
|
+
let inspectedTrustArtifacts = 0;
|
|
8529
|
+
if (state.syncBack.status !== 'not_created') {
|
|
8530
|
+
if (!state.syncBack.proposalPath) {
|
|
8531
|
+
checks.push({ level: 'FAIL', check: 'sync_back_monotonicity', message: `${runId}: sync-back status is ${state.syncBack.status} but proposalPath is missing.`, action: 'Keep sync-back transitions monotonic and preserve the proposal path from verify/apply.' });
|
|
8532
|
+
}
|
|
8533
|
+
else if (!state.syncBack.proposalDigest) {
|
|
8534
|
+
checks.push({ level: 'WARN', check: 'sync_back_proposal_digest', message: `${runId}: sync-back proposal ${state.syncBack.proposalPath} has no recorded digest.`, action: 'Re-run verify with the Phase 6.9 runtime so inspect/apply can detect proposal drift.' });
|
|
8535
|
+
}
|
|
8536
|
+
else {
|
|
8537
|
+
try {
|
|
8538
|
+
const proposal = await readArtifact(projectRoot, runId, toArtifactRootRelativePath(state.syncBack.proposalPath));
|
|
8539
|
+
const digest = hashDocumentContent(proposal);
|
|
8540
|
+
if (digest !== state.syncBack.proposalDigest) {
|
|
8541
|
+
checks.push({ level: 'FAIL', check: 'sync_back_proposal_digest', message: `${runId}: sync-back proposal ${state.syncBack.proposalPath} digest changed from ${state.syncBack.proposalDigest} to ${digest}.`, action: 'Restore the verified proposal or re-run verify before sync-back apply.' });
|
|
8542
|
+
}
|
|
8543
|
+
}
|
|
8544
|
+
catch (error) {
|
|
8545
|
+
checks.push({ level: 'FAIL', check: 'sync_back_proposal_digest', message: `${runId}: cannot read sync-back proposal ${state.syncBack.proposalPath}: ${messageFromError(error)}`, action: 'Restore the proposal artifact before sync-back inspect/apply.' });
|
|
8546
|
+
}
|
|
8547
|
+
}
|
|
8548
|
+
}
|
|
8549
|
+
for (const [taskId, taskState] of Object.entries(state.tasks)) {
|
|
8550
|
+
for (const coverage of acceptanceCoverageEntries(taskState)) {
|
|
8551
|
+
if (coverage.status === 'PASS') {
|
|
8552
|
+
const evidence = typeof coverage.evidence === 'string' ? coverage.evidence : '';
|
|
8553
|
+
const issueCodes = Array.isArray(coverage.issueCodes) ? coverage.issueCodes.join(',') : '';
|
|
8554
|
+
if (/Mentioned in artifacts\//i.test(evidence) || !isRecord(coverage.policyDecision) || issueCodes.includes('MENTION_ONLY')) {
|
|
8555
|
+
checks.push({ level: 'FAIL', check: 'acceptance_trust', message: `${runId}/${taskId}: acceptance ${String(coverage.acceptance ?? 'unknown')} is PASS without policy-backed source evidence.`, action: `Use ${SDD_EVIDENCE_CONTRACT} claims; mention-only acceptance coverage cannot satisfy PASS.` });
|
|
8556
|
+
}
|
|
8557
|
+
if (containsTemplatePlaceholder(evidence)) {
|
|
8558
|
+
checks.push({ level: 'FAIL', check: 'acceptance_trust', message: `${runId}/${taskId}: acceptance ${String(coverage.acceptance ?? 'unknown')} PASS evidence contains template placeholder text.`, action: 'Replace generated TODO/template acceptance text with real source evidence.' });
|
|
8559
|
+
}
|
|
8560
|
+
}
|
|
8561
|
+
}
|
|
8562
|
+
}
|
|
8563
|
+
for (const artifact of state.artifacts) {
|
|
8564
|
+
const looksLikeValidatorArtifact = artifact.agent === 'validator' || /(?:^|\/)validation-[^/]+\.md$/i.test(artifact.path);
|
|
8565
|
+
if (!looksLikeValidatorArtifact) {
|
|
8566
|
+
continue;
|
|
8567
|
+
}
|
|
8568
|
+
try {
|
|
8569
|
+
const artifactRootRelativePath = toArtifactRootRelativePath(artifact.path);
|
|
8570
|
+
const raw = await readArtifact(projectRoot, runId, artifactRootRelativePath);
|
|
8571
|
+
const parsed = parseSddResultMarkdown(raw);
|
|
8572
|
+
if (!parsed.result || parsed.result.agent !== 'validator' || (parsed.result.status !== 'PASS' && parsed.result.status !== 'PASS_WITH_GAPS')) {
|
|
8573
|
+
continue;
|
|
8574
|
+
}
|
|
8575
|
+
inspectedTrustArtifacts += 1;
|
|
8576
|
+
const trust = validateArtifactTrust(raw, parsed.result, artifact.path, { expectedTask: artifact.task ?? parsed.result.task, expectedAgent: 'validator' });
|
|
8577
|
+
if (!trust.valid) {
|
|
8578
|
+
for (const issue of trust.issues) {
|
|
8579
|
+
checks.push({ level: 'FAIL', check: 'artifact_trust', message: `${runId}/${artifact.path}: ${issue.message}`, action: issue.recommendation });
|
|
8580
|
+
}
|
|
8581
|
+
}
|
|
8582
|
+
for (const claim of trust.claims) {
|
|
8583
|
+
for (const ref of claimMaterialRefs(claim)) {
|
|
8584
|
+
if (!materials.has(ref)) {
|
|
8585
|
+
checks.push({ level: 'FAIL', check: 'material_provenance', message: `${runId}/${artifact.path}: material evidence ${ref} has no matching invocation ledger entry.`, action: 'Record material/tool/command provenance in invocations.jsonl before using the material as source evidence.' });
|
|
8586
|
+
}
|
|
8587
|
+
}
|
|
8588
|
+
}
|
|
8589
|
+
}
|
|
8590
|
+
catch (error) {
|
|
8591
|
+
checks.push({ level: 'FAIL', check: 'artifact_trust', message: `${runId}/${artifact.path}: cannot inspect validator artifact: ${messageFromError(error)}`, action: 'Restore the validator artifact or remove it from accepted run evidence.' });
|
|
8592
|
+
}
|
|
8593
|
+
}
|
|
8594
|
+
if (inspectedTrustArtifacts > 0 && !checks.some((check) => check.check === 'artifact_trust' || check.check === 'material_provenance')) {
|
|
8595
|
+
checks.push({ level: 'PASS', check: 'artifact_trust', message: `${runId}: inspected ${inspectedTrustArtifacts} validator trust artifact(s).` });
|
|
8596
|
+
}
|
|
8597
|
+
return checks;
|
|
8598
|
+
}
|
|
8599
|
+
function runStateMatchesPartition(state, partition) {
|
|
8600
|
+
if (state.partition === partition) {
|
|
8601
|
+
return true;
|
|
8602
|
+
}
|
|
8603
|
+
return state.gitBranch ? branchToSafePartition(state.gitBranch) === partition : false;
|
|
8604
|
+
}
|
|
7310
8605
|
async function inspectRunEvidence(projectRoot, options = {}) {
|
|
7311
8606
|
const runsDir = getRunsDir(projectRoot);
|
|
7312
8607
|
const entries = await readdir(runsDir, { withFileTypes: true });
|
|
@@ -7323,14 +8618,18 @@ async function inspectRunEvidence(projectRoot, options = {}) {
|
|
|
7323
8618
|
unreadableRunIds.push(runId);
|
|
7324
8619
|
}
|
|
7325
8620
|
}
|
|
7326
|
-
const
|
|
7327
|
-
|
|
8621
|
+
const branchPartition = options.branch ? branchToSafePartition(options.branch) : null;
|
|
8622
|
+
const scopedStates = branchPartition
|
|
8623
|
+
? states.filter((entry) => runStateMatchesPartition(entry.state, branchPartition))
|
|
8624
|
+
: states;
|
|
8625
|
+
const nonArchived = scopedStates.filter((entry) => entry.state.status !== 'archived');
|
|
8626
|
+
let inspected = options.allRuns ? scopedStates : nonArchived;
|
|
7328
8627
|
if (!options.allRuns && options.latestOnly && inspected.length > 0) {
|
|
7329
8628
|
inspected = [inspected.slice().sort((left, right) => Date.parse(right.state.updatedAt) - Date.parse(left.state.updatedAt))[0]];
|
|
7330
8629
|
}
|
|
7331
8630
|
const inspectedRunIds = new Set(inspected.map((entry) => entry.runId));
|
|
7332
|
-
const skippedArchived =
|
|
7333
|
-
const skippedByScope =
|
|
8631
|
+
const skippedArchived = scopedStates.length - nonArchived.length;
|
|
8632
|
+
const skippedByScope = scopedStates.filter((entry) => !inspectedRunIds.has(entry.runId) && entry.state.status !== 'archived').length;
|
|
7334
8633
|
if (skippedArchived > 0 && !options.allRuns) {
|
|
7335
8634
|
checks.push({ level: 'PASS', check: 'run_evidence_scope', message: `Skipped ${skippedArchived} archived run(s); use sdd doctor --all-runs for historical audit.` });
|
|
7336
8635
|
}
|
|
@@ -7362,11 +8661,24 @@ async function inspectRunEvidence(projectRoot, options = {}) {
|
|
|
7362
8661
|
const teamSessionRecords = await listTeamSessionRecords(projectRoot, runId);
|
|
7363
8662
|
const workerRuntimeList = await listResidentWorkerRuntimes(projectRoot, { runId });
|
|
7364
8663
|
const routePreflightEvents = events.filter((event) => event.event === 'agent_router_preflight');
|
|
8664
|
+
const invocationLedger = await listInvocationLedgerEntries(projectRoot, runId);
|
|
7365
8665
|
for (const record of agentExecutionRecords) {
|
|
7366
8666
|
for (const issue of validateAgentExecutionRecordShape(runId, record)) {
|
|
7367
8667
|
issueCount += 1;
|
|
7368
8668
|
checks.push({ level: 'FAIL', check: 'agent_execution_record', message: `${runId}/${record.executionId ?? 'unknown'}: ${issue.message}`, action: issue.recommendation });
|
|
7369
8669
|
}
|
|
8670
|
+
const delegation = record.delegationId ? state.delegations[record.delegationId] : null;
|
|
8671
|
+
if (delegation) {
|
|
8672
|
+
const expectedProfile = toAgentProfileId(delegation.agent);
|
|
8673
|
+
if (record.taskId !== delegation.task) {
|
|
8674
|
+
issueCount += 1;
|
|
8675
|
+
checks.push({ level: 'FAIL', check: 'agent_route_consistency', message: `${runId}/${record.executionId}: execution task ${record.taskId} does not match delegation task ${delegation.task}.`, action: 'Persist per-delegation execution records from the matching route decision.' });
|
|
8676
|
+
}
|
|
8677
|
+
if (expectedProfile && (record.profile !== expectedProfile || record.toolPermission?.profile !== expectedProfile || record.routeDecision.recommendedProfile !== expectedProfile)) {
|
|
8678
|
+
issueCount += 1;
|
|
8679
|
+
checks.push({ level: 'FAIL', check: 'agent_route_consistency', message: `${runId}/${record.executionId}: execution profile/tool-permission/route does not match delegation agent ${delegation.agent}.`, action: 'Route each delegation independently and persist matching profile, tool permission, and route decision.' });
|
|
8680
|
+
}
|
|
8681
|
+
}
|
|
7370
8682
|
}
|
|
7371
8683
|
for (const record of teamSessionRecords) {
|
|
7372
8684
|
for (const issue of validateTeamSessionRecordShape(runId, record)) {
|
|
@@ -7398,6 +8710,10 @@ async function inspectRunEvidence(projectRoot, options = {}) {
|
|
|
7398
8710
|
if (agentExecutionRecords.length > 0 || teamSessionRecords.length > 0 || routePreflightEvents.length > 0) {
|
|
7399
8711
|
checks.push({ level: 'PASS', check: 'agent_team_execution_records', message: `${runId}: inspected ${agentExecutionRecords.length} agent execution record(s), ${teamSessionRecords.length} team session record(s), and ${routePreflightEvents.length} router preflight event(s).` });
|
|
7400
8712
|
}
|
|
8713
|
+
for (const check of await inspectRunTrustEvidence(projectRoot, state, invocationLedger)) {
|
|
8714
|
+
issueCount += check.level === 'PASS' ? 0 : 1;
|
|
8715
|
+
checks.push(check);
|
|
8716
|
+
}
|
|
7401
8717
|
for (const delegation of Object.values(state.delegations)) {
|
|
7402
8718
|
const report = await validateDelegationRecord(projectRoot, runId, delegation);
|
|
7403
8719
|
if (report.stale) {
|
|
@@ -7445,10 +8761,10 @@ async function inspectRunEvidence(projectRoot, options = {}) {
|
|
|
7445
8761
|
checks.push({ level: 'WARN', check: 'run_evidence', message: 'No runs found under .sdd/runs.', action: 'Create a run before /sdd-do or /sdd-verify.' });
|
|
7446
8762
|
}
|
|
7447
8763
|
else if (inspected.length === 0 && issueCount === 0) {
|
|
7448
|
-
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.' });
|
|
8764
|
+
checks.push({ level: 'WARN', check: 'run_evidence', message: branchPartition ? `No non-archived runs were inspected for branch ${branchPartition}.` : 'No non-archived runs were inspected.', action: 'Use sdd doctor --all-runs to audit archived history or create a new run.' });
|
|
7449
8765
|
}
|
|
7450
8766
|
else if (issueCount === 0) {
|
|
7451
|
-
checks.push({ level: 'PASS', check: 'run_evidence', message: `Inspected ${inspected.length} run(s); no stale delegation, invalid artifact, terminal event gap, or resident worker runtime issue found.` });
|
|
8767
|
+
checks.push({ level: 'PASS', check: 'run_evidence', message: `Inspected ${inspected.length} run(s); no stale delegation, invalid artifact, terminal event gap, trust evidence, or resident worker runtime issue found.` });
|
|
7452
8768
|
}
|
|
7453
8769
|
return checks;
|
|
7454
8770
|
}
|
|
@@ -7929,8 +9245,10 @@ async function inspectProjectContextPackDoctorContract(projectRoot) {
|
|
|
7929
9245
|
}
|
|
7930
9246
|
export async function readRunEvents(projectRoot, runId) {
|
|
7931
9247
|
const eventPath = path.join(getRunDir(projectRoot, runId), 'events.jsonl');
|
|
7932
|
-
|
|
7933
|
-
|
|
9248
|
+
await importLegacyRunEventsIfNeeded(projectRoot, runId, eventPath);
|
|
9249
|
+
const storedEvents = await readRuntimeRunEvents(projectRoot, runId);
|
|
9250
|
+
if (storedEvents.length > 0 || !await exists(eventPath)) {
|
|
9251
|
+
return storedEvents;
|
|
7934
9252
|
}
|
|
7935
9253
|
const raw = await readFile(eventPath, 'utf8');
|
|
7936
9254
|
return raw.split(/\r?\n/).filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
|