omp-conductor 0.15.9 → 0.15.11
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 +273 -2543
- package/REFERENCE.md +2638 -0
- package/package.json +3 -2
- package/schema/config.schema.json +8 -23
- package/src/arm-challenge.ts +112 -0
- package/src/ask.ts +434 -0
- package/src/board.ts +81 -15
- package/src/brief-upgrade.ts +114 -8
- package/src/briefs/orchestrator.md +55 -29
- package/src/briefs/policy.md +14 -5
- package/src/briefs/worker.md +7 -1
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +190 -1391
- package/src/commands/arm.ts +21 -0
- package/src/commands/board.ts +23 -0
- package/src/commands/brief-upgrade.ts +186 -0
- package/src/commands/context.ts +49 -0
- package/src/commands/daemon.ts +71 -0
- package/src/commands/dashboard.ts +74 -0
- package/src/commands/decision.ts +103 -0
- package/src/commands/disarm.ts +21 -0
- package/src/commands/doctor.ts +98 -0
- package/src/commands/event.ts +62 -0
- package/src/commands/extend.ts +64 -0
- package/src/commands/friction.ts +56 -0
- package/src/commands/help.ts +9 -0
- package/src/commands/hold.ts +26 -0
- package/src/commands/intake.ts +134 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +48 -0
- package/src/commands/report.ts +170 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +93 -0
- package/src/commands/start.ts +23 -0
- package/src/commands/stats.ts +131 -0
- package/src/commands/status.ts +48 -0
- package/src/commands/stop.ts +51 -0
- package/src/commands/tail.ts +109 -0
- package/src/commands/unblock.ts +39 -0
- package/src/commands/upgrade-install.ts +31 -0
- package/src/commands/upgrade-rollback.ts +23 -0
- package/src/commands/upgrade.ts +25 -0
- package/src/commands/verb.ts +83 -0
- package/src/commands/version.ts +30 -0
- package/src/commands/worker.ts +100 -0
- package/src/config-schema.ts +38 -1
- package/src/config.ts +10 -3
- package/src/daemon.ts +613 -94
- package/src/dashboard/app.js +120 -0
- package/src/dashboard/index.html +34 -0
- package/src/dashboard/server.ts +267 -0
- package/src/dashboard/style.css +180 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +795 -0
- package/src/escalate.ts +60 -19
- package/src/failure-class.ts +29 -3
- package/src/fleet.ts +58 -1
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/notices.ts +9 -0
- package/src/omp.ts +2 -0
- package/src/orchestrator-tick.ts +315 -17
- package/src/release-policy.ts +135 -23
- package/src/reports.ts +19 -5
- package/src/setup-host.ts +420 -8
- package/src/setup-install.ts +69 -14
- package/src/setup-wizard.ts +199 -61
- package/src/setup.ts +131 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +206 -21
- package/src/tracker/github.ts +27 -4
- package/src/types.ts +144 -31
- package/src/unblock.ts +55 -11
- package/src/upgrade-journal.ts +220 -0
- package/src/upgrade-verify.ts +506 -0
- package/src/upgrade.ts +295 -26
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +7 -0
- package/systemd/recover-unit-test.sh +428 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `event` — persist one ordinary material outcome without sending it.
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CommandContext } from "./context.ts";
|
|
10
|
+
import { findProject, loadConfig } from "../config.ts";
|
|
11
|
+
import { dbPath, openStore } from "../store.ts";
|
|
12
|
+
|
|
13
|
+
export async function eventCommand(ctx: CommandContext): Promise<void> {
|
|
14
|
+
if (ctx.argv[1] !== "record") {
|
|
15
|
+
process.stderr.write("omp-conductor: event needs the record subcommand\n");
|
|
16
|
+
process.exit(2);
|
|
17
|
+
}
|
|
18
|
+
const category = ctx.flag("category");
|
|
19
|
+
const summary = ctx.flag("summary")?.replace(/\s+/g, " ").trim();
|
|
20
|
+
const evidence = ctx.flag("evidence")?.replace(/\s+/g, " ").trim();
|
|
21
|
+
if (
|
|
22
|
+
category === undefined ||
|
|
23
|
+
!/^[a-z0-9][a-z0-9-]{0,31}$/.test(category) ||
|
|
24
|
+
summary === undefined ||
|
|
25
|
+
summary.length === 0 ||
|
|
26
|
+
summary.length > 240 ||
|
|
27
|
+
summary.startsWith("--") ||
|
|
28
|
+
evidence === undefined ||
|
|
29
|
+
evidence.length === 0 ||
|
|
30
|
+
evidence.length > 500 ||
|
|
31
|
+
evidence.startsWith("--")
|
|
32
|
+
) {
|
|
33
|
+
process.stderr.write(
|
|
34
|
+
"omp-conductor: event record needs --category with a lowercase slug, --summary (1-240 chars), and --evidence (1-500 chars)\n",
|
|
35
|
+
);
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
const rawOccurredAt = ctx.flag("occurred-at");
|
|
39
|
+
const recordedAt = Date.now();
|
|
40
|
+
const occurredAt = rawOccurredAt === undefined ? recordedAt : Date.parse(rawOccurredAt);
|
|
41
|
+
if (!Number.isFinite(occurredAt)) {
|
|
42
|
+
process.stderr.write("omp-conductor: event record --occurred-at needs an ISO timestamp\n");
|
|
43
|
+
process.exit(2);
|
|
44
|
+
}
|
|
45
|
+
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
46
|
+
const store = openStore(dbPath());
|
|
47
|
+
try {
|
|
48
|
+
const event = store.recordMaterialEvent({
|
|
49
|
+
project: project.name,
|
|
50
|
+
category,
|
|
51
|
+
summary,
|
|
52
|
+
evidence,
|
|
53
|
+
occurredAt,
|
|
54
|
+
recordedAt,
|
|
55
|
+
});
|
|
56
|
+
process.stdout.write(
|
|
57
|
+
`event ${event.id} recorded for ${project.name} (${event.category}, ${new Date(event.occurredAt).toISOString()}) — nothing sent\n`,
|
|
58
|
+
);
|
|
59
|
+
} finally {
|
|
60
|
+
store.close();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `extend` — raise a live run's turn ceiling, or set a bounded one-shot ceiling after a failed, killed, orphaned or blocked run.
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CommandContext } from "./context.ts";
|
|
10
|
+
import { findProject, loadConfig } from "../config.ts";
|
|
11
|
+
import { livingDaemon } from "../lifecycle.ts";
|
|
12
|
+
|
|
13
|
+
export async function extendCommand(ctx: CommandContext): Promise<void> {
|
|
14
|
+
const issue = ctx.issueArg("extend", ctx.argv[1]);
|
|
15
|
+
const maxTurns = ctx.turnsFlag();
|
|
16
|
+
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
17
|
+
const daemon = livingDaemon();
|
|
18
|
+
if (daemon === undefined) throw new Error("daemon is not running");
|
|
19
|
+
if (daemon.project !== undefined && daemon.project !== project.name) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`daemon serves project "${daemon.project}", not requested project "${project.name}"`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
const response = await fetch(
|
|
25
|
+
`http://127.0.0.1:${daemon.port}/runs/${issue}/turn-limit`,
|
|
26
|
+
{
|
|
27
|
+
method: "PUT",
|
|
28
|
+
headers: { "content-type": "application/json" },
|
|
29
|
+
body: JSON.stringify({ project: project.name, maxTurns }),
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
const payload = (await response.json()) as {
|
|
33
|
+
error?: unknown;
|
|
34
|
+
kind?: unknown;
|
|
35
|
+
runId?: unknown;
|
|
36
|
+
maxTurns?: unknown;
|
|
37
|
+
issue?: unknown;
|
|
38
|
+
nextAttemptMaxTurns?: unknown;
|
|
39
|
+
baseMaxTurns?: unknown;
|
|
40
|
+
};
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
if (
|
|
47
|
+
payload.kind === "next-attempt" &&
|
|
48
|
+
payload.issue === issue &&
|
|
49
|
+
typeof payload.nextAttemptMaxTurns === "number" &&
|
|
50
|
+
typeof payload.baseMaxTurns === "number"
|
|
51
|
+
) {
|
|
52
|
+
process.stdout.write(
|
|
53
|
+
`#${issue} next attempt turn ceiling set to ${payload.nextAttemptMaxTurns} ` +
|
|
54
|
+
`(project base ${payload.baseMaxTurns})\n`,
|
|
55
|
+
);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (typeof payload.runId !== "string" || typeof payload.maxTurns !== "number") {
|
|
59
|
+
throw new Error("daemon returned an invalid turn-extension response");
|
|
60
|
+
}
|
|
61
|
+
process.stdout.write(
|
|
62
|
+
`#${issue} turn ceiling extended to ${payload.maxTurns} (run ${payload.runId})\n`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `friction` — record a bounded observation the daemon cannot classify itself.
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CommandContext } from "./context.ts";
|
|
10
|
+
import { findProject, loadConfig } from "../config.ts";
|
|
11
|
+
import { dbPath, openStore } from "../store.ts";
|
|
12
|
+
|
|
13
|
+
const FRICTION_FEEDBACK_KINDS = {
|
|
14
|
+
"escalation-digest": "feedback:escalation-should-digest",
|
|
15
|
+
"report-noise": "feedback:report-noise",
|
|
16
|
+
"report-surprise": "feedback:report-surprise",
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
type FrictionFeedbackName = keyof typeof FRICTION_FEEDBACK_KINDS;
|
|
20
|
+
|
|
21
|
+
export async function frictionCommand(ctx: CommandContext): Promise<void> {
|
|
22
|
+
const name = ctx.argv[1] as FrictionFeedbackName | undefined;
|
|
23
|
+
if (name === undefined || !Object.hasOwn(FRICTION_FEEDBACK_KINDS, name)) {
|
|
24
|
+
process.stderr.write(
|
|
25
|
+
"omp-conductor: friction needs one of: escalation-digest, report-noise, report-surprise\n",
|
|
26
|
+
);
|
|
27
|
+
process.exit(2);
|
|
28
|
+
}
|
|
29
|
+
const rawDetail = ctx.flag("detail");
|
|
30
|
+
const detail = rawDetail?.replace(/\s+/g, " ").trim();
|
|
31
|
+
if (
|
|
32
|
+
detail === undefined ||
|
|
33
|
+
detail.length === 0 ||
|
|
34
|
+
detail.length > 160 ||
|
|
35
|
+
rawDetail?.startsWith("--") === true
|
|
36
|
+
) {
|
|
37
|
+
process.stderr.write("omp-conductor: friction needs --detail with 1-160 characters\n");
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
const issueText = ctx.flag("issue");
|
|
41
|
+
const issue = issueText === undefined ? undefined : ctx.issueArg("friction --issue", issueText);
|
|
42
|
+
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
43
|
+
const store = openStore(dbPath());
|
|
44
|
+
try {
|
|
45
|
+
store.recordFriction(project.name, {
|
|
46
|
+
kind: FRICTION_FEEDBACK_KINDS[name],
|
|
47
|
+
occurrences: 1,
|
|
48
|
+
...(issue === undefined ? {} : { issue }),
|
|
49
|
+
sample: detail,
|
|
50
|
+
at: Date.now(),
|
|
51
|
+
});
|
|
52
|
+
} finally {
|
|
53
|
+
store.close();
|
|
54
|
+
}
|
|
55
|
+
process.stdout.write(`friction recorded for ${project.name}: ${name} — ${detail}\n`);
|
|
56
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `help` / `--help` / `-h`: print the shared usage text. The text itself stays
|
|
3
|
+
* in cli.ts as `USAGE` — the contract of the per-verb module split (#462) keeps
|
|
4
|
+
* the shared usage text next to the registration table, so it is passed in
|
|
5
|
+
* rather than re-exported.
|
|
6
|
+
*/
|
|
7
|
+
export function helpCommand(usage: string): void {
|
|
8
|
+
process.stdout.write(`${usage}\n`);
|
|
9
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hold` — soft stop: pause claiming AND disarm ticks; daemon and pane stay up.
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CommandContext } from "./context.ts";
|
|
10
|
+
import { hold } from "../fleet.ts";
|
|
11
|
+
|
|
12
|
+
export async function holdCommand(ctx: CommandContext): Promise<void> {
|
|
13
|
+
const keepTicks = ctx.argv.includes("--keep-ticks");
|
|
14
|
+
for (const project of ctx.targetProjects()) {
|
|
15
|
+
const r = hold(project.name, "hold", keepTicks ? { keepTicks: true } : {});
|
|
16
|
+
process.stdout.write(
|
|
17
|
+
`held — claiming paused` +
|
|
18
|
+
`${r.wasPaused ? " (already paused)" : ""}` +
|
|
19
|
+
(r.disarmed === undefined
|
|
20
|
+
? "; ticks left armed — resume restores the fleet with no new arm challenge"
|
|
21
|
+
: `; ticks disarmed at ${r.disarmed.path}` +
|
|
22
|
+
`${r.disarmed.wasArmed ? "" : " (was already disarmed)"}`) +
|
|
23
|
+
`\ndaemon and pane left running; stop to stop the daemon too\n`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `intake` — capture a raw idea durably before it becomes anything (#298).
|
|
3
|
+
*
|
|
4
|
+
* The store is the point of the verb: an idea captured at 02:00 is still there
|
|
5
|
+
* after a restart, and `status` counts what is still pending so a backlog
|
|
6
|
+
* cannot quietly empty itself. This surface only records, lists and dismisses;
|
|
7
|
+
* turning an idea into an issue is #300, which uses `resolveIntake`'s
|
|
8
|
+
* `groomed` path.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { CommandContext } from "./context.ts";
|
|
12
|
+
import { findProject, loadConfig } from "../config.ts";
|
|
13
|
+
import { dbPath, openStore } from "../store.ts";
|
|
14
|
+
|
|
15
|
+
const INTAKE_USAGE = `omp-conductor intake — capture raw ideas durably.
|
|
16
|
+
|
|
17
|
+
usage:
|
|
18
|
+
omp-conductor intake "<text>" [--project NAME]
|
|
19
|
+
omp-conductor intake list [--project NAME]
|
|
20
|
+
omp-conductor intake dismiss <id> [--project NAME]
|
|
21
|
+
|
|
22
|
+
Captures one raw idea into the local store and prints its id. list shows what
|
|
23
|
+
is still pending (id, age, text), oldest first; dismiss drops one by id. The
|
|
24
|
+
capture is durable — it lives in the sqlite store, not in a session — so it
|
|
25
|
+
survives daemon restarts, and an idea can later be groomed into an issue.`;
|
|
26
|
+
|
|
27
|
+
/** Flags the intake surface understands. `--project` is consumed by
|
|
28
|
+
* {@link CommandContext.projectFlag}; the value token stays in argv. */
|
|
29
|
+
const INTAKE_FLAGS: Record<string, true> = { "--project": true };
|
|
30
|
+
|
|
31
|
+
/** Every remaining token after the subverb must be one of the known flags
|
|
32
|
+
* (or its value), otherwise the operator typo'd something that would be
|
|
33
|
+
* silently ignored — the same exit-2 scan `stats` runs (#462). */
|
|
34
|
+
function assertKnownArgs(ctx: CommandContext, from: number): void {
|
|
35
|
+
for (let i = from; i < ctx.argv.length; i++) {
|
|
36
|
+
const token = ctx.argv[i];
|
|
37
|
+
if (token === undefined) continue;
|
|
38
|
+
if (INTAKE_FLAGS[token] === true) {
|
|
39
|
+
i += 1;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (token.startsWith("--project=")) continue;
|
|
43
|
+
process.stderr.write(`omp-conductor: intake: unexpected argument "${token}"\n`);
|
|
44
|
+
process.exit(2);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function intakeCommand(ctx: CommandContext): Promise<void> {
|
|
49
|
+
const sub = ctx.argv[1];
|
|
50
|
+
if (sub === "--help" || sub === "-h") {
|
|
51
|
+
process.stdout.write(INTAKE_USAGE);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
55
|
+
const store = openStore(dbPath());
|
|
56
|
+
try {
|
|
57
|
+
if (sub === "list") {
|
|
58
|
+
assertKnownArgs(ctx, 2);
|
|
59
|
+
const pending = store.pendingIntake(project.name);
|
|
60
|
+
if (pending.length === 0) {
|
|
61
|
+
process.stdout.write("no pending ideas\n");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
for (const item of pending) {
|
|
66
|
+
// Age at capture granularity: minutes for a fresh idea, hours once it
|
|
67
|
+
// has been waiting a while — the unit the operator actually cares about.
|
|
68
|
+
const minutes = Math.max(0, Math.round((now - item.createdAt) / 60_000));
|
|
69
|
+
const age = minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h`;
|
|
70
|
+
process.stdout.write(`${item.id} ${age.padStart(5)} ${item.text}\n`);
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (sub === "dismiss") {
|
|
76
|
+
assertKnownArgs(ctx, 3);
|
|
77
|
+
const id = ctx.argv[2];
|
|
78
|
+
if (id === undefined || id.startsWith("--")) {
|
|
79
|
+
process.stderr.write("omp-conductor: intake dismiss needs the idea id\n");
|
|
80
|
+
process.exit(2);
|
|
81
|
+
}
|
|
82
|
+
if (!store.resolveIntake(id, "dismissed")) {
|
|
83
|
+
process.stderr.write(
|
|
84
|
+
`omp-conductor: no intake item ${id} for ${project.name} — it was already resolved, or the id is wrong\n`,
|
|
85
|
+
);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
process.stdout.write(`intake ${id} dismissed\n`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (sub !== undefined && sub.startsWith("--") && INTAKE_FLAGS[sub] !== true) {
|
|
93
|
+
process.stderr.write(`omp-conductor: intake: unexpected argument "${sub}"\n`);
|
|
94
|
+
process.exit(2);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Capture: the text is the first non-flag token, wherever it sits. Flags
|
|
98
|
+
// may come before or after it; anything beyond one text token is a typo.
|
|
99
|
+
let text: string | undefined;
|
|
100
|
+
for (let i = 1; i < ctx.argv.length; i++) {
|
|
101
|
+
const token = ctx.argv[i];
|
|
102
|
+
if (token === undefined) continue;
|
|
103
|
+
if (INTAKE_FLAGS[token] === true) {
|
|
104
|
+
i += 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (token.startsWith("--project=")) continue;
|
|
108
|
+
if (token.startsWith("--")) {
|
|
109
|
+
process.stderr.write(`omp-conductor: intake: unexpected argument "${token}"\n`);
|
|
110
|
+
process.exit(2);
|
|
111
|
+
}
|
|
112
|
+
if (text !== undefined) {
|
|
113
|
+
process.stderr.write(`omp-conductor: intake: unexpected argument "${token}"\n`);
|
|
114
|
+
process.exit(2);
|
|
115
|
+
}
|
|
116
|
+
text = token;
|
|
117
|
+
}
|
|
118
|
+
const trimmed = text?.trim();
|
|
119
|
+
if (trimmed === undefined || trimmed.length === 0) {
|
|
120
|
+
process.stderr.write(
|
|
121
|
+
"omp-conductor: intake needs a non-empty idea text — e.g. omp-conductor intake \"ship the intake command\"\n",
|
|
122
|
+
);
|
|
123
|
+
process.exit(2);
|
|
124
|
+
}
|
|
125
|
+
const item = store.recordIntake({
|
|
126
|
+
project: project.name,
|
|
127
|
+
text: trimmed,
|
|
128
|
+
at: Date.now(),
|
|
129
|
+
});
|
|
130
|
+
process.stdout.write(`intake ${item.id} recorded — see it with: omp-conductor intake list\n`);
|
|
131
|
+
} finally {
|
|
132
|
+
store.close();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ledger` — every conductor-verb call and how the daemon decided it, plus the durable per-issue turn budgets.
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CommandContext } from "./context.ts";
|
|
10
|
+
import { findProject, loadConfig } from "../config.ts";
|
|
11
|
+
import { dbPath, openStore } from "../store.ts";
|
|
12
|
+
import { formatVerbLedgerEntry } from "../verbs/ledger.ts";
|
|
13
|
+
|
|
14
|
+
export async function ledgerCommand(ctx: CommandContext): Promise<void> {
|
|
15
|
+
const cfg = loadConfig();
|
|
16
|
+
const p = findProject(cfg, ctx.projectFlag);
|
|
17
|
+
const issueFlag = ctx.flag("issue");
|
|
18
|
+
const issue = issueFlag === undefined ? undefined : ctx.issueArg("ledger", issueFlag);
|
|
19
|
+
const limitFlag = ctx.flag("limit");
|
|
20
|
+
const limit = limitFlag === undefined ? 50 : Number.parseInt(limitFlag, 10);
|
|
21
|
+
if (!Number.isSafeInteger(limit) || limit < 1) {
|
|
22
|
+
process.stderr.write(`omp-conductor: ledger --limit needs a positive integer, got "${limitFlag ?? ""}"\n`);
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
// Read-only, WAL, same as `tail`: this never contends with the daemon
|
|
27
|
+
// writing the rows it is printing.
|
|
28
|
+
const store = openStore(dbPath());
|
|
29
|
+
try {
|
|
30
|
+
const entries = store.verbLedger(p.name, {
|
|
31
|
+
...(issue === undefined ? {} : { issue }),
|
|
32
|
+
limit,
|
|
33
|
+
});
|
|
34
|
+
const overrides = store.turnOverrideLedger(p.name, {
|
|
35
|
+
...(issue === undefined ? {} : { issue }),
|
|
36
|
+
limit,
|
|
37
|
+
});
|
|
38
|
+
if (entries.length === 0 && overrides.length === 0) {
|
|
39
|
+
process.stdout.write(
|
|
40
|
+
`no conductor actions recorded for ${p.name}` +
|
|
41
|
+
`${issue === undefined ? "" : ` on #${String(issue)}`}\n`,
|
|
42
|
+
);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const blocks: string[] = [];
|
|
46
|
+
if (entries.length > 0) {
|
|
47
|
+
const refused = entries.filter((e) => e.decision === "refused").length;
|
|
48
|
+
blocks.push(
|
|
49
|
+
`${p.name} — ${entries.length} verb call(s), ${refused} refused (newest first)\n` +
|
|
50
|
+
entries.flatMap(formatVerbLedgerEntry).join("\n"),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (overrides.length > 0) {
|
|
54
|
+
blocks.push(
|
|
55
|
+
`${p.name} — ${overrides.length} turn override(s) (newest first)\n` +
|
|
56
|
+
overrides
|
|
57
|
+
.map(
|
|
58
|
+
(entry) =>
|
|
59
|
+
` ${new Date(entry.setAt).toISOString()} #${entry.issue} ` +
|
|
60
|
+
`${entry.maxTurns} turns`,
|
|
61
|
+
)
|
|
62
|
+
.join("\n"),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
process.stdout.write(`${blocks.join("\n\n")}\n`);
|
|
66
|
+
} finally {
|
|
67
|
+
store.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `message` — deliver one direct Telegram message to the project's own chat and forum topic, applying the same availability policy as a tick.
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CommandContext } from "./context.ts";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { findProject, loadConfig } from "../config.ts";
|
|
12
|
+
import { deliverOperatorMessage, type OperatorMessageOutcome } from "../reports.ts";
|
|
13
|
+
import { dbPath, openStore } from "../store.ts";
|
|
14
|
+
|
|
15
|
+
export async function messageCommand(ctx: CommandContext): Promise<void> {
|
|
16
|
+
const rawText = ctx.flag("text");
|
|
17
|
+
const body = rawText?.trim();
|
|
18
|
+
if (body === undefined || body.length === 0 || rawText?.startsWith("--") === true) {
|
|
19
|
+
process.stderr.write("omp-conductor: message needs --text with the message to deliver\n");
|
|
20
|
+
process.exit(2);
|
|
21
|
+
}
|
|
22
|
+
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
23
|
+
const store = openStore(dbPath());
|
|
24
|
+
let outcome: OperatorMessageOutcome;
|
|
25
|
+
try {
|
|
26
|
+
outcome = await deliverOperatorMessage(project, body, {
|
|
27
|
+
store,
|
|
28
|
+
at: Date.now(),
|
|
29
|
+
noticeId: randomUUID(),
|
|
30
|
+
});
|
|
31
|
+
} catch (err) {
|
|
32
|
+
process.stderr.write(
|
|
33
|
+
`omp-conductor: message: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
34
|
+
);
|
|
35
|
+
process.exit(2);
|
|
36
|
+
} finally {
|
|
37
|
+
store.close();
|
|
38
|
+
}
|
|
39
|
+
process.stdout.write(
|
|
40
|
+
outcome.kind === "sent"
|
|
41
|
+
? // Not "into the topic": a stale topic degrades to the flat chat with
|
|
42
|
+
// its own warning on stderr, and this line must not contradict it.
|
|
43
|
+
`message delivered to ${project.name}'s configured Telegram target (${outcome.category})\n`
|
|
44
|
+
: `held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
|
|
45
|
+
`${outcome.reason === "availability" ? "outside the availability window" : "a digest-only category"})\n` +
|
|
46
|
+
"nothing was sent; the daemon releases it with the next digest or working-hours catch-up\n",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `report` — hand a rendered report to the daemon's durable outbox; the daemon owns delivery with bounded retries.
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CommandContext } from "./context.ts";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { interruptDisposition } from "../availability.ts";
|
|
12
|
+
import { findProject, loadConfig } from "../config.ts";
|
|
13
|
+
import { digestDue } from "../digest-schedule.ts";
|
|
14
|
+
import { dbPath, openStore } from "../store.ts";
|
|
15
|
+
import { digestDedupeKey } from "../reports.ts";
|
|
16
|
+
import { DEFAULT_REPORT_POLICY, REPORT_KINDS, type InterruptCategory, type ReportKind } from "../types.ts";
|
|
17
|
+
|
|
18
|
+
export async function reportCommand(ctx: CommandContext): Promise<void> {
|
|
19
|
+
const rawText = ctx.flag("text");
|
|
20
|
+
const body = rawText?.trim();
|
|
21
|
+
if (body === undefined || body.length === 0 || rawText?.startsWith("--") === true) {
|
|
22
|
+
process.stderr.write("omp-conductor: report needs --text with the rendered report\n");
|
|
23
|
+
process.exit(2);
|
|
24
|
+
}
|
|
25
|
+
const rawKind = ctx.flag("kind") ?? "material";
|
|
26
|
+
// Fail closed on an unknown kind rather than defaulting to `material`: a
|
|
27
|
+
// typo'd `--kind diggest` that silently became a material report would
|
|
28
|
+
// bypass the daily-digest guard, which is the one thing the kind is for.
|
|
29
|
+
if (!(REPORT_KINDS as readonly string[]).includes(rawKind)) {
|
|
30
|
+
process.stderr.write(
|
|
31
|
+
`omp-conductor: report --kind must be one of: ${REPORT_KINDS.join(", ")}\n`,
|
|
32
|
+
);
|
|
33
|
+
process.exit(2);
|
|
34
|
+
}
|
|
35
|
+
const kind = rawKind as ReportKind;
|
|
36
|
+
if (kind !== "digest" && (ctx.argv.includes("--events") || ctx.argv.includes("--notices"))) {
|
|
37
|
+
process.stderr.write(
|
|
38
|
+
"omp-conductor: report --events and --notices are valid only with --kind digest\n",
|
|
39
|
+
);
|
|
40
|
+
process.exit(2);
|
|
41
|
+
}
|
|
42
|
+
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
43
|
+
const store = openStore(dbPath());
|
|
44
|
+
try {
|
|
45
|
+
const at = Date.now();
|
|
46
|
+
// #229: the policy decides what may interrupt the phone. A kind the
|
|
47
|
+
// policy defers is refused here rather than silently turning into a
|
|
48
|
+
// page, or a digest going out off-schedule.
|
|
49
|
+
const policy = project.reporting;
|
|
50
|
+
const digestPolicy = policy?.digest ?? { cadence: "per-tick" };
|
|
51
|
+
if (kind === "digest") {
|
|
52
|
+
if (digestPolicy.cadence === "none") {
|
|
53
|
+
process.stderr.write(`omp-conductor: report: digest cadence is "none" for this project\n`);
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
if (digestPolicy.cadence === "daily" && digestPolicy.at !== undefined) {
|
|
57
|
+
const lastKey = store.lastDigestDedupeKey(project.name);
|
|
58
|
+
const lastDay = lastKey === undefined ? undefined : lastKey.slice("digest:".length);
|
|
59
|
+
if (!digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at)) {
|
|
60
|
+
process.stderr.write(
|
|
61
|
+
`omp-conductor: report: the daily digest is not due until ${digestPolicy.at}` +
|
|
62
|
+
`${digestPolicy.timezone === undefined ? "" : ` ${digestPolicy.timezone}`}\n`,
|
|
63
|
+
);
|
|
64
|
+
process.exit(2);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Every non-digest kind is an interrupt category, and the caller's kind
|
|
69
|
+
// IS the category: admission runs the shared interruptDisposition with
|
|
70
|
+
// that category, so a `fleet-stopped` report under scope=escalations is
|
|
71
|
+
// admitted while `material` stays digest-only under that same policy.
|
|
72
|
+
if (kind !== "digest") {
|
|
73
|
+
const category: InterruptCategory = kind;
|
|
74
|
+
const disposition = interruptDisposition(policy, category, at);
|
|
75
|
+
if (disposition === "digest") {
|
|
76
|
+
process.stderr.write(
|
|
77
|
+
`omp-conductor: report: ${category} updates are digest-only under this reporting policy; fold this into the next digest (--kind digest)\n`,
|
|
78
|
+
);
|
|
79
|
+
process.exit(2);
|
|
80
|
+
}
|
|
81
|
+
// One escalation handoff per event while the first has not landed.
|
|
82
|
+
// Material reports describe one ordinary outcome each time, so they
|
|
83
|
+
// keep their historical no-dedup behaviour. The escalation categories
|
|
84
|
+
// refuse an identical retry only while the earlier handoff is still
|
|
85
|
+
// owed — an outbox report awaiting delivery or a held notice not yet
|
|
86
|
+
// digested (#453). The notifications ledger cannot decide this: its
|
|
87
|
+
// rows are permanent, so a same-text recurrence would be refused
|
|
88
|
+
// forever once the first occurrence was marked; a delivered handoff
|
|
89
|
+
// means the identical text is a new event, not a double-send.
|
|
90
|
+
if (category !== "material") {
|
|
91
|
+
if (store.hasOpenEscalationHandoff(project.name, category, body)) {
|
|
92
|
+
process.stderr.write(
|
|
93
|
+
`omp-conductor: report: an identical ${category} handoff for ${project.name} is still queued undelivered ` +
|
|
94
|
+
"— this retry was refused so the operator is not paged twice; nothing new was queued, " +
|
|
95
|
+
"check `omp-conductor status` for what is still awaiting delivery\n",
|
|
96
|
+
);
|
|
97
|
+
process.exit(2);
|
|
98
|
+
}
|
|
99
|
+
if (disposition === "availability") {
|
|
100
|
+
const noticeId = randomUUID();
|
|
101
|
+
store.addHeldNotice({
|
|
102
|
+
id: noticeId,
|
|
103
|
+
project: project.name,
|
|
104
|
+
category,
|
|
105
|
+
summary: body.split("\n", 1)[0]!.slice(0, 240),
|
|
106
|
+
detail: body,
|
|
107
|
+
createdAt: at,
|
|
108
|
+
releaseOnAvailable: true,
|
|
109
|
+
});
|
|
110
|
+
process.stdout.write(
|
|
111
|
+
`held notice ${noticeId} queued for ${project.name} (${category}; quiet hours)\n` +
|
|
112
|
+
"the daemon will include it in the next digest or working-hours catch-up\n",
|
|
113
|
+
);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const { report } = store.enqueueReport({ project: project.name, kind, body, at });
|
|
117
|
+
process.stdout.write(
|
|
118
|
+
`report ${report.id} queued for ${project.name} (${kind})\n` +
|
|
119
|
+
"the daemon owns delivery from here; omp-conductor status shows it until it lands\n",
|
|
120
|
+
);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (disposition === "availability") {
|
|
124
|
+
const noticeId = randomUUID();
|
|
125
|
+
store.addHeldNotice({
|
|
126
|
+
id: noticeId,
|
|
127
|
+
project: project.name,
|
|
128
|
+
category: "material",
|
|
129
|
+
summary: body.split("\n", 1)[0]!.slice(0, 240),
|
|
130
|
+
detail: body,
|
|
131
|
+
createdAt: at,
|
|
132
|
+
releaseOnAvailable: true,
|
|
133
|
+
});
|
|
134
|
+
process.stdout.write(
|
|
135
|
+
`held notice ${noticeId} queued for ${project.name} (material; quiet hours)\n` +
|
|
136
|
+
"the daemon will include it in the next digest or working-hours catch-up\n",
|
|
137
|
+
);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const draft = {
|
|
142
|
+
project: project.name,
|
|
143
|
+
kind,
|
|
144
|
+
body,
|
|
145
|
+
// Only a daily digest is at-most-once. Per-tick digests and material
|
|
146
|
+
// reports describe new outcomes each time, so they carry no daily key.
|
|
147
|
+
...(kind === "digest" && digestPolicy.cadence === "daily"
|
|
148
|
+
? { dedupeKey: digestDedupeKey(at, digestPolicy.timezone) }
|
|
149
|
+
: {}),
|
|
150
|
+
at,
|
|
151
|
+
};
|
|
152
|
+
const { report, deduped } =
|
|
153
|
+
kind === "digest"
|
|
154
|
+
? store.enqueueDigestReport(
|
|
155
|
+
draft,
|
|
156
|
+
ctx.digestIdsFlag("events"),
|
|
157
|
+
ctx.digestIdsFlag("notices"),
|
|
158
|
+
)
|
|
159
|
+
: store.enqueueReport(draft);
|
|
160
|
+
process.stdout.write(
|
|
161
|
+
deduped
|
|
162
|
+
? `today's digest was already handed over as report ${report.id} (${report.state}) — nothing queued\n` +
|
|
163
|
+
"the ledger decides this, not your memory of the last tick; newly named rows remain owed for a later digest\n"
|
|
164
|
+
: `report ${report.id} queued for ${project.name} (${kind})\n` +
|
|
165
|
+
"the daemon owns delivery from here; omp-conductor status shows it until it lands\n",
|
|
166
|
+
);
|
|
167
|
+
} finally {
|
|
168
|
+
store.close();
|
|
169
|
+
}
|
|
170
|
+
}
|