omp-conductor 0.19.6 → 0.20.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.
Files changed (71) hide show
  1. package/REFERENCE.md +27 -2
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/arm-challenge.ts +204 -85
  6. package/src/ask.ts +130 -615
  7. package/src/board.ts +7 -1
  8. package/src/brief-upgrade.ts +24 -0
  9. package/src/briefs/console.md +253 -0
  10. package/src/briefs/correction.md +203 -0
  11. package/src/briefs/orchestrator.md +167 -97
  12. package/src/briefs/policy.md +19 -16
  13. package/src/briefs/to-spec.md +76 -9
  14. package/src/briefs/worker.md +50 -16
  15. package/src/cli.ts +4 -0
  16. package/src/command-manifest.ts +54 -8
  17. package/src/commands/arm.ts +113 -49
  18. package/src/commands/console.ts +70 -0
  19. package/src/commands/context.ts +2 -0
  20. package/src/commands/epic.ts +132 -0
  21. package/src/commands/extend.ts +9 -1
  22. package/src/commands/intake.ts +44 -14
  23. package/src/commands/stats.ts +19 -4
  24. package/src/commands/worker.ts +9 -1
  25. package/src/config-schema.ts +13 -0
  26. package/src/config.ts +27 -0
  27. package/src/daemon/ack.ts +159 -0
  28. package/src/daemon/admission-pass.ts +135 -0
  29. package/src/daemon/brief.ts +461 -0
  30. package/src/daemon/deps.ts +539 -0
  31. package/src/daemon/dispatch.ts +1779 -0
  32. package/src/daemon/drain.ts +185 -0
  33. package/src/daemon/groom-pass.ts +412 -0
  34. package/src/daemon/http.ts +417 -0
  35. package/src/daemon/integrity.ts +108 -0
  36. package/src/daemon/panes.ts +180 -0
  37. package/src/daemon/review.ts +1888 -0
  38. package/src/daemon/runtime.ts +736 -0
  39. package/src/daemon/settle-pass.ts +589 -0
  40. package/src/daemon/supervision.ts +438 -0
  41. package/src/daemon/tick.ts +968 -0
  42. package/src/daemon/views.ts +751 -0
  43. package/src/daemon.ts +105 -7832
  44. package/src/dashboard/app.js +58 -0
  45. package/src/dashboard/controls.ts +22 -3
  46. package/src/dashboard/server.ts +4 -0
  47. package/src/diff-flags.ts +24 -3
  48. package/src/doctor.ts +17 -12
  49. package/src/escalate.ts +39 -21
  50. package/src/failure-class.ts +75 -1
  51. package/src/fleet.ts +1218 -304
  52. package/src/groom.ts +461 -0
  53. package/src/http-token.ts +142 -0
  54. package/src/knowledge.ts +229 -0
  55. package/src/mining.ts +316 -0
  56. package/src/orchestrator-tick.ts +428 -1681
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +72 -6
  59. package/src/setup-host.ts +32 -9
  60. package/src/setup-wizard.ts +55 -7
  61. package/src/setup.ts +229 -3
  62. package/src/stats.ts +257 -2
  63. package/src/status-render.ts +158 -7
  64. package/src/store.ts +646 -26
  65. package/src/to-spec.ts +194 -21
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +435 -15
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +384 -12
  70. package/src/wake.ts +19 -2
  71. package/src/worker.ts +456 -1
@@ -18,7 +18,7 @@ inside your own worktree.
18
18
  branch. Never switch branches and never touch a path outside the worktree (write/edit/read/grep/glob are also blocked mechanically outside this checkout; `bash` is still a must-not — do not use it to escape).
19
19
 
20
20
  **You have this host's GitHub credentials, and you must not publish with them.**
21
- Every push and every PR goes through the four `conductor_*` tools below, because
21
+ Every push and every PR goes through the five `conductor_*` tools below, because
22
22
  the dispatcher owns the settlement record: work it did not perform is work it
23
23
  cannot account for, and the checks that would have refused a bad push never ran.
24
24
  Reading with `gh` is fine; `git push`, `gh pr create` and `gh pr merge` are not
@@ -47,7 +47,7 @@ files are canonical; your priors are not.
47
47
 
48
48
  {{ACCEPTANCE_CRITERIA}}
49
49
 
50
- {{ISSUE_COMMENTS}}{{FILE_LANE}}{{MODEL}}## How to work
50
+ {{ISSUE_COMMENTS}}{{FILE_LANE}}{{MODEL}}{{KNOWLEDGE}}## How to work
51
51
 
52
52
  1. **Understand before editing — and ask the graph before you grep.** Your turns
53
53
  are mostly spent finding code, not writing it, and running out of turns
