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.
- package/REFERENCE.md +27 -2
- package/agents/to-spec.md +76 -9
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
- package/src/arm-challenge.ts +204 -85
- package/src/ask.ts +130 -615
- package/src/board.ts +7 -1
- package/src/brief-upgrade.ts +24 -0
- package/src/briefs/console.md +253 -0
- package/src/briefs/correction.md +203 -0
- package/src/briefs/orchestrator.md +167 -97
- package/src/briefs/policy.md +19 -16
- package/src/briefs/to-spec.md +76 -9
- package/src/briefs/worker.md +50 -16
- package/src/cli.ts +4 -0
- package/src/command-manifest.ts +54 -8
- package/src/commands/arm.ts +113 -49
- package/src/commands/console.ts +70 -0
- package/src/commands/context.ts +2 -0
- package/src/commands/epic.ts +132 -0
- package/src/commands/extend.ts +9 -1
- package/src/commands/intake.ts +44 -14
- package/src/commands/stats.ts +19 -4
- package/src/commands/worker.ts +9 -1
- package/src/config-schema.ts +13 -0
- package/src/config.ts +27 -0
- package/src/daemon/ack.ts +159 -0
- package/src/daemon/admission-pass.ts +135 -0
- package/src/daemon/brief.ts +461 -0
- package/src/daemon/deps.ts +539 -0
- package/src/daemon/dispatch.ts +1779 -0
- package/src/daemon/drain.ts +185 -0
- package/src/daemon/groom-pass.ts +412 -0
- package/src/daemon/http.ts +417 -0
- package/src/daemon/integrity.ts +108 -0
- package/src/daemon/panes.ts +180 -0
- package/src/daemon/review.ts +1888 -0
- package/src/daemon/runtime.ts +736 -0
- package/src/daemon/settle-pass.ts +589 -0
- package/src/daemon/supervision.ts +438 -0
- package/src/daemon/tick.ts +968 -0
- package/src/daemon/views.ts +751 -0
- package/src/daemon.ts +105 -7832
- package/src/dashboard/app.js +58 -0
- package/src/dashboard/controls.ts +22 -3
- package/src/dashboard/server.ts +4 -0
- package/src/diff-flags.ts +24 -3
- package/src/doctor.ts +17 -12
- package/src/escalate.ts +39 -21
- package/src/failure-class.ts +75 -1
- package/src/fleet.ts +1218 -304
- package/src/groom.ts +461 -0
- package/src/http-token.ts +142 -0
- package/src/knowledge.ts +229 -0
- package/src/mining.ts +316 -0
- package/src/orchestrator-tick.ts +428 -1681
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +72 -6
- package/src/setup-host.ts +32 -9
- package/src/setup-wizard.ts +55 -7
- package/src/setup.ts +229 -3
- package/src/stats.ts +257 -2
- package/src/status-render.ts +158 -7
- package/src/store.ts +646 -26
- package/src/to-spec.ts +194 -21
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +435 -15
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +384 -12
- package/src/wake.ts +19 -2
- package/src/worker.ts +456 -1
package/src/briefs/worker.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
{{GATES}}
|
|
109
|
+
{{GATES}}{{CI_OWNED_GATES}}
|
|
112
110
|
|
|
113
111
|
{{SHARED_HOST_NOTICE}}
|
|
114
112
|
{{HOST_CONSTRAINTS}}
|
|
115
113
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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** →
|
|
157
|
-
|
|
158
|
-
|
|
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.
|
|
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),
|
package/src/command-manifest.ts
CHANGED
|
@@ -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: "
|
|
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.
|
|
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: [
|
|
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
|
{
|
package/src/commands/arm.ts
CHANGED
|
@@ -1,61 +1,125 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `arm` —
|
|
2
|
+
* `arm` — the two mechanical halves of the arming ceremony.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
if (
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
{
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
+
}
|
package/src/commands/context.ts
CHANGED
|
@@ -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
|
+
}
|