@wildorder/nightshift 0.14.0 → 0.16.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 +52 -41
- package/dist/author.d.ts +8 -3
- package/dist/author.d.ts.map +1 -1
- package/dist/author.js +231 -20
- package/dist/author.js.map +1 -1
- package/dist/cli.js +3 -7
- package/dist/cli.js.map +1 -1
- package/dist/manifest.d.ts +10 -2
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +17 -2
- package/dist/manifest.js.map +1 -1
- package/dist/run-program.d.ts.map +1 -1
- package/dist/run-program.js +19 -4
- package/dist/run-program.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ npx --yes @wildorder/nightshift --help
|
|
|
33
33
|
|
|
34
34
|
What you need before anything else:
|
|
35
35
|
|
|
36
|
-
- **Node.js
|
|
36
|
+
- **Node.js 22+** and a **git repository** (an existing project or a fresh one).
|
|
37
37
|
- **At least one coding-agent CLI** installed and authenticated on the
|
|
38
38
|
machine that will run programs — `claude` (Claude Code) and `codex` are
|
|
39
39
|
the two exercised daily. Two different providers is the intended shape:
|
|
@@ -77,36 +77,38 @@ things only you can write:
|
|
|
77
77
|
"authorAgent": { "command": "claude", "args": ["-p", "--model", "opus"], "promptMode": "stdin" },
|
|
78
78
|
"deciderAgent": { "command": "codex", "args": ["exec", "--model", "gpt-5.6-sol"], "promptMode": "stdin" },
|
|
79
79
|
"reviewerAgent": { "command": "codex", "args": ["exec"] },
|
|
80
|
-
"recoveryAgent": { "command": "codex", "args": ["exec", "--model", "gpt-5.6-sol"
|
|
80
|
+
"recoveryAgent": { "command": "codex", "args": ["exec", "--model", "gpt-5.6-sol"], "promptMode": "stdin" },
|
|
81
|
+
"permits": {
|
|
82
|
+
"policy": "Anything inside this repository is fine, including installing dev dependencies and creating fixture files. Do not reach the network beyond npm install. Never push, publish, or change global git or npm configuration.",
|
|
83
|
+
"allow": ["Bash(git stash:*)"],
|
|
84
|
+
"deny": ["Bash(git push:*)", "Bash(npm publish:*)", "Bash(git config --global:*)", "Bash(npm config set:*)"]
|
|
85
|
+
}
|
|
81
86
|
}
|
|
82
87
|
```
|
|
83
88
|
|
|
84
|
-
Notice what is missing: no `--permission-mode`, no `--allowedTools
|
|
85
|
-
derives each role's permission posture itself — from
|
|
86
|
-
`authorAgent`, and `recoveryAgent` get a workspace posture;
|
|
87
|
-
`reviewerAgent` get read-only, never edit-capable
|
|
88
|
-
from the host it runs on (a claude `auto`
|
|
89
|
-
macOS/Linux, a `--sandbox workspace-write`
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
permission-bearing flag an operator
|
|
93
|
-
`--allowedTools`, `--sandbox`, and their siblings — always wins,
|
|
94
|
-
role is reported as operator-configured rather than silently
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
"deny": ["Bash(rm -rf *)"]
|
|
104
|
-
}
|
|
105
|
-
```
|
|
89
|
+
Notice what is missing: no `--permission-mode`, no `--allowedTools`, no
|
|
90
|
+
`--sandbox`. nightshift derives each role's permission posture itself — from
|
|
91
|
+
the role (`agent`, `authorAgent`, and `recoveryAgent` get a workspace posture;
|
|
92
|
+
`deciderAgent` and `reviewerAgent` get read-only, never edit-capable
|
|
93
|
+
regardless of the host) and from the host it runs on (a claude `auto`
|
|
94
|
+
classifier plus an OS sandbox on macOS/Linux, a `--sandbox workspace-write`
|
|
95
|
+
grant on codex write seats and `--sandbox read-only` on its reply-only seats,
|
|
96
|
+
a documented fallback when a capability cannot be detected). Any
|
|
97
|
+
permission-bearing flag an operator writes anyway — `--permission-mode`,
|
|
98
|
+
`--allowedTools`, `--sandbox`, and their siblings — always wins untouched,
|
|
99
|
+
and that role is reported as operator-configured rather than silently
|
|
100
|
+
overridden.
|
|
101
|
+
|
|
102
|
+
The optional `permits` block, shown above as this repository uses it, widens
|
|
103
|
+
or narrows the derived posture without hand-writing provider flags: `policy`
|
|
104
|
+
is prose the implementer and recovery briefs quote verbatim (the same thing
|
|
105
|
+
you would tell a contractor on day one), `allow` names commands that never
|
|
106
|
+
wait on the classifier, and `deny` is the short catastrophic list that binds
|
|
107
|
+
in every mode.
|
|
106
108
|
|
|
107
109
|
`allow`/`deny` use Claude's rule syntax and fold into the generated settings
|
|
108
|
-
file every claude spawn reads;
|
|
109
|
-
|
|
110
|
+
file every claude spawn reads; codex has no per-command rules, so on those
|
|
111
|
+
seats only the sandbox and the brief's policy apply. On a throwaway CI runner, set
|
|
110
112
|
`NIGHTSHIFT_CONTAINMENT=ephemeral` in the environment to let workspace-posture
|
|
111
113
|
roles skip permission prompts entirely (`ci init github`'s hosted variant sets
|
|
112
114
|
this for you; never set it on a machine you or someone else works on
|
|
@@ -118,14 +120,14 @@ derived posture a real run would use, verifies on disk that the writing seats
|
|
|
118
120
|
can write, and reports whether they can run the verify commands — the
|
|
119
121
|
environment failures that otherwise burn a whole run's budget (a sandboxed
|
|
120
122
|
shell, an untrusted workspace, a read-only recovery agent) show up as one
|
|
121
|
-
labeled line each instead.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
decider and reviewer
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
123
|
+
labeled line each instead. On codex, `exec` defaults to a read-only sandbox,
|
|
124
|
+
which is the right posture for the decider and reviewer seats and exactly
|
|
125
|
+
wrong for a seat that must fix a failing tree — so nightshift spawns
|
|
126
|
+
`recoveryAgent` with `--sandbox workspace-write` and, rather than trusting the
|
|
127
|
+
default, the decider and reviewer with an explicit `--sandbox read-only`. A
|
|
128
|
+
codex user config can widen the default (a `sandbox = "elevated"` setting, a
|
|
129
|
+
trusted-project entry), and a seat labeled read-only that can write is exactly
|
|
130
|
+
the kind of quiet mismatch the posture lines exist to prevent.
|
|
129
131
|
|
|
130
132
|
Only `agent` is required to run; every other role degrades loudly, not
|
|
131
133
|
silently, when absent. The verify commands are the contract every
|
|
@@ -230,16 +232,25 @@ or to see the specs before a build starts:
|
|
|
230
232
|
nightshift author <program-id>
|
|
231
233
|
```
|
|
232
234
|
|
|
233
|
-
A `taskFile`
|
|
234
|
-
|
|
235
|
+
A finished `taskFile` is always kept, never overwritten, and an interrupted
|
|
236
|
+
attempt — one a crash or a killed process cut off partway through — is
|
|
237
|
+
re-authored automatically, without `--force`: a durable marker in the
|
|
238
|
+
manifest, not the file's existence, is what proves authoring finished.
|
|
239
|
+
|
|
240
|
+
Two flags select a range and a force level, independently:
|
|
235
241
|
|
|
236
242
|
```sh
|
|
237
|
-
nightshift author <program-id> --
|
|
243
|
+
nightshift author <program-id> --from WS-03
|
|
244
|
+
nightshift author <program-id> --from WS-03 --force
|
|
238
245
|
```
|
|
239
246
|
|
|
240
|
-
`--
|
|
241
|
-
|
|
242
|
-
|
|
247
|
+
`--from <workstream-id>` resumes from that workstream onward, in dependency
|
|
248
|
+
order; everything before it is left untouched. Within the selected range,
|
|
249
|
+
plain authoring (no `--force`) only writes missing or interrupted specs —
|
|
250
|
+
completed and hand-authored ones are kept. Adding `--force` regenerates
|
|
251
|
+
every selected spec, including already-completed ones, so `--from WS-03
|
|
252
|
+
--force` regenerates WS-03 and everything after it. A bare `--force` with no
|
|
253
|
+
`--from` selects the whole roster and regenerates every spec in the program.
|
|
243
254
|
|
|
244
255
|
## Flipping a decision
|
|
245
256
|
|
|
@@ -268,7 +279,7 @@ implementer brief carries it as binding.
|
|
|
268
279
|
|
|
269
280
|
## Development
|
|
270
281
|
|
|
271
|
-
Requires Node.js
|
|
282
|
+
Requires Node.js 22+.
|
|
272
283
|
|
|
273
284
|
```sh
|
|
274
285
|
npm ci
|
package/dist/author.d.ts
CHANGED
|
@@ -7,9 +7,10 @@ export interface AuthorOptions {
|
|
|
7
7
|
cwd: string;
|
|
8
8
|
programId: string;
|
|
9
9
|
config: NightshiftConfig;
|
|
10
|
-
/** Workstream
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
/** Workstream id to begin authoring from, in stable authoring order.
|
|
11
|
+
* Undefined selects the whole roster. Validated before any agent spawns. */
|
|
12
|
+
from?: string;
|
|
13
|
+
/** Re-author every selected workstream, even ones already complete or legacy. */
|
|
13
14
|
force?: boolean;
|
|
14
15
|
agentRunner?: AgentRunner;
|
|
15
16
|
/** Defaults to a no-op context; the owner (`runProgram`, or the `author`
|
|
@@ -67,6 +68,10 @@ export interface AuthorResult {
|
|
|
67
68
|
* spawned; surfaced here, never fatal — mirrors run-program.ts's
|
|
68
69
|
* WorkstreamResult.stageErrors (WS-04 SC-12). */
|
|
69
70
|
stageErrors?: string[];
|
|
71
|
+
/** What the runner had to do about the author's reply — a summary lifted
|
|
72
|
+
* out of the spec file, or none emitted at all. Rendered in the report so
|
|
73
|
+
* a degraded reply is stated, never silently substituted. */
|
|
74
|
+
notes?: string[];
|
|
70
75
|
}
|
|
71
76
|
export interface AuthorStageResult {
|
|
72
77
|
programId: string;
|
package/dist/author.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"author.d.ts","sourceRoot":"","sources":["../src/author.ts"],"names":[],"mappings":"AAEA,OAAO,EAOL,KAAK,WAAW,EAEjB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,KAAK,EAAe,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACjE,OAAO,EAAwB,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAkBzE,OAAO,EASL,KAAK,iBAAiB,EAEvB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAiC,KAAK,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAe9E,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,gBAAgB,CAAC;IACzB
|
|
1
|
+
{"version":3,"file":"author.d.ts","sourceRoot":"","sources":["../src/author.ts"],"names":[],"mappings":"AAEA,OAAO,EAOL,KAAK,WAAW,EAEjB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,KAAK,EAAe,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACjE,OAAO,EAAwB,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAkBzE,OAAO,EASL,KAAK,iBAAiB,EAEvB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAiC,KAAK,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAe9E,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,gBAAgB,CAAC;IACzB;iFAC6E;IAC7E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iFAAiF;IACjF,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B;;sCAEkC;IAClC,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7B,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;IACjB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACvB;;;;OAIG;IACH,OAAO,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACvB;AAED,MAAM,MAAM,aAAa,GACrB;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,GACvC;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAClB;IAAE,MAAM,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACpC;IAAE,MAAM,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzC,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,aAAa,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,gEAAgE;IAChE,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,+EAA+E;IAC/E,kBAAkB,EAAE,MAAM,EAAE,CAAC;IAC7B,qEAAqE;IACrE,oBAAoB,EAAE,MAAM,EAAE,CAAC;IAC/B,uEAAuE;IACvE,mBAAmB,EAAE,MAAM,EAAE,CAAC;IAC9B,8FAA8F;IAC9F,UAAU,EAAE,OAAO,CAAC;IACpB,gFAAgF;IAChF,YAAY,CAAC,EAAE,iBAAiB,CAAC;IACjC;;sDAEkD;IAClD,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB;;kEAE8D;IAC9D,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,kEAAkE;IAClE,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,8EAA8E;IAC9E,mBAAmB,EAAE,OAAO,CAAC;CAC9B;AAktBD;;;;;GAKG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,iBAAiB,CAAC,CA6jB5B"}
|
package/dist/author.js
CHANGED
|
@@ -6,7 +6,7 @@ import { NOOP_PERMITS_CONTEXT } from "./permits.js";
|
|
|
6
6
|
import { decisionContract, decisionFingerprint, extractDecisions, } from "./decision.js";
|
|
7
7
|
import { appendLedgerEvents } from "./decision-ledger.js";
|
|
8
8
|
import { reviewDecisions, triageFindings } from "./decider-review.js";
|
|
9
|
-
import { findCycles, topologicalLevels } from "./graph.js";
|
|
9
|
+
import { findCycles, stableTopologicalOrder, topologicalLevels } from "./graph.js";
|
|
10
10
|
import { loadManifest, saveManifest, specInputsHash, } from "./manifest.js";
|
|
11
11
|
import { extractFindings, findingsContract, findingsToLedgerEvents, hasRoutableEvidence, locateInRepo, reviewerAbsentOutcome, runReviewPass, verifyEvidence, } from "./review-pass.js";
|
|
12
12
|
import { defaultGitOps, downstreamCone } from "./run-program.js";
|
|
@@ -31,6 +31,102 @@ async function readFinishedSpec(root, workstream) {
|
|
|
31
31
|
return undefined;
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Classifies a workstream's spec by runner-owned lifecycle state, not by
|
|
36
|
+
* file existence alone: a non-empty `taskFile` proves only that writing
|
|
37
|
+
* started, never that authoring finished (WS-01 SC-01/SC-02/SC-03).
|
|
38
|
+
* Precedence: `missing` first regardless of the marker (a deleted taskFile is
|
|
39
|
+
* always re-authored), then `interrupted` (a prior attempt did not finish,
|
|
40
|
+
* even though it left a non-empty partial file), then the two kept classes.
|
|
41
|
+
*/
|
|
42
|
+
function classifySpec(existing, workstream) {
|
|
43
|
+
if (existing === undefined)
|
|
44
|
+
return "missing";
|
|
45
|
+
if (workstream.specStatus === "in_progress")
|
|
46
|
+
return "interrupted";
|
|
47
|
+
if (workstream.specStatus === "complete")
|
|
48
|
+
return "complete";
|
|
49
|
+
return "legacy";
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* True when a *returned* (not thrown) author invocation reported failure —
|
|
53
|
+
* a nonzero exit code or an undelivered prompt. Each means the process
|
|
54
|
+
* itself reported failure, so any spec it may have written cannot be
|
|
55
|
+
* trusted as complete (WS-01 SC-07): the safe direction is to re-author.
|
|
56
|
+
*
|
|
57
|
+
* A missing summary block is deliberately *not* a protocol failure. The
|
|
58
|
+
* summary contract (`agent-summary.ts`) says a missing block is never fatal,
|
|
59
|
+
* and the implementer path honors that; the author path once did not, and
|
|
60
|
+
* the first real run to hit it discarded a complete 36 KB spec because the
|
|
61
|
+
* agent ended the *file* with its summary instead of its *reply*. The spec
|
|
62
|
+
* is the artifact; its presence is checked deterministically above. The
|
|
63
|
+
* reply's shape is a proxy, and grading the proxy is the courtroom.
|
|
64
|
+
*/
|
|
65
|
+
function isAuthorProtocolFailure(invocation) {
|
|
66
|
+
return invocation.exitCode !== 0 || invocation.inputError !== undefined;
|
|
67
|
+
}
|
|
68
|
+
function describeProtocolFailure(invocation) {
|
|
69
|
+
const parts = [`exit code ${invocation.exitCode}`];
|
|
70
|
+
if (invocation.inputError !== undefined)
|
|
71
|
+
parts.push(`input error: ${invocation.inputError}`);
|
|
72
|
+
return parts.join(", ");
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A fenced summary block that ends the spec file, with nothing after it.
|
|
76
|
+
* Only a *trailing* block is treated as a misplaced reply: a summary block
|
|
77
|
+
* quoted mid-document is spec content and stays untouched.
|
|
78
|
+
*/
|
|
79
|
+
const TRAILING_SUMMARY_BLOCK = /(?:\r?\n)*```summary[^\S\r\n]*\r?\n([\s\S]*?)```\s*$/u;
|
|
80
|
+
/**
|
|
81
|
+
* When an author emitted no summary in its reply, the block usually landed
|
|
82
|
+
* at the end of the spec it wrote — the agent treated the file as its
|
|
83
|
+
* output. Lift it out: the summary is recorded as the agent's own words and
|
|
84
|
+
* the spec no longer carries a reply artifact the implementer would read.
|
|
85
|
+
* Returns the salvaged summary and the spec content *before* stripping (so
|
|
86
|
+
* decision blocks the agent also wrote into the file can be journaled), or
|
|
87
|
+
* undefined when the file has no trailing summary block. Never throws.
|
|
88
|
+
*/
|
|
89
|
+
async function salvageSummaryFromSpec(root, workstream) {
|
|
90
|
+
const path = join(root, workstream.taskFile);
|
|
91
|
+
let content;
|
|
92
|
+
try {
|
|
93
|
+
content = await readFile(path, "utf8");
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const match = TRAILING_SUMMARY_BLOCK.exec(content);
|
|
99
|
+
const text = match?.[1]?.trim();
|
|
100
|
+
if (match === null || text === undefined || text === "")
|
|
101
|
+
return undefined;
|
|
102
|
+
const stripped = `${content.slice(0, match.index).replace(/\s+$/u, "")}\n`;
|
|
103
|
+
await writeFile(path, stripped, "utf8");
|
|
104
|
+
return { summary: text, spec: content };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The summary and decision source for an author reply, degrading in order:
|
|
108
|
+
* the reply's own block; a block salvaged from the end of the spec file; the
|
|
109
|
+
* reply's output tail. Each degradation is stated in `notes` so the run
|
|
110
|
+
* report says what happened instead of silently substituting.
|
|
111
|
+
*/
|
|
112
|
+
async function resolveAuthorReply(root, workstream, invocation, notes) {
|
|
113
|
+
const fromReply = resolveSummary(invocation.output);
|
|
114
|
+
if (fromReply.available)
|
|
115
|
+
return { summary: fromReply, decisionSource: invocation.output };
|
|
116
|
+
const salvaged = await salvageSummaryFromSpec(root, workstream);
|
|
117
|
+
if (salvaged !== undefined) {
|
|
118
|
+
notes.push(`${workstream.id}'s author wrote its summary block into ${workstream.taskFile} ` +
|
|
119
|
+
`instead of ending its reply with it; the runner lifted the block out of the ` +
|
|
120
|
+
`spec and recorded it as the summary.`);
|
|
121
|
+
return {
|
|
122
|
+
summary: { text: salvaged.summary, available: true, needsPermission: [] },
|
|
123
|
+
decisionSource: `${invocation.output}\n${salvaged.spec}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
notes.push(`${workstream.id}'s author ended its reply without a summary block; the tail of ` +
|
|
127
|
+
`its reply was recorded instead. The spec it wrote is kept.`);
|
|
128
|
+
return { summary: fromReply, decisionSource: invocation.output };
|
|
129
|
+
}
|
|
34
130
|
async function programDocumentSection(root, manifest) {
|
|
35
131
|
const relPath = join("docs", "programs", `${manifest.program.id}-program.md`);
|
|
36
132
|
let content;
|
|
@@ -237,6 +333,9 @@ async function authorBrief(root, manifest, workstream, rosterById, reauthorNote)
|
|
|
237
333
|
"",
|
|
238
334
|
"- Write exactly one file: this workstream's taskFile (create parent",
|
|
239
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.",
|
|
240
339
|
"- Never commit; the runner owns commits.",
|
|
241
340
|
"",
|
|
242
341
|
decisionContract(),
|
|
@@ -477,7 +576,6 @@ export async function authorProgram(options) {
|
|
|
477
576
|
const git = options.git ?? defaultGitOps;
|
|
478
577
|
const log = options.log ?? ((line) => console.log(line));
|
|
479
578
|
const now = options.now ?? (() => new Date());
|
|
480
|
-
const only = options.only;
|
|
481
579
|
const force = options.force === true;
|
|
482
580
|
const reviewed = options.reviewed ?? new Set();
|
|
483
581
|
const triaged = options.triaged ?? new Set();
|
|
@@ -504,6 +602,25 @@ export async function authorProgram(options) {
|
|
|
504
602
|
.map((cycle) => cycle.join(" -> "))
|
|
505
603
|
.join("; ")}. Re-plan with /plan-program.`);
|
|
506
604
|
}
|
|
605
|
+
// The selected range (SC-05): computed once, up front, from the manifest
|
|
606
|
+
// as loaded — before any agent spawns — so `--from` validation and the
|
|
607
|
+
// selected set are deterministic, independent of dependency edges an
|
|
608
|
+
// author might discover mid-run. The same stable order the authoring loop
|
|
609
|
+
// below walks level-by-level, so selection and execution agree by
|
|
610
|
+
// construction.
|
|
611
|
+
const stableOrder = stableTopologicalOrder(manifest.workstreams);
|
|
612
|
+
let selectedIds;
|
|
613
|
+
if (options.from === undefined) {
|
|
614
|
+
selectedIds = new Set(stableOrder.map((workstream) => workstream.id));
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
const fromIndex = stableOrder.findIndex((workstream) => workstream.id === options.from);
|
|
618
|
+
if (fromIndex === -1) {
|
|
619
|
+
throw new Error(`Unknown workstream id "${options.from}" for --from. Valid ids: ` +
|
|
620
|
+
`${stableOrder.map((workstream) => workstream.id).join(", ")}.`);
|
|
621
|
+
}
|
|
622
|
+
selectedIds = new Set(stableOrder.slice(fromIndex).map((workstream) => workstream.id));
|
|
623
|
+
}
|
|
507
624
|
const isRepository = await git.isRepository(root);
|
|
508
625
|
if (!isRepository) {
|
|
509
626
|
log("warning: not a git repository — commits and decision anchors are unavailable");
|
|
@@ -548,9 +665,31 @@ export async function authorProgram(options) {
|
|
|
548
665
|
};
|
|
549
666
|
return base;
|
|
550
667
|
}
|
|
668
|
+
// Out-of-range keep (SC-05): a workstream before `--from` is kept and
|
|
669
|
+
// never drift-checked — the operator explicitly asked to start
|
|
670
|
+
// elsewhere, so this is neither authored nor parked. Decided ahead of
|
|
671
|
+
// both classification and the drift check.
|
|
672
|
+
if (!selectedIds.has(workstream.id)) {
|
|
673
|
+
return base;
|
|
674
|
+
}
|
|
551
675
|
const existing = await readFinishedSpec(root, workstream);
|
|
552
|
-
const
|
|
676
|
+
const classification = classifySpec(existing, workstream);
|
|
677
|
+
const shouldAuthor = force || classification === "missing" || classification === "interrupted";
|
|
553
678
|
if (!shouldAuthor) {
|
|
679
|
+
// Legacy specHash migration (SC-03): a legacy spec (no specStatus
|
|
680
|
+
// marker) whose specHash matches the manifest's current inputs is
|
|
681
|
+
// recognized as completed legacy Nightshift authoring and normalized
|
|
682
|
+
// — persisted immediately, since a kept-only invocation otherwise
|
|
683
|
+
// never saves.
|
|
684
|
+
if (classification === "legacy" &&
|
|
685
|
+
workstream.specHash !== undefined &&
|
|
686
|
+
specInputsHash(manifest, workstream) === workstream.specHash) {
|
|
687
|
+
workstream.specStatus = "complete";
|
|
688
|
+
await saveManifest(root, options.programId, manifest, { log });
|
|
689
|
+
log(`${workstream.id}: recognized as completed legacy authoring — ` +
|
|
690
|
+
'normalized to specStatus "complete"');
|
|
691
|
+
return base;
|
|
692
|
+
}
|
|
554
693
|
// Drift check: the spec exists and is about to be graded against, but
|
|
555
694
|
// if the manifest entry changed since it was authored, the spec text
|
|
556
695
|
// may promise something the manifest no longer does. The manifest is
|
|
@@ -574,7 +713,7 @@ export async function authorProgram(options) {
|
|
|
574
713
|
reason: `the manifest entry (name, scope, dependencies, or the program's ` +
|
|
575
714
|
`success criteria) changed after ${workstream.taskFile} was ` +
|
|
576
715
|
`authored, so the spec may be stale. Re-author it with ` +
|
|
577
|
-
`\`nightshift author ${options.programId} --
|
|
716
|
+
`\`nightshift author ${options.programId} --from ${workstream.id} --force\``,
|
|
578
717
|
};
|
|
579
718
|
return base;
|
|
580
719
|
}
|
|
@@ -593,6 +732,12 @@ export async function authorProgram(options) {
|
|
|
593
732
|
const allDecisionsById = new Map();
|
|
594
733
|
const first = await authorBrief(root, manifest, workstream, rosterById);
|
|
595
734
|
base.demotedDependencies = first.demoted;
|
|
735
|
+
// Stamped before the agent can write anything (SC-01): the agent has
|
|
736
|
+
// whole-file write access, so a crash at any point after this line
|
|
737
|
+
// leaves a durable `in_progress` marker the next attempt classifies as
|
|
738
|
+
// interrupted, regardless of whether a partial file exists.
|
|
739
|
+
workstream.specStatus = "in_progress";
|
|
740
|
+
await saveManifest(root, options.programId, manifest, { log });
|
|
596
741
|
let invocation;
|
|
597
742
|
try {
|
|
598
743
|
invocation = await invokeAgent(agentRunner, author, first.brief, root, permits, "authorAgent", {
|
|
@@ -608,7 +753,9 @@ export async function authorProgram(options) {
|
|
|
608
753
|
// path rather than the build one. Nothing was written, so this parks
|
|
609
754
|
// the workstream exactly like the "no spec was written" branch below,
|
|
610
755
|
// instead of letting the rejection travel through authorProgram (which
|
|
611
|
-
// runProgram awaits unguarded) into a process crash.
|
|
756
|
+
// runProgram awaits unguarded) into a process crash. `specStatus`
|
|
757
|
+
// stays `in_progress` — never cleared on a failure path — so a later
|
|
758
|
+
// retry resumes without needing --force (SC-07).
|
|
612
759
|
workstream.status = "parked";
|
|
613
760
|
await saveManifest(root, options.programId, manifest, { log });
|
|
614
761
|
for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
|
|
@@ -621,8 +768,30 @@ export async function authorProgram(options) {
|
|
|
621
768
|
};
|
|
622
769
|
return base;
|
|
623
770
|
}
|
|
624
|
-
|
|
625
|
-
|
|
771
|
+
if (isAuthorProtocolFailure(invocation)) {
|
|
772
|
+
// The process reported failure or its prompt was not fully delivered —
|
|
773
|
+
// any spec it may have written cannot be trusted as complete (SC-07).
|
|
774
|
+
// Park exactly like a spawn failure and leave `specStatus` at
|
|
775
|
+
// `in_progress` so a force-free retry re-authors.
|
|
776
|
+
workstream.status = "parked";
|
|
777
|
+
await saveManifest(root, options.programId, manifest, { log });
|
|
778
|
+
for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
|
|
779
|
+
blockedCone.add(id);
|
|
780
|
+
}
|
|
781
|
+
base.summary = resolveSummary(invocation.output).text;
|
|
782
|
+
base.outcome = {
|
|
783
|
+
status: "failed",
|
|
784
|
+
reason: `the author agent reported a protocol failure (${describeProtocolFailure(invocation)}); ` +
|
|
785
|
+
`not trusted as a finished spec.`,
|
|
786
|
+
};
|
|
787
|
+
return base;
|
|
788
|
+
}
|
|
789
|
+
const notes = [];
|
|
790
|
+
const firstReply = await resolveAuthorReply(root, workstream, invocation, notes);
|
|
791
|
+
base.summary = firstReply.summary.text;
|
|
792
|
+
if (notes.length > 0)
|
|
793
|
+
(base.notes ??= []).push(...notes);
|
|
794
|
+
let parsed = extractDecisions(firstReply.decisionSource);
|
|
626
795
|
base.decisionErrors.push(...parsed.errors);
|
|
627
796
|
for (const decision of parsed.decisions) {
|
|
628
797
|
allDecisionsById.set(decisionFingerprint(workstream.id, decision), decision);
|
|
@@ -686,19 +855,49 @@ export async function authorProgram(options) {
|
|
|
686
855
|
`pass: ${error.message}. The pre-discovery spec was kept.`);
|
|
687
856
|
}
|
|
688
857
|
if (reauthorInvocation !== undefined) {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
858
|
+
if (isAuthorProtocolFailure(reauthorInvocation)) {
|
|
859
|
+
// A *returned* protocol failure on the re-author pass is treated
|
|
860
|
+
// exactly like one on the first invocation (SC-07): the process
|
|
861
|
+
// reported failure or its prompt was not fully delivered, so
|
|
862
|
+
// nothing this pass produced can be trusted — park, isolate the
|
|
863
|
+
// downstream cone, and leave specStatus at in_progress for a
|
|
864
|
+
// force-free retry. This differs from a *thrown* spawn error
|
|
865
|
+
// above, which fails open because the first pass's spec is
|
|
866
|
+
// otherwise untouched; a returned failure carries no such
|
|
867
|
+
// guarantee.
|
|
868
|
+
workstream.status = "parked";
|
|
869
|
+
await saveManifest(root, options.programId, manifest, { log });
|
|
870
|
+
for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
|
|
871
|
+
blockedCone.add(id);
|
|
872
|
+
}
|
|
873
|
+
base.summary = resolveSummary(reauthorInvocation.output).text;
|
|
874
|
+
base.outcome = {
|
|
875
|
+
status: "failed",
|
|
876
|
+
reason: `the author agent reported a protocol failure during ${workstream.id}'s ` +
|
|
877
|
+
`re-author pass (${describeProtocolFailure(reauthorInvocation)}); ` +
|
|
878
|
+
`not trusted as a finished spec.`,
|
|
879
|
+
};
|
|
880
|
+
return base;
|
|
695
881
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
882
|
+
else {
|
|
883
|
+
invocation = reauthorInvocation;
|
|
884
|
+
const reauthorNotes = [];
|
|
885
|
+
const reauthorReply = await resolveAuthorReply(root, workstream, reauthorInvocation, reauthorNotes);
|
|
886
|
+
base.summary = reauthorReply.summary.text;
|
|
887
|
+
if (reauthorNotes.length > 0)
|
|
888
|
+
(base.notes ??= []).push(...reauthorNotes);
|
|
889
|
+
parsed = extractDecisions(reauthorReply.decisionSource);
|
|
890
|
+
base.decisionErrors.push(...parsed.errors);
|
|
891
|
+
for (const decision of parsed.decisions) {
|
|
892
|
+
allDecisionsById.set(decisionFingerprint(workstream.id, decision), decision);
|
|
893
|
+
}
|
|
894
|
+
await journalDecisions(workstream, parsed.decisions, baseCommit);
|
|
895
|
+
base.decisionIds.push(...parsed.decisions.map((decision) => decisionFingerprint(workstream.id, decision)));
|
|
896
|
+
const secondDeclaration = extractDependenciesDeclaration(invocation.output);
|
|
897
|
+
if (secondDeclaration.ids.length > 0) {
|
|
898
|
+
log(`${workstream.id}: additional dependency declaration after the ` +
|
|
899
|
+
`re-author pass was not honored: ${secondDeclaration.ids.join(", ")}`);
|
|
900
|
+
}
|
|
702
901
|
}
|
|
703
902
|
}
|
|
704
903
|
}
|
|
@@ -749,13 +948,25 @@ export async function authorProgram(options) {
|
|
|
749
948
|
await appendLedgerEvents(root, options.programId, critiqueEvents);
|
|
750
949
|
// Stamped after every dependency merge above, so the hash records the
|
|
751
950
|
// manifest exactly as this spec's author last saw it — a later manifest
|
|
752
|
-
// edit is drift; authoring's own merges are not.
|
|
951
|
+
// edit is drift; authoring's own merges are not. `specStatus` is
|
|
952
|
+
// deliberately NOT stamped `complete` in this same save (SC-01, SC-02):
|
|
953
|
+
// inside a repository, the durable signal is the git commit below, and a
|
|
954
|
+
// crash or throw from `commitPaths` between this save and the commit
|
|
955
|
+
// must still classify as interrupted on retry, not falsely complete with
|
|
956
|
+
// an unmade commit.
|
|
753
957
|
workstream.specHash = specInputsHash(manifest, workstream);
|
|
754
958
|
await saveManifest(root, options.programId, manifest, { log });
|
|
755
959
|
let commit;
|
|
756
960
|
if (isRepository) {
|
|
757
961
|
commit = await git.commitPaths(root, `nightshift(${options.programId}): author ${workstream.id} ${workstream.name}`, [workstream.taskFile, "docs/programs"]);
|
|
758
962
|
}
|
|
963
|
+
// Outside a git repository there is no commit to wait for — the finished
|
|
964
|
+
// spec, its specHash, and this manifest save are already the durable
|
|
965
|
+
// state, so `complete` is stamped right here. Inside one, this still
|
|
966
|
+
// only runs after `commitPaths` above resolves, so the transition is
|
|
967
|
+
// recoverable on both sides of the commit (SC-01, SC-02).
|
|
968
|
+
workstream.specStatus = "complete";
|
|
969
|
+
await saveManifest(root, options.programId, manifest, { log });
|
|
759
970
|
{
|
|
760
971
|
const spawnErrors = await reviewWorkstreamDecisions(workstream.id, [...allDecisionsById.values()], baseCommit);
|
|
761
972
|
if (spawnErrors.length > 0)
|