omp-conductor 0.15.10 → 0.15.12
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 -2544
- package/REFERENCE.md +2680 -0
- package/package.json +3 -2
- package/schema/config.schema.json +11 -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 +113 -34
- package/src/briefs/policy.md +33 -8
- package/src/briefs/worker.md +18 -9
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +225 -1406
- 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 +150 -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 +100 -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 +155 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +96 -0
- package/src/commands/report.ts +206 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +143 -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 +32 -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 +47 -1
- package/src/config.ts +45 -4
- package/src/daemon.ts +972 -101
- package/src/dashboard/app.js +459 -0
- package/src/dashboard/index.html +61 -0
- package/src/dashboard/server.ts +481 -0
- package/src/dashboard/style.css +348 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +932 -0
- package/src/escalate.ts +2 -2
- package/src/failure-class.ts +66 -3
- package/src/fleet.ts +58 -1
- package/src/gitops.ts +157 -0
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/model-fallback.ts +177 -0
- package/src/notices.ts +9 -0
- package/src/omp.ts +93 -13
- package/src/orchestrator-tick.ts +414 -20
- package/src/orchestrator.ts +4 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +342 -30
- package/src/reports.ts +19 -5
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +663 -25
- package/src/setup-install.ts +292 -28
- package/src/setup-wizard.ts +255 -74
- package/src/setup.ts +156 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +219 -22
- package/src/tracker/github.ts +74 -4
- package/src/types.ts +215 -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 +385 -58
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/src/worker.ts +3 -3
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +14 -3
- package/systemd/recover-unit-test.sh +428 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `doctor` — read-only deployment health, one finding per past failure mode
|
|
3
|
+
* (#287, legibility 1/5).
|
|
4
|
+
*
|
|
5
|
+
* The probe logic lives in ../doctor.ts with every I/O seam injectable; this
|
|
6
|
+
* module only parses the doctor's own flags, renders the findings the runner
|
|
7
|
+
* produced (the same findings `--json` emits), and derives the exit code:
|
|
8
|
+
* nonzero iff the report contains a failing finding.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { CommandContext } from "./context.ts";
|
|
12
|
+
import { runDoctor, type DoctorReport, type FindingStatus } from "../doctor.ts";
|
|
13
|
+
|
|
14
|
+
const DOCTOR_USAGE = `omp-conductor doctor — check the deployment faults that have already cost
|
|
15
|
+
debugging sessions, mechanically and read-only.
|
|
16
|
+
|
|
17
|
+
usage:
|
|
18
|
+
omp-conductor doctor [--project NAME] [--json] [--probe-telegram]
|
|
19
|
+
|
|
20
|
+
Checks, each one the mechanical version of a past production incident:
|
|
21
|
+
gh auth token alive and scopes cover every configured repo (a token
|
|
22
|
+
that expired under a live daemon failed every tracker call
|
|
23
|
+
silently)
|
|
24
|
+
labels every configured routing/state label exists with exact case
|
|
25
|
+
— label matching is case-sensitive and a mismatch fails
|
|
26
|
+
silently (README Limitations)
|
|
27
|
+
systemd unit the installed units vs the staged render (User=, environment,
|
|
28
|
+
working directory, exec, restart/success status) and the
|
|
29
|
+
ownership/modes of the unit and runtime dirs (the worker
|
|
30
|
+
die-at-turn-0 class)
|
|
31
|
+
config config.json validates, and its pre-write backup exists and
|
|
32
|
+
is fresh
|
|
33
|
+
store PRAGMA integrity_check
|
|
34
|
+
spend telemetry every completed run recording $0.00 means the USD cap cannot
|
|
35
|
+
fire — $0.00 is not proof of no spend
|
|
36
|
+
timezone reporting timezones are known IANA zones
|
|
37
|
+
telegram bot health, and one self-identified probe message
|
|
38
|
+
|
|
39
|
+
flags:
|
|
40
|
+
--json print the stable report shape {project, checkedAt,
|
|
41
|
+
status, findings:[{id,status,summary,fix?}]} — the CI
|
|
42
|
+
contract
|
|
43
|
+
--probe-telegram the ONE opt-in side effect: send one self-identified
|
|
44
|
+
test message through the report transport
|
|
45
|
+
--project NAME narrow to one project. With no name, the host-wide
|
|
46
|
+
facts are checked once and every configured project's
|
|
47
|
+
per-project facts (labels, timezone, telegram) are
|
|
48
|
+
checked, each named.
|
|
49
|
+
|
|
50
|
+
Exit code is 0 only when nothing failed; warnings do not fail the run.`;
|
|
51
|
+
|
|
52
|
+
const LABEL_BY_STATUS: Record<FindingStatus, string> = { pass: "PASS", warn: "WARN", fail: "FAIL" };
|
|
53
|
+
|
|
54
|
+
/** Renders the same findings `--json` emits, so the two cannot disagree. */
|
|
55
|
+
export function renderDoctorReport(report: DoctorReport): string {
|
|
56
|
+
const width = Math.max(...report.findings.map((f) => f.id.length).concat(0), 8);
|
|
57
|
+
const lines = [
|
|
58
|
+
`omp-conductor doctor — ${report.project}`,
|
|
59
|
+
`status: ${report.status.toUpperCase()} (${report.findings.filter((f) => f.status === "fail").length} failed · ${report.findings.filter((f) => f.status === "warn").length} warnings) · checked ${report.checkedAt}`,
|
|
60
|
+
"",
|
|
61
|
+
];
|
|
62
|
+
for (const f of report.findings) {
|
|
63
|
+
const pad = " ".repeat(Math.max(0, width - f.id.length));
|
|
64
|
+
lines.push(` ${LABEL_BY_STATUS[f.status].padEnd(5)} ${f.id}${pad} ${f.summary}`);
|
|
65
|
+
if (f.fix !== undefined) lines.push(` ${" ".repeat(width)} fix: ${f.fix}`);
|
|
66
|
+
}
|
|
67
|
+
return `${lines.join("\n")}\n`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function doctorCommand(ctx: CommandContext): Promise<void> {
|
|
71
|
+
// Help first: parsing stops before any probe, config read, or side effect.
|
|
72
|
+
if (ctx.argv[1] === "--help" || ctx.argv[1] === "-h") {
|
|
73
|
+
process.stdout.write(`${DOCTOR_USAGE}\n`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Every remaining token after the verb is accounted for: the two flag
|
|
77
|
+
// presence markers and the `--project NAME` pair cli.ts already parsed.
|
|
78
|
+
const DOCTOR_FLAGS: Record<string, true> = {
|
|
79
|
+
"--json": true,
|
|
80
|
+
"--probe-telegram": true,
|
|
81
|
+
"--project": true,
|
|
82
|
+
};
|
|
83
|
+
for (let i = 1; i < ctx.argv.length; i++) {
|
|
84
|
+
const token = ctx.argv[i];
|
|
85
|
+
if (token === undefined) continue;
|
|
86
|
+
if (DOCTOR_FLAGS[token] !== true) {
|
|
87
|
+
process.stderr.write(`omp-conductor: doctor: unexpected argument "${token}"\n`);
|
|
88
|
+
process.exit(2);
|
|
89
|
+
}
|
|
90
|
+
if (token === "--project") i += 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const report = await runDoctor(ctx.projectFlag, {
|
|
94
|
+
probeTelegram: ctx.argv.includes("--probe-telegram"),
|
|
95
|
+
});
|
|
96
|
+
if (ctx.argv.includes("--json")) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
97
|
+
else process.stdout.write(renderDoctorReport(report));
|
|
98
|
+
// The one contract CI reads: nonzero iff something failed. Warnings pass.
|
|
99
|
+
if (report.status === "fail") process.exitCode = 1;
|
|
100
|
+
}
|
|
@@ -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,155 @@
|
|
|
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 records, lists, dismisses and —
|
|
7
|
+
* once the orchestrator has groomed an idea into an issue (#300) — marks the
|
|
8
|
+
* provenance of that grooming via `resolveIntake`'s `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
|
+
omp-conductor intake groomed <id> --issue <url> [--project NAME]
|
|
22
|
+
|
|
23
|
+
Captures one raw idea into the local store and prints its id. list shows what
|
|
24
|
+
is still pending (id, age, text), oldest first; dismiss drops one by id. The
|
|
25
|
+
capture is durable — it lives in the sqlite store, not in a session — so it
|
|
26
|
+
survives daemon restarts. The orchestrator files the idea as an issue and then
|
|
27
|
+
marks that provenance with groomed: an id already resolved is a no-op with a
|
|
28
|
+
message, never an error, because ticks retry.`;
|
|
29
|
+
|
|
30
|
+
/** Flags the intake surface understands. `--project` is consumed by
|
|
31
|
+
* {@link CommandContext.projectFlag}; the value token stays in argv. */
|
|
32
|
+
const INTAKE_FLAGS: Record<string, true> = { "--project": true, "--issue": true };
|
|
33
|
+
|
|
34
|
+
/** Every remaining token after the subverb must be one of the known flags
|
|
35
|
+
* (or its value), otherwise the operator typo'd something that would be
|
|
36
|
+
* silently ignored — the same exit-2 scan `stats` runs (#462). */
|
|
37
|
+
function assertKnownArgs(ctx: CommandContext, from: number): void {
|
|
38
|
+
for (let i = from; i < ctx.argv.length; i++) {
|
|
39
|
+
const token = ctx.argv[i];
|
|
40
|
+
if (token === undefined) continue;
|
|
41
|
+
if (INTAKE_FLAGS[token] === true) {
|
|
42
|
+
i += 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (token.startsWith("--project=")) continue;
|
|
46
|
+
process.stderr.write(`omp-conductor: intake: unexpected argument "${token}"\n`);
|
|
47
|
+
process.exit(2);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function intakeCommand(ctx: CommandContext): Promise<void> {
|
|
52
|
+
const sub = ctx.argv[1];
|
|
53
|
+
if (sub === "--help" || sub === "-h") {
|
|
54
|
+
process.stdout.write(INTAKE_USAGE);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
58
|
+
const store = openStore(dbPath());
|
|
59
|
+
try {
|
|
60
|
+
if (sub === "list") {
|
|
61
|
+
assertKnownArgs(ctx, 2);
|
|
62
|
+
const pending = store.pendingIntake(project.name);
|
|
63
|
+
if (pending.length === 0) {
|
|
64
|
+
process.stdout.write("no pending ideas\n");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
for (const item of pending) {
|
|
69
|
+
// Age at capture granularity: minutes for a fresh idea, hours once it
|
|
70
|
+
// has been waiting a while — the unit the operator actually cares about.
|
|
71
|
+
const minutes = Math.max(0, Math.round((now - item.createdAt) / 60_000));
|
|
72
|
+
const age = minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h`;
|
|
73
|
+
process.stdout.write(`${item.id} ${age.padStart(5)} ${item.text}\n`);
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (sub === "dismiss") {
|
|
79
|
+
assertKnownArgs(ctx, 3);
|
|
80
|
+
const id = ctx.argv[2];
|
|
81
|
+
if (id === undefined || id.startsWith("--")) {
|
|
82
|
+
process.stderr.write("omp-conductor: intake dismiss needs the idea id\n");
|
|
83
|
+
process.exit(2);
|
|
84
|
+
}
|
|
85
|
+
if (!store.resolveIntake(id, "dismissed")) {
|
|
86
|
+
process.stderr.write(
|
|
87
|
+
`omp-conductor: no intake item ${id} for ${project.name} — it was already resolved, or the id is wrong\n`,
|
|
88
|
+
);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
process.stdout.write(`intake ${id} dismissed\n`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (sub === "groomed") {
|
|
96
|
+
assertKnownArgs(ctx, 3);
|
|
97
|
+
const id = ctx.argv[2];
|
|
98
|
+
const url = ctx.flag("issue")?.trim();
|
|
99
|
+
if (id === undefined || id.startsWith("--") || url === undefined || url.length === 0) {
|
|
100
|
+
process.stderr.write("omp-conductor: intake groomed needs the idea id and --issue <url>\n");
|
|
101
|
+
process.exit(2);
|
|
102
|
+
}
|
|
103
|
+
if (!store.resolveIntake(id, "groomed", url)) {
|
|
104
|
+
// Ticks retry: an id that is already groomed (or unknown) is a no-op
|
|
105
|
+
// with a message, not an error, so a re-filed tick never fails here.
|
|
106
|
+
process.stdout.write(`intake ${id} already resolved or unknown — nothing to do\n`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
process.stdout.write(`intake ${id} groomed → ${url}\n`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (sub !== undefined && sub.startsWith("--") && INTAKE_FLAGS[sub] !== true) {
|
|
114
|
+
process.stderr.write(`omp-conductor: intake: unexpected argument "${sub}"\n`);
|
|
115
|
+
process.exit(2);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Capture: the text is the first non-flag token, wherever it sits. Flags
|
|
119
|
+
// may come before or after it; anything beyond one text token is a typo.
|
|
120
|
+
let text: string | undefined;
|
|
121
|
+
for (let i = 1; i < ctx.argv.length; i++) {
|
|
122
|
+
const token = ctx.argv[i];
|
|
123
|
+
if (token === undefined) continue;
|
|
124
|
+
if (INTAKE_FLAGS[token] === true) {
|
|
125
|
+
i += 1;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (token.startsWith("--project=")) continue;
|
|
129
|
+
if (token.startsWith("--")) {
|
|
130
|
+
process.stderr.write(`omp-conductor: intake: unexpected argument "${token}"\n`);
|
|
131
|
+
process.exit(2);
|
|
132
|
+
}
|
|
133
|
+
if (text !== undefined) {
|
|
134
|
+
process.stderr.write(`omp-conductor: intake: unexpected argument "${token}"\n`);
|
|
135
|
+
process.exit(2);
|
|
136
|
+
}
|
|
137
|
+
text = token;
|
|
138
|
+
}
|
|
139
|
+
const trimmed = text?.trim();
|
|
140
|
+
if (trimmed === undefined || trimmed.length === 0) {
|
|
141
|
+
process.stderr.write(
|
|
142
|
+
"omp-conductor: intake needs a non-empty idea text — e.g. omp-conductor intake \"ship the intake command\"\n",
|
|
143
|
+
);
|
|
144
|
+
process.exit(2);
|
|
145
|
+
}
|
|
146
|
+
const item = store.recordIntake({
|
|
147
|
+
project: project.name,
|
|
148
|
+
text: trimmed,
|
|
149
|
+
at: Date.now(),
|
|
150
|
+
});
|
|
151
|
+
process.stdout.write(`intake ${item.id} recorded — see it with: omp-conductor intake list\n`);
|
|
152
|
+
} finally {
|
|
153
|
+
store.close();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -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,96 @@
|
|
|
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
|
+
* A question-shaped message — text that begins with the floor's `QUESTION:`
|
|
5
|
+
* marker, or an explicitly declared escalation `--category` that is not
|
|
6
|
+
* `material` — is an ask, not a send: the row is recorded before anything is
|
|
7
|
+
* delivered (#520), so "asked once, no reply" is a durable open decision row
|
|
8
|
+
* re-surfaced in every tick until answered or the seven-day expiry, and the
|
|
9
|
+
* caller never has to remember a separate `decision open`.
|
|
10
|
+
*
|
|
11
|
+
* Moved out of cli.ts's switch by the per-verb module split (#462);
|
|
12
|
+
* only the case wrapper, the injected `ctx` lookups and the imports
|
|
13
|
+
* changed from the original bodies.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { CommandContext } from "./context.ts";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { findProject, loadConfig } from "../config.ts";
|
|
19
|
+
import { deliverOperatorMessage, operatorMessageCategory, type OperatorMessageOutcome } from "../reports.ts";
|
|
20
|
+
import { dbPath, openStore } from "../store.ts";
|
|
21
|
+
import { INTERRUPT_CATEGORIES, type DecisionRecord, type InterruptCategory } from "../types.ts";
|
|
22
|
+
|
|
23
|
+
/** The floor's "this needs an answer" marker, as every other classifier reads it. */
|
|
24
|
+
const QUESTION_MARKER = /^\s*QUESTION:\s/i;
|
|
25
|
+
|
|
26
|
+
export async function messageCommand(ctx: CommandContext): Promise<void> {
|
|
27
|
+
const rawText = ctx.flag("text");
|
|
28
|
+
const body = rawText?.trim();
|
|
29
|
+
if (body === undefined || body.length === 0 || rawText?.startsWith("--") === true) {
|
|
30
|
+
process.stderr.write("omp-conductor: message needs --text with the message to deliver\n");
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
const categoryRaw = ctx.flag("category");
|
|
34
|
+
if (
|
|
35
|
+
categoryRaw !== undefined &&
|
|
36
|
+
!(INTERRUPT_CATEGORIES as readonly string[]).includes(categoryRaw)
|
|
37
|
+
) {
|
|
38
|
+
process.stderr.write(
|
|
39
|
+
`omp-conductor: message category must be one of ${INTERRUPT_CATEGORIES.join(", ")} — a question's category is part of the ask, never a text prefix\n`,
|
|
40
|
+
);
|
|
41
|
+
process.exit(2);
|
|
42
|
+
}
|
|
43
|
+
const category = categoryRaw as InterruptCategory | undefined;
|
|
44
|
+
const blocks = ctx.flag("blocks")?.trim();
|
|
45
|
+
|
|
46
|
+
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
47
|
+
const store = openStore(dbPath());
|
|
48
|
+
// The durable row exists before the delivery attempt, exactly like the
|
|
49
|
+
// `conductor_ask` tool: a crash at any point leaves a recorded question, so
|
|
50
|
+
// the "asked, awaiting the operator" half of the ask cannot be lost. Silence
|
|
51
|
+
// means the row stays open — re-surfaced in every tick until answered or the
|
|
52
|
+
// seven-day expiry; it never records an approval.
|
|
53
|
+
const effectiveCategory = category ?? operatorMessageCategory(body);
|
|
54
|
+
const isAsk = QUESTION_MARKER.test(body) || effectiveCategory !== "material";
|
|
55
|
+
let row: DecisionRecord | undefined;
|
|
56
|
+
let outcome: OperatorMessageOutcome;
|
|
57
|
+
try {
|
|
58
|
+
if (isAsk) {
|
|
59
|
+
row = store.createDecision({
|
|
60
|
+
project: project.name,
|
|
61
|
+
// The row archives the question, not the delivery prefix.
|
|
62
|
+
question: body.replace(QUESTION_MARKER, "").trim(),
|
|
63
|
+
...(blocks === undefined ? {} : { blocks }),
|
|
64
|
+
at: Date.now(),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
outcome = await deliverOperatorMessage(project, body, {
|
|
68
|
+
store,
|
|
69
|
+
at: Date.now(),
|
|
70
|
+
noticeId: randomUUID(),
|
|
71
|
+
...(category === undefined ? {} : { category }),
|
|
72
|
+
});
|
|
73
|
+
} catch (err) {
|
|
74
|
+
process.stderr.write(
|
|
75
|
+
`omp-conductor: message: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
76
|
+
);
|
|
77
|
+
process.exit(2);
|
|
78
|
+
} finally {
|
|
79
|
+
store.close();
|
|
80
|
+
}
|
|
81
|
+
if (row !== undefined) {
|
|
82
|
+
process.stdout.write(
|
|
83
|
+
`decision ${row.id} recorded — parked on silence: the question stays open until answered or the seven-day expiry. ` +
|
|
84
|
+
`Resolve it later with: omp-conductor decision resolve ${row.id} --answer "..."\n`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
process.stdout.write(
|
|
88
|
+
outcome.kind === "sent"
|
|
89
|
+
? // Not "into the topic": a stale topic degrades to the flat chat with
|
|
90
|
+
// its own warning on stderr, and this line must not contradict it.
|
|
91
|
+
`message delivered to ${project.name}'s configured Telegram target (${outcome.category})\n`
|
|
92
|
+
: `held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
|
|
93
|
+
`${outcome.reason === "availability" ? "outside the availability window" : "a digest-only category"})\n` +
|
|
94
|
+
"nothing was sent; the daemon releases it with the next digest or working-hours catch-up\n",
|
|
95
|
+
);
|
|
96
|
+
}
|