@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 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 20+** and a **git repository** (an existing project or a fresh one).
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", "--sandbox", "workspace-write"], "promptMode": "stdin" }
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`. nightshift
85
- derives each role's permission posture itself — from the role (`agent`,
86
- `authorAgent`, and `recoveryAgent` get a workspace posture; `deciderAgent` and
87
- `reviewerAgent` get read-only, never edit-capable regardless of the host) and
88
- from the host it runs on (a claude `auto` classifier plus an OS sandbox on
89
- macOS/Linux, a `--sandbox workspace-write` grant on codex, a documented
90
- fallback when a capability cannot be detected). `recoveryAgent`'s
91
- `--sandbox workspace-write` above is still honored untouched: any
92
- permission-bearing flag an operator already wrote — `--permission-mode`,
93
- `--allowedTools`, `--sandbox`, and their siblings — always wins, and that
94
- role is reported as operator-configured rather than silently overridden.
95
-
96
- An optional `permits` block widens or narrows the derived posture without
97
- hand-writing provider flags:
98
-
99
- ```json
100
- "permits": {
101
- "policy": "free prose read by the implementer and recovery briefs",
102
- "allow": ["Bash(git stash:*)"],
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; `policy` is prose the implementer/recovery
109
- briefs quote verbatim. On a throwaway CI runner, set
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. Note `recoveryAgent`'s `--sandbox workspace-write`
122
- above: `codex exec` defaults to a read-only sandbox, which is the right
123
- posture for the decider and reviewer seats and exactly wrong for a seat that
124
- must fix a failing tree. nightshift does not trust that default, though: the
125
- decider and reviewer seats are spawned with an explicit `--sandbox read-only`,
126
- because a codex user config can widen the default (a `sandbox = "elevated"`
127
- setting, a trusted-project entry) and a seat labeled read-only that can write
128
- is exactly the kind of quiet mismatch the posture lines exist to prevent.
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` that already exists is always kept, never overwritten. To
234
- deliberately re-author one after a plan edit, name it explicitly and force it:
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> --only WS-03 --force
243
+ nightshift author <program-id> --from WS-03
244
+ nightshift author <program-id> --from WS-03 --force
238
245
  ```
239
246
 
240
- `--force` on its own does nothing to existing specs — it only takes effect
241
- for ids named in `--only`, so a bare `--force` can never silently regenerate
242
- a program's worth of hand-reviewed specs.
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 20+.
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 ids eligible for `force` re-authoring. */
11
- only?: string[];
12
- /** Re-author ids named in `only`, even though their spec already exists. */
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;
@@ -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,wDAAwD;IACxD,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,4EAA4E;IAC5E,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;CACxB;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;AA6lBD;;;;;GAKG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,iBAAiB,CAAC,CA8b5B"}
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 shouldAuthor = existing === undefined || (force && only !== undefined && only.includes(workstream.id));
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} --only ${workstream.id} --force\``,
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
- base.summary = resolveSummary(invocation.output).text;
625
- let parsed = extractDecisions(invocation.output);
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
- invocation = reauthorInvocation;
690
- base.summary = resolveSummary(invocation.output).text;
691
- parsed = extractDecisions(invocation.output);
692
- base.decisionErrors.push(...parsed.errors);
693
- for (const decision of parsed.decisions) {
694
- allDecisionsById.set(decisionFingerprint(workstream.id, decision), decision);
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
- await journalDecisions(workstream, parsed.decisions, baseCommit);
697
- base.decisionIds.push(...parsed.decisions.map((decision) => decisionFingerprint(workstream.id, decision)));
698
- const secondDeclaration = extractDependenciesDeclaration(invocation.output);
699
- if (secondDeclaration.ids.length > 0) {
700
- log(`${workstream.id}: additional dependency declaration after the ` +
701
- `re-author pass was not honored: ${secondDeclaration.ids.join(", ")}`);
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)