@@ -106,19 +106,15 @@ A push is expensive: every push starts a full CI cycle on shared self-hosted
106
106
  runners, minutes when healthy and far longer under load. **Never use CI as a
107
107
  linter.**
108
108
 
109
- These are the exact gates for `{{REPO}}`:
110
-
111
- {{GATES}}
109
+ {{GATES}}{{CI_OWNED_GATES}}
112
110
 
113
111
  {{SHARED_HOST_NOTICE}}
114
112
  {{HOST_CONSTRAINTS}}
115
113
 
116
- Run every one of them, from the directory listed, over the **whole tree** — not
117
- just the directory you edited. Linting only the source dir is how an error in a
118
- migration, a config file or a script reaches the runners.
119
-
120
- **Do not run** docker builds, image builds, production builds, browser/e2e suites,
121
- or the full test suite on this host. It is shared, and CI owns the heavy gates.
114
+ **Do not run** docker builds, image builds, production builds, or browser/e2e
115
+ suites on this host. It is shared, and CI owns the heavy gates. For the
116
+ configured gates that judgement is already made above and it is not yours to
117
+ revisit: run the ones listed as yours, and leave the CI-owned ones to CI.
122
118
 
123
119
  ## Push and get to green
124
120
 
@@ -153,14 +149,38 @@ credential.
153
149
  quote. If the tool is not mounted for this fleet, stop and report `blocked`
154
150
  saying you could not observe CI; never guess green.
155
151
  6. **Green** → stop and report `pushed-green`, quoting that same head SHA.
156
- **Red** → diagnose the real cause and make **one** corrective push. Red a
157
- second time stop, do not push again, and report `failed` with the failure
158
- digest (job name plus the decisive log lines).
152
+ **Red** → read what CI actually printed before you touch anything.
153
+ `conductor_ci_logs()` answers with the failing jobs' own failed steps at that
154
+ head, through the same bounded log read the dispatcher's own failure
155
+ classifier uses. It is read-only, it changes nothing, and it is not ledgered
156
+ — but it is not free in context either: read it once and fix from what it
157
+ said, rather than polling it or re-running the whole suite locally hoping to
158
+ reproduce a runner-only failure. If it cannot read the logs it says so; an
159
+ unreadable log is never reported to you as "no failures". Then make **one**
160
+ corrective push. Red a second time → stop, do not push again, and report
161
+ `failed` with the failure digest (job name plus the decisive log lines).
162
+ 7. **A runner-infrastructure red does not spend one of those two.** When the
163
+ failing job's own text says the *runner* failed rather than your diff — it
164
+ lost communication with the server, it received a shutdown signal, or no
165
+ runner ever acquired the job — `conductor_ci_logs` says so itself, in the
166
+ first line of its answer, quoting the wording it matched. That reply is the
167
+ ruling: do not change the diff for it, do not spend a corrective push on it,
168
+ wait, and poll `conductor_pr_status` again. If it is still red, stop and
169
+ report with that wording quoted — the dispatcher re-runs infrastructure
170
+ checks itself and does not charge the attempt.
171
+
172
+ You do not make this call, and you must not claim it. If the reply does not
173
+ name the infrastructure wording, the red counts, whatever it looks like to
174
+ you: a job cancelled for reasons the log does not name, a job that ran until
175
+ its execution-time ceiling, a log that could not be read, a red that "passes
176
+ locally". When some jobs are infrastructure and one is not, the reply says
177
+ the red counts — a real failure outranks any number of infra ones, so fix the
178
+ real one.
159
179
 
160
180
  ## Your verb surface — what you can do, and what answers back
161
181
 
162
182
  Editing, building and testing are yours: ordinary `bash` inside your worktree,
163
- unmediated. What leaves this machine is not. Four tools are the *only* route to
183
+ unmediated. What leaves this machine is not. Five tools are the *only* route to
164
184
  GitHub you may take — another one exists on this host, and taking it is a
165
185
  reportable breach rather than a shortcut:
166
186
 
@@ -169,6 +189,7 @@ reportable breach rather than a shortcut:
169
189
  | `conductor_push` | Publishes **this run's branch**, fast-forward only. |
170
190
  | `conductor_pr_create` | Opens **this run's** PR: head is your branch, base is the repo default. |
171
191
  | `conductor_pr_status` | Reads the live PR state, head and checks. A poll, not a watcher. |
192
+ | `conductor_ci_logs` | Reads the failing CI jobs' own log output at the PR head. Read-only and bounded; call it on a red check you have to diagnose. |
172
193
  | `conductor_pr_update_branch` | Merges the base into your PR when it has fallen behind. |
173
194
 
174
195
  Three things follow, and they are worth reading once rather than rediscovering:
@@ -233,7 +254,11 @@ at session start. The expected shape:
233
254
  "prUrl": "https://github.com/.../pull/N",
