@staix/agent-hub 0.12.23 → 0.12.24
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 +98 -0
- package/README.md +8 -3
- package/docs/addons/claude-mem.md +24 -0
- package/docs/addons/codex-plugin.md +8 -0
- package/docs/addons/dot-admit.md +8 -0
- package/docs/addons/open-code-review.md +8 -0
- package/docs/addons/ponytail.md +23 -0
- package/docs/addons/ripwire.md +22 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +8 -1
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +7 -1
- package/docs/agent-notes/local-worker.md +4 -2
- package/docs/agent-notes/tasks.md +4 -0
- package/docs/agent-notes/tests.md +2 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +3 -0
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +651 -14
- package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
- package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
- package/docs/profiles.md +57 -0
- package/docs/quickstart.md +1 -1
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +111 -4
- package/docs/smoke.md +282 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +601 -7
- package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
- package/docs/specs/2026-10-12-office-view-design.md +212 -0
- package/docs/text-projection.md +19 -0
- package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
- package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
- package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
- package/docs/verification/2026-10-13-0.12.24.md +94 -0
- package/docs/verification/2026-10-13-board-phone.md +31 -0
- package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
- package/docs/verified.json +22 -13
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
- package/plugins/agent-hub/server.js +1175 -339
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp.ts +112 -13
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +11 -0
- package/src/adapters/jcode.ts +110 -0
- package/src/adapters/local-worker.ts +10 -7
- package/src/adapters/pi.ts +94 -20
- package/src/cli/attention.ts +60 -0
- package/src/cli/bench.ts +24 -5
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +103 -17
- package/src/cli/console.ts +54 -8
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +35 -9
- package/src/cli/identity.ts +18 -7
- package/src/cli/init.ts +48 -10
- package/src/cli/jcode-mcp.ts +58 -0
- package/src/cli/kimi-mcp.ts +53 -0
- package/src/cli/launch.ts +16 -1
- package/src/cli/main.ts +392 -57
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +45 -4
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +38 -5
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-runtime.ts +32 -3
- package/src/cli/watch.ts +31 -0
- package/src/hub/activity.ts +53 -0
- package/src/hub/ask.ts +10 -5
- package/src/hub/bench.ts +10 -3
- package/src/hub/board.ts +222 -16
- package/src/hub/bus.ts +48 -9
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +12 -7
- package/src/hub/config-trust.ts +4 -2
- package/src/hub/crash.ts +9 -0
- package/src/hub/daemon.ts +910 -120
- package/src/hub/delivery-journal.ts +5 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +12 -2
- package/src/hub/facts.ts +26 -7
- package/src/hub/git.ts +8 -2
- package/src/hub/github-import.ts +28 -0
- package/src/hub/hub-tools.ts +49 -10
- package/src/hub/inference.ts +1 -1
- package/src/hub/lifecycle-schema.ts +17 -0
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +12 -5
- package/src/hub/path-policy.ts +96 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/permission-mode.ts +19 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- package/src/hub/research.ts +144 -21
- package/src/hub/roles.ts +123 -0
- package/src/hub/roster.ts +23 -0
- package/src/hub/routing.ts +18 -5
- package/src/hub/sdlc-queue.ts +112 -0
- package/src/hub/sdlc-release.ts +125 -0
- package/src/hub/sdlc.ts +250 -0
- package/src/hub/settings.ts +34 -15
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +89 -15
- package/src/hub/start-mode.ts +7 -5
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +892 -87
- package/src/hub/text-projection.ts +126 -0
- package/src/hub/tooling.ts +159 -0
- package/src/hub/ui.ts +16 -2
- package/src/hub/workflow.ts +71 -0
- package/src/local/sandbox.ts +6 -4
- package/src/local/tools.ts +11 -6
- package/src/memory/brief.ts +28 -14
- package/src/memory/import.ts +55 -0
- package/src/memory/recall.ts +84 -0
- package/src/memory/store.ts +750 -0
- package/src/memory/sync.ts +145 -0
- package/src/pet/NOTICE.md +10 -0
- package/src/pet/characters.ts +1438 -0
- package/src/pet/config.ts +31 -0
- package/src/pet/frame.ts +17 -0
- package/src/pet/pixels.ts +28 -0
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +842 -46
- package/src/ui/needs.ts +58 -0
- package/src/ui/office-model.ts +440 -0
- package/src/ui/peer-palette.ts +16 -0
- package/src/ui/task-board.ts +49 -0
- package/src/ui/task-progress.ts +5 -1
- package/templates/AGENTS.block.md +3 -2
- package/templates/config.json +5 -1
- package/templates/profiles/dev.json +22 -0
- package/templates/profiles/general.json +26 -0
- package/templates/profiles/research.json +57 -0
- package/templates/profiles/writing.json +28 -0
- package/templates/roles/NOTICE.md +26 -0
- package/templates/roles/dev/backend-engineer.md +26 -0
- package/templates/roles/dev/code-reviewer.md +26 -0
- package/templates/roles/dev/frontend-engineer.md +26 -0
- package/templates/roles/dev/security-reviewer.md +26 -0
- package/templates/roles/dev/software-architect.md +26 -0
- package/templates/roles/dev/test-engineer.md +26 -0
- package/templates/roles/research/analyst.md +26 -0
- package/templates/roles/research/fact-checker.md +26 -0
- package/templates/roles/research/research-lead.md +26 -0
- package/templates/roles/research/researcher.md +26 -0
- package/templates/roles/research/statistician.md +26 -0
package/src/hub/sdlc.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { phaseReceipt, type PhaseReceipt, type PhaseView } from "./workflow.ts";
|
|
2
|
+
import { constants, openSync, fstatSync, readSync, closeSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { childEnv, stopOwnedProcess } from "./child-process.ts";
|
|
6
|
+
|
|
7
|
+
export const SDLC_STAGES = ["spec", "plan", "implement", "review", "verify", "merge", "release"] as const;
|
|
8
|
+
export type SdlcBaseStage = typeof SDLC_STAGES[number];
|
|
9
|
+
export type SdlcStage = string;
|
|
10
|
+
export type StageGate = "issue" | "pr" | "local" | "review" | "checks" | "phase";
|
|
11
|
+
export interface StageDefinition { base: SdlcBaseStage; actor: string; entry_gates: StageGate[]; exit_gates: StageGate[]; after: string[]; }
|
|
12
|
+
export type SdlcMode = "off" | "advise" | "enforce";
|
|
13
|
+
export interface SdlcConfig { workflow?: "gsd-lite"; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; }
|
|
14
|
+
export interface SdlcReceipt {
|
|
15
|
+
stage: SdlcStage;
|
|
16
|
+
phase?: PhaseReceipt;
|
|
17
|
+
repo?: string;
|
|
18
|
+
issue?: number;
|
|
19
|
+
pr?: number;
|
|
20
|
+
head?: string;
|
|
21
|
+
reviewedHead?: string;
|
|
22
|
+
localHead?: string;
|
|
23
|
+
localResult?: "passed" | "failed" | "unknown";
|
|
24
|
+
observed?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface PrEvidence {
|
|
27
|
+
head: string;
|
|
28
|
+
state: "OPEN" | "MERGED" | "CLOSED";
|
|
29
|
+
checks: { name: string; state: "passed" | "failed" | "pending" | "unknown" }[];
|
|
30
|
+
review: "approved" | "changes_requested" | "unknown";
|
|
31
|
+
unresolved: number | null;
|
|
32
|
+
pendingReviews: number | null;
|
|
33
|
+
paths: string[];
|
|
34
|
+
pathsComplete?: boolean;
|
|
35
|
+
draft?: boolean;
|
|
36
|
+
base?: string;
|
|
37
|
+
baseHead?: string;
|
|
38
|
+
mergeCommit?: string;
|
|
39
|
+
}
|
|
40
|
+
const sha = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{40}$/.test(value);
|
|
41
|
+
const repo = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith("-");
|
|
42
|
+
const id = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
43
|
+
|
|
44
|
+
const STAGE_ID = /^[a-z][a-z0-9_-]{0,47}$/;
|
|
45
|
+
const STAGE_GATES: StageGate[] = ["issue", "pr", "local", "review", "checks", "phase"];
|
|
46
|
+
const baseActor = (base: SdlcBaseStage) => base === "review" || base === "verify" ? "reviewer" : base === "merge" || base === "release" ? "person" : "owner/planner";
|
|
47
|
+
export function stageDefinition(config: SdlcConfig, stage: string): StageDefinition | undefined {
|
|
48
|
+
if (!config.stages.includes(stage)) return undefined;
|
|
49
|
+
if (config.definitions && Object.hasOwn(config.definitions, stage)) return config.definitions[stage];
|
|
50
|
+
if (!(SDLC_STAGES as readonly string[]).includes(stage)) return undefined;
|
|
51
|
+
const base = stage as SdlcBaseStage;
|
|
52
|
+
return { base, actor: baseActor(base), entry_gates: [], exit_gates: [], after: [] };
|
|
53
|
+
}
|
|
54
|
+
export function lifecycleStage(config: SdlcConfig, base: SdlcBaseStage): string {
|
|
55
|
+
return config.stages.find(stage => stageDefinition(config, stage)?.base === base) ?? base;
|
|
56
|
+
}
|
|
57
|
+
function definitions(raw: unknown, stages: unknown): { stages: string[]; definitions: Record<string, StageDefinition> } {
|
|
58
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) || Object.keys(raw).length > 32) throw new Error("sdlc.definitions: expected at most 32 stage definitions");
|
|
59
|
+
if (!Array.isArray(stages) || !stages.length || stages.length > 32 || stages.some(s => typeof s !== "string" || !STAGE_ID.test(s)) || new Set(stages).size !== stages.length) throw new Error("sdlc.stages: expected 1..32 distinct bounded stage ids");
|
|
60
|
+
const result: Record<string, StageDefinition> = Object.create(null);
|
|
61
|
+
for (const [id, value] of Object.entries(raw)) {
|
|
62
|
+
if (!STAGE_ID.test(id) || !stages.includes(id) || !value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc.definitions: invalid or unlisted stage");
|
|
63
|
+
const def = value as Record<string, unknown>;
|
|
64
|
+
if (Object.keys(def).some(k => !["base", "actor", "entry_gates", "exit_gates", "after"].includes(k)) || !(SDLC_STAGES as readonly unknown[]).includes(def.base)) throw new Error("sdlc.definitions: unknown field or base");
|
|
65
|
+
const base = def.base as SdlcBaseStage;
|
|
66
|
+
if ((SDLC_STAGES as readonly string[]).includes(id) && id !== base) throw new Error("sdlc.definitions: built-in stage base cannot change");
|
|
67
|
+
const actor = def.actor ?? baseActor(base);
|
|
68
|
+
if (typeof actor !== "string" || actor !== baseActor(base) && !["owner", "reviewer", "person", "planner"].includes(actor)) throw new Error("sdlc.definitions.actor: unknown actor");
|
|
69
|
+
const gates = (key: string): StageGate[] => {
|
|
70
|
+
const list = def[key] ?? [];
|
|
71
|
+
if (!Array.isArray(list) || list.length > 6 || list.some(g => !STAGE_GATES.includes(g)) || new Set(list).size !== list.length) throw new Error(`sdlc.definitions.${key}: unknown or duplicate gate`);
|
|
72
|
+
return [...list] as StageGate[];
|
|
73
|
+
};
|
|
74
|
+
const after = def.after ?? [];
|
|
75
|
+
if (!Array.isArray(after) || after.length > 8 || after.some(a => typeof a !== "string" || !stages.includes(a) || stages.indexOf(a) >= stages.indexOf(id)) || new Set(after).size !== after.length) throw new Error("sdlc.definitions.after: dependencies must be distinct earlier stages (no missing, self, forward or cyclic edge)");
|
|
76
|
+
result[id] = { base, actor: actor as string, entry_gates: gates("entry_gates"), exit_gates: gates("exit_gates"), after: [...after] as string[] };
|
|
77
|
+
}
|
|
78
|
+
const config = { stages, definitions: result } as SdlcConfig;
|
|
79
|
+
if (stages.some(s => !stageDefinition(config, s)) || !["review", "merge"].every(base => stages.some(s => stageDefinition(config, s)?.base === base))) throw new Error("sdlc.stages: define every custom id and include review and merge base destinations");
|
|
80
|
+
return { stages: [...stages], definitions: result };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function sdlcConfig(value: unknown): SdlcConfig {
|
|
84
|
+
if (value === undefined) return { mode: "off", requiredChecks: [], stages: [...SDLC_STAGES] };
|
|
85
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc: expected a table");
|
|
86
|
+
const raw = value as Record<string, unknown>;
|
|
87
|
+
for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
|
|
88
|
+
const mode = raw.mode ?? "off";
|
|
89
|
+
if (mode !== "off" && mode !== "advise" && mode !== "enforce") throw new Error("sdlc.mode: expected off, advise or enforce");
|
|
90
|
+
const workflow = raw.workflow ?? "none";
|
|
91
|
+
if (workflow !== "none" && workflow !== "gsd-lite") throw new Error("sdlc.workflow: expected none or gsd-lite");
|
|
92
|
+
const checks = raw.required_checks ?? [];
|
|
93
|
+
if (!Array.isArray(checks) || checks.length > 32 || checks.some(c => typeof c !== "string" || !c.trim() || c.length > 120 || /[\r\n\x00-\x1f]/.test(c)) || new Set(checks).size !== checks.length) throw new Error("sdlc.required_checks: expected at most 32 unique nonempty names");
|
|
94
|
+
if (raw.definitions !== undefined) {
|
|
95
|
+
const custom = definitions(raw.definitions, raw.stages);
|
|
96
|
+
if (workflow !== "gsd-lite" && Object.values(custom.definitions).some(d => [...d.entry_gates, ...d.exit_gates].includes("phase"))) throw new Error("sdlc.definitions: phase gates require workflow = gsd-lite");
|
|
97
|
+
return { mode, requiredChecks: [...checks] as string[], ...custom, ...(workflow === "gsd-lite" ? { workflow } : {}) };
|
|
98
|
+
}
|
|
99
|
+
const stages = raw.stages ?? [...SDLC_STAGES];
|
|
100
|
+
if (!Array.isArray(stages) || stages.length !== SDLC_STAGES.length || stages.some((stage, i) => stage !== SDLC_STAGES[i])) throw new Error("sdlc.stages: use spec, plan, implement, review, verify, merge, release in that order; custom stages are not supported yet");
|
|
101
|
+
return { mode, requiredChecks: [...checks] as string[], stages: [...stages] as SdlcStage[], ...(workflow === "gsd-lite" ? { workflow } : {}) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function loadSdlc(cwd: string): SdlcConfig {
|
|
105
|
+
try {
|
|
106
|
+
const fd = openSync(join(cwd, ".agenthub", "sdlc.toml"), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
107
|
+
try {
|
|
108
|
+
const before = fstatSync(fd), cap = 64 * 1024;
|
|
109
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > cap) throw new Error("sdlc.toml must be a regular single-link file within 64 KiB");
|
|
110
|
+
const bytes = Buffer.alloc(cap + 1); let length = 0, n = 0;
|
|
111
|
+
while ((n = readSync(fd, bytes, length, bytes.length - length, null)) > 0) { length += n; if (length > cap) throw new Error("sdlc.toml exceeds 64 KiB"); }
|
|
112
|
+
const after = fstatSync(fd);
|
|
113
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || length !== after.size) throw new Error("sdlc.toml changed during read");
|
|
114
|
+
return sdlcConfig(Bun.TOML.parse(bytes.subarray(0, length).toString("utf8")));
|
|
115
|
+
} finally { closeSync(fd); }
|
|
116
|
+
}
|
|
117
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return sdlcConfig(undefined); throw error; }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Identities and claimed local receipts only. External facts are read, never accepted as input. */
|
|
121
|
+
export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined = undefined, config: SdlcConfig = sdlcConfig(undefined)): SdlcReceipt {
|
|
122
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc receipt: expected an object");
|
|
123
|
+
const raw = value as Record<string, unknown>;
|
|
124
|
+
for (const key of Object.keys(raw)) if (!["stage", "repo", "issue", "pr", "reviewedHead", "localHead", "localResult", "phase"].includes(key)) throw new Error(`sdlc receipt.${key}: unsupported or externally observed field`);
|
|
125
|
+
previous ??= { stage: config.stages[0] ?? "spec" };
|
|
126
|
+
const next = { ...previous };
|
|
127
|
+
if (raw.phase !== undefined) next.phase = phaseReceipt(raw.phase);
|
|
128
|
+
if (raw.stage !== undefined) {
|
|
129
|
+
if (typeof raw.stage !== "string" || !stageDefinition(config, raw.stage)) throw new Error("sdlc receipt.stage: unknown stage");
|
|
130
|
+
next.stage = raw.stage as SdlcStage;
|
|
131
|
+
}
|
|
132
|
+
if (raw.repo !== undefined) { if (!repo(raw.repo)) throw new Error("sdlc receipt.repo: expected owner/repository"); next.repo = raw.repo; }
|
|
133
|
+
for (const key of ["issue", "pr"] as const) if (raw[key] !== undefined) { if (!id(raw[key])) throw new Error(`sdlc receipt.${key}: expected a positive integer`); next[key] = raw[key]; }
|
|
134
|
+
for (const key of ["reviewedHead", "localHead"] as const) if (raw[key] !== undefined) { if (!sha(raw[key])) throw new Error(`sdlc receipt.${key}: expected a full lowercase SHA`); next[key] = raw[key]; }
|
|
135
|
+
if (raw.localResult !== undefined) {
|
|
136
|
+
if (!["passed", "failed", "unknown"].includes(String(raw.localResult))) throw new Error("sdlc receipt.localResult: expected passed, failed or unknown");
|
|
137
|
+
next.localResult = raw.localResult as SdlcReceipt["localResult"];
|
|
138
|
+
}
|
|
139
|
+
// A new artifact cannot inherit another PR's review or local gate.
|
|
140
|
+
if (next.repo !== previous.repo || next.pr !== previous.pr) {
|
|
141
|
+
delete next.head; delete next.observed;
|
|
142
|
+
if (raw.phase === undefined) delete next.phase;
|
|
143
|
+
if (raw.reviewedHead === undefined) delete next.reviewedHead;
|
|
144
|
+
if (raw.localHead === undefined) delete next.localHead;
|
|
145
|
+
if (raw.localResult === undefined) delete next.localResult;
|
|
146
|
+
}
|
|
147
|
+
return next;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function parsePrEvidence(value: unknown): PrEvidence {
|
|
151
|
+
const p = value as Record<string, any>;
|
|
152
|
+
if (!p || !sha(p.headRefOid) || !["OPEN", "CLOSED", "MERGED"].includes(p.state) || !Array.isArray(p.statusCheckRollup) || !Array.isArray(p.files) || p.files.length > 1000) throw new Error("PR readback: invalid or incomplete structured evidence");
|
|
153
|
+
const checks = p.statusCheckRollup.map((c: Record<string, unknown>) => {
|
|
154
|
+
const name = c.name ?? c.context;
|
|
155
|
+
if (typeof name !== "string" || !name || name.length > 120) throw new Error("PR readback: invalid check identity");
|
|
156
|
+
const native = c.conclusion ?? c.state;
|
|
157
|
+
const state = native === "SUCCESS" ? "passed" : ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED"].includes(String(native)) ? "failed" : ["PENDING", "QUEUED", "IN_PROGRESS"].includes(String(c.status ?? native)) ? "pending" : "unknown";
|
|
158
|
+
return { name, state } as PrEvidence["checks"][number];
|
|
159
|
+
});
|
|
160
|
+
const paths = p.files.map((f: Record<string, unknown>) => { if (typeof f.path !== "string" || !f.path || f.path.length > 1000) throw new Error("PR readback: invalid file identity"); return f.path; });
|
|
161
|
+
return { head: p.headRefOid, state: p.state, checks, paths, pathsComplete: Number.isSafeInteger(p.changedFiles) && p.changedFiles >= 0 && p.changedFiles === paths.length, ...(typeof p.isDraft === "boolean" ? { draft: p.isDraft } : {}), ...(typeof p.baseRefName === "string" && p.baseRefName && p.baseRefName.length <= 200 && !/[\x00-\x1f\x7f]/.test(p.baseRefName) ? { base: p.baseRefName } : {}), ...(sha(p.baseRefOid) ? { baseHead: p.baseRefOid } : {}),
|
|
162
|
+
review: p.reviewDecision === "APPROVED" ? "approved" : p.reviewDecision === "CHANGES_REQUESTED" ? "changes_requested" : "unknown",
|
|
163
|
+
pendingReviews: Array.isArray(p.reviewRequests) && p.reviewRequests.length <= 100 && p.reviewRequests.every((r: unknown) => !!r && typeof r === "object") ? p.reviewRequests.length : null,
|
|
164
|
+
unresolved: Number.isSafeInteger(p.unresolvedThreads) && p.unresolvedThreads >= 0 ? p.unresolvedThreads : null,
|
|
165
|
+
...(sha(p.mergeCommit?.oid) ? { mergeCommit: p.mergeCommit.oid } : {}) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface SdlcStatus { workflow?: "gsd-lite"; phase?: PhaseView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
|
|
169
|
+
export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, phase?: PhaseView): SdlcStatus {
|
|
170
|
+
const stage = receipt?.stage ?? config.stages[0] ?? "spec", blockers: string[] = [];
|
|
171
|
+
const definition = stageDefinition(config, stage);
|
|
172
|
+
if (!definition) blockers.push("lifecycle stage definition unknown");
|
|
173
|
+
if (!receipt?.repo || !receipt.issue) blockers.push("issue identity missing");
|
|
174
|
+
if (!receipt?.pr) blockers.push("PR identity missing");
|
|
175
|
+
if (!evidence) blockers.push("current PR evidence unknown");
|
|
176
|
+
else {
|
|
177
|
+
if (evidence.state !== "OPEN") blockers.push("PR is not open");
|
|
178
|
+
if (receipt?.reviewedHead !== evidence.head) blockers.push("current head has not been reviewed");
|
|
179
|
+
if (receipt?.localHead !== evidence.head || receipt.localResult !== "passed") blockers.push("local gate on current head missing or failed");
|
|
180
|
+
if (!config.requiredChecks.length) blockers.push("required check set unknown");
|
|
181
|
+
for (const name of config.requiredChecks) {
|
|
182
|
+
const matches = evidence.checks.filter(c => c.name === name);
|
|
183
|
+
if (!matches.length || matches.some(c => c.state !== "passed")) blockers.push(`required check ${name}: not successful on current head`);
|
|
184
|
+
}
|
|
185
|
+
if (evidence.review !== "approved") blockers.push("GitHub review approval missing or changes requested");
|
|
186
|
+
if (evidence.pendingReviews !== 0) blockers.push("review requests pending or unknown");
|
|
187
|
+
if (evidence.unresolved !== 0) blockers.push("review threads unresolved or unknown");
|
|
188
|
+
}
|
|
189
|
+
if (config.workflow === "gsd-lite") {
|
|
190
|
+
blockers.unshift(...(phase?.blockers ?? ["phase board evidence unknown"]));
|
|
191
|
+
if (!evidence || receipt?.phase?.head !== evidence.head) blockers.push("phase checklist head missing or stale");
|
|
192
|
+
}
|
|
193
|
+
const custom = config.definitions && definition ? stageGates(definition, blockers, receipt, evidence, phase) : undefined;
|
|
194
|
+
if (custom) for (const blocker of [...custom.entry, ...custom.exit]) if (!blockers.includes(blocker)) blockers.push(blocker);
|
|
195
|
+
return { ...(config.workflow ? { workflow: config.workflow, ...(phase ? { phase } : {}) } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
|
|
196
|
+
actor: !definition ? "person" : phase?.blockers.length ? "reviewer/person" : config.definitions && definition ? definition.actor : stage === "review" || stage === "verify" ? "reviewer" : stage === "merge" || stage === "release" ? "person" : "owner/planner",
|
|
197
|
+
next: blockers[0] ?? "person may inspect merge readiness; no automatic merge" };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function stageGates(def: StageDefinition, blockers: string[], receipt: SdlcReceipt | undefined, evidence: PrEvidence | undefined, phase: PhaseView | undefined): { entry: string[]; exit: string[] } {
|
|
201
|
+
const select = (gates: StageGate[]) => [...new Set(gates.flatMap(gate => {
|
|
202
|
+
if (gate === "phase") return [...(phase?.blockers ?? ["phase board evidence unknown"]), ...(!evidence || receipt?.phase?.head !== evidence.head ? ["phase checklist head missing or stale"] : [])];
|
|
203
|
+
const matching = (text: string) => gate === "issue" ? text === "issue identity missing" : gate === "pr" ? ["PR identity missing", "current PR evidence unknown", "PR is not open"].includes(text) : gate === "local" ? ["current PR evidence unknown", "local gate on current head missing or failed"].includes(text) : gate === "checks" ? text === "current PR evidence unknown" || text === "required check set unknown" || text.startsWith("required check ") : ["current PR evidence unknown", "current head has not been reviewed", "GitHub review approval missing or changes requested", "review requests pending or unknown", "review threads unresolved or unknown"].includes(text);
|
|
204
|
+
return blockers.filter(matching);
|
|
205
|
+
}))];
|
|
206
|
+
return { entry: select(def.entry_gates), exit: select(def.exit_gates) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function touchingPrs(prs: { pr: number; paths: string[] }[]): { before: number; after: number; paths: string[] }[] {
|
|
210
|
+
const conflicts: ReturnType<typeof touchingPrs> = [];
|
|
211
|
+
for (let i = 0; i < prs.length; i++) for (const right of prs.slice(i + 1)) {
|
|
212
|
+
const left = prs[i]!, paths = [...new Set(left.paths.filter(path => right.paths.includes(path)))].sort();
|
|
213
|
+
if (paths.length) conflicts.push({ before: left.pr, after: right.pr, paths });
|
|
214
|
+
}
|
|
215
|
+
return conflicts;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export type SdlcGh = (args: string[], timeoutMs?: number, cwd?: string, maxOutputBytes?: number) => Promise<{ code: number; stdout: string }>;
|
|
219
|
+
/** Read-only, fixed argv. Output and time are bounded; diagnostics never expose credentials or remote text. */
|
|
220
|
+
export const sdlcGh: SdlcGh = (args, timeoutMs = 10_000, cwd, maxOutputBytes = 256 * 1024) => new Promise((resolve) => {
|
|
221
|
+
const outputLimit = Number.isSafeInteger(maxOutputBytes) ? Math.min(2 * 1024 * 1024, Math.max(1, maxOutputBytes)) : 256 * 1024;
|
|
222
|
+
const child = spawn("gh", args, { ...(cwd ? { cwd } : {}), env: childEnv(), detached: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
223
|
+
let stdout = "", failed = false;
|
|
224
|
+
const stop = () => { failed = true; void stopOwnedProcess(child, { group: true }).catch(() => {}); };
|
|
225
|
+
const timer = setTimeout(stop, Math.max(1, Math.min(10_000, timeoutMs)));
|
|
226
|
+
child.stdout?.setEncoding("utf8");
|
|
227
|
+
child.stdout?.on("data", chunk => { if (failed) return; stdout += chunk.toString(); if (Buffer.byteLength(stdout) > outputLimit) { stdout = ""; stop(); } });
|
|
228
|
+
child.once("error", () => { failed = true; clearTimeout(timer); resolve({ code: 1, stdout: "" }); });
|
|
229
|
+
child.once("close", code => { clearTimeout(timer); resolve({ code: failed ? 1 : code ?? 1, stdout: failed ? "" : stdout }); });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
export async function readSdlcPr(receipt: SdlcReceipt, run: SdlcGh = sdlcGh): Promise<PrEvidence | undefined> {
|
|
233
|
+
if (!repo(receipt.repo) || !id(receipt.pr)) return undefined;
|
|
234
|
+
try {
|
|
235
|
+
const view = await run(["pr", "view", String(receipt.pr), "--repo", receipt.repo, "--json", "headRefOid,state,statusCheckRollup,reviewDecision,reviewRequests,files,changedFiles,mergeCommit,isDraft,baseRefName,baseRefOid"]);
|
|
236
|
+
if (view.code !== 0) return undefined;
|
|
237
|
+
const [owner, name] = receipt.repo.split("/");
|
|
238
|
+
const threads = await run(["api", "graphql", "-f", "query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){headRefOid reviewThreads(first:100){nodes{isResolved} pageInfo{hasNextPage}}}}}", "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${receipt.pr}`]);
|
|
239
|
+
const p = JSON.parse(view.stdout);
|
|
240
|
+
delete p.unresolvedThreads; // only the complete thread query can establish this fact
|
|
241
|
+
if (threads.code === 0) {
|
|
242
|
+
const current = JSON.parse(threads.stdout)?.data?.repository?.pullRequest;
|
|
243
|
+
const nodes = current?.reviewThreads?.nodes;
|
|
244
|
+
// Paginated or malformed thread data is unknown, never a clean review.
|
|
245
|
+
if (current?.headRefOid === p.headRefOid && current?.reviewThreads?.pageInfo?.hasNextPage === false && Array.isArray(nodes) && nodes.every(n => typeof n?.isResolved === "boolean")) p.unresolvedThreads = nodes.filter(n => !n.isResolved).length;
|
|
246
|
+
else if (current?.headRefOid && current.headRefOid !== p.headRefOid) return undefined;
|
|
247
|
+
}
|
|
248
|
+
return parsePrEvidence(p);
|
|
249
|
+
} catch { return undefined; }
|
|
250
|
+
}
|
package/src/hub/settings.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SUPPORTED_PEERS } from "./peer-catalog.ts";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
@@ -13,14 +14,15 @@ import { OVERLAY_FILE, overlayToml, parseOverlay, parseRouting, routingText, typ
|
|
|
13
14
|
* written by either. Writes go only to the machine-local files (`.agenthub/config.local.json`,
|
|
14
15
|
* `.agenthub/routing.local.toml`), never to a tracked one.
|
|
15
16
|
*/
|
|
16
|
-
export const SETTING_PEERS =
|
|
17
|
+
export const SETTING_PEERS = SUPPORTED_PEERS;
|
|
17
18
|
/** `null` removes the machine-local value, so the shared file's or the default applies again. */
|
|
18
|
-
export type SettingValue = string | boolean | string[] | null;
|
|
19
|
+
export type SettingValue = string | boolean | number | string[] | null;
|
|
19
20
|
export interface SettingDef {
|
|
20
21
|
key: string;
|
|
21
|
-
group: "Permissions" | "Start" | "Routing" | "Switches";
|
|
22
|
+
group: "Permissions" | "Start" | "Routing" | "Switches" | "Roster" | "Roles";
|
|
22
23
|
label: string;
|
|
23
|
-
type: "enum" | "boolean" | "peers";
|
|
24
|
+
type: "enum" | "boolean" | "peers" | "roles" | "integer";
|
|
25
|
+
min?: number; max?: number;
|
|
24
26
|
/** The closed values of an enum; a route setting takes the project's own route ids instead. */
|
|
25
27
|
values?: readonly string[];
|
|
26
28
|
/** `runtime` lives in the running hub only; the others are the machine-local file that holds the value. */
|
|
@@ -29,7 +31,7 @@ export interface SettingDef {
|
|
|
29
31
|
applies: "live" | "peer start" | "hub start";
|
|
30
32
|
/** `raises`: only a settings session or a person's terminal may set it, except to `floor`. */
|
|
31
33
|
risk: "safe" | "raises";
|
|
32
|
-
floor?: string | boolean;
|
|
34
|
+
floor?: string | boolean | number;
|
|
33
35
|
/** Where the value sits inside its file (or, for `runtime`, the peer). */
|
|
34
36
|
path: readonly string[];
|
|
35
37
|
}
|
|
@@ -43,8 +45,12 @@ const perClass = (name: TaskClass): SettingDef[] => [
|
|
|
43
45
|
{ key: `routing.classes.${name}.pi_backend`, group: "Routing", label: `${name}: Pi backend`, type: "enum", values: ["dgx", "mlx"], store: "routing", applies: "live", risk: "raises", path: ["classes", name, "pi_backend"] },
|
|
44
46
|
];
|
|
45
47
|
export const SETTINGS: readonly SettingDef[] = [
|
|
46
|
-
...SETTING_PEERS.map((peer): SettingDef => ({ key: `
|
|
47
|
-
...SETTING_PEERS.map((peer): SettingDef => ({ key: `
|
|
48
|
+
...SETTING_PEERS.map((peer): SettingDef => ({ key: `tasks.wip_limits.${peer}`, group: "Routing", label: `${peer}: planner WIP limit`, type: "integer", min: 0, max: 32, store: "config", applies: "hub start", risk: "raises", floor: 0, path: ["tasks", "wip_limits", peer] })),
|
|
49
|
+
...SETTING_PEERS.map((peer): SettingDef => ({ key: `catalog_roles.${peer}`, group: "Roles", label: `${peer}: catalog guidance`, type: "roles", store: "config", applies: "peer start", risk: "safe", path: ["catalog_roles", peer] })),
|
|
50
|
+
{ key: "scoped_grants.protect_instructions", group: "Permissions", label: "Ask before writing instruction files", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["protect_instruction_files"] },
|
|
51
|
+
...SETTING_PEERS.map((peer): SettingDef => ({ key: `roster.${peer}`, group: "Roster", label: `${peer}: participates`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", peer] })),
|
|
52
|
+
...SETTING_PEERS.filter(peer => peer !== "jcode").map((peer): SettingDef => ({ key: `permission.${peer}`, group: "Permissions", label: `${peer}: mode now`, type: "enum", values: PERMISSION_MODES, store: "runtime", applies: "live", risk: "raises", floor: "ask", path: [peer] })),
|
|
53
|
+
...SETTING_PEERS.filter(peer => peer !== "jcode").map((peer): SettingDef => ({ key: `permission_modes.${peer}`, group: "Permissions", label: `${peer}: mode at hub start`, type: "enum", values: PERMISSION_MODES, store: "config", applies: "hub start", risk: "raises", floor: "ask", path: ["permission_modes", peer] })),
|
|
48
54
|
...START_MODE_PEERS.map((peer): SettingDef => ({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (tui unless set)`, type: "enum", values: START_MODES, store: "config", applies: "peer start", risk: "raises", floor: "tui", path: ["peers", peer, "start_mode"] })),
|
|
49
55
|
{ key: "pi.auto_start", group: "Start", label: "Start Pi with the hub", type: "boolean", store: "config", applies: "hub start", risk: "raises", floor: false, path: ["pi", "auto_start"] },
|
|
50
56
|
{ key: "routing.stay_switch", group: "Routing", label: "Stay or switch (hub/auto and stage routes)", type: "enum", values: ["off", "shadow", "enforce"], store: "routing", applies: "live", risk: "raises", path: ["stay_switch"] },
|
|
@@ -59,7 +65,7 @@ export const SETTINGS: readonly SettingDef[] = [
|
|
|
59
65
|
* Never editable from settings, whatever the session: these choose what the hub executes or where secrets and task
|
|
60
66
|
* text go. A key equal to an entry, or below it, is refused by name.
|
|
61
67
|
*/
|
|
62
|
-
export const NEVER_EDITABLE = ["kimi_cmd", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal"] as const;
|
|
68
|
+
export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal"] as const;
|
|
63
69
|
|
|
64
70
|
/** The definition of a key, or why there is none. */
|
|
65
71
|
export function settingDef(key: unknown): SettingDef | string {
|
|
@@ -76,10 +82,19 @@ export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonl
|
|
|
76
82
|
if (def.store === "runtime") throw new Error(`${def.key} has no stored value to remove`);
|
|
77
83
|
return null;
|
|
78
84
|
}
|
|
85
|
+
if (def.type === "integer") {
|
|
86
|
+
if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < (def.min ?? 0) || raw > (def.max ?? Number.MAX_SAFE_INTEGER)) throw new Error(`${def.key} takes an integer from ${def.min ?? 0} to ${def.max ?? Number.MAX_SAFE_INTEGER}`);
|
|
87
|
+
return raw;
|
|
88
|
+
}
|
|
79
89
|
if (def.type === "boolean") {
|
|
80
90
|
if (typeof raw !== "boolean") throw new Error(`${def.key} takes true or false`);
|
|
81
91
|
return raw;
|
|
82
92
|
}
|
|
93
|
+
if (def.type === "roles") {
|
|
94
|
+
const allowed = def.values ?? routes;
|
|
95
|
+
if (!Array.isArray(raw) || raw.length > 2 || new Set(raw).size !== raw.length || raw.some(id => typeof id !== "string" || !allowed.includes(id))) throw new Error(`${def.key} takes at most two distinct compatible catalog ids; allowed: ${allowed.join(", ") || "(none)"}`);
|
|
96
|
+
return [...raw] as string[];
|
|
97
|
+
}
|
|
83
98
|
if (def.type === "peers") {
|
|
84
99
|
if (!Array.isArray(raw) || raw.length > PEERS_MAX || raw.some((p) => typeof p !== "string" || !PEER.test(p)) || new Set(raw).size !== raw.length) throw new Error(`${def.key} takes up to ${PEERS_MAX} distinct peer ids`);
|
|
85
100
|
return [...(raw as string[])];
|
|
@@ -92,8 +107,9 @@ export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonl
|
|
|
92
107
|
/** A terminal's text for a value: `inherit` removes the machine-local value, a peers setting takes a comma list. */
|
|
93
108
|
export function parseSettingText(def: SettingDef, text: string): unknown {
|
|
94
109
|
if (text === "inherit") return null;
|
|
110
|
+
if (def.type === "integer") return /^(0|[1-9]\d*)$/.test(text) ? Number(text) : text;
|
|
95
111
|
if (def.type === "boolean") return text === "true" ? true : text === "false" ? false : text;
|
|
96
|
-
if (def.type === "peers") return text.split(",").map((p) => p.trim()).filter(Boolean);
|
|
112
|
+
if (def.type === "peers" || def.type === "roles") return text.split(",").map((p) => p.trim()).filter(Boolean);
|
|
97
113
|
return text;
|
|
98
114
|
}
|
|
99
115
|
export const settingText = (value: unknown): string => value === null || value === undefined ? "(not set)" : Array.isArray(value) ? value.join(",") || "(none)" : String(value);
|
|
@@ -101,7 +117,8 @@ export const settingText = (value: unknown): string => value === null || value =
|
|
|
101
117
|
/** `ordinary`: a dashboard session from `ahub ui`. `settings`: one from `ahub ui --settings`. `terminal`: `ahub settings`. */
|
|
102
118
|
export type SettingAuthority = "ordinary" | "settings" | "terminal";
|
|
103
119
|
/** Why this authority may not set this value, or undefined. Removing a stored value can raise, so it counts as one. */
|
|
104
|
-
export function settingRefusal(def: SettingDef, value: SettingValue, authority: SettingAuthority): string | undefined {
|
|
120
|
+
export function settingRefusal(def: SettingDef | string, value: SettingValue, authority: SettingAuthority): string | undefined {
|
|
121
|
+
if (typeof def === "string") return def || "invalid setting definition";
|
|
105
122
|
if (def.risk === "safe" || authority !== "ordinary") return undefined;
|
|
106
123
|
if (value !== null && value === def.floor) return undefined;
|
|
107
124
|
return `${def.key} can widen what agents do without asking: open a settings session with ahub ui --settings, or use ahub settings in a terminal`;
|
|
@@ -334,10 +351,10 @@ export function ignoredLine(ignored: readonly string[] | undefined, def: Setting
|
|
|
334
351
|
});
|
|
335
352
|
}
|
|
336
353
|
export interface SettingRow {
|
|
337
|
-
key: string; group: SettingDef["group"]; label: string; type: SettingDef["type"]; values?: readonly string[];
|
|
354
|
+
key: string; group: SettingDef["group"]; label: string; type: SettingDef["type"]; values?: readonly string[]; min?: number; max?: number;
|
|
338
355
|
/** The file a write goes to, or "this hub run". */
|
|
339
356
|
file: string;
|
|
340
|
-
applies: SettingDef["applies"]; risk: SettingDef["risk"]; floor?: string | boolean;
|
|
357
|
+
applies: SettingDef["applies"]; risk: SettingDef["risk"]; floor?: string | boolean | number;
|
|
341
358
|
/** What is in force now; null when nothing sets it. */
|
|
342
359
|
value: SettingValue;
|
|
343
360
|
/** Which file the value in force comes from. */
|
|
@@ -364,15 +381,16 @@ export function settingRows(input: {
|
|
|
364
381
|
routingProblem?: string;
|
|
365
382
|
/** The loader's own lines about machine-local fields it ignored (`HubConfig.ignored`). */
|
|
366
383
|
ignored?: string[];
|
|
384
|
+
roles?: (peer: string) => { value: SettingValue; pending?: SettingValue; values: string[]; note?: string };
|
|
367
385
|
}): SettingRow[] {
|
|
368
386
|
const raw = (name: string): unknown => { try { return JSON.parse(readFileSync(join(input.cwd, ".agenthub", name), "utf8")); } catch { return undefined; } };
|
|
369
387
|
const local = raw(CONFIG_LOCAL), shared = raw("config.json");
|
|
370
388
|
const projectRouting = existsSync(join(input.cwd, ".agenthub", "routing.toml"));
|
|
371
389
|
const routes = [...Object.keys(input.routing.routes), ...Object.keys(input.routing.hub_routes ?? {})];
|
|
372
|
-
const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" ? value : Array.isArray(value) ? value.map(String) : null;
|
|
390
|
+
const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
|
|
373
391
|
return SETTINGS.map((def): SettingRow => {
|
|
374
|
-
const base = { key: def.key, group: def.group, label: def.label, type: def.type, applies: def.applies, risk: def.risk, ...(def.floor === undefined ? {} : { floor: def.floor }),
|
|
375
|
-
...(def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!) } : {}) };
|
|
392
|
+
const base = { key: def.key, group: def.group, label: def.label, type: def.type, ...(def.type === "integer" ? { min: def.min, max: def.max } : {}), applies: def.applies, risk: def.risk, ...(def.floor === undefined ? {} : { floor: def.floor }),
|
|
393
|
+
...(def.key === "scoped_grants.protect_instructions" ? { hint: "AGENTS.md, CLAUDE.md and QWEN.md remain grantable by default; enabling this asks before scoped writes and edits" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!) } : {}) };
|
|
376
394
|
if (def.store === "runtime") {
|
|
377
395
|
const mode = input.mode(def.path[0]!);
|
|
378
396
|
return { ...base, values: def.values!, file: "this hub run", value: mode.value ?? null, source: "this hub run", ...(mode.note ? { note: mode.note } : {}) };
|
|
@@ -382,6 +400,7 @@ export function settingRows(input: {
|
|
|
382
400
|
return { ...base, ...(def.type === "enum" ? { values: def.values ?? routes } : {}), file: OVERLAY_FILE, value, source: input.routing.sources?.[def.key.slice("routing.".length)] ?? (value === null ? "default" : projectRouting ? "routing.toml" : "default"),
|
|
383
401
|
...(input.routingProblem ? { note: `the routing files do not load, so the last policy that did stays in force: ${input.routingProblem}` } : {}) };
|
|
384
402
|
}
|
|
403
|
+
if (def.type === "roles" && input.roles) return { ...base, ...input.roles(def.path[1]!), file: CONFIG_LOCAL, source: valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : valueAt(shared, def.path) !== undefined ? "config.json" : "profile/default", hint: "Guidance only; grants no tool or permission. At most two roles, applied at the next peer start." };
|
|
385
404
|
const value = scalar(input.running(def.path));
|
|
386
405
|
const stored = input.next ? scalar(input.next(def.path)) : value;
|
|
387
406
|
const source = valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : valueAt(shared, def.path) !== undefined ? "config.json" : "default";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import role0 from "../../templates/roles/dev/software-architect.md" with { type: "text" };
|
|
2
|
+
import role1 from "../../templates/roles/dev/backend-engineer.md" with { type: "text" };
|
|
3
|
+
import role2 from "../../templates/roles/dev/frontend-engineer.md" with { type: "text" };
|
|
4
|
+
import role3 from "../../templates/roles/dev/test-engineer.md" with { type: "text" };
|
|
5
|
+
import role4 from "../../templates/roles/dev/code-reviewer.md" with { type: "text" };
|
|
6
|
+
import role5 from "../../templates/roles/dev/security-reviewer.md" with { type: "text" };
|
|
7
|
+
import role6 from "../../templates/roles/research/research-lead.md" with { type: "text" };
|
|
8
|
+
import role7 from "../../templates/roles/research/researcher.md" with { type: "text" };
|
|
9
|
+
import role8 from "../../templates/roles/research/analyst.md" with { type: "text" };
|
|
10
|
+
import role9 from "../../templates/roles/research/statistician.md" with { type: "text" };
|
|
11
|
+
import role10 from "../../templates/roles/research/fact-checker.md" with { type: "text" };
|
|
12
|
+
|
|
13
|
+
export const SHIPPED_ROLES: Readonly<Record<string, string>> = {
|
|
14
|
+
"software-architect": role0,
|
|
15
|
+
"backend-engineer": role1,
|
|
16
|
+
"frontend-engineer": role2,
|
|
17
|
+
"test-engineer": role3,
|
|
18
|
+
"code-reviewer": role4,
|
|
19
|
+
"security-reviewer": role5,
|
|
20
|
+
"research-lead": role6,
|
|
21
|
+
"researcher": role7,
|
|
22
|
+
"analyst": role8,
|
|
23
|
+
"statistician": role9,
|
|
24
|
+
"fact-checker": role10,
|
|
25
|
+
};
|
package/src/hub/snapshots.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync, utimesSync } from "node:fs";
|
|
2
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { childEnv } from "./child-process.ts";
|
|
6
6
|
import type { Touch } from "./conflicts.ts";
|
|
7
7
|
import { hubGitSync } from "./git.ts";
|
|
8
|
+
import { realPath } from "./project.ts";
|
|
8
9
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -15,10 +16,10 @@ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
|
15
16
|
* ponytail: the trees are unreferenced, so `git gc` may prune them after gc.pruneExpire (two weeks by default); undo
|
|
16
17
|
* of an older turn says so. Refs under refs/agenthub/ would keep them, at the cost of showing up in the user's repo.
|
|
17
18
|
*/
|
|
18
|
-
const git = (top: string, args: string[], env: Record<string, string> = {}) =>
|
|
19
|
-
hubGitSync(
|
|
19
|
+
const git = (top: string | Repo, args: string[], env: Record<string, string> = {}) =>
|
|
20
|
+
hubGitSync(repoGitArgs(top, args), {
|
|
20
21
|
encoding: "utf8",
|
|
21
|
-
env: { ...
|
|
22
|
+
env: { ...repoGitEnv(top), ...env },
|
|
22
23
|
// These run inside the bus's state change: a hung git (a huge new directory, a stuck filter) must not freeze the hub.
|
|
23
24
|
timeout: SNAPSHOT_TIMEOUT_MS,
|
|
24
25
|
});
|
|
@@ -29,14 +30,73 @@ export interface Repo {
|
|
|
29
30
|
top: string;
|
|
30
31
|
dir: string;
|
|
31
32
|
prefix: string;
|
|
33
|
+
shadow?: true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Refuse links throughout metadata, including loose-object fan-outs and packed-object files. */
|
|
37
|
+
function shadowStorageSafe(dir: string): boolean {
|
|
38
|
+
let entries = 0;
|
|
39
|
+
const visit = (path: string, depth: number): boolean => {
|
|
40
|
+
if (++entries > 20000 || depth > 32) return false;
|
|
41
|
+
const st = lstatSync(path);
|
|
42
|
+
if (st.isSymbolicLink()) return false;
|
|
43
|
+
if (st.isFile()) return st.nlink === 1;
|
|
44
|
+
if (!st.isDirectory()) return false;
|
|
45
|
+
return readdirSync(path).every(name => visit(join(path, name), depth + 1));
|
|
46
|
+
};
|
|
47
|
+
try {
|
|
48
|
+
if (lstatSync(join(dir, "commondir"), { throwIfNoEntry: false }) || lstatSync(join(dir, "objects/info/alternates"), { throwIfNoEntry: false })) return false;
|
|
49
|
+
return visit(dir, 0);
|
|
50
|
+
} catch { return false; }
|
|
51
|
+
}
|
|
52
|
+
/** Explicit flags keep the shadow work tree at its verified project root. */
|
|
53
|
+
export function repoGitArgs(repo: string | Repo, args: string[]): string[] {
|
|
54
|
+
const top = typeof repo === "string" ? repo : repo.top;
|
|
55
|
+
if (typeof repo !== "string" && repo.shadow) {
|
|
56
|
+
const parent = lstatSync(join(repo.top, ".agenthub"), { throwIfNoEntry: false });
|
|
57
|
+
const ignore = parent?.isDirectory() && !parent.isSymbolicLink() ? lstatSync(join(repo.top, ".agenthub/ignore"), { throwIfNoEntry: false }) : undefined;
|
|
58
|
+
if (!parent?.isDirectory() || parent.isSymbolicLink() || ignore && (!ignore.isFile() || ignore.isSymbolicLink() || ignore.nlink !== 1) || repo.dir !== join(repo.top, ".agenthub/shadow.git") || !shadowStorageSafe(repo.dir)) throw new Error("shadow repository metadata is invalid or linked; operation refused");
|
|
59
|
+
}
|
|
60
|
+
return ["-C", top, ...(typeof repo !== "string" && repo.shadow ? ["--git-dir=" + repo.dir, "--work-tree=" + repo.top, "-c", "core.excludesFile=" + join(repo.top, ".agenthub", "ignore")] : []), ...args];
|
|
61
|
+
}
|
|
62
|
+
export function repoGitEnv(repo: string | Repo, env = process.env): NodeJS.ProcessEnv {
|
|
63
|
+
return { ...childEnv(env), ...(typeof repo !== "string" && repo.shadow ? { GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null", GIT_ATTR_NOSYSTEM: "1" } : {}) };
|
|
32
64
|
}
|
|
33
65
|
|
|
34
66
|
/** The repository holding `root`, or undefined when it is not inside a work tree. */
|
|
35
67
|
export function repoOf(root: string): Repo | undefined {
|
|
36
68
|
const r = git(root, ["rev-parse", "--show-toplevel", "--absolute-git-dir", "--show-prefix"]);
|
|
37
|
-
if (r.status
|
|
38
|
-
|
|
39
|
-
|
|
69
|
+
if (r.status === 0) {
|
|
70
|
+
const [top, dir, prefix = ""] = r.stdout.replace(/\n$/, "").split("\n");
|
|
71
|
+
if (top && dir) return { top, dir, prefix };
|
|
72
|
+
}
|
|
73
|
+
const top = realPath(root), hub = join(top, ".agenthub"), dir = join(hub, "shadow.git");
|
|
74
|
+
const parent = lstatSync(hub, { throwIfNoEntry: false });
|
|
75
|
+
if (!parent?.isDirectory() || parent.isSymbolicLink()) return undefined;
|
|
76
|
+
const own = lstatSync(dir, { throwIfNoEntry: false });
|
|
77
|
+
if (!own?.isDirectory() || own.isSymbolicLink()) return undefined;
|
|
78
|
+
for (const name of ["objects", "refs", "hooks", "info"]) { const st = lstatSync(join(dir, name), { throwIfNoEntry: false }); if (!st?.isDirectory() || st.isSymbolicLink()) return undefined; }
|
|
79
|
+
for (const file of ["config", "HEAD"]) { const st = lstatSync(join(dir, file), { throwIfNoEntry: false }); if (!st?.isFile() || st.isSymbolicLink() || st.nlink !== 1) return undefined; }
|
|
80
|
+
if (existsSync(join(dir, "commondir")) || existsSync(join(dir, "objects/info/alternates"))) return undefined;
|
|
81
|
+
const ignore = lstatSync(join(hub, "ignore"), { throwIfNoEntry: false });
|
|
82
|
+
if (ignore && (!ignore.isFile() || ignore.isSymbolicLink() || ignore.nlink !== 1)) return undefined;
|
|
83
|
+
if (!shadowStorageSafe(dir)) return undefined;
|
|
84
|
+
const repo: Repo = { top, dir: realPath(dir), prefix: "", shadow: true };
|
|
85
|
+
const probe = git(repo, ["rev-parse", "--absolute-git-dir"]);
|
|
86
|
+
return probe.status === 0 && realPath(probe.stdout.trim()) === repo.dir ? repo : undefined;
|
|
87
|
+
}
|
|
88
|
+
/** Opt-in creation only; a real repository is never replaced or modified. */
|
|
89
|
+
export function initShadow(root: string): boolean {
|
|
90
|
+
const top = realPath(root), hub = join(top, ".agenthub"), dir = join(hub, "shadow.git"), existing = repoOf(top);
|
|
91
|
+
if (existing) { if (!existing.shadow) throw new Error("project already has a Git work tree; shadow mode is for non-git folders"); return false; }
|
|
92
|
+
const parent = lstatSync(hub, { throwIfNoEntry: false });
|
|
93
|
+
if (parent && (!parent.isDirectory() || parent.isSymbolicLink())) throw new Error(".agenthub must be a project directory, not a link");
|
|
94
|
+
if (lstatSync(dir, { throwIfNoEntry: false })) throw new Error("existing shadow metadata is invalid; nothing was replaced");
|
|
95
|
+
mkdirSync(hub, { recursive: true });
|
|
96
|
+
const result = hubGitSync(["init", "--bare", "--quiet", dir], { cwd: top, encoding: "utf8", timeout: SNAPSHOT_TIMEOUT_MS, env: { ...childEnv(process.env), GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" } });
|
|
97
|
+
if (result.status !== 0) throw new Error("could not initialize shadow repository");
|
|
98
|
+
writeFileSync(join(dir, "info/exclude"), ".agenthub/shadow.git/\n.agenthub/state/\n.agenthub/archive/\n.agenthub/config.local.json\n.agenthub/routing.local.toml\n", { mode: 0o600 });
|
|
99
|
+
return true;
|
|
40
100
|
}
|
|
41
101
|
|
|
42
102
|
/**
|
|
@@ -62,12 +122,12 @@ export function snapshot(repo: Repo): string | undefined {
|
|
|
62
122
|
utimesSync(env.GIT_INDEX_FILE, atime, mtime);
|
|
63
123
|
}
|
|
64
124
|
// `ahub reset --all` archives hub.db (task text, PII included) under .agenthub/archive: never copy it either.
|
|
65
|
-
if (git(repo
|
|
125
|
+
if (git(repo, ["add", "-A", "--", scope(repo), ":(exclude,glob)**/.agenthub/state/**", ":(exclude,glob)**/.agenthub/archive/**", ...(repo.shadow ? [":(exclude,glob)**/.agenthub/shadow.git/**"] : [])], env).status !== 0) return undefined;
|
|
66
126
|
// The copy starts from the user's index: hub state somebody tracked or staged is still in it, and the exclude above
|
|
67
127
|
// only keeps `add` from touching it. Take it out explicitly; -f because staged state the hub has rewritten since
|
|
68
128
|
// matches neither HEAD nor the file, which `rm --cached` otherwise refuses. Only the copy changes.
|
|
69
|
-
if (git(repo
|
|
70
|
-
const r = git(repo
|
|
129
|
+
if (git(repo, ["rm", "-r", "-f", "--cached", "--quiet", "--ignore-unmatch", "--", ":(glob)**/.agenthub/state/**", ":(glob)**/.agenthub/archive/**", ...(repo.shadow ? [":(glob)**/.agenthub/shadow.git/**"] : [])], env).status !== 0) return undefined;
|
|
130
|
+
const r = git(repo, ["write-tree"], env);
|
|
71
131
|
return r.status === 0 ? r.stdout.trim() : undefined;
|
|
72
132
|
} finally {
|
|
73
133
|
rmSync(tmp, { recursive: true, force: true });
|
|
@@ -76,14 +136,14 @@ export function snapshot(repo: Repo): string | undefined {
|
|
|
76
136
|
|
|
77
137
|
/** Project paths (relative to the top level) that differ between two trees. */
|
|
78
138
|
export function changedPaths(repo: Repo, from: string, to: string): string[] {
|
|
79
|
-
const r = git(repo
|
|
139
|
+
const r = git(repo, ["diff-tree", "-r", "--no-renames", "--name-only", "-z", from, to, "--", scope(repo)]);
|
|
80
140
|
return r.status === 0 ? r.stdout.split("\0").filter(Boolean) : [];
|
|
81
141
|
}
|
|
82
142
|
|
|
83
|
-
const inTree = (top: string, tree: string, path: string) => git(top, ["rev-parse", "--verify", "--quiet", `${tree}:${path}`]).status === 0;
|
|
143
|
+
const inTree = (top: string | Repo, tree: string, path: string) => git(top, ["rev-parse", "--verify", "--quiet", `${tree}:${path}`]).status === 0;
|
|
84
144
|
|
|
85
145
|
/** Whether a snapshot's tree is still in the object store (`git gc` prunes unreferenced ones eventually). */
|
|
86
|
-
export const hasTree = (top: string, tree: string): boolean => git(top, ["cat-file", "-e", `${tree}^{tree}`]).status === 0;
|
|
146
|
+
export const hasTree = (top: string | Repo, tree: string): boolean => git(top, ["cat-file", "-e", `${tree}^{tree}`]).status === 0;
|
|
87
147
|
|
|
88
148
|
export interface UndoPlan {
|
|
89
149
|
restore: string[];
|
|
@@ -122,10 +182,10 @@ export function planUndo(repo: Repo, turn: { start_tree: string; end_tree: strin
|
|
|
122
182
|
export function restore(repo: Repo, startTree: string, paths: string[]): void {
|
|
123
183
|
// Removals first: after a case-only rename on a case-insensitive disk, `Foo.ts` and `foo.ts` are one file, and
|
|
124
184
|
// removing the created spelling after restoring the old one would delete it.
|
|
125
|
-
const back = paths.filter((p) => inTree(repo
|
|
185
|
+
const back = paths.filter((p) => inTree(repo, startTree, p));
|
|
126
186
|
for (const path of paths.filter((p) => !back.includes(p))) rmSync(join(repo.top, path), { force: true });
|
|
127
187
|
for (const path of back) {
|
|
128
|
-
if (git(repo
|
|
188
|
+
if (git(repo, ["restore", `--source=${startTree}`, "--worktree", "--", literal(path)]).status !== 0) throw new Error(`could not restore ${path}`);
|
|
129
189
|
}
|
|
130
190
|
}
|
|
131
191
|
|
|
@@ -156,6 +216,20 @@ export class Turns {
|
|
|
156
216
|
// ponytail: rows of finished tasks stay (a few per file per task); prune by task state if hub.db ever grows.
|
|
157
217
|
this.db.run("CREATE TABLE IF NOT EXISTS touches (task INTEGER NOT NULL, peer TEXT NOT NULL, path TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (task, peer, path))");
|
|
158
218
|
this.db.run("CREATE INDEX IF NOT EXISTS touches_path ON touches (path)"); // the PreToolUse hook looks up one path
|
|
219
|
+
this.db.run("CREATE TABLE IF NOT EXISTS task_snapshots (task INTEGER NOT NULL, proposed_at INTEGER NOT NULL, start_tree TEXT, PRIMARY KEY (task, proposed_at))");
|
|
220
|
+
}
|
|
221
|
+
/** A proposal's original project snapshot; task ids may be reused after reset. Never repair a missing baseline later. */
|
|
222
|
+
rememberTaskStart(task: number, proposedAt: number, tree: string | undefined): void {
|
|
223
|
+
this.db.query("INSERT OR IGNORE INTO task_snapshots (task, proposed_at, start_tree) VALUES (?, ?, ?)").run(task, proposedAt, tree ?? null);
|
|
224
|
+
}
|
|
225
|
+
taskStart(task: number, proposedAt: number): string | undefined {
|
|
226
|
+
try {
|
|
227
|
+
const row = this.db.query("SELECT start_tree FROM task_snapshots WHERE task = ? AND proposed_at = ?").get(task, proposedAt) as { start_tree: string | null } | null;
|
|
228
|
+
return row?.start_tree ?? undefined;
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (/no such table/.test((error as Error).message)) return undefined;
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
159
233
|
}
|
|
160
234
|
begin(id: string, peer: string, startTree: string | undefined): void {
|
|
161
235
|
this.db.query("INSERT OR REPLACE INTO turns (id, peer, started, start_tree) VALUES (?, ?, ?, ?)").run(id, peer, Date.now(), startTree ?? null);
|