@wildorder/nightshift 0.16.0 → 0.18.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/README.md +289 -1
- package/dist/agent-runner.d.ts +95 -5
- package/dist/agent-runner.d.ts.map +1 -1
- package/dist/agent-runner.js +442 -32
- package/dist/agent-runner.js.map +1 -1
- package/dist/agent-session.d.ts +123 -0
- package/dist/agent-session.d.ts.map +1 -0
- package/dist/agent-session.js +114 -0
- package/dist/agent-session.js.map +1 -0
- package/dist/atomic-write.d.ts +18 -0
- package/dist/atomic-write.d.ts.map +1 -0
- package/dist/atomic-write.js +74 -0
- package/dist/atomic-write.js.map +1 -0
- package/dist/author.d.ts +118 -4
- package/dist/author.d.ts.map +1 -1
- package/dist/author.js +650 -163
- package/dist/author.js.map +1 -1
- package/dist/causal-analysis.d.ts +216 -0
- package/dist/causal-analysis.d.ts.map +1 -0
- package/dist/causal-analysis.js +786 -0
- package/dist/causal-analysis.js.map +1 -0
- package/dist/cli.js +22 -2
- package/dist/cli.js.map +1 -1
- package/dist/concurrency.d.ts +24 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/concurrency.js +28 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/decider-review.d.ts +13 -1
- package/dist/decider-review.d.ts.map +1 -1
- package/dist/decider-review.js +21 -7
- package/dist/decider-review.js.map +1 -1
- package/dist/decision-ledger.d.ts +7 -0
- package/dist/decision-ledger.d.ts.map +1 -1
- package/dist/decision-ledger.js +25 -10
- package/dist/decision-ledger.js.map +1 -1
- package/dist/detect-package-manager.d.ts +10 -0
- package/dist/detect-package-manager.d.ts.map +1 -1
- package/dist/detect-package-manager.js +12 -0
- package/dist/detect-package-manager.js.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +24 -5
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +254 -108
- package/dist/manifest.js.map +1 -1
- package/dist/permits.d.ts +18 -0
- package/dist/permits.d.ts.map +1 -1
- package/dist/permits.js +155 -0
- package/dist/permits.js.map +1 -1
- package/dist/preflight.d.ts +3 -0
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +73 -57
- package/dist/preflight.js.map +1 -1
- package/dist/prompt-telemetry.d.ts +64 -0
- package/dist/prompt-telemetry.d.ts.map +1 -0
- package/dist/prompt-telemetry.js +112 -0
- package/dist/prompt-telemetry.js.map +1 -0
- package/dist/provider-telemetry.d.ts +106 -0
- package/dist/provider-telemetry.d.ts.map +1 -0
- package/dist/provider-telemetry.js +423 -0
- package/dist/provider-telemetry.js.map +1 -0
- package/dist/review-pass.d.ts +52 -10
- package/dist/review-pass.d.ts.map +1 -1
- package/dist/review-pass.js +114 -30
- package/dist/review-pass.js.map +1 -1
- package/dist/run-analytics-report.d.ts +286 -0
- package/dist/run-analytics-report.d.ts.map +1 -0
- package/dist/run-analytics-report.js +1242 -0
- package/dist/run-analytics-report.js.map +1 -0
- package/dist/run-analytics.d.ts +844 -0
- package/dist/run-analytics.d.ts.map +1 -0
- package/dist/run-analytics.js +777 -0
- package/dist/run-analytics.js.map +1 -0
- package/dist/run-log.d.ts +16 -0
- package/dist/run-log.d.ts.map +1 -0
- package/dist/run-log.js +32 -0
- package/dist/run-log.js.map +1 -0
- package/dist/run-program.d.ts +268 -4
- package/dist/run-program.d.ts.map +1 -1
- package/dist/run-program.js +2508 -762
- package/dist/run-program.js.map +1 -1
- package/dist/scheduler.d.ts +75 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +126 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/verify-cache.d.ts +64 -0
- package/dist/verify-cache.d.ts.map +1 -0
- package/dist/verify-cache.js +56 -0
- package/dist/verify-cache.js.map +1 -0
- package/dist/whole-program-review.d.ts +3 -0
- package/dist/whole-program-review.d.ts.map +1 -1
- package/dist/whole-program-review.js +8 -1
- package/dist/whole-program-review.js.map +1 -1
- package/dist/worktree.d.ts +124 -0
- package/dist/worktree.d.ts.map +1 -0
- package/dist/worktree.js +397 -0
- package/dist/worktree.js.map +1 -0
- package/dist/write-queue.d.ts +25 -0
- package/dist/write-queue.d.ts.map +1 -0
- package/dist/write-queue.js +52 -0
- package/dist/write-queue.js.map +1 -0
- package/package.json +2 -2
- package/skills/plan-program/SKILL.md +37 -5
- package/dist/worktree-guard.d.ts +0 -40
- package/dist/worktree-guard.d.ts.map +0 -1
- package/dist/worktree-guard.js +0 -91
- package/dist/worktree-guard.js.map +0 -1
package/dist/author.js
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import { readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { defaultAgentRunner, describeAgent, invokeAgent, resolveAuthorAgent, resolveDeciderAgent, resolveReviewerAgent, } from "./agent-runner.js";
|
|
4
|
+
import { describeSessionDecline, spawnWithSessionContinuity, SESSION_CONTINUATION_PREAMBLE, } from "./agent-session.js";
|
|
4
5
|
import { resolveSummary, summaryContract } from "./agent-summary.js";
|
|
5
6
|
import { NOOP_PERMITS_CONTEXT } from "./permits.js";
|
|
6
7
|
import { decisionContract, decisionFingerprint, extractDecisions, } from "./decision.js";
|
|
7
|
-
import { appendLedgerEvents } from "./decision-ledger.js";
|
|
8
|
+
import { appendLedgerEvents, readDecisionLedger } from "./decision-ledger.js";
|
|
8
9
|
import { reviewDecisions, triageFindings } from "./decider-review.js";
|
|
9
|
-
import {
|
|
10
|
+
import { fingerprint } from "./findings.js";
|
|
11
|
+
import { findCycles, stableTopologicalOrder } from "./graph.js";
|
|
10
12
|
import { loadManifest, saveManifest, specInputsHash, } from "./manifest.js";
|
|
11
|
-
import { extractFindings, findingsContract, findingsToLedgerEvents, hasRoutableEvidence, locateInRepo, reviewerAbsentOutcome, runReviewPass, verifyEvidence, } from "./review-pass.js";
|
|
12
|
-
import {
|
|
13
|
+
import { extractFindings, findingsContract, findingsToLedgerEvents, hasRoutableEvidence, locateInRepo, reviewerAbsentOutcome, runReviewPass, specCritiqueBudget, verifyEvidence, } from "./review-pass.js";
|
|
14
|
+
import { createBriefBuilder, demotedDependenciesPoint, promptComponentSizePoints, } from "./prompt-telemetry.js";
|
|
15
|
+
import { commitEvidence, defaultGitOps, downstreamCone, timed } from "./run-program.js";
|
|
16
|
+
import { NOOP_RUN_RECORDER, recordWorkstreamWaiting, } from "./run-analytics.js";
|
|
17
|
+
import { schedulerLog, workstreamLog } from "./run-log.js";
|
|
18
|
+
import { createDeferred, createMutex } from "./concurrency.js";
|
|
19
|
+
import { normalizeMaxParallel, runReadySet } from "./scheduler.js";
|
|
20
|
+
import { enqueueProgramWrite, programWriteKey } from "./write-queue.js";
|
|
13
21
|
/**
|
|
14
22
|
* The authoring stage: for every workstream whose spec does not exist, a
|
|
15
23
|
* clean agent writes it — human-first, grounded in the repository,
|
|
@@ -221,22 +229,31 @@ function prerequisitesSection(manifest, workstream) {
|
|
|
221
229
|
lines.push("Acceptance criteria in this spec may assert only outcomes the executing", "agent and the runner control. An outcome that depends on one of the human", "actions above — the infrastructure it creates, the secret it sets — must", "not appear as a criterion this run is expected to turn green: no actor in", "this run can make it true.", "", "End-to-end tests that need a human action are still written now, but", "gated on the prerequisite's observable — skip the test when its", "verifyCommand would not pass, with a skip message that names the HP id", '(for example, "skipped: awaiting HP-01"). The gated test is the honest', "second check: it asserts the real outcome and runs for real once the", "human has acted.", "");
|
|
222
230
|
return lines;
|
|
223
231
|
}
|
|
224
|
-
async function dependencySpecsSection(root, workstream, rosterById) {
|
|
232
|
+
async function dependencySpecsSection(root, workstream, rosterById, specFinal) {
|
|
225
233
|
if (workstream.dependencies.length === 0) {
|
|
226
234
|
return {
|
|
227
235
|
section: ["## Dependency specs", "", "No dependencies declared.", ""],
|
|
228
236
|
demoted: [],
|
|
237
|
+
demotedBytes: 0,
|
|
229
238
|
};
|
|
230
239
|
}
|
|
231
240
|
const entries = await Promise.all(workstream.dependencies
|
|
232
241
|
.map((id) => rosterById.get(id))
|
|
233
242
|
.filter((entry) => entry !== undefined)
|
|
234
|
-
.map(async (entry) =>
|
|
235
|
-
entry
|
|
236
|
-
|
|
237
|
-
|
|
243
|
+
.map(async (entry) => {
|
|
244
|
+
const raw = await readFinishedSpec(root, entry);
|
|
245
|
+
// A dependency whose flow is still running, or that settled without
|
|
246
|
+
// finishing (a parked protocol failure or throw — WS-03 §3.3), is
|
|
247
|
+
// never read as content, even when a non-empty file exists: its
|
|
248
|
+
// bytes on disk may still change (in flight) or were never trusted
|
|
249
|
+
// in the first place (interrupted). It renders as its roster line,
|
|
250
|
+
// exactly like a genuinely missing spec, below.
|
|
251
|
+
const content = raw !== undefined && (specFinal === undefined || specFinal(entry.id)) ? raw : undefined;
|
|
252
|
+
return { entry, content };
|
|
253
|
+
}));
|
|
238
254
|
let total = entries.reduce((sum, e) => sum + (e.content?.length ?? 0), 0);
|
|
239
255
|
const demoted = new Set();
|
|
256
|
+
let demotedBytes = 0;
|
|
240
257
|
const bySizeDesc = entries
|
|
241
258
|
.filter((e) => e.content !== undefined)
|
|
242
259
|
.sort((a, b) => (b.content?.length ?? 0) - (a.content?.length ?? 0));
|
|
@@ -245,6 +262,7 @@ async function dependencySpecsSection(root, workstream, rosterById) {
|
|
|
245
262
|
break;
|
|
246
263
|
demoted.add(e.entry.id);
|
|
247
264
|
total -= e.content?.length ?? 0;
|
|
265
|
+
demotedBytes += e.content === undefined ? 0 : Buffer.byteLength(e.content, "utf8");
|
|
248
266
|
}
|
|
249
267
|
const lines = ["## Dependency specs", ""];
|
|
250
268
|
for (const { entry, content } of entries) {
|
|
@@ -262,7 +280,7 @@ async function dependencySpecsSection(root, workstream, rosterById) {
|
|
|
262
280
|
lines.push(`### ${entry.id} ${entry.name}`, "", content.trim(), "");
|
|
263
281
|
}
|
|
264
282
|
lines.push("");
|
|
265
|
-
return { section: lines, demoted: [...demoted] };
|
|
283
|
+
return { section: lines, demoted: [...demoted], demotedBytes };
|
|
266
284
|
}
|
|
267
285
|
const SPEC_CONTRACT = [
|
|
268
286
|
"## Write a spec for a human",
|
|
@@ -307,42 +325,50 @@ block entirely when you have nothing to add.
|
|
|
307
325
|
function dependenciesContract() {
|
|
308
326
|
return DEPENDENCIES_CONTRACT;
|
|
309
327
|
}
|
|
310
|
-
|
|
328
|
+
/**
|
|
329
|
+
* Builds the author's brief, byte-for-byte identical to the plain
|
|
330
|
+
* `array.join("\n")` this produced before WS-03 — but through a
|
|
331
|
+
* `BriefBuilder` so every line is also classified into an SC-07 prompt
|
|
332
|
+
* component, with `components` summing exactly to the joined brief's byte
|
|
333
|
+
* length.
|
|
334
|
+
*/
|
|
335
|
+
async function authorBrief(root, manifest, workstream, rosterById, reauthorNote,
|
|
336
|
+
/**
|
|
337
|
+
* True when `id`'s spec is final: its authoring flow has settled *and* the
|
|
338
|
+
* spec on disk classifies `complete` or `legacy`. Both halves are load-
|
|
339
|
+
* bearing. A flow that is still running may rewrite its file during
|
|
340
|
+
* critique; a flow that settled on a protocol failure or a throw leaves a
|
|
341
|
+
* non-empty file the runner has explicitly refused to trust and marked
|
|
342
|
+
* `in_progress` (WS-03 §3.3). Either way the dependency renders as its
|
|
343
|
+
* roster line, never as the text currently on disk — a non-empty taskFile
|
|
344
|
+
* proves only that writing started, which is the distinction `classifySpec`
|
|
345
|
+
* already draws for the workstream's own spec. `authorProgram` always
|
|
346
|
+
* supplies this; it is optional only so `authorBrief`'s pure unit tests
|
|
347
|
+
* keep today's shape.
|
|
348
|
+
*/
|
|
349
|
+
specFinal) {
|
|
311
350
|
const programDoc = await programDocumentSection(root, manifest);
|
|
312
|
-
const deps = await dependencySpecsSection(root, workstream, rosterById);
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
"",
|
|
334
|
-
"- Write exactly one file: this workstream's taskFile (create parent",
|
|
335
|
-
" directories as needed). Do not write or edit any other file.",
|
|
336
|
-
"- Your reply is a separate channel from that file. The decision and",
|
|
337
|
-
" summary blocks described below go in your reply, never inside the",
|
|
338
|
-
" taskFile — the runner reads them from what you say, not what you wrote.",
|
|
339
|
-
"- Never commit; the runner owns commits.",
|
|
340
|
-
"",
|
|
341
|
-
decisionContract(),
|
|
342
|
-
"",
|
|
343
|
-
summaryContract(),
|
|
344
|
-
].join("\n");
|
|
345
|
-
return { brief, demoted: deps.demoted };
|
|
351
|
+
const deps = await dependencySpecsSection(root, workstream, rosterById, specFinal);
|
|
352
|
+
const builder = createBriefBuilder();
|
|
353
|
+
builder.push("framing", `# Workstream ${workstream.id}: ${workstream.name}`, "");
|
|
354
|
+
builder.push("program-narrative", `Program: ${manifest.program.id} — ${manifest.program.name}`);
|
|
355
|
+
builder.push("framing", "", "Write this workstream's specification document — the narrative file a", "human and a later implementing agent will both read to build it.", "");
|
|
356
|
+
builder.push("program-narrative", ...programDoc);
|
|
357
|
+
builder.push("success-criteria", ...successCriteriaSection(manifest));
|
|
358
|
+
builder.push("scope", ...scopeSection(workstream));
|
|
359
|
+
builder.push("roster", ...rosterSection(manifest));
|
|
360
|
+
builder.push("prerequisites", ...prerequisitesSection(manifest, workstream));
|
|
361
|
+
builder.push("dependency-specs", ...deps.section);
|
|
362
|
+
builder.push("re-author-note", ...(reauthorNote ? [reauthorNote, ""] : []));
|
|
363
|
+
builder.push("runner-instructions", SPEC_CONTRACT, GROUNDING_CONTRACT, dependenciesContract());
|
|
364
|
+
builder.push("framing", "", "## Rules", "", "- Write exactly one file: this workstream's taskFile (create parent", " directories as needed). Do not write or edit any other file.", "- Your reply is a separate channel from that file. The decision and", " summary blocks described below go in your reply, never inside the", " taskFile — the runner reads them from what you say, not what you wrote.", "- Never commit; the runner owns commits.", "");
|
|
365
|
+
builder.push("runner-instructions", decisionContract(), "", summaryContract());
|
|
366
|
+
return {
|
|
367
|
+
brief: builder.join(),
|
|
368
|
+
demoted: deps.demoted,
|
|
369
|
+
demotedBytes: deps.demotedBytes,
|
|
370
|
+
components: builder.components(),
|
|
371
|
+
};
|
|
346
372
|
}
|
|
347
373
|
const SPEC_CRITIQUE_FRAMING = [
|
|
348
374
|
"## Critique this spec",
|
|
@@ -416,36 +442,76 @@ function specCritiqueReviewerBrief(root, manifest, workstream, rosterById, spec,
|
|
|
416
442
|
].join("\n");
|
|
417
443
|
})();
|
|
418
444
|
}
|
|
419
|
-
function
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
.
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
445
|
+
function findingsListLines(findings) {
|
|
446
|
+
return findings.map((finding) => {
|
|
447
|
+
const evidence = finding.evidence
|
|
448
|
+
.map((entry) => {
|
|
449
|
+
if (entry.kind === "location") {
|
|
450
|
+
return `${entry.file}:${entry.startLine}${entry.excerpt ? ` — ${entry.excerpt}` : ""}`;
|
|
451
|
+
}
|
|
452
|
+
if (entry.kind === "concern") {
|
|
453
|
+
return entry.detail ? `${entry.named} — ${entry.detail}` : entry.named;
|
|
454
|
+
}
|
|
455
|
+
return `${entry.metric}: ${entry.value}`;
|
|
456
|
+
})
|
|
457
|
+
.join("; ");
|
|
458
|
+
return [
|
|
459
|
+
`- **${finding.severity}** (${finding.category}) ${finding.subject}: ${finding.message}`,
|
|
460
|
+
evidence ? ` Evidence: ${evidence}` : undefined,
|
|
461
|
+
]
|
|
462
|
+
.filter((line) => line !== undefined)
|
|
463
|
+
.join("\n");
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* The author's spec-revision re-brief (WS-09 §3.7.2): `binding` is the
|
|
468
|
+
* decider's `fix-now` triage — a required correction — and `advisory` is
|
|
469
|
+
* everything else the reviewer raised this cycle, left to the writer's
|
|
470
|
+
* judgment. Mirrors `testCritiqueFixBrief` on the build side.
|
|
471
|
+
*/
|
|
472
|
+
function specCritiqueWriterBrief(workstream, spec, binding, advisory, resumed, deciderConfigured) {
|
|
473
|
+
const bindingSection = binding.length > 0
|
|
474
|
+
? [
|
|
475
|
+
"## The decider ruled: fix these now",
|
|
476
|
+
"",
|
|
477
|
+
"An independent reviewer raised these and the decider triaged them",
|
|
478
|
+
"as worth one bounded fix attempt before the run proceeds. Apply",
|
|
479
|
+
"them.",
|
|
480
|
+
"",
|
|
481
|
+
...findingsListLines(binding),
|
|
482
|
+
"",
|
|
483
|
+
]
|
|
484
|
+
: [];
|
|
485
|
+
const advisorySection = advisory.length > 0
|
|
486
|
+
? [
|
|
487
|
+
"## Also raised, for your judgment",
|
|
488
|
+
"",
|
|
489
|
+
...(deciderConfigured
|
|
490
|
+
? [
|
|
491
|
+
"The reviewer raised these too. The decider did not mark them",
|
|
492
|
+
"for an immediate fix. Weigh them and apply what you agree",
|
|
493
|
+
"with.",
|
|
494
|
+
]
|
|
495
|
+
: [
|
|
496
|
+
"No decider is configured for this run, so none of these were",
|
|
497
|
+
"triaged. Weigh them and apply what you agree with.",
|
|
498
|
+
]),
|
|
499
|
+
"",
|
|
500
|
+
...findingsListLines(advisory),
|
|
501
|
+
"",
|
|
502
|
+
]
|
|
503
|
+
: [];
|
|
504
|
+
const bothEmpty = binding.length === 0 && advisory.length === 0;
|
|
441
505
|
return [
|
|
442
506
|
`# Your spec for ${workstream.id}: ${workstream.name} was reviewed`,
|
|
443
507
|
"",
|
|
508
|
+
...(resumed ? [SESSION_CONTINUATION_PREAMBLE] : []),
|
|
444
509
|
"An independent reviewer read the spec you wrote and raised the",
|
|
445
510
|
"following:",
|
|
446
511
|
"",
|
|
447
|
-
...
|
|
448
|
-
|
|
512
|
+
...(bothEmpty
|
|
513
|
+
? ["(no findings — this should not happen; treat as a clean read.)", ""]
|
|
514
|
+
: [...bindingSection, ...advisorySection]),
|
|
449
515
|
"Here is the spec as it currently stands:",
|
|
450
516
|
"",
|
|
451
517
|
"## Current spec",
|
|
@@ -464,12 +530,64 @@ function specCritiqueWriterBrief(workstream, spec, findings) {
|
|
|
464
530
|
function hasFindingsBlock(output) {
|
|
465
531
|
return /```findings/u.test(output);
|
|
466
532
|
}
|
|
533
|
+
function createSpecGate(args) {
|
|
534
|
+
const { root, git, isRepository, workstream, programId, log, pushStageError } = args;
|
|
535
|
+
return {
|
|
536
|
+
async snapshot() {
|
|
537
|
+
if (!isRepository)
|
|
538
|
+
return { dirty: [], untracked: [] };
|
|
539
|
+
const [dirty, untracked] = await Promise.all([
|
|
540
|
+
git.dirtyPaths(root),
|
|
541
|
+
git.untrackedPaths(root),
|
|
542
|
+
]);
|
|
543
|
+
return { dirty, untracked };
|
|
544
|
+
},
|
|
545
|
+
async check({ before, snapshot }) {
|
|
546
|
+
const after = await readFinishedSpec(root, workstream);
|
|
547
|
+
if (after === undefined) {
|
|
548
|
+
await writeFile(join(root, workstream.taskFile), before, "utf8");
|
|
549
|
+
const failure = `${workstream.id}: the spec-critique writer left the spec empty or ` +
|
|
550
|
+
"missing; the pre-fix spec was restored";
|
|
551
|
+
log(failure);
|
|
552
|
+
pushStageError(failure);
|
|
553
|
+
return { ok: false, failure, stray: [] };
|
|
554
|
+
}
|
|
555
|
+
let stray = [];
|
|
556
|
+
if (isRepository) {
|
|
557
|
+
const [dirtyNow, untrackedNow] = await Promise.all([
|
|
558
|
+
git.dirtyPaths(root),
|
|
559
|
+
git.untrackedPaths(root),
|
|
560
|
+
]);
|
|
561
|
+
const before_ = new Set([...snapshot.dirty, ...snapshot.untracked]);
|
|
562
|
+
const now_ = new Set([...dirtyNow, ...untrackedNow]);
|
|
563
|
+
const taskPrefix = `tasks/${programId}/`;
|
|
564
|
+
stray = [...now_].filter((path) => !before_.has(path) &&
|
|
565
|
+
!path.startsWith(taskPrefix) &&
|
|
566
|
+
!path.startsWith("docs/programs/"));
|
|
567
|
+
if (stray.length > 0) {
|
|
568
|
+
const message = `${workstream.id}: the spec-critique writer touched path(s) outside ` +
|
|
569
|
+
`its own spec: ${stray.join(", ")}`;
|
|
570
|
+
log(message);
|
|
571
|
+
pushStageError(message);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return { ok: true, stray };
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
}
|
|
467
578
|
async function runSpecCritique(options) {
|
|
468
579
|
const reviewer = resolveReviewerAgent(options.config);
|
|
469
580
|
if (!reviewer)
|
|
470
|
-
return reviewerAbsentOutcome();
|
|
581
|
+
return { outcome: reviewerAbsentOutcome() };
|
|
582
|
+
const recorder = options.recorder ?? NOOP_RUN_RECORDER;
|
|
471
583
|
const locate = (file) => locateInRepo(options.root, file);
|
|
472
|
-
const
|
|
584
|
+
const span = recorder.span({ stage: "spec-critique", workstream: options.workstream.id });
|
|
585
|
+
// Explicit parenting (WS-01 §3.3): the reviewer/writer spawns below nest
|
|
586
|
+
// under this span because the code says so, never because of what
|
|
587
|
+
// happens to be open on the implicit stack — the one other spot besides
|
|
588
|
+
// `run` itself that relied on implicit nesting.
|
|
589
|
+
const scope = span.scope();
|
|
590
|
+
const review = async (round, priorOpen) => {
|
|
473
591
|
const currentSpec = (await readFinishedSpec(options.root, options.workstream)) ?? options.spec;
|
|
474
592
|
const brief = await specCritiqueReviewerBrief(options.root, options.manifest, options.workstream, options.rosterById, currentSpec, priorOpen);
|
|
475
593
|
let invocation;
|
|
@@ -479,7 +597,7 @@ async function runSpecCritique(options) {
|
|
|
479
597
|
programId: options.programId,
|
|
480
598
|
label: `${options.workstream.id}-spec-critique-reviewer`,
|
|
481
599
|
log: options.log,
|
|
482
|
-
});
|
|
600
|
+
}, scope, { workstream: options.workstream.id, attemptIndex: round });
|
|
483
601
|
}
|
|
484
602
|
catch (error) {
|
|
485
603
|
// Fail open, matching the equivalent build-side guard in
|
|
@@ -496,17 +614,89 @@ async function runSpecCritique(options) {
|
|
|
496
614
|
const ran = invocation.exitCode === 0 && hasFindingsBlock(invocation.output);
|
|
497
615
|
return { findings, errors: parsed.errors, ran };
|
|
498
616
|
};
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
617
|
+
let sessionFallbackNote;
|
|
618
|
+
const respond = async (cycle, findings) => {
|
|
619
|
+
const before = (await readFinishedSpec(options.root, options.workstream)) ?? options.spec;
|
|
620
|
+
// Step 2: record. Every routable finding this cycle raised reaches the
|
|
621
|
+
// ledger — not a post-critique residue (WS-09 §3.7.2), anchored to the
|
|
622
|
+
// same pre-workstream commit `authorWorkstream` uses elsewhere.
|
|
623
|
+
const routable = findings.filter(hasRoutableEvidence);
|
|
624
|
+
const events = findingsToLedgerEvents({
|
|
625
|
+
workstreamId: options.workstream.id,
|
|
626
|
+
findings: routable,
|
|
627
|
+
...(options.baseCommit === undefined ? {} : { baseCommit: options.baseCommit }),
|
|
628
|
+
now: options.now,
|
|
629
|
+
});
|
|
630
|
+
await timed(scope, { stage: "ledger-persist", workstream: options.workstream.id }, () => appendLedgerEvents(options.root, options.programId, events));
|
|
631
|
+
// Step 3: triage — one decider spawn per routed finding, before the writer runs.
|
|
632
|
+
const triageErrors = await options.triage(events, options.baseCommit);
|
|
633
|
+
for (const message of triageErrors)
|
|
634
|
+
options.pushStageError(message);
|
|
635
|
+
// Step 4: split. Read the ledger projection *after* triage. A
|
|
636
|
+
// `human-decided` finding never reaches the writer; a `fix-now` finding
|
|
637
|
+
// with no fix attempt on it yet is binding; everything else is advisory.
|
|
638
|
+
const ledger = await readDecisionLedger(options.root, options.programId);
|
|
639
|
+
const recordsById = new Map(ledger.findings.map((record) => [record.id, record]));
|
|
640
|
+
const binding = [];
|
|
641
|
+
const bindingIds = [];
|
|
642
|
+
const advisory = [];
|
|
643
|
+
for (const finding of findings) {
|
|
644
|
+
const id = fingerprint({ ...finding, workstreamId: options.workstream.id });
|
|
645
|
+
const record = recordsById.get(id);
|
|
646
|
+
if (record?.status === "human-decided")
|
|
647
|
+
continue;
|
|
648
|
+
if (record?.status === "fix-now" && record.fixAttempt === undefined) {
|
|
649
|
+
binding.push(finding);
|
|
650
|
+
bindingIds.push(id);
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
advisory.push(finding);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
const gateSnapshot = await options.specGate.snapshot();
|
|
657
|
+
const spawnOnce = (resume, resumed) => {
|
|
658
|
+
const brief = specCritiqueWriterBrief(options.workstream, before, binding, advisory, resumed, options.deciderConfigured);
|
|
659
|
+
return invokeAgent(options.agentRunner, options.author, brief, options.root, options.permits, "authorAgent", {
|
|
505
660
|
root: options.root,
|
|
506
661
|
programId: options.programId,
|
|
507
662
|
label: `${options.workstream.id}-spec-critique-writer`,
|
|
508
663
|
log: options.log,
|
|
664
|
+
}, scope, { workstream: options.workstream.id, attemptIndex: cycle, attemptReason: "triaged-findings" }, resume === undefined ? {} : { handle: resume });
|
|
665
|
+
};
|
|
666
|
+
let invocation;
|
|
667
|
+
try {
|
|
668
|
+
const spawned = await spawnWithSessionContinuity({
|
|
669
|
+
resume: options.authorSession,
|
|
670
|
+
spawn: spawnOnce,
|
|
671
|
+
untouched: async () => (await readFinishedSpec(options.root, options.workstream)) === before,
|
|
672
|
+
log: options.log,
|
|
673
|
+
observeContinuity: (continuity) => {
|
|
674
|
+
try {
|
|
675
|
+
scope.point({
|
|
676
|
+
kind: "session-continuity",
|
|
677
|
+
coverage: "observed",
|
|
678
|
+
label: continuity.mode,
|
|
679
|
+
...(continuity.declineReason ? { detail: continuity.declineReason } : {}),
|
|
680
|
+
dimensions: {
|
|
681
|
+
stage: "spec-critique",
|
|
682
|
+
workstream: options.workstream.id,
|
|
683
|
+
role: "authorAgent",
|
|
684
|
+
attemptIndex: cycle,
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
catch (error) {
|
|
689
|
+
options.log(`run-analytics: session continuity point failed: ${error.message}`);
|
|
690
|
+
}
|
|
691
|
+
if (continuity.mode === "fresh" && continuity.declineReason) {
|
|
692
|
+
sessionFallbackNote =
|
|
693
|
+
`The spec revision could not continue the author's own session ` +
|
|
694
|
+
`(${describeSessionDecline(continuity.declineReason)}) and a fresh agent ` +
|
|
695
|
+
`applied the findings instead.`;
|
|
696
|
+
}
|
|
697
|
+
},
|
|
509
698
|
});
|
|
699
|
+
invocation = spawned.result;
|
|
510
700
|
}
|
|
511
701
|
catch (error) {
|
|
512
702
|
// Fail open: the spec is left as the reviewer last saw it and the
|
|
@@ -516,9 +706,47 @@ async function runSpecCritique(options) {
|
|
|
516
706
|
`findings for ${options.workstream.id}: ${error.message}`);
|
|
517
707
|
return { note: "(spec critique response not attempted — the author agent could not be spawned)" };
|
|
518
708
|
}
|
|
709
|
+
// Step 5: the runner's own deterministic spec gate — artifact, scope, change.
|
|
710
|
+
const gate = await options.specGate.check({ before, snapshot: gateSnapshot });
|
|
711
|
+
// Step 6: journal the fix-now outcome, read *after* the gate so a
|
|
712
|
+
// restore is reflected. `kept` is the runner's own reading of the file —
|
|
713
|
+
// never inferred from the writer's prose.
|
|
714
|
+
const after = (await readFinishedSpec(options.root, options.workstream)) ?? "";
|
|
715
|
+
if (bindingIds.length > 0) {
|
|
716
|
+
const kept = gate.ok && after !== before;
|
|
717
|
+
const bindingSubjects = binding.map((finding) => finding.subject).join(", ");
|
|
718
|
+
const writerNote = resolveSummary(invocation.output).text;
|
|
719
|
+
const failureSuffix = gate.ok ? "the spec file was not changed" : (gate.failure ?? "the spec gate failed");
|
|
720
|
+
const note = kept
|
|
721
|
+
? `(fix-now: ${bindingSubjects}) ${writerNote}`
|
|
722
|
+
: `(fix-now: ${bindingSubjects}) ${writerNote} (${failureSuffix})`;
|
|
723
|
+
const fixNowEvents = bindingIds.map((id) => ({
|
|
724
|
+
kind: "finding-fix-attempted",
|
|
725
|
+
at: options.now().toISOString(),
|
|
726
|
+
id,
|
|
727
|
+
outcome: kept ? "kept" : "failed",
|
|
728
|
+
note,
|
|
729
|
+
attemptedBy: "implementer",
|
|
730
|
+
}));
|
|
731
|
+
await timed(scope, { stage: "ledger-persist", workstream: options.workstream.id }, () => appendLedgerEvents(options.root, options.programId, fixNowEvents));
|
|
732
|
+
}
|
|
519
733
|
return { note: resolveSummary(invocation.output).text };
|
|
520
734
|
};
|
|
521
|
-
|
|
735
|
+
const budget = specCritiqueBudget(options.workstream.size);
|
|
736
|
+
try {
|
|
737
|
+
const outcome = await runReviewPass({
|
|
738
|
+
cycles: budget.cycles,
|
|
739
|
+
cycleNote: budget.note,
|
|
740
|
+
review,
|
|
741
|
+
respond,
|
|
742
|
+
});
|
|
743
|
+
span.close({ outcome: "success" });
|
|
744
|
+
return { outcome, ...(sessionFallbackNote ? { sessionFallbackNote } : {}) };
|
|
745
|
+
}
|
|
746
|
+
catch (error) {
|
|
747
|
+
span.close({ outcome: "failed" });
|
|
748
|
+
throw error;
|
|
749
|
+
}
|
|
522
750
|
}
|
|
523
751
|
function mergeDependencies(workstream, rosterIds, declaredIds) {
|
|
524
752
|
const merged = [];
|
|
@@ -563,23 +791,25 @@ function extractDependenciesDeclaration(output) {
|
|
|
563
791
|
return { ids: json };
|
|
564
792
|
}
|
|
565
793
|
/**
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
794
|
+
* Throws for exactly the reasons `authorProgram` throws today — no author
|
|
795
|
+
* agent configured, a cyclic manifest, an unknown `--from` id — and throws
|
|
796
|
+
* them before any agent spawns, so a caller that never reaches the scheduler
|
|
797
|
+
* fails identically to today.
|
|
570
798
|
*/
|
|
571
|
-
export async function
|
|
799
|
+
export async function prepareAuthoring(options) {
|
|
572
800
|
const root = resolve(options.cwd);
|
|
573
801
|
const config = options.config;
|
|
574
802
|
const agentRunner = options.agentRunner ?? defaultAgentRunner;
|
|
575
803
|
const permits = options.permits ?? NOOP_PERMITS_CONTEXT;
|
|
576
804
|
const git = options.git ?? defaultGitOps;
|
|
577
|
-
const
|
|
805
|
+
const baseLog = options.log ?? ((line) => console.log(line));
|
|
806
|
+
const log = baseLog;
|
|
578
807
|
const now = options.now ?? (() => new Date());
|
|
579
808
|
const force = options.force === true;
|
|
580
809
|
const reviewed = options.reviewed ?? new Set();
|
|
581
810
|
const triaged = options.triaged ?? new Set();
|
|
582
|
-
const
|
|
811
|
+
const recorder = options.recorder ?? NOOP_RUN_RECORDER;
|
|
812
|
+
const manifest = options.manifest ?? (await loadManifest(root, options.programId));
|
|
583
813
|
const resolvedAuthor = resolveAuthorAgent(config);
|
|
584
814
|
if (!resolvedAuthor) {
|
|
585
815
|
throw new Error("No author agent configured, and no implementer to fall back to. Set " +
|
|
@@ -602,6 +832,13 @@ export async function authorProgram(options) {
|
|
|
602
832
|
.map((cycle) => cycle.join(" -> "))
|
|
603
833
|
.join("; ")}. Re-plan with /plan-program.`);
|
|
604
834
|
}
|
|
835
|
+
// Built once over the initial roster (ids never change; only individual
|
|
836
|
+
// `.dependencies` arrays mutate in place) and shared by every flow — see
|
|
837
|
+
// the manifest-object sharing rationale in the spec's §2. `rosterById`'s
|
|
838
|
+
// values are the same live `Workstream` objects `manifest.workstreams`
|
|
839
|
+
// holds, so a later mutation is visible through this map with no rebuild.
|
|
840
|
+
const rosterById = new Map(manifest.workstreams.map((w) => [w.id, w]));
|
|
841
|
+
const rosterIds = new Set(rosterById.keys());
|
|
605
842
|
// The selected range (SC-05): computed once, up front, from the manifest
|
|
606
843
|
// as loaded — before any agent spawns — so `--from` validation and the
|
|
607
844
|
// selected set are deterministic, independent of dependency edges an
|
|
@@ -625,26 +862,182 @@ export async function authorProgram(options) {
|
|
|
625
862
|
if (!isRepository) {
|
|
626
863
|
log("warning: not a git repository — commits and decision anchors are unavailable");
|
|
627
864
|
}
|
|
628
|
-
const results = [];
|
|
629
|
-
const processed = new Set();
|
|
630
865
|
const blockedCone = new Set();
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
866
|
+
// The pipeline's run-scoped state (WS-05 §3.3): `dispatched` and
|
|
867
|
+
// `settledSet` are maintained by `authorWorkstream` itself, on entry and in
|
|
868
|
+
// its `finally`, rather than by a scheduler this module owns — WS-05's
|
|
869
|
+
// unified scheduler (or this module's own `authorProgram`, over
|
|
870
|
+
// `runReadySet`) decides *when* a workstream launches; readiness here only
|
|
871
|
+
// answers "has this dependency's spec finished", which `specFinal`/
|
|
872
|
+
// `specFinalSync` and the deferred re-author below still need. Ties no
|
|
873
|
+
// longer need breaking here — whichever scheduler owns dispatch order
|
|
874
|
+
// already breaks them against `manifest.workstreams`'s own array order.
|
|
875
|
+
const dispatched = new Set();
|
|
876
|
+
const settledSet = new Set();
|
|
877
|
+
const resultsById = new Map();
|
|
878
|
+
const settleDeferreds = new Map();
|
|
879
|
+
// Guards the merge critical section only (WS-05 §3.7): pushing a
|
|
880
|
+
// discovered dependency edge, saving the manifest, and checking for a
|
|
881
|
+
// cycle. No git operation ever runs while this is held, and this is never
|
|
882
|
+
// nested with `rootIndexMutex` or held across an `await` on it.
|
|
883
|
+
const graphMutex = createMutex();
|
|
884
|
+
// Guards every operation that takes `.git/index.lock` in the root
|
|
885
|
+
// checkout — this workstream's own spec commits, and (via
|
|
886
|
+
// `AuthorOptions.rootIndexMutex`) `runProgram`'s integration squashes and
|
|
887
|
+
// report commit (WS-05 §3.7). Injected by `runProgram` so both callers
|
|
888
|
+
// share one serializer; the standalone `author` CLI gets a private one,
|
|
889
|
+
// which costs nothing since nothing else commits there.
|
|
890
|
+
const rootIndexMutex = options.rootIndexMutex ?? createMutex();
|
|
891
|
+
const monotonicFn = options.monotonic ?? (() => performance.now());
|
|
892
|
+
const scopeFor = (id) => options.runSpan ? options.runSpan.scope({ workstream: id }) : recorder;
|
|
893
|
+
function settleDeferredFor(id) {
|
|
894
|
+
let deferred = settleDeferreds.get(id);
|
|
895
|
+
if (deferred === undefined) {
|
|
896
|
+
deferred = createDeferred();
|
|
897
|
+
settleDeferreds.set(id, deferred);
|
|
898
|
+
}
|
|
899
|
+
return deferred;
|
|
639
900
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
901
|
+
/**
|
|
902
|
+
* True when `id`'s spec is final: its authoring flow has settled *and*
|
|
903
|
+
* the spec now on disk classifies as `complete` or `legacy` — the same
|
|
904
|
+
* lifecycle test `classifySpec` already applies to a workstream's own
|
|
905
|
+
* spec, given no weaker a bar for a dependency (WS-03 §3.3). Settlement
|
|
906
|
+
* alone is not enough: a settled-but-parked dependency (a returned
|
|
907
|
+
* protocol failure or an unexpected throw) deliberately leaves
|
|
908
|
+
* `specStatus` at `in_progress` and can still have a non-empty draft file
|
|
909
|
+
* on disk, and a settled dependency that was kept out of selection or
|
|
910
|
+
* otherwise never authored has no file at all — both must classify as
|
|
911
|
+
* not-final, not merely "settled". This is the authoritative, async form
|
|
912
|
+
* used by the discovery path to decide the one re-author pass.
|
|
913
|
+
* `specFinalSync` below is this same test's cheaper half, valid only
|
|
914
|
+
* where the caller has already read the file itself and just needs to
|
|
915
|
+
* know whether that read may be trusted — the two must not drift apart,
|
|
916
|
+
* so both live here together.
|
|
917
|
+
*/
|
|
918
|
+
async function specFinal(id) {
|
|
919
|
+
if (!settledSet.has(id))
|
|
920
|
+
return false;
|
|
921
|
+
const dep = rosterById.get(id);
|
|
922
|
+
if (!dep)
|
|
923
|
+
return false;
|
|
924
|
+
const spec = await readFinishedSpec(root, dep);
|
|
925
|
+
const classification = classifySpec(spec, dep);
|
|
926
|
+
return classification === "complete" || classification === "legacy";
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* The synchronous half of `specFinal`: settled and not `in_progress`,
|
|
930
|
+
* with no file read of its own. Safe only for `dependencySpecsSection`,
|
|
931
|
+
* which already read the file itself and gates on `raw !== undefined`
|
|
932
|
+
* before ever consulting this predicate — so the `missing` classification
|
|
933
|
+
* is already handled there and this only needs to catch `interrupted`.
|
|
934
|
+
*/
|
|
935
|
+
function specFinalSync(id) {
|
|
936
|
+
if (!settledSet.has(id))
|
|
937
|
+
return false;
|
|
938
|
+
const dep = rosterById.get(id);
|
|
939
|
+
return dep !== undefined && dep.specStatus !== "in_progress";
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* WS-05 §3.3: wraps WS-03's `handle` in the dispatch/settle bookkeeping
|
|
943
|
+
* the deferred re-author (below, inside `handle`) still reads —
|
|
944
|
+
* `dispatched`/`settledSet` mark this workstream the instant *this*
|
|
945
|
+
* function is entered, whichever scheduler called it, and the settle
|
|
946
|
+
* deferred always resolves in a `finally` so a sibling awaiting it (or a
|
|
947
|
+
* drain after an error) can never hang. A caller is expected to invoke
|
|
948
|
+
* this exactly once per workstream in the roster.
|
|
949
|
+
*/
|
|
950
|
+
async function authorWorkstream(workstream) {
|
|
951
|
+
dispatched.add(workstream.id);
|
|
952
|
+
try {
|
|
953
|
+
const result = await handle(workstream);
|
|
954
|
+
resultsById.set(workstream.id, result);
|
|
955
|
+
return result;
|
|
956
|
+
}
|
|
957
|
+
catch (error) {
|
|
958
|
+
// A throw (a discovered edge creating a cycle, or anything else
|
|
959
|
+
// unexpected) is not swallowed here — it propagates to the caller's
|
|
960
|
+
// own scheduler, which drains every other in-flight flow before
|
|
961
|
+
// rethrowing (WS-04's `runReadySet`). Logged here because this is the
|
|
962
|
+
// one place that still knows which siblings were in flight when it
|
|
963
|
+
// happened.
|
|
964
|
+
const siblings = [...dispatched].filter((id) => !settledSet.has(id) && id !== workstream.id);
|
|
965
|
+
schedulerLog(baseLog)(`authoring stopped after an error in ${workstream.id}: ${error.message}` +
|
|
966
|
+
(siblings.length > 0
|
|
967
|
+
? `; draining in-flight workstream(s): ${siblings.join(", ")}`
|
|
968
|
+
: ""));
|
|
969
|
+
throw error;
|
|
970
|
+
}
|
|
971
|
+
finally {
|
|
972
|
+
// Resolved in a `finally` on every path, including a throw: a settle
|
|
973
|
+
// deferred left pending would hang every sibling waiting on it and
|
|
974
|
+
// hang the drain above — the one failure mode that produces a run
|
|
975
|
+
// which never terminates.
|
|
976
|
+
settledSet.add(workstream.id);
|
|
977
|
+
settleDeferredFor(workstream.id).resolve();
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
function haltAuthorWorkstream(workstream, reason) {
|
|
981
|
+
dispatched.add(workstream.id);
|
|
982
|
+
const result = {
|
|
983
|
+
id: workstream.id,
|
|
984
|
+
name: workstream.name,
|
|
985
|
+
outcome: { status: "parked", reason },
|
|
986
|
+
decisionIds: [],
|
|
987
|
+
decisionErrors: [],
|
|
988
|
+
mergedDependencies: [],
|
|
989
|
+
unknownDependencyIds: [],
|
|
990
|
+
demotedDependencies: [],
|
|
991
|
+
reauthored: false,
|
|
992
|
+
};
|
|
993
|
+
resultsById.set(workstream.id, result);
|
|
994
|
+
settledSet.add(workstream.id);
|
|
995
|
+
settleDeferredFor(workstream.id).resolve();
|
|
996
|
+
return result;
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* `results` is a contract (rendered in the report, pinned by tests): it is
|
|
1000
|
+
* ordered by the final dependency graph, never by completion or dispatch
|
|
1001
|
+
* order, both of which are timing-dependent above `cap: 1` (WS-05 §3.9).
|
|
1002
|
+
* `stableTopologicalOrder` is a pure function of the manifest with no
|
|
1003
|
+
* timing input at all, and it cannot throw here (the graph is acyclic) —
|
|
1004
|
+
* the fallback exists only so a surprising order beats a crashed run that
|
|
1005
|
+
* authored everything.
|
|
1006
|
+
*/
|
|
1007
|
+
function finish() {
|
|
1008
|
+
let orderedIds;
|
|
1009
|
+
try {
|
|
1010
|
+
orderedIds = stableTopologicalOrder(manifest.workstreams).map((w) => w.id);
|
|
1011
|
+
}
|
|
1012
|
+
catch {
|
|
1013
|
+
orderedIds = [...resultsById.keys()];
|
|
1014
|
+
}
|
|
1015
|
+
const results = [];
|
|
1016
|
+
for (const id of orderedIds) {
|
|
1017
|
+
const result = resultsById.get(id);
|
|
1018
|
+
if (result === undefined) {
|
|
1019
|
+
// Unreachable when every workstream in the roster went through
|
|
1020
|
+
// `authorWorkstream` exactly once, which every caller of this
|
|
1021
|
+
// pipeline is required to do (WS-04's `runReadySet` settles every
|
|
1022
|
+
// dispatched item before it lets the caller proceed) — filtered
|
|
1023
|
+
// defensively anyway per SC-12's fail-open rule.
|
|
1024
|
+
schedulerLog(baseLog)(`warning: ${id} produced no author result — omitted from the report`);
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
results.push(result);
|
|
1028
|
+
}
|
|
1029
|
+
const complete = results.every((result) => result.outcome.status === "authored" || result.outcome.status === "kept");
|
|
1030
|
+
return {
|
|
1031
|
+
programId: options.programId,
|
|
1032
|
+
complete,
|
|
1033
|
+
results,
|
|
1034
|
+
borrowedImplementer,
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
return { borrowedImplementer, manifest, authorWorkstream, haltAuthorWorkstream, finish };
|
|
647
1038
|
async function handle(workstream) {
|
|
1039
|
+
const log = workstreamLog(baseLog, workstream.id);
|
|
1040
|
+
const scope = scopeFor(workstream.id);
|
|
648
1041
|
const base = {
|
|
649
1042
|
id: workstream.id,
|
|
650
1043
|
name: workstream.name,
|
|
@@ -658,7 +1051,7 @@ export async function authorProgram(options) {
|
|
|
658
1051
|
};
|
|
659
1052
|
if (blockedCone.has(workstream.id)) {
|
|
660
1053
|
workstream.status = "parked";
|
|
661
|
-
await saveManifest(root, options.programId, manifest, { log });
|
|
1054
|
+
await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
662
1055
|
base.outcome = {
|
|
663
1056
|
status: "parked",
|
|
664
1057
|
reason: "an upstream dependency's authoring failed; parked, not attempted",
|
|
@@ -685,9 +1078,8 @@ export async function authorProgram(options) {
|
|
|
685
1078
|
workstream.specHash !== undefined &&
|
|
686
1079
|
specInputsHash(manifest, workstream) === workstream.specHash) {
|
|
687
1080
|
workstream.specStatus = "complete";
|
|
688
|
-
await saveManifest(root, options.programId, manifest, { log });
|
|
689
|
-
log(
|
|
690
|
-
'normalized to specStatus "complete"');
|
|
1081
|
+
await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
1082
|
+
log('recognized as completed legacy authoring — normalized to specStatus "complete"');
|
|
691
1083
|
return base;
|
|
692
1084
|
}
|
|
693
1085
|
// Drift check: the spec exists and is about to be graded against, but
|
|
@@ -702,12 +1094,12 @@ export async function authorProgram(options) {
|
|
|
702
1094
|
const current = specInputsHash(manifest, workstream);
|
|
703
1095
|
if (current !== workstream.specHash) {
|
|
704
1096
|
workstream.status = "parked";
|
|
705
|
-
await saveManifest(root, options.programId, manifest, { log });
|
|
1097
|
+
await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
706
1098
|
for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
|
|
707
1099
|
blockedCone.add(id);
|
|
708
1100
|
}
|
|
709
|
-
log(
|
|
710
|
-
"
|
|
1101
|
+
log("the manifest entry drifted since its spec was authored — parked " +
|
|
1102
|
+
"instead of building against stale spec text");
|
|
711
1103
|
base.outcome = {
|
|
712
1104
|
status: "parked",
|
|
713
1105
|
reason: `the manifest entry (name, scope, dependencies, or the program's ` +
|
|
@@ -720,9 +1112,7 @@ export async function authorProgram(options) {
|
|
|
720
1112
|
}
|
|
721
1113
|
return base;
|
|
722
1114
|
}
|
|
723
|
-
log(`${workstream.
|
|
724
|
-
const rosterById = new Map(manifest.workstreams.map((w) => [w.id, w]));
|
|
725
|
-
const rosterIds = new Set(rosterById.keys());
|
|
1115
|
+
log(`${workstream.name}: authoring`);
|
|
726
1116
|
const baseCommit = isRepository ? await git.currentCommit(root) : undefined;
|
|
727
1117
|
// Keyed by fingerprint, last-wins: a re-author pass can re-emit a
|
|
728
1118
|
// decision with the same id but revised context/options/chosen (the
|
|
@@ -730,14 +1120,24 @@ export async function authorProgram(options) {
|
|
|
730
1120
|
// workstream's current truth. `.set()` on each push naturally keeps the
|
|
731
1121
|
// latest occurrence rather than the first.
|
|
732
1122
|
const allDecisionsById = new Map();
|
|
733
|
-
|
|
1123
|
+
// WS-08: last-wins across the author and (optional) re-author spawns —
|
|
1124
|
+
// whichever pass most recently reported a session id is the one the
|
|
1125
|
+
// spec-critique writer resumes.
|
|
1126
|
+
let authorSession;
|
|
1127
|
+
const first = await authorBrief(root, manifest, workstream, rosterById, undefined, specFinalSync);
|
|
734
1128
|
base.demotedDependencies = first.demoted;
|
|
1129
|
+
for (const point of promptComponentSizePoints(first.components, { role: "authorAgent" })) {
|
|
1130
|
+
scope.point(point);
|
|
1131
|
+
}
|
|
1132
|
+
const firstDemotedPoint = demotedDependenciesPoint(first.demoted, first.demotedBytes, { role: "authorAgent" });
|
|
1133
|
+
if (firstDemotedPoint)
|
|
1134
|
+
scope.point(firstDemotedPoint);
|
|
735
1135
|
// Stamped before the agent can write anything (SC-01): the agent has
|
|
736
1136
|
// whole-file write access, so a crash at any point after this line
|
|
737
1137
|
// leaves a durable `in_progress` marker the next attempt classifies as
|
|
738
1138
|
// interrupted, regardless of whether a partial file exists.
|
|
739
1139
|
workstream.specStatus = "in_progress";
|
|
740
|
-
await saveManifest(root, options.programId, manifest, { log });
|
|
1140
|
+
await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
741
1141
|
let invocation;
|
|
742
1142
|
try {
|
|
743
1143
|
invocation = await invokeAgent(agentRunner, author, first.brief, root, permits, "authorAgent", {
|
|
@@ -745,7 +1145,9 @@ export async function authorProgram(options) {
|
|
|
745
1145
|
programId: options.programId,
|
|
746
1146
|
label: `${workstream.id}-author`,
|
|
747
1147
|
log,
|
|
748
|
-
});
|
|
1148
|
+
}, scope, { stage: "author", workstream: workstream.id });
|
|
1149
|
+
if (invocation.session)
|
|
1150
|
+
authorSession = invocation.session;
|
|
749
1151
|
}
|
|
750
1152
|
catch (error) {
|
|
751
1153
|
// A spawn error here means the author agent never started — the same
|
|
@@ -757,7 +1159,7 @@ export async function authorProgram(options) {
|
|
|
757
1159
|
// stays `in_progress` — never cleared on a failure path — so a later
|
|
758
1160
|
// retry resumes without needing --force (SC-07).
|
|
759
1161
|
workstream.status = "parked";
|
|
760
|
-
await saveManifest(root, options.programId, manifest, { log });
|
|
1162
|
+
await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
761
1163
|
for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
|
|
762
1164
|
blockedCone.add(id);
|
|
763
1165
|
}
|
|
@@ -774,7 +1176,7 @@ export async function authorProgram(options) {
|
|
|
774
1176
|
// Park exactly like a spawn failure and leave `specStatus` at
|
|
775
1177
|
// `in_progress` so a force-free retry re-authors.
|
|
776
1178
|
workstream.status = "parked";
|
|
777
|
-
await saveManifest(root, options.programId, manifest, { log });
|
|
1179
|
+
await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
778
1180
|
for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
|
|
779
1181
|
blockedCone.add(id);
|
|
780
1182
|
}
|
|
@@ -800,36 +1202,64 @@ export async function authorProgram(options) {
|
|
|
800
1202
|
base.decisionIds.push(...parsed.decisions.map((decision) => decisionFingerprint(workstream.id, decision)));
|
|
801
1203
|
const declaration = extractDependenciesDeclaration(invocation.output);
|
|
802
1204
|
if (declaration.error)
|
|
803
|
-
log(
|
|
804
|
-
|
|
1205
|
+
log(declaration.error);
|
|
1206
|
+
// The merge critical section (§3.3): pushing the discovered edges,
|
|
1207
|
+
// saving the manifest, and checking for a cycle run as one serialised
|
|
1208
|
+
// step through the graph mutex, so two concurrent merges cannot jointly
|
|
1209
|
+
// create a cycle. No git operation ever runs while this is held (WS-05
|
|
1210
|
+
// §3.7) — the commit below is a separate, later critical section on a
|
|
1211
|
+
// separate mutex. Nothing here calls `log` — the re-entrancy discipline
|
|
1212
|
+
// WS-01 applies inside its own write queue applies here too; the two
|
|
1213
|
+
// messages below are collected and emitted after the section returns.
|
|
1214
|
+
const merge = await graphMutex.runExclusive(async () => {
|
|
1215
|
+
const result = mergeDependencies(workstream, rosterIds, declaration.ids);
|
|
1216
|
+
if (result.merged.length > 0) {
|
|
1217
|
+
const originalDependencies = workstream.dependencies.filter((id) => !result.merged.includes(id));
|
|
1218
|
+
await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
1219
|
+
const cyclesNow = findCycles(manifest.workstreams);
|
|
1220
|
+
if (cyclesNow.length > 0) {
|
|
1221
|
+
workstream.dependencies = originalDependencies;
|
|
1222
|
+
await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
1223
|
+
throw new Error(`Dependency cycle(s) created by a discovered edge from ${workstream.id}: ` +
|
|
1224
|
+
`${cyclesNow.map((cycle) => cycle.join(" -> ")).join("; ")}. Re-plan with /plan-program.`);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
return result;
|
|
1228
|
+
});
|
|
805
1229
|
base.unknownDependencyIds = merge.unknown;
|
|
806
1230
|
if (merge.unknown.length > 0) {
|
|
807
|
-
log(
|
|
1231
|
+
log(`unknown dependency id(s) declared, ignored: ${merge.unknown.join(", ")}`);
|
|
808
1232
|
}
|
|
809
1233
|
if (merge.merged.length > 0) {
|
|
810
|
-
const originalDependencies = workstream.dependencies.filter((id) => !merge.merged.includes(id));
|
|
811
1234
|
base.mergedDependencies = merge.merged;
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1235
|
+
log(`merged new dependency edge(s): ${merge.merged.join(", ")}`);
|
|
1236
|
+
// Classify each merged dependency (§3.3): a sibling still in flight is
|
|
1237
|
+
// awaited on its settle deferred — never on a not-yet-dispatched one,
|
|
1238
|
+
// which would risk a deadlock at `cap: 1` — and only a spec that
|
|
1239
|
+
// classifies `complete` or `legacy` after settlement triggers the one
|
|
1240
|
+
// re-author pass. A settled-but-parked sibling (still `in_progress`),
|
|
1241
|
+
// a settled-but-missing sibling (never authored, e.g. an
|
|
1242
|
+
// out-of-selection keep), and one that was never dispatched at all,
|
|
1243
|
+
// all resolve to `false` through the same `specFinal` check.
|
|
821
1244
|
const needsReauthor = [];
|
|
822
1245
|
for (const id of merge.merged) {
|
|
823
1246
|
const dep = rosterById.get(id);
|
|
824
1247
|
if (!dep)
|
|
825
1248
|
continue;
|
|
826
|
-
if ((
|
|
1249
|
+
if (dispatched.has(id) && !settledSet.has(id)) {
|
|
1250
|
+
const waitStart = monotonicFn();
|
|
1251
|
+
await settleDeferredFor(id).promise;
|
|
1252
|
+
recordWorkstreamWaiting(recorder, workstream.id, {
|
|
1253
|
+
cause: "dependency",
|
|
1254
|
+
waitedMs: monotonicFn() - waitStart,
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
if (await specFinal(id))
|
|
827
1258
|
needsReauthor.push(id);
|
|
828
1259
|
}
|
|
829
1260
|
if (needsReauthor.length > 0) {
|
|
830
1261
|
base.reauthored = true;
|
|
831
|
-
log(
|
|
832
|
-
needsReauthor.join(", "));
|
|
1262
|
+
log(`re-authoring once with newly discovered dependency spec(s): ${needsReauthor.join(", ")}`);
|
|
833
1263
|
const note = [
|
|
834
1264
|
"## You are being re-authored",
|
|
835
1265
|
"",
|
|
@@ -840,11 +1270,21 @@ export async function authorProgram(options) {
|
|
|
840
1270
|
"re-author pass.",
|
|
841
1271
|
"",
|
|
842
1272
|
].join("\n");
|
|
843
|
-
const second = await authorBrief(root, manifest, workstream, rosterById, note);
|
|
1273
|
+
const second = await authorBrief(root, manifest, workstream, rosterById, note, specFinalSync);
|
|
844
1274
|
base.demotedDependencies = second.demoted;
|
|
1275
|
+
for (const point of promptComponentSizePoints(second.components, { role: "authorAgent" })) {
|
|
1276
|
+
scope.point(point);
|
|
1277
|
+
}
|
|
1278
|
+
const secondDemotedPoint = demotedDependenciesPoint(second.demoted, second.demotedBytes, {
|
|
1279
|
+
role: "authorAgent",
|
|
1280
|
+
});
|
|
1281
|
+
if (secondDemotedPoint)
|
|
1282
|
+
scope.point(secondDemotedPoint);
|
|
845
1283
|
let reauthorInvocation;
|
|
846
1284
|
try {
|
|
847
|
-
reauthorInvocation = await invokeAgent(agentRunner, author, second.brief, root, permits, "authorAgent", { root, programId: options.programId, label: `${workstream.id}-author-reauthor`, log });
|
|
1285
|
+
reauthorInvocation = await invokeAgent(agentRunner, author, second.brief, root, permits, "authorAgent", { root, programId: options.programId, label: `${workstream.id}-author-reauthor`, log }, scope, { stage: "re-author", workstream: workstream.id });
|
|
1286
|
+
if (reauthorInvocation.session)
|
|
1287
|
+
authorSession = reauthorInvocation.session;
|
|
848
1288
|
}
|
|
849
1289
|
catch (error) {
|
|
850
1290
|
// Fail open: the first pass already wrote a spec and journaled
|
|
@@ -866,7 +1306,7 @@ export async function authorProgram(options) {
|
|
|
866
1306
|
// otherwise untouched; a returned failure carries no such
|
|
867
1307
|
// guarantee.
|
|
868
1308
|
workstream.status = "parked";
|
|
869
|
-
await saveManifest(root, options.programId, manifest, { log });
|
|
1309
|
+
await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
870
1310
|
for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
|
|
871
1311
|
blockedCone.add(id);
|
|
872
1312
|
}
|
|
@@ -895,8 +1335,8 @@ export async function authorProgram(options) {
|
|
|
895
1335
|
base.decisionIds.push(...parsed.decisions.map((decision) => decisionFingerprint(workstream.id, decision)));
|
|
896
1336
|
const secondDeclaration = extractDependenciesDeclaration(invocation.output);
|
|
897
1337
|
if (secondDeclaration.ids.length > 0) {
|
|
898
|
-
log(
|
|
899
|
-
`
|
|
1338
|
+
log(`additional dependency declaration after the re-author pass was not ` +
|
|
1339
|
+
`honored: ${secondDeclaration.ids.join(", ")}`);
|
|
900
1340
|
}
|
|
901
1341
|
}
|
|
902
1342
|
}
|
|
@@ -905,7 +1345,7 @@ export async function authorProgram(options) {
|
|
|
905
1345
|
const final = await readFinishedSpec(root, workstream);
|
|
906
1346
|
if (final === undefined) {
|
|
907
1347
|
workstream.status = "parked";
|
|
908
|
-
await saveManifest(root, options.programId, manifest, { log });
|
|
1348
|
+
await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
909
1349
|
for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
|
|
910
1350
|
blockedCone.add(id);
|
|
911
1351
|
}
|
|
@@ -929,23 +1369,32 @@ export async function authorProgram(options) {
|
|
|
929
1369
|
agentRunner,
|
|
930
1370
|
permits,
|
|
931
1371
|
author,
|
|
1372
|
+
deciderConfigured: decider !== undefined,
|
|
1373
|
+
baseCommit,
|
|
1374
|
+
now,
|
|
932
1375
|
log,
|
|
933
1376
|
pushStageError: (message) => (base.stageErrors ??= []).push(message),
|
|
1377
|
+
recorder: scope,
|
|
1378
|
+
triage: (events, triageBaseCommit) => reviewWorkstreamFindings(workstream.id, events, triageBaseCommit),
|
|
1379
|
+
specGate: createSpecGate({
|
|
1380
|
+
root,
|
|
1381
|
+
git,
|
|
1382
|
+
isRepository,
|
|
1383
|
+
workstream,
|
|
1384
|
+
programId: options.programId,
|
|
1385
|
+
log,
|
|
1386
|
+
pushStageError: (message) => (base.stageErrors ??= []).push(message),
|
|
1387
|
+
}),
|
|
1388
|
+
...(authorSession ? { authorSession } : {}),
|
|
934
1389
|
});
|
|
935
|
-
base.specCritique = critique;
|
|
1390
|
+
base.specCritique = critique.outcome;
|
|
1391
|
+
if (critique.sessionFallbackNote)
|
|
1392
|
+
(base.notes ??= []).push(critique.sessionFallbackNote);
|
|
936
1393
|
const postCritique = await readFinishedSpec(root, workstream);
|
|
937
1394
|
if (postCritique === undefined) {
|
|
938
1395
|
await writeFile(join(root, workstream.taskFile), final, "utf8");
|
|
939
|
-
log(
|
|
940
|
-
"the pre-critique spec was restored");
|
|
1396
|
+
log("a critique round left the spec empty or missing; the pre-critique spec was restored");
|
|
941
1397
|
}
|
|
942
|
-
const critiqueEvents = findingsToLedgerEvents({
|
|
943
|
-
workstreamId: workstream.id,
|
|
944
|
-
findings: critique.open.filter(hasRoutableEvidence),
|
|
945
|
-
...(baseCommit === undefined ? {} : { baseCommit }),
|
|
946
|
-
now,
|
|
947
|
-
});
|
|
948
|
-
await appendLedgerEvents(root, options.programId, critiqueEvents);
|
|
949
1398
|
// Stamped after every dependency merge above, so the hash records the
|
|
950
1399
|
// manifest exactly as this spec's author last saw it — a later manifest
|
|
951
1400
|
// edit is drift; authoring's own merges are not. `specStatus` is
|
|
@@ -955,10 +1404,18 @@ export async function authorProgram(options) {
|
|
|
955
1404
|
// must still classify as interrupted on retry, not falsely complete with
|
|
956
1405
|
// an unmade commit.
|
|
957
1406
|
workstream.specHash = specInputsHash(manifest, workstream);
|
|
958
|
-
await saveManifest(root, options.programId, manifest, { log });
|
|
1407
|
+
await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
1408
|
+
// Serialised through `rootIndexMutex` (WS-05 §3.7): concurrent authors
|
|
1409
|
+
// — and, once integration can run at the same time, WS-02's squash —
|
|
1410
|
+
// share one git index, and `git commit` has no queue of its own; two
|
|
1411
|
+
// commits at the same instant fail on `index.lock` rather than
|
|
1412
|
+
// serialising. Also routed through the manifest/ledger write queue,
|
|
1413
|
+
// keyed the same as `saveManifest`/`appendLedgerEvents`: this commit's
|
|
1414
|
+
// `git add` stages `docs/programs`, which a concurrent ledger append
|
|
1415
|
+
// could otherwise be caught mid-write.
|
|
959
1416
|
let commit;
|
|
960
1417
|
if (isRepository) {
|
|
961
|
-
commit = await git.commitPaths(root, `nightshift(${options.programId}): author ${workstream.id} ${workstream.name}`, [workstream.taskFile, "docs/programs"]);
|
|
1418
|
+
commit = await rootIndexMutex.runExclusive(() => enqueueProgramWrite(programWriteKey(root, options.programId), () => timed(scope, { stage: "git-commit", workstream: workstream.id }, () => git.commitPaths(root, `nightshift(${options.programId}): author ${workstream.id} ${workstream.name}`, [workstream.taskFile, "docs/programs"]), commitEvidence)));
|
|
962
1419
|
}
|
|
963
1420
|
// Outside a git repository there is no commit to wait for — the finished
|
|
964
1421
|
// spec, its specHash, and this manifest save are already the durable
|
|
@@ -966,24 +1423,17 @@ export async function authorProgram(options) {
|
|
|
966
1423
|
// only runs after `commitPaths` above resolves, so the transition is
|
|
967
1424
|
// recoverable on both sides of the commit (SC-01, SC-02).
|
|
968
1425
|
workstream.specStatus = "complete";
|
|
969
|
-
await saveManifest(root, options.programId, manifest, { log });
|
|
1426
|
+
await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
970
1427
|
{
|
|
971
1428
|
const spawnErrors = await reviewWorkstreamDecisions(workstream.id, [...allDecisionsById.values()], baseCommit);
|
|
972
1429
|
if (spawnErrors.length > 0)
|
|
973
1430
|
(base.stageErrors ??= []).push(...spawnErrors);
|
|
974
1431
|
}
|
|
975
|
-
// Findings triage against the same pre-workstream diff base the decision
|
|
976
|
-
// review used above — a spec-critique finding has no separate green
|
|
977
|
-
// anchor the way a test-critique fix does.
|
|
978
|
-
{
|
|
979
|
-
const spawnErrors = await reviewWorkstreamFindings(workstream.id, critiqueEvents, baseCommit);
|
|
980
|
-
if (spawnErrors.length > 0)
|
|
981
|
-
(base.stageErrors ??= []).push(...spawnErrors);
|
|
982
|
-
}
|
|
983
1432
|
base.outcome = { status: "authored", ...(commit === undefined ? {} : { commit }) };
|
|
984
1433
|
return base;
|
|
985
1434
|
}
|
|
986
1435
|
async function journalDecisions(workstream, decisions, baseCommit) {
|
|
1436
|
+
const log = workstreamLog(baseLog, workstream.id);
|
|
987
1437
|
const events = decisions.map((decision) => ({
|
|
988
1438
|
kind: "decision-recorded",
|
|
989
1439
|
at: now().toISOString(),
|
|
@@ -993,12 +1443,13 @@ export async function authorProgram(options) {
|
|
|
993
1443
|
...(baseCommit === undefined ? {} : { baseCommit }),
|
|
994
1444
|
decidedBy: "implementer",
|
|
995
1445
|
}));
|
|
996
|
-
await appendLedgerEvents(root, options.programId, events);
|
|
1446
|
+
await timed(scopeFor(workstream.id), { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, options.programId, events));
|
|
997
1447
|
for (const decision of decisions) {
|
|
998
|
-
log(
|
|
1448
|
+
log(`decision: ${decision.title} -> ${decision.chosen}`);
|
|
999
1449
|
}
|
|
1000
1450
|
}
|
|
1001
1451
|
function reviewWorkstreamDecisions(workstreamId, decisions, baseCommit) {
|
|
1452
|
+
const log = workstreamLog(baseLog, workstreamId);
|
|
1002
1453
|
return reviewDecisions({
|
|
1003
1454
|
root,
|
|
1004
1455
|
programId: options.programId,
|
|
@@ -1016,9 +1467,11 @@ export async function authorProgram(options) {
|
|
|
1016
1467
|
now,
|
|
1017
1468
|
log,
|
|
1018
1469
|
observe: { root, programId: options.programId, label: `${workstreamId}-decider-decision`, log },
|
|
1470
|
+
recorder: scopeFor(workstreamId),
|
|
1019
1471
|
});
|
|
1020
1472
|
}
|
|
1021
1473
|
function reviewWorkstreamFindings(workstreamId, findings, baseCommit) {
|
|
1474
|
+
const log = workstreamLog(baseLog, workstreamId);
|
|
1022
1475
|
return triageFindings({
|
|
1023
1476
|
root,
|
|
1024
1477
|
programId: options.programId,
|
|
@@ -1036,7 +1489,41 @@ export async function authorProgram(options) {
|
|
|
1036
1489
|
now,
|
|
1037
1490
|
log,
|
|
1038
1491
|
observe: { root, programId: options.programId, label: `${workstreamId}-decider-finding`, log },
|
|
1492
|
+
recorder: scopeFor(workstreamId),
|
|
1039
1493
|
});
|
|
1040
1494
|
}
|
|
1041
1495
|
}
|
|
1496
|
+
/**
|
|
1497
|
+
* The authoring stage, standalone: writes every missing workstream spec, one
|
|
1498
|
+
* clean agent at a time, in dependency order, at `--max-parallel`'s cap —
|
|
1499
|
+
* `runReadySet` over `manifest.workstreams` directly, the same scheduler
|
|
1500
|
+
* `runProgram`'s unified pipeline (WS-05 §3.1) drives, just with only one
|
|
1501
|
+
* kind of item. `--max-parallel 1` reproduces the pre-WS-03 serial behaviour
|
|
1502
|
+
* exactly, including dispatch order and the absence of any deferred
|
|
1503
|
+
* re-author. This module no longer contains a hand-written scheduler of its
|
|
1504
|
+
* own (WS-04 §8, WS-05 §3.3): one implementation, two callers.
|
|
1505
|
+
*/
|
|
1506
|
+
export async function authorProgram(options) {
|
|
1507
|
+
const pipeline = await prepareAuthoring(options);
|
|
1508
|
+
const baseLog = options.log ?? ((line) => console.log(line));
|
|
1509
|
+
const monotonicFn = options.monotonic ?? (() => performance.now());
|
|
1510
|
+
const cap = normalizeMaxParallel(options.maxParallel);
|
|
1511
|
+
schedulerLog(baseLog)(`authoring ${pipeline.manifest.workstreams.length} workstream(s)` +
|
|
1512
|
+
(cap === Infinity ? "" : ` (cap: ${cap})`));
|
|
1513
|
+
await runReadySet({
|
|
1514
|
+
items: pipeline.manifest.workstreams,
|
|
1515
|
+
cap,
|
|
1516
|
+
monotonic: monotonicFn,
|
|
1517
|
+
classify: () => "launch",
|
|
1518
|
+
launch: (workstream) => pipeline.authorWorkstream(workstream).then(() => undefined),
|
|
1519
|
+
// Genuinely unreachable on the standalone path: an environmental halt is
|
|
1520
|
+
// a property of a *run*, and this stage has no build phase to produce
|
|
1521
|
+
// one. The unified scheduler's author branch does classify "settle" on a
|
|
1522
|
+
// halt (WS-05 §3.1, §3.6); this one never can.
|
|
1523
|
+
settle: () => {
|
|
1524
|
+
throw new Error("unreachable: authoring items never classify settle");
|
|
1525
|
+
},
|
|
1526
|
+
});
|
|
1527
|
+
return pipeline.finish();
|
|
1528
|
+
}
|
|
1042
1529
|
//# sourceMappingURL=author.js.map
|