234
255
  "headSha": "<the 40-char head you watched go green>",
235
256
  "summary": "What you changed and why — the narrative a reviewer reads.",
236
- "proof": ["bun test omp/src/worker.test.ts"]
257
+ "proof": ["bun test omp/src/worker.test.ts"],
258
+ "discoveries": [
259
+ "settlement flags are rendered in omp/src/diff-flags.ts, not in settlement.ts",
260
+ "store migrations here are PRAGMA-guarded ALTERs — never a table rebuild"
261
+ ]
237
262
  }
238
263
  ```
239
264
 
@@ -246,6 +271,15 @@ the stored report, so `summary` is what a reviewer reads and `proof` is the
246
271
  evidence; the `changed:` file list is derived from the PR's own diff, never
247
272
  written by you.
248
273
 
274
+ `discoveries` is optional, and it is not about you. It is for durable facts
275
+ about **this repo** that the next worker would otherwise pay turns to
276
+ rediscover: where a behaviour really lives, a convention that is not where you
277
+ looked first, a trap that cost you a turn. The fleet accumulates them per repo
278
+ and renders them into later briefs — so "I fixed the parser" is not a
279
+ discovery, while "the parser's callers are generated from `schema/` and must be
280
+ regenerated with `bun run schema`" is. Leave the field out rather than filling
281
+ it.
282
+
249
283
  Never report success you have not observed. "Should pass CI" is not a state,
250
284
  and `green` means you watched the checks go green — not that you expect them
251
285
  to.
package/src/cli.ts CHANGED
@@ -14,12 +14,14 @@ import { armCommand } from "./commands/arm.ts";
14
14
  import { boardCommand } from "./commands/board.ts";
15
15
  import { companionCommand } from "./commands/companion.ts";
16
16
  import { briefUpgradeCommand } from "./commands/brief-upgrade.ts";
17
+ import { consoleCommand } from "./commands/console.ts";
17
18
  import { daemonCommand } from "./commands/daemon.ts";
18
19
  import { dashboardCommand } from "./commands/dashboard.ts";
19
20
  import { decisionCommand } from "./commands/decision.ts";
20
21
  import { disarmCommand } from "./commands/disarm.ts";
21
22
  import { doctorCommand } from "./commands/doctor.ts";
22
23
  import { drainCommand } from "./commands/drain.ts";
24
+ import { epicCommand } from "./commands/epic.ts";
23
25
  import { eventCommand } from "./commands/event.ts";
24
26
  import { extendCommand } from "./commands/extend.ts";
25
27
  import { frictionCommand } from "./commands/friction.ts";
@@ -281,6 +283,7 @@ export function commandHandlers(ctx: CommandContext): Record<string, CommandHand
281
283
  ledger: () => ledgerCommand(ctx),
282
284
  board: () => boardCommand(ctx),
283
285
  companion: () => companionCommand(ctx),
286
+ console: () => consoleCommand(ctx),
284
287
  dashboard: () => dashboardCommand(ctx),
285
288
  hold: () => holdCommand(ctx),
286
289
  drain: () => drainCommand(ctx),
@@ -299,6 +302,7 @@ export function commandHandlers(ctx: CommandContext): Record<string, CommandHand
299
302
  decision: () => decisionCommand(ctx),
300
303
  watch: () => watchCommand(ctx),
301
304
  intake: () => intakeCommand(ctx),
305
+ epic: () => epicCommand(ctx),
302
306
  friction: () => frictionCommand(ctx),
303
307
  "restore-db": () => restoreDbCommand(ctx),
304
308
  "snapshot-db": () => snapshotDbCommand(ctx),
@@ -166,6 +166,13 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
166
166
  toggle("--once", "draw one frame and exit instead of holding the pane"),
167
167
  ],
168
168
  },
169
+ {
170
+ name: "console",
171
+ description: "provision the operator console workspace and print its pane command",
172
+ scope: "project",
173
+ usage: ["console [--project NAME]"],
174
+ flags: [project()],
175
+ },
169
176
  {
170
177
  name: "dashboard",
171
178
  description: "serve the read-only browser dashboard",
@@ -263,10 +270,15 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
263
270
  },
264
271
  {
265
272
  name: "arm",
266
- description: "prove Telegram delivery and arm scheduled ticks",
273
+ description: "send the arming challenge, then verify the operator's reply",
267
274
  scope: "fleet",
268
- usage: ["arm [--project NAME | --all]"],
269
- flags: [project(), all()],
275
+ usage: ["arm [--project NAME | --all]", 'arm --reply "OPERATOR MESSAGE" [--project NAME]'],
276
+ flags: [value("--reply", "verify the operator's verbatim reply and write the arm marker"), project(), all()],
277
+ details: `arm files a short-lived challenge, sends it to the paired Telegram owner and returns
278
+ immediately — it never waits for the reply, because the reply lands in the console session.
279
+ Nothing is armed until the second half: pass the operator's message verbatim to
280
+ arm --reply, which classifies it and arms exactly the projects that challenge recorded.
281
+ A wrong, expired or absent code arms nothing and says which of the three it was.`,
270
282
  },
271
283
  {
272
284
  name: "disarm",
@@ -466,6 +478,31 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
466
478
  ],
467
479
  positionals: [{ name: "action" }, { name: "id" }],
468
480
  },
481
+ {
482
+ name: "epic",
483
+ description: "approve, revoke, or list standing operator approvals of epic scope",
484
+ scope: "project",
485
+ usage: [
486
+ "epic approve <issue> [--project NAME]",
487
+ "epic revoke <issue> [--project NAME]",
488
+ "epic list [--project NAME] [--json]",
489
+ ],
490
+ details: `An approved epic is standing consent to its scope: brief-complete children of it pass
491
+ the promotion ready gate and are queued without a fresh operator decision. Approval is a durable
492
+ fact of its own, not a resolved decision row — it never expires, and a gate can read it without
493
+ parsing prose. approve is idempotent and first-write-wins: re-approving keeps the original
494
+ timestamp and reports that nothing changed. revoke withdraws consent, so the epic's children need
495
+ a decision again, and is a no-op on an epic that was never approved. Whether the issue number
496
+ really names an epic is not checked here: parents and children are native GitHub sub-issues, which
497
+ the daemon resolves when it promotes.`,
498
+ subcommands: [
499
+ { name: "approve", description: "approve an epic's scope" },
500
+ { name: "revoke", description: "withdraw an epic's approval" },
501
+ { name: "list", description: "list approved epics" },
502
+ ],
503
+ flags: [toggle("--json", "print the approval list as stable JSON"), project()],
504
+ positionals: [{ name: "action" }, { name: "issue" }],
505
+ },
469
506
  {
470
507
  name: "watch",
471
508
  description: "record, list, or withdraw orchestrator-only conditions and carry notes",
@@ -503,22 +540,31 @@ closed first) is withdrawn by the daemon itself.`,
503
540
  scope: "project",
