@staix/agent-hub 0.23.1 → 0.25.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/CHANGELOG.md +24 -0
- package/README.md +32 -5
- package/docs/agent-notes/adapters.md +5 -2
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +16 -2
- package/docs/agent-notes/tasks.md +9 -2
- package/docs/agent-notes/tests.md +3 -0
- package/docs/dogfood.md +16 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +198 -5
- package/docs/profiles.md +2 -0
- package/docs/quickstart.md +19 -4
- package/docs/security.md +225 -8
- package/docs/specs/2026-09-19-agent-hub-design.md +54 -1
- package/docs/specs/2026-09-19-multi-project-design.md +12 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +351 -0
- package/docs/verification/2026-10-31-0.24.0.md +63 -0
- package/docs/verification/2026-10-31-claude-implementer-2.1.316.md +65 -0
- package/docs/verification/2026-11-01-0.25.0.md +91 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +31 -15
- package/package.json +4 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5120 -4710
- package/src/adapters/claude-channel.ts +29 -6
- package/src/adapters/compat.ts +3 -3
- package/src/adapters/definitions/native-cli-claude-setup.ts +4 -0
- package/src/adapters/definitions/native-cli-doctor.ts +30 -0
- package/src/adapters/jcode.ts +3 -0
- package/src/adapters/registry.ts +1 -1
- package/src/cli/acon-register.ts +29 -0
- package/src/cli/autonomy-homes.ts +33 -0
- package/src/cli/autonomy-offline.ts +75 -0
- package/src/cli/autonomy-projects.ts +103 -0
- package/src/cli/autonomy.ts +44 -0
- package/src/cli/claude-homes.ts +123 -0
- package/src/cli/claude-profile-hook.ts +130 -0
- package/src/cli/handoff-args.ts +27 -0
- package/src/cli/handoff-output.ts +25 -0
- package/src/cli/help.ts +8 -4
- package/src/cli/identity.ts +8 -1
- package/src/cli/main.ts +174 -33
- package/src/cli/output.ts +8 -5
- package/src/cli/schedule-output.ts +8 -4
- package/src/cli/setup-homes.ts +46 -0
- package/src/cli/setup.ts +1 -1
- package/src/cli/study-output.ts +71 -0
- package/src/cli/study.ts +58 -0
- package/src/cli/task-output.ts +1 -0
- package/src/hub/action-class.ts +203 -14
- package/src/hub/action-enforce.ts +8 -6
- package/src/hub/action-policy.ts +95 -9
- package/src/hub/autonomy-consent.ts +173 -0
- package/src/hub/autonomy-defaults.ts +58 -0
- package/src/hub/autonomy.ts +232 -0
- package/src/hub/board.ts +871 -2
- package/src/hub/channel-project.ts +61 -0
- package/src/hub/config-apply.ts +3 -2
- package/src/hub/confirmation-matrix.ts +202 -0
- package/src/hub/control-client.ts +10 -1
- package/src/hub/daemon.ts +546 -27
- package/src/hub/envelope.ts +2 -0
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +12 -0
- package/src/hub/handoff.ts +126 -0
- package/src/hub/hub-tools.ts +12 -2
- package/src/hub/manager.ts +64 -57
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/path-policy.ts +12 -8
- package/src/hub/project.ts +14 -5
- package/src/hub/provenance.ts +30 -0
- package/src/hub/research-campaign.ts +265 -0
- package/src/hub/research-experiments.ts +445 -0
- package/src/hub/restart.ts +26 -0
- package/src/hub/schedules.ts +210 -9
- package/src/hub/session-identity.ts +49 -0
- package/src/hub/settings.ts +105 -8
- package/src/hub/task-repository.ts +11 -6
- package/src/hub/tasks.ts +894 -22
- package/src/hub/ui.ts +15 -6
- package/src/memory/capture.ts +2 -2
- package/src/ui/index.html +5 -3
- package/src/ui/office-space.ts +118 -0
- package/src/ui/plugin.ts +293 -0
- package/templates/AGENTS.block.md +1 -0
- package/templates/presets/autonomous.json +161 -0
- package/templates/presets/confirmation-matrix.json +943 -0
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +4 -4
- package/templates/profiles/writing.json +1 -1
- package/templates/roles/research/analyst.md +1 -0
- package/templates/roles/research/fact-checker.md +1 -0
- package/templates/roles/research/research-lead.md +1 -0
- package/templates/roles/research/researcher.md +1 -0
- package/templates/roles/research/statistician.md +1 -0
package/src/cli/output.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, termin
|
|
|
7
7
|
import { taskProgress } from "../ui/task-progress.ts";
|
|
8
8
|
import { backendLabel } from "./status-lines.ts";
|
|
9
9
|
import { ownerIsolationText } from "./role-holders.ts";
|
|
10
|
+
import { schedulePeriodText, scheduleWhen } from "../hub/schedules.ts";
|
|
10
11
|
import { formatReport, formatTaskReport, type Report, type TaskReport } from "../hub/report.ts";
|
|
11
12
|
import type { Counter, LimitsResult, TotalResult, UsageResult } from "../hub/usage-rollup.ts";
|
|
12
13
|
|
|
@@ -20,7 +21,7 @@ export interface StatusOutput {
|
|
|
20
21
|
/** #843 AC3: installed agent CLIs not on a verified version; ids, version, state and a receipt path only. */
|
|
21
22
|
agentDrift?: readonly { id: string; version: string; state: string; receipt: string }[];
|
|
22
23
|
/** #512a: one row per machine-local schedule; ids, times and a state only, never task text. */
|
|
23
|
-
schedules?: readonly { id: string; every
|
|
24
|
+
schedules?: readonly { id: string; every?: string; cron?: string; zone?: string; class: string; queue: string; next: number; last?: number; task?: number; state: string; coalesced?: number; error?: string }[];
|
|
24
25
|
taskStalls?: readonly { task: number; cause: { kind: string; peer?: string; dependency?: number; code: string } }[];
|
|
25
26
|
pendingTaskOffers?: readonly { task: number; peer: string }[];
|
|
26
27
|
isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
|
|
@@ -218,8 +219,8 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
218
219
|
for (const pending of data.retainedConfig) out.push(...detail("retained reason", `${pending.key}: ${pending.reason}`, columns, "attention"));
|
|
219
220
|
}
|
|
220
221
|
for (const row of data.schedules ?? []) {
|
|
221
|
-
const when = (at: number) => new Date(at).toISOString().replace("T", " ").slice(0, 16) + "Z";
|
|
222
|
-
const text = `${row.state === "paused" ? `paused (ahub schedule resume ${row.id}); would be next ${when(row.next)}` : row.state === "scheduled" ? `next ${when(row.next)}` : row.state === "due" ? "due now" : row.state === "waiting" ? `due, waiting for task #${row.task} to close` : "due, held (the hub is stopping or recovering)"};
|
|
222
|
+
const when = (at: number) => row.zone ? scheduleWhen(row, at) : new Date(at).toISOString().replace("T", " ").slice(0, 16) + "Z";
|
|
223
|
+
const text = `${row.state === "paused" ? `paused (ahub schedule resume ${row.id}); would be next ${when(row.next)}` : row.state === "scheduled" ? `next ${when(row.next)}` : row.state === "due" ? "due now" : row.state === "waiting" ? `due, waiting for task #${row.task} to close` : "due, held (the hub is stopping or recovering)"}; ${schedulePeriodText(row)}; ${row.class} to ${row.queue}${row.last ? `; last ${when(row.last)}${row.task ? ` as task #${row.task}` : ""}` : "; never run"}${row.coalesced ? `; ${row.coalesced} skipped run${row.coalesced === 1 ? "" : "s"} coalesced` : ""}${row.error ? `; last error: ${row.error}` : ""}`;
|
|
223
224
|
out.push(...detail(`schedule ${row.id}`, text, columns, row.error ? "attention" : undefined));
|
|
224
225
|
}
|
|
225
226
|
if (data.queueBackpressure) {
|
|
@@ -347,6 +348,8 @@ export function renderBacklog(cards: any[], columns?: number, now = Date.now(),
|
|
|
347
348
|
return [...rows(names, table, columns), [], ...lines(summary, columns)];
|
|
348
349
|
}
|
|
349
350
|
/** #724: one line per task. TITLE goes last and is cut to the room left; at a narrow width REVIEWER, CLASS and AGE give way first. */
|
|
351
|
+
/** #604: a task another project handed over says so on the board: the project id and the peer, both ids and nothing a handoff wrote. */
|
|
352
|
+
const handoffMark = (task: any): string => task?.handoffOrigin ? ` (handed off from ${String(task.handoffOrigin.projectId).slice(0, 24)} by ${String(task.handoffOrigin.peer).slice(0, 32)})` : "";
|
|
350
353
|
function compactBoard(tasks: any[], data: OutputCell[][], columns: number, staged: boolean): Span[][] {
|
|
351
354
|
const head = ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "AFTER", ...(staged ? ["STAGE"] : []), "TITLE"];
|
|
352
355
|
let table = tasks.map((task, i) => {
|
|
@@ -355,7 +358,7 @@ function compactBoard(tasks: any[], data: OutputCell[][], columns: number, stage
|
|
|
355
358
|
const meter = /^\[[^\]]*\](?: cancelled)?/.exec(row[7]?.text ?? "")?.[0] ?? "[----]";
|
|
356
359
|
const stage = task.lifecycle ? `${meter} ${task.lifecycle.stage}: ${stageCode(String(task.lifecycle.next ?? ""))}` : meter;
|
|
357
360
|
const short = task.lifecycle ? `${meter} ${stageCode(String(task.lifecycle.next ?? ""))}` : meter;
|
|
358
|
-
return [row[0]!, row[1]!, row[2]!, row[3]!, row[4]!, row[5]!, span(after, undefined, true), ...(staged ? [{ ...span(stage, row[7]!.tone, true), short }] : []), span(flat(task.title))];
|
|
361
|
+
return [row[0]!, row[1]!, row[2]!, row[3]!, row[4]!, row[5]!, span(after, undefined, true), ...(staged ? [{ ...span(stage, row[7]!.tone, true), short }] : []), span(`${flat(task.title)}${handoffMark(task)}`)];
|
|
359
362
|
});
|
|
360
363
|
let names = [...head];
|
|
361
364
|
const drop = (name: string) => { const at = names.indexOf(name); if (at < 0) return; names = names.filter((_, i) => i !== at); table = table.map(row => row.filter((_, i) => i !== at)); };
|
|
@@ -389,7 +392,7 @@ export function renderBoard(tasks: any[], columns?: number, now = Date.now(), fu
|
|
|
389
392
|
const row = cells(s, task, false, now, stages);
|
|
390
393
|
row[2] = span(row[2]!.text, peerTone(task.owner ?? ""));
|
|
391
394
|
row[3] = span(row[3]!.text, peerTone(task.reviewer ?? ""));
|
|
392
|
-
row[6] = span(`${task.title}${task.deps?.length ? ` after ${task.deps.map((id: number) => `#${id}`).join(", ")}` : ""}${task.signals?.includes("pii") ? ` (ahub task show ${task.id})` : ""}`);
|
|
395
|
+
row[6] = span(`${task.title}${handoffMark(task)}${task.deps?.length ? ` after ${task.deps.map((id: number) => `#${id}`).join(", ")}` : ""}${task.signals?.includes("pii") ? ` (ahub task show ${task.id})` : ""}`);
|
|
393
396
|
if (allTasks === null) row.pop();
|
|
394
397
|
return row;
|
|
395
398
|
});
|
|
@@ -3,22 +3,26 @@
|
|
|
3
3
|
import { blank, grid, heading, pair, s } from "./sdlc-output.ts";
|
|
4
4
|
import { moment } from "./sdlc-output.ts";
|
|
5
5
|
import type { Span, Tone } from "./console-state.ts";
|
|
6
|
+
import { schedulePeriodText, scheduleWhen } from "../hub/schedules.ts";
|
|
6
7
|
|
|
7
8
|
const list = (value: unknown): any[] => Array.isArray(value) ? value : [];
|
|
8
9
|
const TONE: Record<string, Tone | undefined> = { due: "attention", waiting: "attention", held: "attention", paused: "muted", scheduled: undefined };
|
|
9
10
|
const WHEN: Record<string, string> = { scheduled: "next", due: "due now", waiting: "due, waiting for its task to close", held: "due, held (the hub is stopping or recovering)", paused: "paused" };
|
|
10
11
|
|
|
11
12
|
/** The next run, as the person reads it: a paused schedule says so and what it would have been. */
|
|
12
|
-
export const nextText = (row: any, now: number): string =>
|
|
13
|
-
|
|
13
|
+
export const nextText = (row: any, now: number): string => {
|
|
14
|
+
// A calendar schedule reads its next run in its own zone, with the relative time after it; an `every` schedule keeps the UTC form.
|
|
15
|
+
const at = (instant: unknown) => row?.zone && typeof instant === "number" && Number.isFinite(instant) ? `${scheduleWhen(row, instant)} (${moment(instant, now).replace(/^.*?\((.*)\)$/, "$1")})` : moment(instant, now);
|
|
16
|
+
return row?.state === "paused" ? `paused (would be ${at(row.next)})` : row?.state === "scheduled" ? at(row.next) : WHEN[row?.state] ?? "unknown";
|
|
17
|
+
};
|
|
14
18
|
|
|
15
19
|
/** `ahub schedule list`: one row per configured schedule with its next run. */
|
|
16
20
|
export function renderScheduleList(rows: unknown, columns?: number, now = Date.now()): Span[][] {
|
|
17
21
|
const schedules = list(rows);
|
|
18
22
|
if (!schedules.length) return [[s("no schedules configured; add them under `schedules` in .agenthub/config.local.json", "muted")]];
|
|
19
23
|
const out: Span[][] = [...heading(`Schedules (${schedules.length})`, columns)];
|
|
20
|
-
out.push(...grid(["ID", "
|
|
21
|
-
s(row.id, "issueRef"), s(row.every), s(row.class), s(row.queue), s(row.state, TONE[row.state]), s(nextText(row, now), TONE[row.state]),
|
|
24
|
+
out.push(...grid(["ID", "SCHEDULE", "CLASS", "QUEUE", "STATE", "NEXT RUN", "LAST RUN", "TASK"], schedules.map((row: any) => [
|
|
25
|
+
s(row.id, "issueRef"), s(row.cron !== undefined ? schedulePeriodText(row) : String(row.every)), s(row.class), s(row.queue), s(row.state, TONE[row.state]), s(nextText(row, now), TONE[row.state]),
|
|
22
26
|
s(row.last ? moment(row.last, now) : "never", "muted"), s(row.task === undefined ? "-" : `#${row.task}`, "taskRef"),
|
|
23
27
|
]), columns));
|
|
24
28
|
for (const row of schedules) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { checkClaudeHome, claudeHomeEnvironment, type ClaudeHome, type ClaudeHomeProbe } from "./claude-homes.ts";
|
|
2
|
+
import { installClaudeProfileHook } from "./claude-profile-hook.ts";
|
|
3
|
+
|
|
4
|
+
export interface SetupHomesPorts {
|
|
5
|
+
env: NodeJS.ProcessEnv;
|
|
6
|
+
packageRoot: string;
|
|
7
|
+
yes: boolean;
|
|
8
|
+
interactive?: boolean;
|
|
9
|
+
homes: readonly ClaudeHome[];
|
|
10
|
+
probe: ClaudeHomeProbe;
|
|
11
|
+
run(argv: readonly string[], env: NodeJS.ProcessEnv): Promise<number>;
|
|
12
|
+
ask(prompt: string): Promise<string | null>;
|
|
13
|
+
print(line: string): void;
|
|
14
|
+
current?(): void;
|
|
15
|
+
}
|
|
16
|
+
/** Selected homes only. Native plugin operations and the project-neutral hook share one person confirmation. */
|
|
17
|
+
export async function setupClaudeHomes(ports: SetupHomesPorts): Promise<void> {
|
|
18
|
+
ports.current?.();
|
|
19
|
+
if (!ports.homes.length) { ports.print("No Claude configuration home in use was found; set CLAUDE_CONFIG_DIR to select one."); return; }
|
|
20
|
+
const initial = [];
|
|
21
|
+
for (const home of ports.homes) initial.push(await checkClaudeHome(home, ports.packageRoot, ports.env, ports.probe, undefined, true));
|
|
22
|
+
for (const check of initial) {
|
|
23
|
+
ports.print(`Claude home ${check.home.path}: plugin ${check.plugin.state}; hook ${check.hook.state}.`);
|
|
24
|
+
if (check.plugin.state === "unknown" || check.hook.state === "refused") throw new Error(`Claude setup cannot safely inspect ${check.home.path}; nothing was changed`);
|
|
25
|
+
}
|
|
26
|
+
for (const check of initial) if (check.next) ports.print(`${check.home.path}: ${check.next.argv.join(" ")} (${check.next.why})`);
|
|
27
|
+
if (initial.every(check => !check.next && check.hook.state === "installed")) return;
|
|
28
|
+
if (!ports.yes && ports.interactive === false) throw new Error("not a terminal: nothing was changed; rerun with --yes to apply");
|
|
29
|
+
if (!ports.yes && !["y", "yes"].includes((await ports.ask("Install the hub plugin and project-neutral hook in these Claude homes? [y/N] "))?.trim().toLowerCase() ?? "")) { ports.print("Claude setup was not changed."); return; }
|
|
30
|
+
ports.current?.();
|
|
31
|
+
for (const check of initial) {
|
|
32
|
+
let current = check;
|
|
33
|
+
for (let step = 0; current.next && step < 6; step++) {
|
|
34
|
+
ports.print(`${check.home.path}: ${current.next.argv.join(" ")} (${current.next.why})`);
|
|
35
|
+
ports.current?.();
|
|
36
|
+
if (await ports.run(current.next.argv, claudeHomeEnvironment(check.home, ports.env)) !== 0) throw new Error(`Claude plugin setup failed for ${check.home.path}; no later home was changed`);
|
|
37
|
+
current = await checkClaudeHome({ ...check.home, exists: true }, ports.packageRoot, ports.env, ports.probe, undefined, true);
|
|
38
|
+
if (current.plugin.state === "unknown") throw new Error(`Claude plugin inventory is unavailable for ${check.home.path}`);
|
|
39
|
+
}
|
|
40
|
+
if (current.next) throw new Error(`Claude setup exceeded six plugin steps for ${check.home.path}`);
|
|
41
|
+
ports.current?.();
|
|
42
|
+
const hook = installClaudeProfileHook(check.home);
|
|
43
|
+
if (hook.state !== "installed") throw new Error(hook.reason);
|
|
44
|
+
ports.print(`${check.home.path}: plugin current; hook ${hook.changed ? "installed" : "current"}. ${hook.reason}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/cli/setup.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Compatibility exports; native plugin policy belongs to its adapter definition leaf. */
|
|
2
|
-
export { PLUGIN, parseList, pluginState, nextStep, type InstalledPlugin, type Marketplace, type PluginState, type SetupStep } from "../adapters/definitions/native-cli-claude-setup.ts";
|
|
2
|
+
export { PLUGIN, CLAUDE_PLUGIN_INVENTORY, parseList, pluginState, nextStep, type InstalledPlugin, type Marketplace, type PluginState, type SetupStep } from "../adapters/definitions/native-cli-claude-setup.ts";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Human output for `ahub study draft|revise|approve|approve-protocol|close|show`. Pure: the hub's parsed JSON in, spans out, no I/O. `--json` is the caller's branch and prints the same text.
|
|
2
|
+
// A campaign is named by ids, revisions, states and counts. The hub already stubs private campaigns, tasks and sources; this file never asks for a question, scope, path or excerpt.
|
|
3
|
+
import { blank, grid, heading, pair, s } from "./sdlc-output.ts";
|
|
4
|
+
import type { Span, Tone } from "./console-state.ts";
|
|
5
|
+
|
|
6
|
+
const list = (value: unknown): any[] => Array.isArray(value) ? value : [];
|
|
7
|
+
const count = (value: unknown): number => typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
8
|
+
const STATUS_TONE: Record<string, Tone | undefined> = { approved: undefined, draft: "attention", superseded: "muted", closed: "muted" };
|
|
9
|
+
|
|
10
|
+
/** `ahub study show` with no id: one page of campaigns of this project, with the command that continues it when the hub says there is more. */
|
|
11
|
+
export function renderStudyList(page: unknown, columns?: number): Span[][] {
|
|
12
|
+
const campaigns = list((page as any)?.campaigns), next = typeof (page as any)?.next === "string" ? (page as any).next as string : undefined;
|
|
13
|
+
if (!campaigns.length) return [[s("no research campaigns in this project; a planner or you draft one with `ahub study draft <parent> <contract.json>`", "muted")]];
|
|
14
|
+
const out: Span[][] = [...heading(`Research campaigns (${campaigns.length}${next ? ", more follow" : ""})`, columns)];
|
|
15
|
+
out.push(...grid(["ID", "REV", "STATUS", "PARENT", "UNITS"], campaigns.map((row: any) => [
|
|
16
|
+
s(row.id, "issueRef"), s(`r${count(row.revision)}`), s(row.status, STATUS_TONE[row.status]), s(`#${count(row.parentTaskId)}`, "taskRef"), s(count(row.unitCount)),
|
|
17
|
+
]), columns));
|
|
18
|
+
if (next) out.push(blank, [s(`more campaigns: ahub study show --after ${next}`, "muted")]);
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SUPPORT_TONE: Record<string, Tone | undefined> = { enforceable: undefined, supported: undefined, advisory: "muted", unavailable: "attention", unsupported: "attention", unknown: "attention" };
|
|
23
|
+
|
|
24
|
+
/** `ahub study show <id>`: the coverage and stop projection the hub computed. Held work is said first, with every reason. */
|
|
25
|
+
export function renderStudyShow(projection: unknown, columns?: number): Span[][] {
|
|
26
|
+
const p = projection as any, units = list(p?.units), rounds = list(p?.discoveryRounds), reasons = list(p?.reasons);
|
|
27
|
+
const out: Span[][] = [...heading(`Research campaign ${p?.id ?? "?"}, revision ${count(p?.revision)}${p?.held ? ", dispatch HELD" : ""}`, columns)];
|
|
28
|
+
for (const reason of reasons) out.push(...pair("held", reason, columns, 8, "attention"));
|
|
29
|
+
if (p?.sharedPeerLedger) out.push(...pair("budget", "one run ledger is shared by the peers", columns, 8, "muted"));
|
|
30
|
+
if (rounds.length) out.push(...pair("rounds", rounds.map((round: any) => `${count(round.round)}: ${round.complete ? "discovery complete" : "discovery open"}`).join(", "), columns, 8));
|
|
31
|
+
out.push(blank);
|
|
32
|
+
if (!units.length) return [...out, [s("no units", "muted")]];
|
|
33
|
+
out.push(...grid(["UNIT", "ROUND", "TASK", "STATE", "PURPOSE", "HANDOFF", "SOURCES"], units.map((unit: any) => {
|
|
34
|
+
const handoff = unit.handoff ?? {};
|
|
35
|
+
const evidence = handoff.insufficientEvidence ? "insufficient evidence" : handoff.complete ? "complete" : handoff.present ? "incomplete" : "none";
|
|
36
|
+
const covered = list(unit.sourceIds).length;
|
|
37
|
+
const note = [unit.intentionalIndependentCheck ? "independent check" : "", list(unit.duplicateSourceIds).length ? `${list(unit.duplicateSourceIds).length} duplicated` : ""].filter(Boolean).join(", ");
|
|
38
|
+
return [s(unit.unitId, "issueRef"), s(count(unit.round)), s(unit.taskId == null ? "-" : `#${unit.taskId}`, "taskRef"), s(unit.state), s(unit.purpose), s(evidence, handoff.complete || handoff.insufficientEvidence ? undefined : "attention"),
|
|
39
|
+
s(covered ? `${list(handoff.coveredSourceIds).length}/${covered} covered${note ? `, ${note}` : ""}` : note || "-", "muted")];
|
|
40
|
+
}), columns));
|
|
41
|
+
// Every resource of a unit is shown whenever the campaign names a budget, with its support state: a limit the ledger does not carry (`null`) is a placeholder, not a reason to hide a row that says
|
|
42
|
+
// `unavailable` or `advisory`. A campaign that names no budget has nothing to say (its `tokens` row reads `unavailable` whatever the ledger is), so the hub's `budgetNamed` decides, not the values.
|
|
43
|
+
const budgeted = units.flatMap((unit: any) => Object.entries(unit.budget ?? {}).filter(([, value]: [string, any]) => value && typeof value === "object").map(([name, value]: [string, any]) => ({ unit: unit.unitId, name, value })));
|
|
44
|
+
if (budgeted.length && p?.budgetNamed === true) {
|
|
45
|
+
out.push(blank, ...heading("Budget", columns));
|
|
46
|
+
out.push(...grid(["UNIT", "RESOURCE", "SUPPORT", "USED", "LIMIT"], budgeted.map(({ unit, name, value }) => [
|
|
47
|
+
s(unit, "issueRef"), s(name), s(value.support, SUPPORT_TONE[value.support]), s(value.used === null || value.used === undefined ? "-" : value.used), s(value.limit === null || value.limit === undefined ? "-" : value.limit),
|
|
48
|
+
]), columns));
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const VERB: Record<string, string> = { draft: "drafted", revise: "drafted as the next revision", approve: "approved", close: "closed" };
|
|
54
|
+
|
|
55
|
+
/** The receipt of a draft, revision, approval or close. */
|
|
56
|
+
export function renderStudyReceipt(op: string, receipt: unknown, columns?: number): Span[][] {
|
|
57
|
+
const r = receipt as any, units = list(r?.units);
|
|
58
|
+
if (op === "approve_protocol") {
|
|
59
|
+
const hash = (value: unknown) => typeof value === "string" && value.length === 64 && /^[a-f0-9]{64}$/.test(value) ? value : "unknown";
|
|
60
|
+
const by = typeof r?.approvedBy === "string" && /^[a-z][a-z0-9_-]{0,63}$/.test(r.approvedBy) ? r.approvedBy : "unknown";
|
|
61
|
+
const at = typeof r?.approvedAt === "number" && Number.isFinite(r.approvedAt) && r.approvedAt >= 0 && r.approvedAt <= 8_640_000_000_000_000
|
|
62
|
+
? new Date(r.approvedAt).toISOString().slice(0, 19).replace("T", " ") + " UTC" : "unknown";
|
|
63
|
+
return [...heading("Research protocol approval", columns), ...pair("protocol hash", hash(r?.protocolHash), columns, 16),
|
|
64
|
+
...pair("evaluator hash", hash(r?.evaluatorHash), columns, 16), ...pair("approved by", by, columns, 16), ...pair("approved at", at, columns, 16)];
|
|
65
|
+
}
|
|
66
|
+
const out: Span[][] = [...heading(`Research campaign ${r?.id ?? "?"} revision ${count(r?.revision)} ${VERB[op] ?? op}`, columns)];
|
|
67
|
+
out.push(...pair("status", r?.status, columns, 8, STATUS_TONE[r?.status]), ...pair("parent", `#${count(r?.parentTaskId)}`, columns, 8, "taskRef"), ...pair("units", count(r?.unitCount), columns, 8));
|
|
68
|
+
if (units.length) out.push(blank, ...grid(["UNIT", "TASK", "ROUND"], units.map((unit: any) => [s(unit.unitId, "issueRef"), s(`#${count(unit.taskId)}`, "taskRef"), s(count(unit.round))]), columns));
|
|
69
|
+
if (op === "draft" || op === "revise") out.push(blank, [s(`nothing is dispatched until you approve: ahub study approve ${r?.id} ${count(r?.revision)}`, "muted")]);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
package/src/cli/study.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Argument parsing for `ahub study` (#601). Pure: argv in, one request description or a usage line out, so the I/O in main.ts stays a thin dispatcher and every form is testable without a daemon.
|
|
2
|
+
export const STUDY_USAGE = "usage: ahub study draft <parent> <contract.json> | revise <id> <revision> <contract.json> | approve <id> <revision> | approve-protocol <hash> | close <id> <revision> --reason <text> | show [<id> | --after <id>] [--json] [--color=auto|always|never]";
|
|
3
|
+
|
|
4
|
+
export type StudyRequest =
|
|
5
|
+
| { op: "draft"; parentTaskId: number; file: string }
|
|
6
|
+
| { op: "revise"; id: string; revision: number; file: string }
|
|
7
|
+
| { op: "approve"; id: string; revision: number }
|
|
8
|
+
| { op: "approve_protocol"; hash: string }
|
|
9
|
+
| { op: "close"; id: string; revision: number; reason: string }
|
|
10
|
+
| { op: "show"; id?: string; after?: string };
|
|
11
|
+
|
|
12
|
+
const ID = /^rc-[a-f0-9]{12}$/, INT = /^[1-9]\d{0,8}$/;
|
|
13
|
+
|
|
14
|
+
/** `operands` is argv with the presentation flags (`--json`, `--color=`) already removed by `splitPresentation`. `--reason` takes the rest of the words, as `task cancel --reason` does. */
|
|
15
|
+
export function parseStudyArgs(operands: readonly string[]): StudyRequest | { usage: string } {
|
|
16
|
+
const usage = (detail?: string) => ({ usage: detail ? `${detail}\n${STUDY_USAGE}` : STUDY_USAGE });
|
|
17
|
+
const [sub, ...rest] = operands;
|
|
18
|
+
const id = (value: string | undefined) => value !== undefined && ID.test(value) ? value : undefined;
|
|
19
|
+
const int = (value: string | undefined) => value !== undefined && INT.test(value) ? Number(value) : undefined;
|
|
20
|
+
if (sub === "approve-protocol") {
|
|
21
|
+
if (rest.length !== 1) return usage();
|
|
22
|
+
const hash = rest[0];
|
|
23
|
+
return hash?.length === 64 && /^[a-f0-9]{64}$/.test(hash) ? { op: "approve_protocol", hash } : usage("a protocol hash is 64 lowercase hexadecimal characters");
|
|
24
|
+
}
|
|
25
|
+
if (sub === "show") {
|
|
26
|
+
// `--after <id>` continues a list from the cursor a previous page printed; it has no meaning with an id, which shows one campaign.
|
|
27
|
+
if (rest[0] === "--after") {
|
|
28
|
+
if (rest.length !== 2) return usage();
|
|
29
|
+
return id(rest[1]) ? { op: "show", after: rest[1]! } : usage("a campaign id looks like rc-0123456789ab");
|
|
30
|
+
}
|
|
31
|
+
if (rest.length > 1) return usage();
|
|
32
|
+
if (rest[0] === undefined) return { op: "show" };
|
|
33
|
+
return id(rest[0]) ? { op: "show", id: rest[0] } : usage("a campaign id looks like rc-0123456789ab");
|
|
34
|
+
}
|
|
35
|
+
if (sub === "draft") {
|
|
36
|
+
const parent = int(rest[0]);
|
|
37
|
+
if (parent === undefined || !rest[1] || rest.length !== 2) return usage();
|
|
38
|
+
return { op: "draft", parentTaskId: parent, file: rest[1] };
|
|
39
|
+
}
|
|
40
|
+
if (sub === "revise") {
|
|
41
|
+
const revision = int(rest[1]);
|
|
42
|
+
if (!id(rest[0]) || revision === undefined || !rest[2] || rest.length !== 3) return usage();
|
|
43
|
+
return { op: "revise", id: rest[0]!, revision, file: rest[2] };
|
|
44
|
+
}
|
|
45
|
+
if (sub === "approve") {
|
|
46
|
+
const revision = int(rest[1]);
|
|
47
|
+
if (!id(rest[0]) || revision === undefined || rest.length !== 2) return usage();
|
|
48
|
+
return { op: "approve", id: rest[0]!, revision };
|
|
49
|
+
}
|
|
50
|
+
if (sub === "close") {
|
|
51
|
+
const revision = int(rest[1]);
|
|
52
|
+
const at = rest.indexOf("--reason");
|
|
53
|
+
if (!id(rest[0]) || revision === undefined || at !== 2) return usage();
|
|
54
|
+
const reason = rest.slice(at + 1).join(" ").trim();
|
|
55
|
+
return reason ? { op: "close", id: rest[0]!, revision, reason } : usage("study close needs a reason");
|
|
56
|
+
}
|
|
57
|
+
return usage();
|
|
58
|
+
}
|
package/src/cli/task-output.ts
CHANGED
|
@@ -104,6 +104,7 @@ function taskBlock(task: any, columns: number | undefined, now: number, detail:
|
|
|
104
104
|
...pair("class", dash(task?.class), columns, w, undefined, " "), ...pair("owner", taskHolderText(task, "owner"), columns, w, undefined, " "), ...pair("reviewer", taskHolderText(task, "reviewer"), columns, w, undefined, " "));
|
|
105
105
|
if (task?.planner !== undefined) out.push(...pair("planner", taskHolderText(task, "planner"), columns, w, undefined, " "));
|
|
106
106
|
if (task?.reserved) out.push(...pair("reserved", task.reserved, columns, w, undefined, " "));
|
|
107
|
+
if (task?.handoffOrigin) out.push(...pair("from", `project ${task.handoffOrigin.projectId}, handed off by ${task.handoffOrigin.peer}${typeof task.handoffOrigin.task === "number" ? ` (their task #${task.handoffOrigin.task})` : ""}`, columns, w, "attention", " "));
|
|
107
108
|
out.push(...pair("after", ids(task?.deps), columns, w, undefined, " "));
|
|
108
109
|
if (typeof task?.release === "number") out.push(...pair("release", `#${task.release}`, columns, w, undefined, " "));
|
|
109
110
|
if (typeof task?.rank === "number" || typeof task?.priority === "number") out.push(...pair("planning", [typeof task.rank === "number" ? `rank ${task.rank}` : "", typeof task.priority === "number" ? `priority P${task.priority}` : "", task.pinned ? "pinned" : ""].filter(Boolean).join(", "), columns, w, undefined, " "));
|
package/src/hub/action-class.ts
CHANGED
|
@@ -6,7 +6,7 @@ export type ActionClass = typeof ACTION_CLASSES[number];
|
|
|
6
6
|
const SHELL_TOOLS = new Set(["bash", "shell", "shell_command", "exec_command", "run_shell_command"]);
|
|
7
7
|
const LIMIT = 16_384;
|
|
8
8
|
/** `nested`: the commands of each substitution in the segment (`$(...)`, a backtick). They run in a subshell, so their `cd` never moves the outer cwd. */
|
|
9
|
-
type Command = { words: string[]; outputs: string[]; nested?: Command[][] };
|
|
9
|
+
type Command = { words: string[]; outputs: string[]; nested?: Command[][]; /** A redirect of any kind (`>`, `>>`, `<`, `>|`): its target is not recorded as a word, so a caller that needs to know whether the command writes anything must count it. */ redirected?: true };
|
|
10
10
|
|
|
11
11
|
/** Bound the whole input before accepting any prefix, including unsupported suffixes. */
|
|
12
12
|
function segmentLimit(text: string): boolean {
|
|
@@ -105,7 +105,7 @@ function commands(text: string, nest = 0): Command[] | undefined {
|
|
|
105
105
|
if (text.length > LIMIT || text.includes("\0") || !segmentLimit(text)) return;
|
|
106
106
|
const result: Command[] = [];
|
|
107
107
|
let words: string[] = [], outputs: string[] = [], word = "", active = false, quote = "";
|
|
108
|
-
let redirect: false | "overwrite" | "skip" = false;
|
|
108
|
+
let redirect: false | "overwrite" | "skip" = false, redirected = false;
|
|
109
109
|
const flush = () => {
|
|
110
110
|
if (!active) return;
|
|
111
111
|
if (redirect === "overwrite") outputs.push(word);
|
|
@@ -113,7 +113,7 @@ function commands(text: string, nest = 0): Command[] | undefined {
|
|
|
113
113
|
word = ""; active = false; redirect = false;
|
|
114
114
|
};
|
|
115
115
|
let nested: Command[][] = [];
|
|
116
|
-
const finish = () => { flush(); if (words.length || outputs.length || nested.length) result.push({ words, outputs, ...(nested.length ? { nested } : {}) }); words = []; outputs = []; nested = []; };
|
|
116
|
+
const finish = () => { flush(); if (words.length || outputs.length || nested.length) result.push({ words, outputs, ...(nested.length ? { nested } : {}), ...(redirected ? { redirected: true as const } : {}) }); words = []; outputs = []; nested = []; redirected = false; };
|
|
117
117
|
for (let i = 0; i < text.length; i++) {
|
|
118
118
|
const c = text[i]!;
|
|
119
119
|
if (quote === "'") { if (c === "'") quote = ""; else word += c; continue; }
|
|
@@ -149,6 +149,7 @@ function commands(text: string, nest = 0): Command[] | undefined {
|
|
|
149
149
|
i += 1 + duplicate[0].length; continue;
|
|
150
150
|
}
|
|
151
151
|
redirect = c === "<" || text[i + 1] === ">" ? "skip" : "overwrite";
|
|
152
|
+
redirected = true;
|
|
152
153
|
if (text[i + 1] === ">" || text[i + 1] === "|") i++;
|
|
153
154
|
continue;
|
|
154
155
|
}
|
|
@@ -428,10 +429,191 @@ function curlWrites(input: string[]): boolean {
|
|
|
428
429
|
return writes();
|
|
429
430
|
}
|
|
430
431
|
|
|
431
|
-
|
|
432
|
+
/**
|
|
433
|
+
* #999 T3, the floor policy: what the classifier needs to tell an ordinary branch push or own-repository pull request from a release-grade one.
|
|
434
|
+
* The classifier stays pure, so the host supplies these facts. A fact that is missing is unknown, and unknown keeps the strictest class (#762).
|
|
435
|
+
* Without a `FloorFacts` argument nothing here runs and every class is exactly what it was before the floor existed.
|
|
436
|
+
*/
|
|
437
|
+
export interface FloorFacts {
|
|
438
|
+
/** The default branch from the authoritative remote read. Without it every push still asks. */
|
|
439
|
+
defaultBranch?: string;
|
|
440
|
+
/** The project's own GitHub repository, `owner/name`. Without it a `--repo` flag still asks. */
|
|
441
|
+
repository?: string;
|
|
442
|
+
/** Whether a local tag of that name exists in `cwd`: `false` only when git proved there is none. */
|
|
443
|
+
refIsTag?: (name: string, cwd: string) => boolean | undefined;
|
|
444
|
+
/** The branch checked out in `cwd`, or undefined when detached or unknown. */
|
|
445
|
+
currentBranch?: (cwd: string) => string | undefined;
|
|
446
|
+
/** Whether `cwd` is inside the project's own git repository (its top level is the execution root), so `origin` and `gh` mean the project's remote. `true` only when proven: a nested clone has another `origin`. */
|
|
447
|
+
inOwnRepository?: (cwd: string) => boolean | undefined;
|
|
448
|
+
/**
|
|
449
|
+
* Whether a plain `git push origin <branch>` from `cwd` can only write the project's own repository and only the named branch: every effective push URL of `origin`
|
|
450
|
+
* (`remote.origin.pushurl`, `url.*.pushInsteadOf`) is the project's repository, and no configuration can add refs to the push (`push.followTags`, `push.pushOption`,
|
|
451
|
+
* `remote.origin.push`, `remote.origin.mirror`), and the hub's own environment overrides none of it. `true` only when proven.
|
|
452
|
+
*/
|
|
453
|
+
pushIsOwn?: (cwd: string) => boolean | undefined;
|
|
454
|
+
/**
|
|
455
|
+
* Whether `gh` run in `cwd` with no `--repo` resolves to the project's own repository: the checkout has no remote but `origin`, its `gh-resolved` marker does not name another
|
|
456
|
+
* repository, and neither `GH_REPO` nor `GH_HOST` is set in the hub's environment. `true` only when proven.
|
|
457
|
+
*/
|
|
458
|
+
ghDefaultIsOwn?: (cwd: string) => boolean | undefined;
|
|
459
|
+
}
|
|
460
|
+
/** The only `git push` options the floor lets through. Force, delete, tags, mirror, prune, no-verify, push options and another repository all ask. */
|
|
461
|
+
const FLOOR_PUSH_LONG = new Set(["--set-upstream", "--quiet", "--verbose", "--dry-run", "--porcelain", "--atomic", "--progress", "--no-progress"]);
|
|
462
|
+
const FLOOR_PUSH_SHORT = "uqvn";
|
|
463
|
+
const BRANCH_NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
464
|
+
const plainBranch = (name: string): boolean => BRANCH_NAME.test(name) && !name.includes("..") && !name.includes("//") && !name.endsWith("/") && !name.endsWith(".lock") && name !== "HEAD";
|
|
465
|
+
const isDefaultBranch = (name: string, floor: FloorFacts): boolean => ["main", "master", floor.defaultBranch ?? ""].some(known => known !== "" && known.toLowerCase() === name.toLowerCase());
|
|
466
|
+
/** Every candidate cwd is a known directory inside the execution root: `cd /elsewhere`, `cd $X` and `cd ~` leave the floor. */
|
|
467
|
+
const withinRoot = (root: string, cwd: string[]): boolean => isAbsolute(root) && cwd.length > 0 && cwd.every(at => at !== "" && !outside(".", root, at));
|
|
468
|
+
/** One push refspec is an ordinary branch push: literal, unforced, a non-default branch that git cannot read as a tag or another ref type. */
|
|
469
|
+
function floorRefspec(spec: string, cwd: string[], floor: FloorFacts): boolean {
|
|
470
|
+
if (!floor.defaultBranch || spec.startsWith("+") || spec.startsWith("-") || opaque(spec)) return false;
|
|
471
|
+
const parts = spec.split(":");
|
|
472
|
+
if (parts.length > 2 || parts.some(part => part === "")) return false; // `:branch` deletes, and `x:` or `a:b:c` is no refspec
|
|
473
|
+
const [src, dst] = parts as [string, string | undefined];
|
|
474
|
+
const target = dst ?? src;
|
|
475
|
+
const names: Array<{ name: string; qualified: boolean }> = [];
|
|
476
|
+
if (dst === undefined && src === "HEAD") {
|
|
477
|
+
for (const at of cwd) { const branch = floor.currentBranch?.(at); if (!branch) return false; names.push({ name: branch, qualified: true }); }
|
|
478
|
+
} else {
|
|
479
|
+
const qualified = target.startsWith("refs/heads/");
|
|
480
|
+
if (target.startsWith("refs/") && !qualified) return false; // refs/tags/, refs/notes/, refs/pull/ ...
|
|
481
|
+
names.push({ name: qualified ? target.slice("refs/heads/".length) : target, qualified });
|
|
482
|
+
}
|
|
483
|
+
for (const { name, qualified } of names) {
|
|
484
|
+
if (!plainBranch(name) || isDefaultBranch(name, floor)) return false;
|
|
485
|
+
if (qualified) continue;
|
|
486
|
+
// Git reads `heads/main` as refs/heads/main and `tags/v1` as a tag, and a bare name that is also a tag is ambiguous: only a proven non-tag passes.
|
|
487
|
+
if (["heads", "tags", "remotes", "refs"].includes(name.split("/")[0]!)) return false;
|
|
488
|
+
if (cwd.some(at => floor.refIsTag?.(name, at) !== false)) return false;
|
|
489
|
+
}
|
|
490
|
+
// `src:dst` with an unqualified dst takes its type from src: a source that may be a tag would push a tag. A qualified source is the ref it names (`refs/tags/v1` is a tag, never probed
|
|
491
|
+
// as a branch called `refs/tags/v1`), and a bare source with a ref-type prefix (`tags/v1`, `heads/x`, `remotes/...`) is not a plain branch name either.
|
|
492
|
+
if (dst !== undefined && src !== "HEAD" && (!dst.startsWith("refs/") || src.startsWith("refs/"))) {
|
|
493
|
+
if (src.startsWith("refs/")) { if (!src.startsWith("refs/heads/") || !plainBranch(src.slice("refs/heads/".length))) return false; }
|
|
494
|
+
else if (!plainBranch(src) || ["heads", "tags", "remotes", "refs"].includes(src.split("/")[0]!) || cwd.some(at => floor.refIsTag?.(src, at) !== false)) return false;
|
|
495
|
+
}
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
/** `git push` the floor does not ask about: an unforced push of non-default branches to `origin`, from inside the execution root. */
|
|
499
|
+
function floorPush(globals: string[], args: string[], root: string, cwd: string[], floor: FloorFacts): boolean {
|
|
500
|
+
if (!withinRoot(root, cwd) || cwd.some(at => floor.inOwnRepository?.(at) !== true || floor.pushIsOwn?.(at) !== true)) return false;
|
|
501
|
+
let at = cwd; // where git runs: `-C dir` moves it, and the tag and branch reads follow
|
|
502
|
+
let moved = false;
|
|
503
|
+
for (let i = 0; i < globals.length; i++) {
|
|
504
|
+
const option = globals[i]!;
|
|
505
|
+
if (["--no-pager", "-P", "-p", "--paginate", "--no-optional-locks"].includes(option)) continue;
|
|
506
|
+
if (option !== "-C" || moved) return false; // -c, --git-dir, --work-tree and the rest can point `origin` anywhere
|
|
507
|
+
const dir = globals[++i];
|
|
508
|
+
if (!dir || opaque(dir) || cwd.some(from => outside(dir, root, from))) return false;
|
|
509
|
+
at = cwd.map(from => resolve(from, dir));
|
|
510
|
+
if (at.some(where => floor.inOwnRepository?.(where) !== true || floor.pushIsOwn?.(where) !== true)) return false; // `-C` into a nested clone reaches that clone's origin
|
|
511
|
+
moved = true;
|
|
512
|
+
}
|
|
513
|
+
const operands: string[] = [];
|
|
514
|
+
let literal = false;
|
|
515
|
+
for (const arg of args) {
|
|
516
|
+
if (opaque(arg)) return false;
|
|
517
|
+
if (!literal && arg === "--") { literal = true; continue; }
|
|
518
|
+
if (!literal && arg.startsWith("--")) { if (!FLOOR_PUSH_LONG.has(arg)) return false; continue; }
|
|
519
|
+
if (!literal && /^-[^-]/.test(arg)) { if (![...arg.slice(1)].every(flag => FLOOR_PUSH_SHORT.includes(flag))) return false; continue; }
|
|
520
|
+
operands.push(arg);
|
|
521
|
+
}
|
|
522
|
+
const [remote, ...specs] = operands;
|
|
523
|
+
// A bare `git push` or `git push origin` follows push.default and may reach the default branch: it keeps asking.
|
|
524
|
+
return remote === "origin" && specs.length > 0 && specs.every(spec => floorRefspec(spec, at, floor));
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* The options of each `gh pr` subcommand the floor knows, with their arity: a value belongs to its option and never names a repository (a `--body` that holds a pull request URL is text, not a selector).
|
|
528
|
+
* An option not listed here is unknown, and an unknown option keeps the call asking: it may take a value the floor would then misread as a positional or a selector.
|
|
529
|
+
* `-R` and `--repo` are handled apart. Sources: `gh pr create|comment|review|merge --help`.
|
|
530
|
+
*/
|
|
531
|
+
const GH_PR_OPTIONS: Readonly<Record<string, { readonly flags: readonly string[]; readonly values: readonly string[] }>> = {
|
|
532
|
+
comment: { flags: ["-e", "--editor", "-w", "--web", "--edit-last", "--delete-last", "--create-if-none", "--yes"], values: ["-b", "--body", "-F", "--body-file"] },
|
|
533
|
+
create: { flags: ["-d", "--draft", "--dry-run", "-f", "--fill", "--fill-first", "--fill-verbose", "-w", "--web", "--no-maintainer-edit"],
|
|
534
|
+
values: ["-a", "--assignee", "-B", "--base", "-b", "--body", "-F", "--body-file", "-H", "--head", "-l", "--label", "-m", "--milestone", "-p", "--project", "-r", "--reviewer", "-T", "--template", "-t", "--title", "--recover"] },
|
|
535
|
+
review: { flags: ["-a", "--approve", "-c", "--comment", "-r", "--request-changes"], values: ["-b", "--body", "-F", "--body-file"] },
|
|
536
|
+
merge: { flags: ["--auto", "-d", "--delete-branch", "--disable-auto", "-m", "--merge", "-r", "--rebase", "-s", "--squash"], values: ["-A", "--author-email", "-b", "--body", "-F", "--body-file", "--match-head-commit", "-t", "--subject"] },
|
|
537
|
+
};
|
|
538
|
+
/** `gh pr create|comment|review|merge` the floor does not ask about: the project's own repository, never an admin merge, never a foreign URL. */
|
|
539
|
+
function floorPullRequest(sub: string, args: string[], root: string, cwd: string[], floor: FloorFacts): boolean {
|
|
540
|
+
const own = floor.repository?.toLowerCase(), options = GH_PR_OPTIONS[sub];
|
|
541
|
+
if (!own || !options || !withinRoot(root, cwd) || cwd.some(at => floor.inOwnRepository?.(at) !== true)) return false; // an unknown repository is not the project's own
|
|
542
|
+
const sameRepository = (value: string | undefined): boolean => !!value && !opaque(value) && value.toLowerCase() === own;
|
|
543
|
+
let explicit = false; // the call names the project's own repository itself, so gh's default and GH_REPO do not matter
|
|
544
|
+
for (let i = 0; i < args.length; i++) {
|
|
545
|
+
const arg = args[i]!;
|
|
546
|
+
if (opaque(arg) || arg === "--admin" || arg.startsWith("--admin=")) return false;
|
|
547
|
+
if (arg === "-R" || arg === "--repo") { if (!sameRepository(args[++i])) return false; explicit = true; continue; }
|
|
548
|
+
if (arg.startsWith("--repo=")) { if (!sameRepository(arg.slice("--repo=".length))) return false; explicit = true; continue; }
|
|
549
|
+
if (/^-R./.test(arg)) { if (!sameRepository(arg.slice(2))) return false; explicit = true; continue; }
|
|
550
|
+
if (arg.startsWith("-")) {
|
|
551
|
+
const [name] = arg.split("=", 1) as [string], inline = arg.includes("=");
|
|
552
|
+
if (options.values.includes(name)) {
|
|
553
|
+
// The value is the option's: never a selector. But an expansion or a glob in it can become several words, one of them a real `-R`/`--repo`, so it is judged before it is skipped.
|
|
554
|
+
const value = inline ? arg.slice(name.length + 1) : args[i + 1];
|
|
555
|
+
if (value === undefined || opaque(value) || globby(value)) return false;
|
|
556
|
+
if (!inline) i++;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
if (options.flags.includes(arg)) continue;
|
|
560
|
+
return false; // `--`, a cluster, an option the floor does not know: unknown is strict
|
|
561
|
+
}
|
|
562
|
+
// A positional: a pull request number or branch, or a URL, which can be another repository's.
|
|
563
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(arg)) {
|
|
564
|
+
const match = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/[0-9]+\/?$/.exec(arg);
|
|
565
|
+
if (!sameRepository(match?.[1])) return false;
|
|
566
|
+
explicit = true;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// With no selector gh picks its own default (`gh repo set-default`, another remote, GH_REPO): only a proven own default passes.
|
|
570
|
+
return explicit || cwd.every(at => floor.ghDefaultIsOwn?.(at) === true);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Commands that cannot change what the floor's facts were read from (git configuration, the checked-out branch, remotes, gh's default repository, the environment). A command
|
|
575
|
+
* earlier on the same line that is anything else ends the floor for the rest of that line: the facts were read before it ran.
|
|
576
|
+
*/
|
|
577
|
+
const FLOOR_INERT_GIT = new Set(["add", "commit", "status", "diff", "log", "show", "rm", "mv", "blame", "ls-files", "rev-parse", "describe", "shortlog"]);
|
|
578
|
+
/** Every `find` primary that writes a file or runs a command: `-fprintf` and the other `-f*` actions create or truncate a named file (`.git/config` included), the `-exec` family runs code, `-delete` removes. */
|
|
579
|
+
const FIND_WRITERS = new Set(["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls"]);
|
|
580
|
+
/**
|
|
581
|
+
* A glob character: the shell may expand an unquoted one into several words, so the value is not the one it looks like.
|
|
582
|
+
* ponytail: the tokenizer drops quote state, so a quoted `--body "why?"` or a markdown `* item` is judged like an unquoted glob and asks. Ceiling: a prose body with `*`, `?` or `[`
|
|
583
|
+
* asks even where the shell would not expand it. Upgrade path: have `commands` mark a word that held an unquoted glob character, then test that mark here.
|
|
584
|
+
*/
|
|
585
|
+
const globby = (word: string): boolean => /[*?[]/.test(word);
|
|
586
|
+
const FLOOR_INERT_PROGRAMS = new Set(["echo", "printf", "true", "false", ":", "ls", "pwd", "cat", "head", "tail", "wc", "rg", "grep", "test", "[", "sleep", "cd", "diff", "stat", "find"]); // not `tree -o` or `file -C`: they write a named file
|
|
587
|
+
function floorInert(command: Command, root: string, cwd: string[], floor: FloorFacts): boolean {
|
|
588
|
+
if (command.outputs.length > 0 || command.redirected || command.nested?.length) return false; // any redirect may write .git/config (`>>` is not an output of the destroy rule); a substitution runs commands of its own
|
|
589
|
+
if (unwrapped(command.words).length !== command.words.length) return false; // `env X=1 ...`, `sudo`, `VAR=x cmd`
|
|
590
|
+
const [name, ...args] = command.words;
|
|
591
|
+
if (!name || opaque(name) || command.words.some(opaque)) return false;
|
|
592
|
+
const program = basename(name);
|
|
593
|
+
if (program === "git") {
|
|
594
|
+
const heads = commandHeads(args, ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"], ["-P", "-p", "--no-pager", "--paginate", "--literal-pathspecs", "--bare", "--no-optional-locks", "--no-replace-objects"], ["push"]);
|
|
595
|
+
if (heads.length !== 1 || args.includes("-c") || args.some(arg => arg.startsWith("--config-env"))) return false;
|
|
596
|
+
// A push the floor itself lets through only writes remote refs and, with `-u`, this branch's upstream: none of what the floor reads (push destination, tag and push settings, the default repository).
|
|
597
|
+
if (heads[0]!.name === "push") return floorPush(args.slice(0, args.length - heads[0]!.args.length - 1), heads[0]!.args, root, cwd, floor);
|
|
598
|
+
// `git diff|log|show|blame --output=<file>` writes a named file (`.git/config` included), and a glob can expand into that option: both end the floor.
|
|
599
|
+
if (["diff", "log", "show", "blame"].includes(heads[0]!.name) && args.some(arg => arg === "--output" || arg.startsWith("--output=") || arg.startsWith("--out") || globby(arg))) return false;
|
|
600
|
+
return FLOOR_INERT_GIT.has(heads[0]!.name);
|
|
601
|
+
}
|
|
602
|
+
if (!FLOOR_INERT_PROGRAMS.has(program)) return false;
|
|
603
|
+
// `find` and `rg` take actions as options: a writer primary (`-fprintf`, `-exec`, `--pre`) makes them non-inert, and so does a glob anywhere in their arguments, because the shell
|
|
604
|
+
// expands an unquoted one before they run and a file named `-fprintf` or `--pre` becomes that option (floor-find-output-invalidates-facts). The tokenizer does not keep quote state,
|
|
605
|
+
// so a quoted `-name '*.ts'` also ends the floor for the rest of the line (the ponytail on `globby`).
|
|
606
|
+
if (program === "find" && args.some(arg => FIND_WRITERS.has(arg) || /^-f(?:print|ls)/.test(arg) || globby(arg))) return false;
|
|
607
|
+
if (program === "rg" && args.some(arg => arg === "--pre" || arg.startsWith("--pre=") || globby(arg))) return false;
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function sequence(parsed: Command[], root: string, initial: string[], found: Set<ActionClass>, depth = 0, floor?: FloorFacts): void {
|
|
432
612
|
const cwd = new Set(initial);
|
|
613
|
+
let facts = floor;
|
|
433
614
|
for (const command of parsed) {
|
|
434
|
-
classify(command, root, [...cwd], found, depth);
|
|
615
|
+
classify(command, root, [...cwd], found, depth, facts);
|
|
616
|
+
if (facts && !floorInert(command, root, [...cwd], facts)) facts = undefined;
|
|
435
617
|
const [program, ...args] = unwrapped(command.words);
|
|
436
618
|
const target = args[0] === "--" ? args[1] : args[0];
|
|
437
619
|
// Retain both outcomes: cd may fail, or be in a pipeline/conditional branch.
|
|
@@ -447,8 +629,10 @@ function sequence(parsed: Command[], root: string, initial: string[], found: Set
|
|
|
447
629
|
}
|
|
448
630
|
}
|
|
449
631
|
}
|
|
450
|
-
function classify(command: Command, root: string, cwd: string[], found: Set<ActionClass>, depth = 0): void {
|
|
451
|
-
for (const group of command.nested ?? []) sequence(group, root, cwd, found, depth); // a substitution's own cwd state; only its classes come back
|
|
632
|
+
function classify(command: Command, root: string, cwd: string[], found: Set<ActionClass>, depth = 0, floor?: FloorFacts): void {
|
|
633
|
+
for (const group of command.nested ?? []) sequence(group, root, cwd, found, depth, floor); // a substitution's own cwd state; only its classes come back
|
|
634
|
+
// The floor applies only to a bare command: `env X=1 git push`, `GIT_SSH_COMMAND=... git push`, `sudo`, `timeout`, `if` and the other wrappers `unwrapped` peels can redirect or re-time a push the floor cannot read.
|
|
635
|
+
const bare = floor !== undefined && unwrapped(command.words).length === command.words.length;
|
|
452
636
|
const words = unwrapped(command.words);
|
|
453
637
|
const program = basename(words.shift() ?? "");
|
|
454
638
|
if (["sh", "bash", "zsh"].includes(program) && depth < 2) {
|
|
@@ -458,7 +642,8 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
|
|
|
458
642
|
if (mode && /^-[A-Za-z]*c[A-Za-z]*$/.test(mode)) {
|
|
459
643
|
if (shell[0] === "--") shell.shift();
|
|
460
644
|
const nested = shell[0] === undefined ? undefined : commands(shell[0].replaceAll(OPAQUE, "$_"));
|
|
461
|
-
|
|
645
|
+
// A wrapped shell (`env GH_REPO=x sh -c '...'`) runs its text under whatever the wrapper set: the floor's authority does not reach inside it.
|
|
646
|
+
if (nested) sequence(nested, root, cwd, found, depth + 1, bare ? floor : undefined);
|
|
462
647
|
}
|
|
463
648
|
}
|
|
464
649
|
if (command.outputs.some(p => cwd.some(at => outside(p, root, at)))) found.add("destroy");
|
|
@@ -467,7 +652,7 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
|
|
|
467
652
|
["-P", "-p", "--no-pager", "--paginate", "--literal-pathspecs", "--bare", "--no-optional-locks", "--no-replace-objects"], ["push", "reset", "clean", "branch"]);
|
|
468
653
|
if (heads.some(head => opaque(head.name))) { found.add("publish"); found.add("destroy"); } // `git $CMD`: the strictest classes git reaches (#762)
|
|
469
654
|
for (const { name: sub, args } of heads) {
|
|
470
|
-
if (sub === "push") found.add("publish");
|
|
655
|
+
if (sub === "push" && !(bare && floorPush(words.slice(0, words.length - args.length - 1), args, root, cwd, floor!))) found.add("publish");
|
|
471
656
|
if (sub === "reset" && (args.some(w => w === "--hard" || w.startsWith("--hard=")) || mayBeFlag(args))) found.add("destroy");
|
|
472
657
|
if (sub === "clean" && forcedClean(args)) found.add("destroy");
|
|
473
658
|
if (sub === "branch" && (flags(args, "D") || args.includes("--delete") && args.includes("--force") || mayBeFlag(args))) found.add("destroy");
|
|
@@ -498,7 +683,11 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
|
|
|
498
683
|
if (program === "pipx" && words[0] === "install" || program === "brew" && ["install", "reinstall"].includes(words[0] ?? "") || ["pip", "pip3"].includes(program) && words[0] === "install" && words.includes("--user")) found.add("install");
|
|
499
684
|
if (program === "gh") {
|
|
500
685
|
if (words[0] === "release" && ["create", "upload", "edit", "delete"].includes(words[1] ?? "")) found.add("publish");
|
|
501
|
-
if (["issue", "pr", "gist"].includes(words[0] ?? "") && ["create", "comment", "edit", "close", "reopen", "merge", "review", "delete"].includes(words[1] ?? ""))
|
|
686
|
+
if (["issue", "pr", "gist"].includes(words[0] ?? "") && ["create", "comment", "edit", "close", "reopen", "merge", "review", "delete"].includes(words[1] ?? "")) {
|
|
687
|
+
// Floor: a pull request in the project's own repository is ordinary work. Issues, gists, edits, closes and deletes keep asking.
|
|
688
|
+
const ordinary = bare && words[0] === "pr" && ["create", "comment", "review", "merge"].includes(words[1]!) && floorPullRequest(words[1]!, words.slice(2), root, cwd, floor!);
|
|
689
|
+
if (!ordinary) found.add("external-write");
|
|
690
|
+
}
|
|
502
691
|
if (opaque(words[0])) { found.add("publish"); found.add("external-write"); } // `gh $GROUP ...`: the strictest classes gh reaches (#762)
|
|
503
692
|
else if (opaque(words[1]) && ["release"].includes(words[0] ?? "")) found.add("publish");
|
|
504
693
|
else if (opaque(words[1]) && ["issue", "pr", "gist", "run", "workflow", "repo", "secret", "variable", "label"].includes(words[0] ?? "")) found.add("external-write");
|
|
@@ -529,7 +718,7 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
|
|
|
529
718
|
* Literal cd retains both success/failure cwd candidates (at most 128); it may
|
|
530
719
|
* overclassify a later relative write and is not a shell control-flow proof.
|
|
531
720
|
*/
|
|
532
|
-
export function classifyAction(tool: string, args: unknown, executionRoot: string): ActionClass[] {
|
|
721
|
+
export function classifyAction(tool: string, args: unknown, executionRoot: string, floor?: FloorFacts): ActionClass[] {
|
|
533
722
|
if (typeof tool !== "string" || tool.length > 128 || typeof executionRoot !== "string" || executionRoot.length > LIMIT) return [];
|
|
534
723
|
if (typeof args === "string") {
|
|
535
724
|
if (args.length > LIMIT) return [];
|
|
@@ -542,14 +731,14 @@ export function classifyAction(tool: string, args: unknown, executionRoot: strin
|
|
|
542
731
|
const boundedArgv = (value: unknown): value is string[] => Array.isArray(value) && value.length <= 256 && value.every(w => typeof w === "string" && w.length <= LIMIT) && value.join("").length <= LIMIT;
|
|
543
732
|
const location = input.cwd ?? input.workdir;
|
|
544
733
|
const cwd = typeof location === "string" && location.startsWith("~") ? "" : typeof location === "string" && location.length <= LIMIT && !/[$`]/.test(location) ? resolve(executionRoot, location) : executionRoot;
|
|
545
|
-
if (tool.toLowerCase() === "git" && boundedArgv(input.args)) classify({ words: ["git", ...input.args], outputs: [] }, executionRoot, [cwd], found);
|
|
734
|
+
if (tool.toLowerCase() === "git" && boundedArgv(input.args)) classify({ words: ["git", ...input.args], outputs: [] }, executionRoot, [cwd], found, 0, floor);
|
|
546
735
|
if (SHELL_TOOLS.has(tool.toLowerCase())) {
|
|
547
736
|
const raw = input.command ?? input.cmd;
|
|
548
737
|
if (typeof raw === "string") {
|
|
549
738
|
const parsed = commands(raw);
|
|
550
|
-
if (parsed) sequence(parsed, executionRoot, [cwd], found);
|
|
739
|
+
if (parsed) sequence(parsed, executionRoot, [cwd], found, 0, floor);
|
|
551
740
|
} else if (boundedArgv(raw)) {
|
|
552
|
-
classify({ words: raw, outputs: [] }, executionRoot, [cwd], found);
|
|
741
|
+
classify({ words: raw, outputs: [] }, executionRoot, [cwd], found, 0, floor);
|
|
553
742
|
}
|
|
554
743
|
}
|
|
555
744
|
return ACTION_CLASSES.filter(value => found.has(value));
|