@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.
Files changed (95) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +32 -5
  3. package/docs/agent-notes/adapters.md +5 -2
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +16 -2
  6. package/docs/agent-notes/tasks.md +9 -2
  7. package/docs/agent-notes/tests.md +3 -0
  8. package/docs/dogfood.md +16 -2
  9. package/docs/memory.md +1 -1
  10. package/docs/operations.md +198 -5
  11. package/docs/profiles.md +2 -0
  12. package/docs/quickstart.md +19 -4
  13. package/docs/security.md +225 -8
  14. package/docs/specs/2026-09-19-agent-hub-design.md +54 -1
  15. package/docs/specs/2026-09-19-multi-project-design.md +12 -1
  16. package/docs/specs/2026-11-01-research-campaigns-design.md +351 -0
  17. package/docs/verification/2026-10-31-0.24.0.md +63 -0
  18. package/docs/verification/2026-10-31-claude-implementer-2.1.316.md +65 -0
  19. package/docs/verification/2026-11-01-0.25.0.md +91 -0
  20. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  21. package/docs/verified.json +31 -15
  22. package/package.json +4 -3
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +5120 -4710
  25. package/src/adapters/claude-channel.ts +29 -6
  26. package/src/adapters/compat.ts +3 -3
  27. package/src/adapters/definitions/native-cli-claude-setup.ts +4 -0
  28. package/src/adapters/definitions/native-cli-doctor.ts +30 -0
  29. package/src/adapters/jcode.ts +3 -0
  30. package/src/adapters/registry.ts +1 -1
  31. package/src/cli/acon-register.ts +29 -0
  32. package/src/cli/autonomy-homes.ts +33 -0
  33. package/src/cli/autonomy-offline.ts +75 -0
  34. package/src/cli/autonomy-projects.ts +103 -0
  35. package/src/cli/autonomy.ts +44 -0
  36. package/src/cli/claude-homes.ts +123 -0
  37. package/src/cli/claude-profile-hook.ts +130 -0
  38. package/src/cli/handoff-args.ts +27 -0
  39. package/src/cli/handoff-output.ts +25 -0
  40. package/src/cli/help.ts +8 -4
  41. package/src/cli/identity.ts +8 -1
  42. package/src/cli/main.ts +174 -33
  43. package/src/cli/output.ts +8 -5
  44. package/src/cli/schedule-output.ts +8 -4
  45. package/src/cli/setup-homes.ts +46 -0
  46. package/src/cli/setup.ts +1 -1
  47. package/src/cli/study-output.ts +71 -0
  48. package/src/cli/study.ts +58 -0
  49. package/src/cli/task-output.ts +1 -0
  50. package/src/hub/action-class.ts +203 -14
  51. package/src/hub/action-enforce.ts +8 -6
  52. package/src/hub/action-policy.ts +95 -9
  53. package/src/hub/autonomy-consent.ts +173 -0
  54. package/src/hub/autonomy-defaults.ts +58 -0
  55. package/src/hub/autonomy.ts +232 -0
  56. package/src/hub/board.ts +871 -2
  57. package/src/hub/channel-project.ts +61 -0
  58. package/src/hub/config-apply.ts +3 -2
  59. package/src/hub/confirmation-matrix.ts +202 -0
  60. package/src/hub/control-client.ts +10 -1
  61. package/src/hub/daemon.ts +546 -27
  62. package/src/hub/envelope.ts +2 -0
  63. package/src/hub/events.ts +4 -1
  64. package/src/hub/execution-budget.ts +12 -0
  65. package/src/hub/handoff.ts +126 -0
  66. package/src/hub/hub-tools.ts +12 -2
  67. package/src/hub/manager.ts +64 -57
  68. package/src/hub/no-writes.ts +1 -1
  69. package/src/hub/path-policy.ts +12 -8
  70. package/src/hub/project.ts +14 -5
  71. package/src/hub/provenance.ts +30 -0
  72. package/src/hub/research-campaign.ts +265 -0
  73. package/src/hub/research-experiments.ts +445 -0
  74. package/src/hub/restart.ts +26 -0
  75. package/src/hub/schedules.ts +210 -9
  76. package/src/hub/session-identity.ts +49 -0
  77. package/src/hub/settings.ts +105 -8
  78. package/src/hub/task-repository.ts +11 -6
  79. package/src/hub/tasks.ts +894 -22
  80. package/src/hub/ui.ts +15 -6
  81. package/src/memory/capture.ts +2 -2
  82. package/src/ui/index.html +5 -3
  83. package/src/ui/office-space.ts +118 -0
  84. package/src/ui/plugin.ts +293 -0
  85. package/templates/AGENTS.block.md +1 -0
  86. package/templates/presets/autonomous.json +161 -0
  87. package/templates/presets/confirmation-matrix.json +943 -0
  88. package/templates/profiles/general.json +1 -1
  89. package/templates/profiles/research.json +4 -4
  90. package/templates/profiles/writing.json +1 -1
  91. package/templates/roles/research/analyst.md +1 -0
  92. package/templates/roles/research/fact-checker.md +1 -0
  93. package/templates/roles/research/research-lead.md +1 -0
  94. package/templates/roles/research/researcher.md +1 -0
  95. 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: string; class: string; queue: string; next: number; last?: number; task?: number; state: string; coalesced?: number; error?: string }[];
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)"}; every ${row.every}; ${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}` : ""}`;
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
- row?.state === "paused" ? `paused (would be ${moment(row.next, now)})` : row?.state === "scheduled" ? moment(row.next, now) : WHEN[row?.state] ?? "unknown";
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", "EVERY", "CLASS", "QUEUE", "STATE", "NEXT RUN", "LAST RUN", "TASK"], schedules.map((row: any) => [
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
+ }
@@ -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
+ }
@@ -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, " "));
@@ -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
- function sequence(parsed: Command[], root: string, initial: string[], found: Set<ActionClass>, depth = 0): void {
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
- if (nested) sequence(nested, root, cwd, found, depth + 1);
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] ?? "")) found.add("external-write");
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));