504
541
  usage: [
505
542
  'intake "<text>" [--project NAME]',
506
- "intake list [--project NAME]",
543
+ "intake list [--json] [--project NAME]",
507
544
  "intake dismiss <id> [--project NAME]",
508
545
  "intake groomed <id> --issue <url> [--project NAME]",
509
546
  ],
510
547
  details: `Captures one raw idea into the local store and prints its id. list shows what
511
- is still pending (id, age, text), oldest first; dismiss drops one by id. The
512
- capture is durable — it lives in the sqlite store, not in a session — so it
548
+ is still pending (id, age, source, text), oldest first; dismiss drops one by id.
549
+ The capture is durable — it lives in the sqlite store, not in a session — so it
513
550
  survives daemon restarts. The orchestrator files the idea as an issue and then
514
551
  marks that provenance with groomed: an id already resolved is a no-op with a
515
- message, never an error, because ticks retry.`,
552
+ message, never an error, because ticks retry.
553
+
554
+ The source column names the signal that filed an item, for the ones the daemon's
555
+ hourly signal-mining pass files itself; a dash means you thought of it. A mined
556
+ item is filed idempotently, so dismissing one dismisses it for good rather than
557
+ seeing it again next hour.`,
516
558
  subcommands: [
517
559
  { name: "list", description: "list pending intake items" },
518
560
  { name: "dismiss", description: "dismiss an intake item" },
519
561
  { name: "groomed", description: "link an intake item to its issue" },
520
562
  ],
521
- flags: [value("--issue", "groomed issue URL"), project()],
563
+ flags: [
564
+ value("--issue", "groomed issue URL"),
565
+ toggle("--json", "print the pending list as stable JSON"),
566
+ project(),
567
+ ],
522
568
  positionals: [{ name: "text|action" }, { name: "id" }],
523
569
  },
524
570
  {
@@ -1,61 +1,125 @@
1
1
  /**
2
- * `arm` — proof-gated: send a Telegram challenge and write the arm marker only after the orchestrator's inbound adapter acknowledges the reply in conductor state (#614).
2
+ * `arm` — the two mechanical halves of the arming ceremony.
3
3
  *
4
- * Moved out of cli.ts's switch by the per-verb module split (#462);
5
- * only the case wrapper, the injected `ctx` lookups and the imports
6
- * changed from the original bodies.
4
+ * `arm` files a challenge, sends it, and returns immediately naming the
5
+ * follow-up command. `arm --reply "<the operator's message>"` verifies that
6
+ * message and writes the arm marker for exactly the projects the challenge
7
+ * recorded. Nothing waits anywhere: the operator's reply lands in the console
8
+ * session, which runs no tick extension, so the in-session acknowledgement wait
9
+ * this replaces could never be satisfied (phase 1 of the orchestrator-workflow
10
+ * redesign).
11
+ *
12
+ * Both halves print copy-pasteable commands, because the reader is usually an
13
+ * agent in a console pane rather than a human at a prompt.
7
14
  */
8
15
 
9
16
  import type { CommandContext } from "./context.ts";
10
- import { armFleet, armTicks } from "../fleet.ts";
17
+ import { armFleet, armReply, armTicks, type ArmChallengeSent } from "../fleet.ts";
11
18
  import { withProgress } from "../ui/progress.ts";
12
19
 
13
20
  export async function armCommand(ctx: CommandContext): Promise<void> {
14
- const targets = ctx.targetProjects();
15
- // One ceremony for the whole fleet (#991). Two projects used to mean two
16
- // sequential handshakes with two codes in one chat, though nothing about the
17
- // fleet's state differed between them. `arm --project X` is untouched: it is
18
- // still exactly one project, one challenge, one marker.
19
- if (ctx.projectFlag === undefined && targets.length > 1) {
20
- const r = await withProgress(
21
- "arm: verifying one arming proof for the fleet…",
22
- "Arming proof verified",
23
- () =>
24
- armFleet(
25
- targets.map((project) => project.name),
26
- { progress: (line) => process.stdout.write(`${line}\n`) },
27
- ),
28
- { plainMessage: true },
29
- );
30
- process.stdout.write(
31
- `ARMED — one inbound round-trip proved with owner ${r.owner}; ticks are now live for ${String(r.armed.length)} project(s).\n` +
32
- r.armed
33
- .map(
34
- (project) =>
35
- ` ${project.project}: marker ${project.path}${project.alreadyArmed ? " (replaced previous marker)" : ""}`,
36
- )
37
- .join("\n") +
38
- "\n",
39
- );
40
- return;
21
+ const reply = replyArgument(ctx);
22
+ if (reply !== undefined) {
23
+ // The verification half is one store read and (on a match) the marker
24
+ // writes: no send, no network, nothing to narrate.
25
+ const result = armReply(reply, ctx.projectFlag);
26
+ if (result.outcome === "refused") throw new Error(result.message);
27
+ process.stdout.write(
28
+ `ARMED reply verified for owner ${result.owner}; ticks are now live for ` +
29
+ `${String(result.armed.length)} project(s).\n` +
30
+ result.armed
31
+ .map(
32
+ (project) =>
33
+ ` ${project.project ?? "this fleet"}: marker ${project.path}` +
34
+ `${project.alreadyArmed ? " (replaced previous marker)" : ""}`,
35
+ )
36
+ .join("\n") +
37
+ `\nchallenge ${result.challengeId} is settled — the same code cannot arm anything again.\n`,
38
+ );
39
+ return;
40
+ }
41
+
42
+ const targets = ctx.targetProjects();
43
+ // One ceremony for the whole fleet (#991). Two projects used to mean two
44
+ // sequential handshakes with two codes in one chat, though nothing about the
45
+ // fleet's state differed between them. `arm --project X` is untouched: it is
46
+ // still exactly one project, one challenge, one marker.
47
+ if (ctx.projectFlag === undefined && targets.length > 1) {
48
+ const sent = await withProgress(
49
+ "arm: sending one arming challenge for the fleet…",
50
+ "Arming challenge sent",
51
+ () =>
52
+ armFleet(
53
+ targets.map((project) => project.name),
54
+ { progress: (line) => process.stdout.write(`${line}\n`) },
55
+ ),
56
+ { plainMessage: true },
57
+ );
58
+ process.stdout.write(challengeReceipt(sent));
59
+ return;
60
+ }
61
+ for (const project of targets) {
62
+ // Proof-neutral wording: `claim-only` performs no Telegram send, so the
63
+ // progress line cannot promise a challenge that never goes out (#613). The
64
+ // result line names the proof that actually settled it.
65
+ const result = await withProgress(
66
+ "arm: verifying the arming proof…",
67
+ "Arming proof settled",
68
+ () => armTicks(project.name, { progress: (line) => process.stdout.write(`${line}\n`) }),
69
+ { plainMessage: true },
70
+ );
71
+ if (result.outcome === "armed") {
72
+ process.stdout.write(
73
+ `ARMED — claim-only plumbing verdict proved; ticks are now live.\n` +
74
+ result.armed
75
+ .map(
76
+ (armed) =>
77
+ `marker ${armed.path}${armed.alreadyArmed ? " (replaced previous marker)" : ""}`,
78
+ )
79
+ .join("\n") +
80
+ "\n",
81
+ );
82
+ continue;
83
+ }
84
+ process.stdout.write(challengeReceipt(result));
85
+ }
41
86
  }
42
- for (const project of targets) {
43
- // Proof-neutral wording: `claim-only` performs no Telegram send, so the
44
- // progress line cannot promise a challenge that never goes out (#613). The
45
- // result line names the proof that actually armed it.
46
- // The pending-proof heartbeat goes straight to stdout (#861): a challenge
47
- // waits on a human for up to five minutes, and `withProgress`'s single step
48
- // line cannot say how much of that window is left. Written unconditionally —
49
- // a piped or plain-UI run needs it more than a TTY does, not less.
50
- const r = await withProgress(
51
- "arm: verifying the arming proof…",
52
- "Arming proof verified",
53
- () => armTicks(project.name, { progress: (line) => process.stdout.write(`${line}\n`) }),
54
- { plainMessage: true },
55
- );
56
- process.stdout.write(
57
- `ARMED ${r.proof === "claim-only" ? "claim-only plumbing verdict proved" : `inbound round-trip proved with owner ${r.owner}`}; ticks are now live.\n` +
58
- `marker ${r.path}${r.alreadyArmed ? " (replaced previous marker)" : ""}\n`,
87
+
88
+ /**
89
+ * The challenge half's receipt: what was sent, how long it lasts, what a reply
90
+ * will arm, and the exact command that arms it. The follow-up line is written
91
+ * verbatim from the result so the printed command and the recorded transaction
92
+ * can never disagree.
93
+ */
94
+ function challengeReceipt(sent: ArmChallengeSent): string {
95
+ return (
96
+ `CHALLENGE SENT a code went to owner ${sent.owner}, valid for ${sent.validFor} ` +
97
+ `(challenge ${sent.challengeId}).\n` +
98
+ `NOTHING IS ARMED YET. When the operator replies, run:\n` +
99
+ ` ${sent.followUp}\n` +
100
+ `That reply will arm ${String(sent.targets.length)} project(s):\n` +
101
+ sent.targets
102
+ .map((target) => ` ${target.project ?? "this fleet"}: marker ${target.armedFile}`)
103
+ .join("\n") +
104
+ "\n"
59
105
  );
60
106
  }
107
+
108
+ /**
109
+ * The `--reply` payload, refusing the shapes that would otherwise send a fresh
110
+ * challenge instead of verifying one. A bare `--reply` (no value) or an empty
111
+ * string is a mistake worth naming: silently re-arming from a verification
112
+ * attempt would leave the operator with two live codes and no idea why.
113
+ */
114
+ function replyArgument(ctx: CommandContext): string | undefined {
115
+ const value = ctx.flag("reply");
116
+ const present = ctx.argv.includes("--reply") || ctx.argv.some((token) => token.startsWith("--reply="));
117
+ if (!present) return undefined;
118
+ if (value === undefined || value.trim().length === 0) {
119
+ throw new Error(
120
+ `arm --reply needs the operator's message, verbatim — for example: ` +
121
+ `omp-conductor arm --reply "FLEET-1A2B3C4D"`,
122
+ );
123
+ }
124
+ return value;
61
125
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `omp-conductor console` — provision the operator console's workspace and
3
+ * print the pane command that launches it.
4
+ *
5
+ * The console is the second session of the two-session split: the tick brain
6
+ * keeps its pane and never receives an operator turn, and the console owns the
7
+ * Telegram conversation. This verb materialises the console's side of that —
8
+ * its own cwd, the rendered floor, the `AGENTS.md` link that makes an `omp`
9
+ * started there load the floor — and then says exactly how to launch it.
10
+ *
11
+ * Idempotent by construction: the plan reports `keep` for a current brief and a
12
+ * correct link, so a re-run rewrites nothing. That matters because both this
13
+ * verb and `setup host` provision the same workspace through the same
14
+ * `planConsole`/`writeConsole` pair, and an operator will run whichever they
15
+ * remember.
16
+ *
17
+ * It launches nothing. `recover.sh` owns pane restoration for the whole fleet
18
+ * (exact-identity rules, one companion per role), so a verb that spawned its
19
+ * own pane here would be a second pane nobody reconciles.
20
+ */
21
+
22
+ import { findProject, loadConfig } from "../config.ts";
23
+ import { consolePaneCommand, planConsole, writeConsole, type ConsolePlan, type ConsoleWrite } from "../setup.ts";
24
+ import type { CommandContext } from "./context.ts";
25
+
26
+ /**
27
+ * The whole printed report, as lines, from facts a caller already holds.
28
+ *
29
+ * Pure so the byte-exact pane command is assertable without a config, a state
30
+ * directory or a captured stdout — the pane command is a contract shared with
31
+ * `recover.sh`, and a test that could only check "contains omp" would not hold
32
+ * it.
33
+ *
34
+ * `otherProjects` is the DM-force-claim decision, expressed as its evidence
35
+ * rather than as a boolean: one Telegram state dir may have exactly one DM
36
+ * owner, so the claim is this console's only when no other project is
37
+ * configured — and when it is not, the operator is told which projects took it
38
+ * away.
39
+ */
40
+ export function consoleReport(
41
+ plan: ConsolePlan,
42
+ written: ConsoleWrite,
43
+ otherProjects: readonly string[],
44
+ ): string[] {
45
+ const dmOwner = otherProjects.length === 0;
46
+ const pane = consolePaneCommand({ cwd: plan.cwd, dmOwner });
47
+ return [
48
+ `console cwd ${plan.cwd}`,
49
+ `console brief ${plan.brief.action} ${plan.brief.path}`,
50
+ plan.link.action === "skip"
51
+ ? `console link ${plan.link.path} — ${plan.link.skippedReason}`
52
+ : `console link ${plan.link.action} ${plan.link.path} -> ${plan.link.target}`,
53
+ `pane command ${pane}`,
54
+ dmOwner
55
+ ? "dm owner yes — this host configures exactly one project, so the console force-claims the Telegram DM owner"
56
+ : `dm owner no — this host also configures ${otherProjects.join(", ")}, and one Telegram state dir has exactly one DM owner; inbound reaches this console by forum-topic routing instead`,
57
+ `recovery recover.sh restores this pane with the fleet; by hand, run: ${pane}`,
58
+ ...(written.wrote.length === 0 ? [] : [`wrote ${written.wrote.join(", ")}`]),
59
+ ...written.warnings.map((w) => `warning ${w}`),
60
+ ];
61
+ }
62
+
63
+ export function consoleCommand(ctx: CommandContext): void {
64
+ const cfg = loadConfig();
65
+ const project = findProject(cfg, ctx.projectFlag);
66
+ const plan = planConsole(project);
67
+ const written = writeConsole(plan);
68
+ const others = cfg.projects.filter((p) => p.name !== project.name).map((p) => p.name);
69
+ process.stdout.write(`${consoleReport(plan, written, others).join("\n")}\n`);
70
+ }
@@ -82,8 +82,10 @@ export const COMMAND_SCOPES: Readonly<Record<string, CommandScope>> = {
82
82
  // project — exactly one project; findProject demands --project when several
83
83
  "brief-upgrade": "project",
84
84
  companion: "project",
85
+ console: "project",
85
86
  decision: "project",
86
87
  drain: "project",
88
+ epic: "project",
87
89
  event: "project",
88
90
  extend: "project",
89
91
  friction: "project",
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `epic` — approve, revoke, or list the operator's standing approvals of epic
3
+ * scope (#1041).
4
+ *
5
+ * The operator surface over the `epic_approvals` table. Approving an epic means
6
+ * exactly one thing: its brief-complete children may pass the ready gate and be
7
+ * queued without asking the operator again. That is why it is a dedicated
8
+ * durable fact and not a resolved decision row — a decision's answer is free
9
+ * text written for a human, and the promotion gate needs a boolean it can read
10
+ * without parsing prose.
11
+ *
12
+ * Whether the issue number names a real epic is the tracker's business, not this
13
+ * verb's: parents and children are native GitHub sub-issues, resolved by the
14
+ * daemon at promotion time. Reading the tracker here would trade a network round
15
+ * trip and a rate-limit failure mode for a check the gate performs anyway.
16
+ */
17
+
18
+ import type { CommandContext } from "./context.ts";
19
+ import { findProject, loadConfig } from "../config.ts";
20
+ import { dbPath, openStore } from "../store.ts";
21
+ import { dim, ok } from "../ui/style.ts";
22
+ import { SESSION_ROLE_ENV } from "../types.ts";
23
+
24
+ /** The flags each subcommand accepts after the subcommand itself. Declared so a
25
+ * typo'd flag exits 2 instead of being ignored while the approval still lands. */
26
+ const EPIC_FLAGS: Readonly<Record<string, readonly string[]>> = {
27
+ approve: ["--project"],
28
+ revoke: ["--project"],
29
+ list: ["--project", "--json"],
30
+ };
31
+
32
+ /**
33
+ * Rejects trailing tokens a subcommand does not declare (the #793 shape: a
34
+ * real-looking flag that belongs to another verb must not be silently dropped).
35
+ * Starts at index 2 because index 0 is the verb and index 1 the subcommand; the
36
+ * issue positional is consumed by the caller before this runs.
37
+ */
38
+ function assertKnownArgs(ctx: CommandContext, sub: string, from: number): void {
39
+ const allowed = EPIC_FLAGS[sub] ?? [];
40
+ for (let i = from; i < ctx.argv.length; i++) {
41
+ const token = ctx.argv[i];
42
+ if (token === undefined) continue;
43
+ const eq = token.startsWith("--") ? token.indexOf("=") : -1;
44
+ const name = eq < 0 ? token : token.slice(0, eq);
45
+ if (allowed.includes(name)) {
46
+ // `--json` takes no value, so only a value-taking flag swallows the next
47
+ // token. `--project` is the only one here.
48
+ if (eq < 0 && name === "--project") i += 1;
49
+ continue;
50
+ }
51
+ process.stderr.write(`omp-conductor: epic ${sub}: unexpected argument "${token}"\n`);
52
+ process.exit(2);
53
+ }
54
+ }
55
+
56
+ export async function epicCommand(ctx: CommandContext): Promise<void> {
57
+ const sub = ctx.argv[1];
58
+ if (sub !== "approve" && sub !== "revoke" && sub !== "list") {
59
+ process.stderr.write(
60
+ sub === undefined
61
+ ? "omp-conductor: epic needs approve, revoke or list\n"
62
+ : `omp-conductor: unknown epic subcommand "${sub}" — expected approve, revoke or list\n`,
63
+ );
64
+ process.exit(2);
65
+ }
66
+ // The issue positional is validated before the config or store is opened, so a
67
+ // typo'd number never creates a database on a fresh host.
68
+ const issue = sub === "list" ? 0 : ctx.issueArg(`epic ${sub}`, ctx.argv[2]);
69
+ assertKnownArgs(ctx, sub, sub === "list" ? 2 : 3);
70
+ const project = findProject(loadConfig(), ctx.projectFlag);
71
+ const store = openStore(dbPath());
72
+ try {
73
+ if (sub === "approve") {
74
+ // Read first, because `approveEpic` is first-write-wins and returns
75
+ // nothing: the operator must be told which of the two happened, and a
76
+ // silent second approve that looks identical to the first is exactly how
77
+ // an operator concludes the approval did not stick.
78
+ const existing = store.epicApproval(project.name, issue);
79
+ if (existing !== undefined) {
80
+ process.stdout.write(
81
+ `epic #${issue} was already approved for ${project.name} by ${existing.approvedBy} at ` +
82
+ `${new Date(existing.approvedAt).toISOString()} — nothing changed\n`,
83
+ );
84
+ return;
85
+ }
86
+ // The same identity spelling `report withdraw` uses: the session role when
87
+ // one is set, else orchestrator inside a Herdr pane, else a human at a
88
+ // shell. Provenance for an audit line, never a gate.
89
+ const sessionRole = process.env[SESSION_ROLE_ENV];
90
+ const inHerdrPane = process.env["HERDR_ENV"] !== undefined && process.env["HERDR_ENV"] !== "";
91
+ const by = sessionRole ?? (inHerdrPane ? "orchestrator" : "operator");
92
+ store.approveEpic(project.name, issue, Date.now(), by);
93
+ process.stdout.write(
94
+ ok(`epic #${issue} approved for ${project.name} by ${by}`) + "\n",
95
+ );
96
+ process.stdout.write(
97
+ dim(
98
+ "brief-complete children of this epic may now be queued without a new operator decision — " +
99
+ `revoke with: omp-conductor epic revoke ${issue} --project ${project.name}`,
100
+ ) + "\n",
101
+ );
102
+ return;
103
+ }
104
+
105
+ if (sub === "revoke") {
106
+ const revoked = store.revokeEpicApproval(project.name, issue);
107
+ process.stdout.write(
108
+ revoked
109
+ ? ok(`epic #${issue} approval revoked for ${project.name} — its children need a decision again`) + "\n"
110
+ : `epic #${issue} was not approved for ${project.name} — nothing to revoke\n`,
111
+ );
112
+ return;
113
+ }
114
+
115
+ const approvals = store.epicApprovals(project.name);
116
+ if (ctx.argv.includes("--json")) {
117
+ process.stdout.write(`${JSON.stringify({ project: project.name, epics: approvals }, null, 2)}\n`);
118
+ return;
119
+ }
120
+ if (approvals.length === 0) {
121
+ process.stdout.write("no approved epics\n");
122
+ return;
123
+ }
124
+ for (const approval of approvals) {
125
+ process.stdout.write(
126
+ `#${approval.issue} ${new Date(approval.approvedAt).toISOString()} ${approval.approvedBy}\n`,
127
+ );
128
+ }
129
+ } finally {
130
+ store.close();
131
+ }
132
+ }