@wildorder/nightshift 0.17.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 +269 -1
- package/dist/agent-runner.d.ts +41 -3
- package/dist/agent-runner.d.ts.map +1 -1
- package/dist/agent-runner.js +197 -12
- 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 +112 -6
- package/dist/author.d.ts.map +1 -1
- package/dist/author.js +597 -137
- package/dist/author.js.map +1 -1
- package/dist/causal-analysis.d.ts +6 -2
- package/dist/causal-analysis.d.ts.map +1 -1
- package/dist/causal-analysis.js +55 -2
- package/dist/causal-analysis.js.map +1 -1
- 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 +10 -3
- package/dist/decider-review.d.ts.map +1 -1
- package/dist/decider-review.js +6 -4
- 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 +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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 +2 -2
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js.map +1 -1
- 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 +114 -1
- package/dist/run-analytics-report.d.ts.map +1 -1
- package/dist/run-analytics-report.js +606 -14
- package/dist/run-analytics-report.js.map +1 -1
- package/dist/run-analytics.d.ts +107 -1
- package/dist/run-analytics.d.ts.map +1 -1
- package/dist/run-analytics.js +257 -25
- package/dist/run-analytics.js.map +1 -1
- 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 +203 -7
- package/dist/run-program.d.ts.map +1 -1
- package/dist/run-program.js +1526 -320
- 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 +2 -2
- package/dist/whole-program-review.d.ts.map +1 -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,17 +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";
|
|
13
|
+
import { extractFindings, findingsContract, findingsToLedgerEvents, hasRoutableEvidence, locateInRepo, reviewerAbsentOutcome, runReviewPass, specCritiqueBudget, verifyEvidence, } from "./review-pass.js";
|
|
12
14
|
import { createBriefBuilder, demotedDependenciesPoint, promptComponentSizePoints, } from "./prompt-telemetry.js";
|
|
13
15
|
import { commitEvidence, defaultGitOps, downstreamCone, timed } from "./run-program.js";
|
|
14
|
-
import { NOOP_RUN_RECORDER } from "./run-analytics.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";
|
|
15
21
|
/**
|
|
16
22
|
* The authoring stage: for every workstream whose spec does not exist, a
|
|
17
23
|
* clean agent writes it — human-first, grounded in the repository,
|
|
@@ -223,7 +229,7 @@ function prerequisitesSection(manifest, workstream) {
|
|
|
223
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.", "");
|
|
224
230
|
return lines;
|
|
225
231
|
}
|
|
226
|
-
async function dependencySpecsSection(root, workstream, rosterById) {
|
|
232
|
+
async function dependencySpecsSection(root, workstream, rosterById, specFinal) {
|
|
227
233
|
if (workstream.dependencies.length === 0) {
|
|
228
234
|
return {
|
|
229
235
|
section: ["## Dependency specs", "", "No dependencies declared.", ""],
|
|
@@ -234,10 +240,17 @@ async function dependencySpecsSection(root, workstream, rosterById) {
|
|
|
234
240
|
const entries = await Promise.all(workstream.dependencies
|
|
235
241
|
.map((id) => rosterById.get(id))
|
|
236
242
|
.filter((entry) => entry !== undefined)
|
|
237
|
-
.map(async (entry) =>
|
|
238
|
-
entry
|
|
239
|
-
|
|
240
|
-
|
|
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
|
+
}));
|
|
241
254
|
let total = entries.reduce((sum, e) => sum + (e.content?.length ?? 0), 0);
|
|
242
255
|
const demoted = new Set();
|
|
243
256
|
let demotedBytes = 0;
|
|
@@ -319,9 +332,23 @@ function dependenciesContract() {
|
|
|
319
332
|
* component, with `components` summing exactly to the joined brief's byte
|
|
320
333
|
* length.
|
|
321
334
|
*/
|
|
322
|
-
async function authorBrief(root, manifest, workstream, rosterById, reauthorNote
|
|
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) {
|
|
323
350
|
const programDoc = await programDocumentSection(root, manifest);
|
|
324
|
-
const deps = await dependencySpecsSection(root, workstream, rosterById);
|
|
351
|
+
const deps = await dependencySpecsSection(root, workstream, rosterById, specFinal);
|
|
325
352
|
const builder = createBriefBuilder();
|
|
326
353
|
builder.push("framing", `# Workstream ${workstream.id}: ${workstream.name}`, "");
|
|
327
354
|
builder.push("program-narrative", `Program: ${manifest.program.id} — ${manifest.program.name}`);
|
|
@@ -415,36 +442,76 @@ function specCritiqueReviewerBrief(root, manifest, workstream, rosterById, spec,
|
|
|
415
442
|
].join("\n");
|
|
416
443
|
})();
|
|
417
444
|
}
|
|
418
|
-
function
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
.
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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;
|
|
440
505
|
return [
|
|
441
506
|
`# Your spec for ${workstream.id}: ${workstream.name} was reviewed`,
|
|
442
507
|
"",
|
|
508
|
+
...(resumed ? [SESSION_CONTINUATION_PREAMBLE] : []),
|
|
443
509
|
"An independent reviewer read the spec you wrote and raised the",
|
|
444
510
|
"following:",
|
|
445
511
|
"",
|
|
446
|
-
...
|
|
447
|
-
|
|
512
|
+
...(bothEmpty
|
|
513
|
+
? ["(no findings — this should not happen; treat as a clean read.)", ""]
|
|
514
|
+
: [...bindingSection, ...advisorySection]),
|
|
448
515
|
"Here is the spec as it currently stands:",
|
|
449
516
|
"",
|
|
450
517
|
"## Current spec",
|
|
@@ -463,13 +530,63 @@ function specCritiqueWriterBrief(workstream, spec, findings) {
|
|
|
463
530
|
function hasFindingsBlock(output) {
|
|
464
531
|
return /```findings/u.test(output);
|
|
465
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
|
+
}
|
|
466
578
|
async function runSpecCritique(options) {
|
|
467
579
|
const reviewer = resolveReviewerAgent(options.config);
|
|
468
580
|
if (!reviewer)
|
|
469
|
-
return reviewerAbsentOutcome();
|
|
581
|
+
return { outcome: reviewerAbsentOutcome() };
|
|
470
582
|
const recorder = options.recorder ?? NOOP_RUN_RECORDER;
|
|
471
583
|
const locate = (file) => locateInRepo(options.root, file);
|
|
472
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();
|
|
473
590
|
const review = async (round, priorOpen) => {
|
|
474
591
|
const currentSpec = (await readFinishedSpec(options.root, options.workstream)) ?? options.spec;
|
|
475
592
|
const brief = await specCritiqueReviewerBrief(options.root, options.manifest, options.workstream, options.rosterById, currentSpec, priorOpen);
|
|
@@ -480,7 +597,7 @@ async function runSpecCritique(options) {
|
|
|
480
597
|
programId: options.programId,
|
|
481
598
|
label: `${options.workstream.id}-spec-critique-reviewer`,
|
|
482
599
|
log: options.log,
|
|
483
|
-
},
|
|
600
|
+
}, scope, { workstream: options.workstream.id, attemptIndex: round });
|
|
484
601
|
}
|
|
485
602
|
catch (error) {
|
|
486
603
|
// Fail open, matching the equivalent build-side guard in
|
|
@@ -497,17 +614,89 @@ async function runSpecCritique(options) {
|
|
|
497
614
|
const ran = invocation.exitCode === 0 && hasFindingsBlock(invocation.output);
|
|
498
615
|
return { findings, errors: parsed.errors, ran };
|
|
499
616
|
};
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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", {
|
|
506
660
|
root: options.root,
|
|
507
661
|
programId: options.programId,
|
|
508
662
|
label: `${options.workstream.id}-spec-critique-writer`,
|
|
509
663
|
log: options.log,
|
|
510
|
-
},
|
|
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
|
+
},
|
|
698
|
+
});
|
|
699
|
+
invocation = spawned.result;
|
|
511
700
|
}
|
|
512
701
|
catch (error) {
|
|
513
702
|
// Fail open: the spec is left as the reviewer last saw it and the
|
|
@@ -517,12 +706,42 @@ async function runSpecCritique(options) {
|
|
|
517
706
|
`findings for ${options.workstream.id}: ${error.message}`);
|
|
518
707
|
return { note: "(spec critique response not attempted — the author agent could not be spawned)" };
|
|
519
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
|
+
}
|
|
520
733
|
return { note: resolveSummary(invocation.output).text };
|
|
521
734
|
};
|
|
735
|
+
const budget = specCritiqueBudget(options.workstream.size);
|
|
522
736
|
try {
|
|
523
|
-
const outcome = await runReviewPass({
|
|
737
|
+
const outcome = await runReviewPass({
|
|
738
|
+
cycles: budget.cycles,
|
|
739
|
+
cycleNote: budget.note,
|
|
740
|
+
review,
|
|
741
|
+
respond,
|
|
742
|
+
});
|
|
524
743
|
span.close({ outcome: "success" });
|
|
525
|
-
return outcome;
|
|
744
|
+
return { outcome, ...(sessionFallbackNote ? { sessionFallbackNote } : {}) };
|
|
526
745
|
}
|
|
527
746
|
catch (error) {
|
|
528
747
|
span.close({ outcome: "failed" });
|
|
@@ -572,24 +791,25 @@ function extractDependenciesDeclaration(output) {
|
|
|
572
791
|
return { ids: json };
|
|
573
792
|
}
|
|
574
793
|
/**
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
578
|
-
*
|
|
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.
|
|
579
798
|
*/
|
|
580
|
-
export async function
|
|
799
|
+
export async function prepareAuthoring(options) {
|
|
581
800
|
const root = resolve(options.cwd);
|
|
582
801
|
const config = options.config;
|
|
583
802
|
const agentRunner = options.agentRunner ?? defaultAgentRunner;
|
|
584
803
|
const permits = options.permits ?? NOOP_PERMITS_CONTEXT;
|
|
585
804
|
const git = options.git ?? defaultGitOps;
|
|
586
|
-
const
|
|
805
|
+
const baseLog = options.log ?? ((line) => console.log(line));
|
|
806
|
+
const log = baseLog;
|
|
587
807
|
const now = options.now ?? (() => new Date());
|
|
588
808
|
const force = options.force === true;
|
|
589
809
|
const reviewed = options.reviewed ?? new Set();
|
|
590
810
|
const triaged = options.triaged ?? new Set();
|
|
591
811
|
const recorder = options.recorder ?? NOOP_RUN_RECORDER;
|
|
592
|
-
const manifest = await loadManifest(root, options.programId);
|
|
812
|
+
const manifest = options.manifest ?? (await loadManifest(root, options.programId));
|
|
593
813
|
const resolvedAuthor = resolveAuthorAgent(config);
|
|
594
814
|
if (!resolvedAuthor) {
|
|
595
815
|
throw new Error("No author agent configured, and no implementer to fall back to. Set " +
|
|
@@ -612,6 +832,13 @@ export async function authorProgram(options) {
|
|
|
612
832
|
.map((cycle) => cycle.join(" -> "))
|
|
613
833
|
.join("; ")}. Re-plan with /plan-program.`);
|
|
614
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());
|
|
615
842
|
// The selected range (SC-05): computed once, up front, from the manifest
|
|
616
843
|
// as loaded — before any agent spawns — so `--from` validation and the
|
|
617
844
|
// selected set are deterministic, independent of dependency edges an
|
|
@@ -635,26 +862,182 @@ export async function authorProgram(options) {
|
|
|
635
862
|
if (!isRepository) {
|
|
636
863
|
log("warning: not a git repository — commits and decision anchors are unavailable");
|
|
637
864
|
}
|
|
638
|
-
const results = [];
|
|
639
|
-
const processed = new Set();
|
|
640
865
|
const blockedCone = new Set();
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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;
|
|
649
900
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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 };
|
|
657
1038
|
async function handle(workstream) {
|
|
1039
|
+
const log = workstreamLog(baseLog, workstream.id);
|
|
1040
|
+
const scope = scopeFor(workstream.id);
|
|
658
1041
|
const base = {
|
|
659
1042
|
id: workstream.id,
|
|
660
1043
|
name: workstream.name,
|
|
@@ -668,7 +1051,7 @@ export async function authorProgram(options) {
|
|
|
668
1051
|
};
|
|
669
1052
|
if (blockedCone.has(workstream.id)) {
|
|
670
1053
|
workstream.status = "parked";
|
|
671
|
-
await timed(
|
|
1054
|
+
await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
672
1055
|
base.outcome = {
|
|
673
1056
|
status: "parked",
|
|
674
1057
|
reason: "an upstream dependency's authoring failed; parked, not attempted",
|
|
@@ -695,9 +1078,8 @@ export async function authorProgram(options) {
|
|
|
695
1078
|
workstream.specHash !== undefined &&
|
|
696
1079
|
specInputsHash(manifest, workstream) === workstream.specHash) {
|
|
697
1080
|
workstream.specStatus = "complete";
|
|
698
|
-
await timed(
|
|
699
|
-
log(
|
|
700
|
-
'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"');
|
|
701
1083
|
return base;
|
|
702
1084
|
}
|
|
703
1085
|
// Drift check: the spec exists and is about to be graded against, but
|
|
@@ -712,12 +1094,12 @@ export async function authorProgram(options) {
|
|
|
712
1094
|
const current = specInputsHash(manifest, workstream);
|
|
713
1095
|
if (current !== workstream.specHash) {
|
|
714
1096
|
workstream.status = "parked";
|
|
715
|
-
await timed(
|
|
1097
|
+
await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
716
1098
|
for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
|
|
717
1099
|
blockedCone.add(id);
|
|
718
1100
|
}
|
|
719
|
-
log(
|
|
720
|
-
"
|
|
1101
|
+
log("the manifest entry drifted since its spec was authored — parked " +
|
|
1102
|
+
"instead of building against stale spec text");
|
|
721
1103
|
base.outcome = {
|
|
722
1104
|
status: "parked",
|
|
723
1105
|
reason: `the manifest entry (name, scope, dependencies, or the program's ` +
|
|
@@ -730,9 +1112,7 @@ export async function authorProgram(options) {
|
|
|
730
1112
|
}
|
|
731
1113
|
return base;
|
|
732
1114
|
}
|
|
733
|
-
log(`${workstream.
|
|
734
|
-
const rosterById = new Map(manifest.workstreams.map((w) => [w.id, w]));
|
|
735
|
-
const rosterIds = new Set(rosterById.keys());
|
|
1115
|
+
log(`${workstream.name}: authoring`);
|
|
736
1116
|
const baseCommit = isRepository ? await git.currentCommit(root) : undefined;
|
|
737
1117
|
// Keyed by fingerprint, last-wins: a re-author pass can re-emit a
|
|
738
1118
|
// decision with the same id but revised context/options/chosen (the
|
|
@@ -740,20 +1120,24 @@ export async function authorProgram(options) {
|
|
|
740
1120
|
// workstream's current truth. `.set()` on each push naturally keeps the
|
|
741
1121
|
// latest occurrence rather than the first.
|
|
742
1122
|
const allDecisionsById = new Map();
|
|
743
|
-
|
|
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);
|
|
744
1128
|
base.demotedDependencies = first.demoted;
|
|
745
1129
|
for (const point of promptComponentSizePoints(first.components, { role: "authorAgent" })) {
|
|
746
|
-
|
|
1130
|
+
scope.point(point);
|
|
747
1131
|
}
|
|
748
1132
|
const firstDemotedPoint = demotedDependenciesPoint(first.demoted, first.demotedBytes, { role: "authorAgent" });
|
|
749
1133
|
if (firstDemotedPoint)
|
|
750
|
-
|
|
1134
|
+
scope.point(firstDemotedPoint);
|
|
751
1135
|
// Stamped before the agent can write anything (SC-01): the agent has
|
|
752
1136
|
// whole-file write access, so a crash at any point after this line
|
|
753
1137
|
// leaves a durable `in_progress` marker the next attempt classifies as
|
|
754
1138
|
// interrupted, regardless of whether a partial file exists.
|
|
755
1139
|
workstream.specStatus = "in_progress";
|
|
756
|
-
await timed(
|
|
1140
|
+
await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
757
1141
|
let invocation;
|
|
758
1142
|
try {
|
|
759
1143
|
invocation = await invokeAgent(agentRunner, author, first.brief, root, permits, "authorAgent", {
|
|
@@ -761,7 +1145,9 @@ export async function authorProgram(options) {
|
|
|
761
1145
|
programId: options.programId,
|
|
762
1146
|
label: `${workstream.id}-author`,
|
|
763
1147
|
log,
|
|
764
|
-
},
|
|
1148
|
+
}, scope, { stage: "author", workstream: workstream.id });
|
|
1149
|
+
if (invocation.session)
|
|
1150
|
+
authorSession = invocation.session;
|
|
765
1151
|
}
|
|
766
1152
|
catch (error) {
|
|
767
1153
|
// A spawn error here means the author agent never started — the same
|
|
@@ -773,7 +1159,7 @@ export async function authorProgram(options) {
|
|
|
773
1159
|
// stays `in_progress` — never cleared on a failure path — so a later
|
|
774
1160
|
// retry resumes without needing --force (SC-07).
|
|
775
1161
|
workstream.status = "parked";
|
|
776
|
-
await timed(
|
|
1162
|
+
await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
777
1163
|
for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
|
|
778
1164
|
blockedCone.add(id);
|
|
779
1165
|
}
|
|
@@ -790,7 +1176,7 @@ export async function authorProgram(options) {
|
|
|
790
1176
|
// Park exactly like a spawn failure and leave `specStatus` at
|
|
791
1177
|
// `in_progress` so a force-free retry re-authors.
|
|
792
1178
|
workstream.status = "parked";
|
|
793
|
-
await timed(
|
|
1179
|
+
await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
794
1180
|
for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
|
|
795
1181
|
blockedCone.add(id);
|
|
796
1182
|
}
|
|
@@ -816,36 +1202,64 @@ export async function authorProgram(options) {
|
|
|
816
1202
|
base.decisionIds.push(...parsed.decisions.map((decision) => decisionFingerprint(workstream.id, decision)));
|
|
817
1203
|
const declaration = extractDependenciesDeclaration(invocation.output);
|
|
818
1204
|
if (declaration.error)
|
|
819
|
-
log(
|
|
820
|
-
|
|
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
|
+
});
|
|
821
1229
|
base.unknownDependencyIds = merge.unknown;
|
|
822
1230
|
if (merge.unknown.length > 0) {
|
|
823
|
-
log(
|
|
1231
|
+
log(`unknown dependency id(s) declared, ignored: ${merge.unknown.join(", ")}`);
|
|
824
1232
|
}
|
|
825
1233
|
if (merge.merged.length > 0) {
|
|
826
|
-
const originalDependencies = workstream.dependencies.filter((id) => !merge.merged.includes(id));
|
|
827
1234
|
base.mergedDependencies = merge.merged;
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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.
|
|
837
1244
|
const needsReauthor = [];
|
|
838
1245
|
for (const id of merge.merged) {
|
|
839
1246
|
const dep = rosterById.get(id);
|
|
840
1247
|
if (!dep)
|
|
841
1248
|
continue;
|
|
842
|
-
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))
|
|
843
1258
|
needsReauthor.push(id);
|
|
844
1259
|
}
|
|
845
1260
|
if (needsReauthor.length > 0) {
|
|
846
1261
|
base.reauthored = true;
|
|
847
|
-
log(
|
|
848
|
-
needsReauthor.join(", "));
|
|
1262
|
+
log(`re-authoring once with newly discovered dependency spec(s): ${needsReauthor.join(", ")}`);
|
|
849
1263
|
const note = [
|
|
850
1264
|
"## You are being re-authored",
|
|
851
1265
|
"",
|
|
@@ -856,19 +1270,21 @@ export async function authorProgram(options) {
|
|
|
856
1270
|
"re-author pass.",
|
|
857
1271
|
"",
|
|
858
1272
|
].join("\n");
|
|
859
|
-
const second = await authorBrief(root, manifest, workstream, rosterById, note);
|
|
1273
|
+
const second = await authorBrief(root, manifest, workstream, rosterById, note, specFinalSync);
|
|
860
1274
|
base.demotedDependencies = second.demoted;
|
|
861
1275
|
for (const point of promptComponentSizePoints(second.components, { role: "authorAgent" })) {
|
|
862
|
-
|
|
1276
|
+
scope.point(point);
|
|
863
1277
|
}
|
|
864
1278
|
const secondDemotedPoint = demotedDependenciesPoint(second.demoted, second.demotedBytes, {
|
|
865
1279
|
role: "authorAgent",
|
|
866
1280
|
});
|
|
867
1281
|
if (secondDemotedPoint)
|
|
868
|
-
|
|
1282
|
+
scope.point(secondDemotedPoint);
|
|
869
1283
|
let reauthorInvocation;
|
|
870
1284
|
try {
|
|
871
|
-
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;
|
|
872
1288
|
}
|
|
873
1289
|
catch (error) {
|
|
874
1290
|
// Fail open: the first pass already wrote a spec and journaled
|
|
@@ -890,7 +1306,7 @@ export async function authorProgram(options) {
|
|
|
890
1306
|
// otherwise untouched; a returned failure carries no such
|
|
891
1307
|
// guarantee.
|
|
892
1308
|
workstream.status = "parked";
|
|
893
|
-
await timed(
|
|
1309
|
+
await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
894
1310
|
for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
|
|
895
1311
|
blockedCone.add(id);
|
|
896
1312
|
}
|
|
@@ -919,8 +1335,8 @@ export async function authorProgram(options) {
|
|
|
919
1335
|
base.decisionIds.push(...parsed.decisions.map((decision) => decisionFingerprint(workstream.id, decision)));
|
|
920
1336
|
const secondDeclaration = extractDependenciesDeclaration(invocation.output);
|
|
921
1337
|
if (secondDeclaration.ids.length > 0) {
|
|
922
|
-
log(
|
|
923
|
-
`
|
|
1338
|
+
log(`additional dependency declaration after the re-author pass was not ` +
|
|
1339
|
+
`honored: ${secondDeclaration.ids.join(", ")}`);
|
|
924
1340
|
}
|
|
925
1341
|
}
|
|
926
1342
|
}
|
|
@@ -929,7 +1345,7 @@ export async function authorProgram(options) {
|
|
|
929
1345
|
const final = await readFinishedSpec(root, workstream);
|
|
930
1346
|
if (final === undefined) {
|
|
931
1347
|
workstream.status = "parked";
|
|
932
|
-
await timed(
|
|
1348
|
+
await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
933
1349
|
for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
|
|
934
1350
|
blockedCone.add(id);
|
|
935
1351
|
}
|
|
@@ -953,24 +1369,32 @@ export async function authorProgram(options) {
|
|
|
953
1369
|
agentRunner,
|
|
954
1370
|
permits,
|
|
955
1371
|
author,
|
|
1372
|
+
deciderConfigured: decider !== undefined,
|
|
1373
|
+
baseCommit,
|
|
1374
|
+
now,
|
|
956
1375
|
log,
|
|
957
1376
|
pushStageError: (message) => (base.stageErrors ??= []).push(message),
|
|
958
|
-
recorder,
|
|
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 } : {}),
|
|
959
1389
|
});
|
|
960
|
-
base.specCritique = critique;
|
|
1390
|
+
base.specCritique = critique.outcome;
|
|
1391
|
+
if (critique.sessionFallbackNote)
|
|
1392
|
+
(base.notes ??= []).push(critique.sessionFallbackNote);
|
|
961
1393
|
const postCritique = await readFinishedSpec(root, workstream);
|
|
962
1394
|
if (postCritique === undefined) {
|
|
963
1395
|
await writeFile(join(root, workstream.taskFile), final, "utf8");
|
|
964
|
-
log(
|
|
965
|
-
"the pre-critique spec was restored");
|
|
1396
|
+
log("a critique round left the spec empty or missing; the pre-critique spec was restored");
|
|
966
1397
|
}
|
|
967
|
-
const critiqueEvents = findingsToLedgerEvents({
|
|
968
|
-
workstreamId: workstream.id,
|
|
969
|
-
findings: critique.open.filter(hasRoutableEvidence),
|
|
970
|
-
...(baseCommit === undefined ? {} : { baseCommit }),
|
|
971
|
-
now,
|
|
972
|
-
});
|
|
973
|
-
await timed(recorder, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, options.programId, critiqueEvents));
|
|
974
1398
|
// Stamped after every dependency merge above, so the hash records the
|
|
975
1399
|
// manifest exactly as this spec's author last saw it — a later manifest
|
|
976
1400
|
// edit is drift; authoring's own merges are not. `specStatus` is
|
|
@@ -980,10 +1404,18 @@ export async function authorProgram(options) {
|
|
|
980
1404
|
// must still classify as interrupted on retry, not falsely complete with
|
|
981
1405
|
// an unmade commit.
|
|
982
1406
|
workstream.specHash = specInputsHash(manifest, workstream);
|
|
983
|
-
await timed(
|
|
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.
|
|
984
1416
|
let commit;
|
|
985
1417
|
if (isRepository) {
|
|
986
|
-
commit = await timed(
|
|
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)));
|
|
987
1419
|
}
|
|
988
1420
|
// Outside a git repository there is no commit to wait for — the finished
|
|
989
1421
|
// spec, its specHash, and this manifest save are already the durable
|
|
@@ -991,24 +1423,17 @@ export async function authorProgram(options) {
|
|
|
991
1423
|
// only runs after `commitPaths` above resolves, so the transition is
|
|
992
1424
|
// recoverable on both sides of the commit (SC-01, SC-02).
|
|
993
1425
|
workstream.specStatus = "complete";
|
|
994
|
-
await timed(
|
|
1426
|
+
await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
995
1427
|
{
|
|
996
1428
|
const spawnErrors = await reviewWorkstreamDecisions(workstream.id, [...allDecisionsById.values()], baseCommit);
|
|
997
1429
|
if (spawnErrors.length > 0)
|
|
998
1430
|
(base.stageErrors ??= []).push(...spawnErrors);
|
|
999
1431
|
}
|
|
1000
|
-
// Findings triage against the same pre-workstream diff base the decision
|
|
1001
|
-
// review used above — a spec-critique finding has no separate green
|
|
1002
|
-
// anchor the way a test-critique fix does.
|
|
1003
|
-
{
|
|
1004
|
-
const spawnErrors = await reviewWorkstreamFindings(workstream.id, critiqueEvents, baseCommit);
|
|
1005
|
-
if (spawnErrors.length > 0)
|
|
1006
|
-
(base.stageErrors ??= []).push(...spawnErrors);
|
|
1007
|
-
}
|
|
1008
1432
|
base.outcome = { status: "authored", ...(commit === undefined ? {} : { commit }) };
|
|
1009
1433
|
return base;
|
|
1010
1434
|
}
|
|
1011
1435
|
async function journalDecisions(workstream, decisions, baseCommit) {
|
|
1436
|
+
const log = workstreamLog(baseLog, workstream.id);
|
|
1012
1437
|
const events = decisions.map((decision) => ({
|
|
1013
1438
|
kind: "decision-recorded",
|
|
1014
1439
|
at: now().toISOString(),
|
|
@@ -1018,12 +1443,13 @@ export async function authorProgram(options) {
|
|
|
1018
1443
|
...(baseCommit === undefined ? {} : { baseCommit }),
|
|
1019
1444
|
decidedBy: "implementer",
|
|
1020
1445
|
}));
|
|
1021
|
-
await timed(
|
|
1446
|
+
await timed(scopeFor(workstream.id), { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, options.programId, events));
|
|
1022
1447
|
for (const decision of decisions) {
|
|
1023
|
-
log(
|
|
1448
|
+
log(`decision: ${decision.title} -> ${decision.chosen}`);
|
|
1024
1449
|
}
|
|
1025
1450
|
}
|
|
1026
1451
|
function reviewWorkstreamDecisions(workstreamId, decisions, baseCommit) {
|
|
1452
|
+
const log = workstreamLog(baseLog, workstreamId);
|
|
1027
1453
|
return reviewDecisions({
|
|
1028
1454
|
root,
|
|
1029
1455
|
programId: options.programId,
|
|
@@ -1041,10 +1467,11 @@ export async function authorProgram(options) {
|
|
|
1041
1467
|
now,
|
|
1042
1468
|
log,
|
|
1043
1469
|
observe: { root, programId: options.programId, label: `${workstreamId}-decider-decision`, log },
|
|
1044
|
-
recorder,
|
|
1470
|
+
recorder: scopeFor(workstreamId),
|
|
1045
1471
|
});
|
|
1046
1472
|
}
|
|
1047
1473
|
function reviewWorkstreamFindings(workstreamId, findings, baseCommit) {
|
|
1474
|
+
const log = workstreamLog(baseLog, workstreamId);
|
|
1048
1475
|
return triageFindings({
|
|
1049
1476
|
root,
|
|
1050
1477
|
programId: options.programId,
|
|
@@ -1062,8 +1489,41 @@ export async function authorProgram(options) {
|
|
|
1062
1489
|
now,
|
|
1063
1490
|
log,
|
|
1064
1491
|
observe: { root, programId: options.programId, label: `${workstreamId}-decider-finding`, log },
|
|
1065
|
-
recorder,
|
|
1492
|
+
recorder: scopeFor(workstreamId),
|
|
1066
1493
|
});
|
|
1067
1494
|
}
|
|
1068
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
|
+
}
|
|
1069
1529
|
//# sourceMappingURL=author.js.map
|