@staix/agent-hub 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +23 -9
- package/docs/agent-notes/README.md +8 -0
- package/docs/agent-notes/adapters.md +21 -4
- package/docs/agent-notes/budget.md +6 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +20 -9
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +21 -4
- package/docs/agent-notes/tests.md +6 -3
- package/docs/dogfood.md +19 -5
- package/docs/events.md +21 -7
- package/docs/memory.md +78 -10
- package/docs/operations.md +197 -39
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +16 -6
- package/docs/sdlc.md +17 -0
- package/docs/security.md +110 -21
- package/docs/smoke.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +156 -16
- package/docs/verification/2026-10-23-0.15.0.md +80 -0
- package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
- package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
- package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
- package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
- package/docs/verification/2026-10-24-amem-runtime.md +69 -0
- package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
- package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
- package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
- package/docs/verification/2026-10-25-0.16.0.md +82 -0
- package/docs/verified.json +40 -19
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3109 -2046
- package/src/adapters/acp.ts +46 -7
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/claude-implementer.ts +243 -0
- package/src/adapters/claude-print-child.ts +90 -0
- package/src/adapters/claude-task-sessions.ts +80 -0
- package/src/adapters/codex-appserver.ts +69 -1
- package/src/adapters/compat.ts +6 -2
- package/src/adapters/conformance/enrollment.ts +2 -1
- package/src/adapters/conformance/fakes/acp-server.ts +3 -1
- package/src/adapters/definitions/builtin-metadata.ts +17 -5
- package/src/adapters/definitions/native-channel-state.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/kimi-wire-usage.ts +303 -0
- package/src/adapters/local-worker.ts +5 -4
- package/src/adapters/registry-types.ts +8 -4
- package/src/adapters/registry.ts +14 -2
- package/src/adapters/reviewer-print.ts +30 -53
- package/src/cli/console-config.ts +123 -0
- package/src/cli/console-state.ts +48 -3
- package/src/cli/console-symbols.ts +3 -2
- package/src/cli/console-usage.ts +5 -2
- package/src/cli/console.ts +30 -2
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +10 -7
- package/src/cli/identity.ts +3 -1
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +91 -19
- package/src/cli/memory.ts +119 -7
- package/src/cli/output.ts +40 -4
- package/src/cli/reset.ts +1 -1
- package/src/cli/settings-edit.ts +89 -0
- package/src/cli/terminal-recovery.ts +1 -6
- package/src/hub/action-class.ts +211 -17
- package/src/hub/action-enforce.ts +90 -0
- package/src/hub/action-policy.ts +40 -17
- package/src/hub/ask.ts +16 -4
- package/src/hub/board.ts +110 -31
- package/src/hub/budget-notes.ts +65 -0
- package/src/hub/budget.ts +44 -7
- package/src/hub/bus.ts +6 -6
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/code-facts.ts +56 -16
- package/src/hub/conductor.ts +5 -2
- package/src/hub/config-apply.ts +12 -0
- package/src/hub/config-edit-host.ts +173 -0
- package/src/hub/config-edit.ts +282 -0
- package/src/hub/config-tier.ts +89 -0
- package/src/hub/control-client.ts +24 -3
- package/src/hub/daemon.ts +614 -104
- package/src/hub/deliverable-files.ts +131 -0
- package/src/hub/deliverables.ts +125 -0
- package/src/hub/delivery-journal.ts +18 -2
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +5 -3
- package/src/hub/hub-tools.ts +22 -6
- package/src/hub/instances.ts +7 -4
- package/src/hub/kimi-usage.ts +156 -0
- package/src/hub/lifecycle.ts +10 -5
- package/src/hub/manager.ts +1 -1
- package/src/hub/memory-code-head.ts +44 -0
- package/src/hub/memory-code-overlay.ts +119 -0
- package/src/hub/memory-plan-guard.ts +10 -1
- package/src/hub/memory-stale-anchors.ts +159 -0
- package/src/hub/memory-symbols.ts +74 -0
- package/src/hub/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/planning.ts +10 -4
- package/src/hub/read-tools.ts +21 -3
- package/src/hub/routing.ts +118 -3
- package/src/hub/sdlc.ts +34 -0
- package/src/hub/settings.ts +18 -3
- package/src/hub/settled-roles.ts +15 -3
- package/src/hub/task-sweep.ts +25 -3
- package/src/hub/tasks.ts +551 -75
- package/src/hub/token-window.ts +46 -0
- package/src/hub/usage-rollup.ts +16 -6
- package/src/hub/window-kind.ts +13 -0
- package/src/hub/workflow.ts +16 -0
- package/src/local/tools.ts +5 -5
- package/src/memory/brief.ts +22 -12
- package/src/memory/capture.ts +26 -40
- package/src/memory/client.ts +137 -65
- package/src/memory/recall.ts +13 -22
- package/src/ui/index.html +215 -19
package/src/hub/code-facts.ts
CHANGED
|
@@ -10,9 +10,12 @@ import { releaseTrackedClean } from "./release-worktree.ts";
|
|
|
10
10
|
|
|
11
11
|
export interface CodeSymbol { name: string; kind: string; path: string; line: number; endLine?: number; ambiguous: boolean; change: "unknown" }
|
|
12
12
|
export type CodeFactResult<T> = { state: "known"; value: T } | { state: "unknown" };
|
|
13
|
-
export interface CodeLocation { repo: string; head: string; private?: boolean }
|
|
13
|
+
export interface CodeLocation { repo: string; head: string; private?: boolean; priority?: "optional" }
|
|
14
|
+
export const OPTIONAL_CODE_UNKNOWN_TTL_MS = 5000;
|
|
14
15
|
const unknown = (): {state:"unknown"} => ({state:"unknown"});
|
|
15
16
|
const SHA = /^[a-f0-9]{40}$/;
|
|
17
|
+
/** These host anchor spellings have no indexed-name lookup attestation in ripwire 0.6.7. */
|
|
18
|
+
export const unindexedSymbolSyntax = (name: string): boolean => /#|:|->/.test(name);
|
|
16
19
|
// Keep the owned group leader alive after the CLI exits, until the host has stopped every group member.
|
|
17
20
|
const LAUNCHER = String.raw`
|
|
18
21
|
const {writeSync,closeSync}=require("node:fs");
|
|
@@ -76,6 +79,9 @@ function changed(raw:string,head:string,base:string,cwd:string): CodeSymbol[] {
|
|
|
76
79
|
/** Sole optional ripwire boundary. No model/config-selected command, no raw output in results or errors. */
|
|
77
80
|
export class CodeFacts {
|
|
78
81
|
private readonly cache=new Map<string,Promise<CodeFactResult<unknown>>>();
|
|
82
|
+
private readonly optionalUnknown=new Map<string,number>();
|
|
83
|
+
private priorityWaiting=0;
|
|
84
|
+
private active?:{id:string;optional:boolean;cancelled:boolean;done:Promise<void>};
|
|
79
85
|
private busy=false;
|
|
80
86
|
private closing=false;
|
|
81
87
|
private pending?:Promise<CodeFactResult<unknown>>;
|
|
@@ -83,13 +89,13 @@ export class CodeFacts {
|
|
|
83
89
|
private failedChild?:ReturnType<typeof spawn>;
|
|
84
90
|
private retained?:{tree:{cwd:string;close:()=>void};child:ReturnType<typeof spawn>};
|
|
85
91
|
async close():Promise<void> {
|
|
86
|
-
this.closing=true;this.cancelActive?.();await this.pending;
|
|
92
|
+
this.closing=true;this.cancelActive?.();await (this.active?.done ?? this.pending);
|
|
87
93
|
if(this.retained){
|
|
88
94
|
try{await stopOwnedProcess(this.retained.child,{group:true,termMs:50,killMs:500});this.retained.tree.close();this.retained=undefined;}
|
|
89
95
|
catch{throw Error("code facts cleanup unconfirmed");}
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
|
-
constructor(private readonly options:{env?:NodeJS.ProcessEnv;timeoutMs?:number}={}) {}
|
|
98
|
+
constructor(private readonly options:{env?:NodeJS.ProcessEnv;timeoutMs?:number;now?:()=>number}={}) {}
|
|
93
99
|
private git(repo:string,args:string[]) {
|
|
94
100
|
return hubGitSync(["-C",repo,...args],{encoding:"utf8",env:hubGitEnv(childEnv(),{repositoryIsolation:true}),timeout:5000,maxBuffer:1024*1024});
|
|
95
101
|
}
|
|
@@ -107,7 +113,7 @@ export class CodeFacts {
|
|
|
107
113
|
return env;
|
|
108
114
|
}
|
|
109
115
|
private async run(bin:string,args:string[],cwd:string):Promise<{code:number;out:string}> {
|
|
110
|
-
if(this.closing)return {code:1,out:""};
|
|
116
|
+
if(this.closing || this.active?.cancelled)return {code:1,out:""};
|
|
111
117
|
return new Promise(resolve=>{
|
|
112
118
|
let child:ReturnType<typeof spawn>;
|
|
113
119
|
try{child=spawn(process.execPath,["-e",LAUNCHER,"--",bin,...args],{cwd,env:this.nativeEnv(cwd),detached:true,stdio:["ignore","pipe","ignore","pipe"]});}catch{resolve({code:1,out:""});return;}
|
|
@@ -164,29 +170,58 @@ export class CodeFacts {
|
|
|
164
170
|
return{cwd,close};
|
|
165
171
|
}catch{close();throw Error();}
|
|
166
172
|
}
|
|
167
|
-
private query<T>(at:CodeLocation,key:string,read:(cwd:string,bin:string)=>Promise<T>):Promise<CodeFactResult<T>> {
|
|
168
|
-
if(this.closing || this.retained || at.private || !SHA.test(at.head))return
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if(this.
|
|
172
|
-
|
|
173
|
+
private async query<T>(at:CodeLocation,key:string,read:(cwd:string,bin:string)=>Promise<T>):Promise<CodeFactResult<T>> {
|
|
174
|
+
if(this.closing || this.retained || at.private || !SHA.test(at.head))return unknown();
|
|
175
|
+
const optional=at.priority==="optional";
|
|
176
|
+
// A read-time graph poll must never take the next slot from review/completion work.
|
|
177
|
+
if(optional && (this.priorityWaiting>0 || this.active && !this.active.optional))return unknown();
|
|
178
|
+
let repo:string;try{repo=realPath(at.repo);}catch{return unknown();}
|
|
179
|
+
const id=JSON.stringify([repo,at.head,key]),hit=this.cache.get(id);
|
|
180
|
+
if(hit && !(this.active?.optional && !optional && this.active.id===id))return structuredClone(await hit) as CodeFactResult<T>;
|
|
181
|
+
if(!optional && this.active?.optional) {
|
|
182
|
+
const interrupted=this.active;
|
|
183
|
+
this.priorityWaiting++;
|
|
184
|
+
interrupted.cancelled=true;this.cache.delete(interrupted.id);this.cancelActive?.();
|
|
185
|
+
try{await interrupted.done;}finally{this.priorityWaiting--;}
|
|
186
|
+
return this.query(at,key,read);
|
|
187
|
+
}
|
|
188
|
+
if(optional) {
|
|
189
|
+
const now=(this.options.now ?? performance.now.bind(performance))(),until=this.optionalUnknown.get(id);
|
|
190
|
+
if(until!==undefined && until>now)return unknown();
|
|
191
|
+
this.optionalUnknown.delete(id);
|
|
192
|
+
}
|
|
193
|
+
if(this.busy)return unknown(); // Preserve default-vs-default contention semantics.
|
|
194
|
+
let finished!:()=>void;
|
|
195
|
+
const active={id,optional,cancelled:false,done:new Promise<void>(resolve=>{finished=resolve;})};
|
|
196
|
+
this.active=active;this.busy=true;this.failedChild=undefined;
|
|
173
197
|
const job=(async()=>{
|
|
174
198
|
let tree:ReturnType<CodeFacts["tree"]>|undefined;let answer:CodeFactResult<T>=unknown();
|
|
175
199
|
try{
|
|
176
200
|
const found=Bun.which("ripwire",{PATH:(this.options.env??process.env).PATH});if(!found)throw Error();const bin=realPath(found);if(within(repo,bin))throw Error();
|
|
177
201
|
tree=this.tree(repo,at.head);
|
|
178
|
-
const version=await this.run(bin,["--version"],tree.cwd);if(version.code!==0 || !/^ripwire 0\.6\.7(?:\s|$)/.test(version.out))throw Error();
|
|
202
|
+
const version=await this.run(bin,["--version"],tree.cwd);if(active.cancelled || version.code!==0 || !/^ripwire 0\.6\.7(?:\s|$)/.test(version.out))throw Error();
|
|
179
203
|
const value=await read(tree.cwd,bin);
|
|
180
|
-
if(this.value(tree.cwd,["rev-parse","HEAD"])!==at.head || !releaseTrackedClean(tree.cwd,args=>{const r=this.git(tree!.cwd,args);return{code:r.status??1,stdout:r.stdout};}))throw Error();
|
|
204
|
+
if(active.cancelled || this.value(tree.cwd,["rev-parse","HEAD"])!==at.head || !releaseTrackedClean(tree.cwd,args=>{const r=this.git(tree!.cwd,args);return{code:r.status??1,stdout:r.stdout};}))throw Error();
|
|
181
205
|
answer={state:"known",value};
|
|
182
206
|
}catch{/* Optional derived evidence is unknown, never a review refusal. */}
|
|
183
|
-
finally{
|
|
207
|
+
finally{
|
|
208
|
+
if(tree && this.failedChild){this.retained={tree,child:this.failedChild};answer=unknown();}else try{tree?.close();}catch{answer=unknown();}
|
|
209
|
+
this.busy=false;if(this.active===active)this.active=undefined;finished();
|
|
210
|
+
}
|
|
184
211
|
return answer;
|
|
185
212
|
})();
|
|
186
213
|
this.pending=job;this.cache.set(id,job);
|
|
187
214
|
if(this.cache.size>128)this.cache.delete(this.cache.keys().next().value!);
|
|
188
|
-
void job.then(answer=>{
|
|
189
|
-
|
|
215
|
+
void job.then(answer=>{
|
|
216
|
+
if(answer.state==="unknown") {
|
|
217
|
+
if(this.cache.get(id)===job)this.cache.delete(id);
|
|
218
|
+
if(optional && !this.closing) {
|
|
219
|
+
this.optionalUnknown.set(id,(this.options.now ?? performance.now.bind(performance))()+OPTIONAL_CODE_UNKNOWN_TTL_MS);
|
|
220
|
+
if(this.optionalUnknown.size>128)this.optionalUnknown.delete(this.optionalUnknown.keys().next().value!);
|
|
221
|
+
}
|
|
222
|
+
} else this.optionalUnknown.delete(id);
|
|
223
|
+
});
|
|
224
|
+
return structuredClone(await job);
|
|
190
225
|
}
|
|
191
226
|
changedSymbols(repo:string,base:string,head:string,options:Omit<CodeLocation,"repo"|"head">={}):Promise<CodeFactResult<CodeSymbol[]>> {
|
|
192
227
|
if(!SHA.test(base))return Promise.resolve(unknown());
|
|
@@ -205,12 +240,17 @@ export class CodeFacts {
|
|
|
205
240
|
if(v.of!==symbol || !Number.isSafeInteger(v.defs)||v.defs<1 || !Number.isSafeInteger(v.count)||v.count<0 || !Array.isArray(v[kind])||v.count!==v[kind].length || v.count>MAX_SYMBOLS || realPath(v.root)!==realPath(cwd) || ![0,1,false,true].includes(v.counts_floor)
|
|
206
241
|
|| !Number.isSafeInteger(v.hop_tested)||v.hop_tested<0||!Number.isSafeInteger(v.hop_untested)||v.hop_untested<0
|
|
207
242
|
|| !Number.isSafeInteger(v.graph_ambiguous)||v.graph_ambiguous<0||!Number.isSafeInteger(v.graph_unresolved)||v.graph_unresolved<0)throw Error();
|
|
243
|
+
if(v.defs!==1 || v.via!==undefined && v.via!=="resolved")throw Error(); // A name-only or multiply-defined seed is unknown, never a known empty neighbourhood.
|
|
208
244
|
return {defs:v.defs,countsFloor:!!v.counts_floor,items:v[kind].map((s:Record<string,unknown>)=>({...cleanSymbol(s.n,s.t,s.p,s.via),ambiguous:s.via==="name" || v.defs>1 || v.graph_ambiguous>0}))};
|
|
209
245
|
}
|
|
210
246
|
exists(repo:string,head:string,symbol:string,options:Omit<CodeLocation,"repo"|"head">={}):Promise<CodeFactResult<{exists:boolean;ambiguous:boolean}>> {
|
|
211
247
|
if(!text(symbol))return Promise.resolve(unknown());
|
|
212
248
|
return this.query({...options,repo,head},`exists:${symbol}`,async(cwd,bin)=>{const r=await this.run(bin,[cwd,`--callers=${symbol}`,"--no-cache"],cwd);
|
|
213
|
-
if(r.code!==0){const n=xml(r.out);if(n.tag==="callers" && n.attrs.schema==="ripwire.callers/v1" && n.attrs.of===symbol && n.attrs.found==="0")
|
|
249
|
+
if(r.code!==0){const n=xml(r.out);if(n.tag==="callers" && n.attrs.schema==="ripwire.callers/v1" && n.attrs.of===symbol && n.attrs.found==="0") {
|
|
250
|
+
// ponytail: ripwire 0.6.7 can miss a qualified name whose bare method exists. Require a CLI/schema qualified-lookup attestation before trusting its absence.
|
|
251
|
+
if(symbol.includes(".") || unindexedSymbolSyntax(symbol))throw Error();
|
|
252
|
+
return{exists:false,ambiguous:false};
|
|
253
|
+
}throw Error();}
|
|
214
254
|
const n=xml(r.out);if(n.tag!=="callers" || n.attrs.schema!=="ripwire.callers/v1" || n.attrs.of!==symbol || !integer(n.attrs.defs)||Number(n.attrs.defs)<1)throw Error();return{exists:true,ambiguous:n.attrs.via==="name" || Number(n.attrs.defs)>1};});
|
|
215
255
|
}
|
|
216
256
|
neighbours(repo:string,head:string,symbol:string,depth=1,options:Omit<CodeLocation,"repo"|"head">={}):Promise<CodeFactResult<{callers:CodeSymbol[];callees:CodeSymbol[];hiddenAmbiguous:number;countsFloor:boolean}>> {
|
package/src/hub/conductor.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { StartMode } from "./start-mode.ts";
|
|
|
9
9
|
import type { HubEvent } from "./events.ts";
|
|
10
10
|
import type { SupervisionFeed } from "./supervision.ts";
|
|
11
11
|
import type { Budget } from "./budget.ts";
|
|
12
|
+
import { windowKind, type WindowKind } from "./window-kind.ts";
|
|
12
13
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
13
14
|
import { supportsHeadlessInstances } from "./instances.ts";
|
|
14
15
|
|
|
@@ -17,9 +18,11 @@ export function conductorProgressSink(record: (event: HubEvent) => void, feed: P
|
|
|
17
18
|
return event => { record(event); if (event.type === "stuck") feed.milestone(event.task, "stuck", { reason: event.category }); };
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
/** What `Budget.status` returns, with the fields added after it optional so an older shape still projects. */
|
|
22
|
+
type PublicBudgetStatus = Record<string, { windows: (Omit<ReturnType<Budget["status"]>[string]["windows"][number], "kind" | "superseded"> & { kind?: WindowKind; superseded?: boolean })[]; paused?: ReturnType<Budget["status"]>[string]["paused"] }>;
|
|
23
|
+
export function publicPeerBudget(status: PublicBudgetStatus, nameable: (text: string) => boolean): Record<string, unknown> {
|
|
21
24
|
return Object.fromEntries(Object.entries(status).map(([peer, value]) => [peer, {
|
|
22
|
-
windows: value.windows.map(window => ({ id: window.id, used: window.used, at: window.at, stale: window.stale,
|
|
25
|
+
windows: value.windows.map(window => ({ id: window.id, kind: windowKind(window), used: window.used, at: window.at, stale: window.stale, ...(window.superseded ? { superseded: true } : {}),
|
|
23
26
|
...(window.resetsAt === undefined ? {} : { resetsAt: window.resetsAt }),
|
|
24
27
|
...(window.windowMins === undefined ? {} : { windowMins: window.windowMins }),
|
|
25
28
|
source: nameable(window.source) ? window.source : "[quota source withheld]",
|
package/src/hub/config-apply.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { externalMetadataDefinitions, type ExternalAdapterMetadata } from "../ad
|
|
|
5
5
|
import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
|
|
6
6
|
import type { HubConfig } from "./daemon.ts";
|
|
7
7
|
import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked } from "./config-trust.ts";
|
|
8
|
+
import { structuralLeaf } from "./config-tier.ts";
|
|
8
9
|
import { createProjectAdapterCatalog, type ProjectAdapterCatalog, ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, SUPPORTED_PEERS, PLANNER_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
|
|
9
10
|
import { CLASSES } from "./board.ts";
|
|
10
11
|
import { permissionDefaults } from "./permission-mode.ts";
|
|
@@ -16,6 +17,7 @@ import { projectionConfig } from "./text-projection.ts";
|
|
|
16
17
|
import { validateInstances, type InstancesConfig } from "./instances.ts";
|
|
17
18
|
import { peerRosterConfig, staleInstanceKey } from "./roster.ts";
|
|
18
19
|
import { CHARACTER_IDS } from "../pet/characters.ts";
|
|
20
|
+
import { checkSettingValue, settingDef } from "./settings.ts";
|
|
19
21
|
import { petPalette } from "../pet/config.ts";
|
|
20
22
|
|
|
21
23
|
export type ApplyClass = "live" | "next use" | "restart";
|
|
@@ -44,6 +46,8 @@ export interface ConfigAuthorizationInput {
|
|
|
44
46
|
acceptedFallback?: boolean;
|
|
45
47
|
/** The prospective settled planner/conductor holder sets change, even if the raw role list shrinks. */
|
|
46
48
|
settledHolderChange?: boolean;
|
|
49
|
+
/** #689: the daemon sets this only for a structural edit it validated and wrote itself; `structuralLeaf` still decides the key. */
|
|
50
|
+
structural?: boolean;
|
|
47
51
|
guard?: ReloadGuard;
|
|
48
52
|
}
|
|
49
53
|
export interface ConfigAuthorizationDecision { key: string; allowed: boolean; guarded: boolean; reason: string }
|
|
@@ -90,6 +94,8 @@ export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuth
|
|
|
90
94
|
// Every other policy change is conservative: person intent never turns tracked or unknown code into host authority.
|
|
91
95
|
if (input.tracked !== false) return result(false, true, input.tracked ? "guarded source is tracked; retain the running value" : "guarded source trust is unknown; retain the running value");
|
|
92
96
|
if (input.authority === "ordinary") return result(false, true, "guarded change needs a selected setting or a confirmed terminal reload");
|
|
97
|
+
// #689: a structural key (never an executable or secret one) written by the daemon's own validated edit, from a settings session.
|
|
98
|
+
if (input.authority === "settings" && !guard && input.structural === true && input.confirmed === true && input.source === "config" && input.sourceFile === "config.local.json" && structuralLeaf(key)) return result(true, true, "confirmed structural edit");
|
|
93
99
|
if (input.authority === "settings" && !guard) return result(false, true, "nonregistered policy requires a confirmed terminal reload");
|
|
94
100
|
if (input.confirmed !== true) return result(false, true, "guarded change needs explicit confirmation for this key");
|
|
95
101
|
return result(true, true, guard ? "confirmed registered setting" : "confirmed terminal reload");
|
|
@@ -184,6 +190,12 @@ export function validateRawConfig(raw: unknown, declarations?: InstancesConfig,
|
|
|
184
190
|
const instances = validateInstances(value.instances ?? declarations, catalog);
|
|
185
191
|
block({ ...schema,
|
|
186
192
|
instances: v => { validateInstances(v, catalog); },
|
|
193
|
+
planner: v => {
|
|
194
|
+
if (v === null) fail("config.planner", "takes a peer id or a nonempty list");
|
|
195
|
+
const def = settingDef("planner", instances, catalog);
|
|
196
|
+
if (typeof def === "string") return fail("config.planner", def);
|
|
197
|
+
checkSettingValue(def, v);
|
|
198
|
+
},
|
|
187
199
|
permission_modes: v => { permissionDefaults(v, instances, catalog); },
|
|
188
200
|
roster: v => { peerRosterConfig(v, instances, catalog, inertModuleRoster); },
|
|
189
201
|
peers: v => { peerStartConfig(v, instances, catalog); },
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { configBackups, parseConfigEditRequest, viewRevisions, planConfigEdit, writeConfigEdit, type CandidateCheck, type ConfigEditPlan } from "./config-edit.ts";
|
|
2
|
+
import { configTier, maskTier3, TIER2_INSTANCE_FIELDS, TIER3_INSTANCE_FIELDS, TIER3_PREFIXES, type ConfigEditFile } from "./config-tier.ts";
|
|
3
|
+
import type { HubEvent } from "./events.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* #689: the daemon's side of structural config edits. Everything the daemon decides is here, with its ports injected, so the
|
|
7
|
+
* daemon itself carries only a few lines of wiring: who may edit (authority), the typed confirmation for tier 3, the hold during
|
|
8
|
+
* recovery or shutdown, the audit event and the scoped reload that applies or marks each key.
|
|
9
|
+
*/
|
|
10
|
+
export type EditAuthority = "ordinary" | "settings" | "terminal";
|
|
11
|
+
export interface ConfigEditHost {
|
|
12
|
+
cwd: string;
|
|
13
|
+
stateDir: string;
|
|
14
|
+
/** The loaders' verdict on a candidate file; throws the loader's reason. */
|
|
15
|
+
check: CandidateCheck;
|
|
16
|
+
/** A reason edits are held now (recovery or shutdown), or undefined. */
|
|
17
|
+
held: () => string | undefined;
|
|
18
|
+
/** Publishes one changed key through the daemon's only reload path, scoped to it. */
|
|
19
|
+
reload: (authority: EditAuthority, key: string) => Promise<Record<string, unknown>>;
|
|
20
|
+
event: (event: Extract<HubEvent, { type: "config_edit" }>) => void;
|
|
21
|
+
notify: (text: string) => void;
|
|
22
|
+
/** Called after a write so status.json and the console snapshot show what is now pending. */
|
|
23
|
+
changed: () => void;
|
|
24
|
+
/** What waits for a restart now, from the reload's own bookkeeping (instance declarations are restart-bound). */
|
|
25
|
+
pending: () => { key: string; reason: string }[];
|
|
26
|
+
/** The running hub's value at a config path, for the form. */
|
|
27
|
+
effective: (path: readonly string[]) => unknown;
|
|
28
|
+
/** The lifecycle the running hub holds, to say what a changed sdlc.toml waits for. */
|
|
29
|
+
runningSdlc: () => { mode: string; requiredChecks: readonly string[] };
|
|
30
|
+
/** The lifecycle the file would give at the next start, or the loader's reason. */
|
|
31
|
+
fileSdlc: () => { mode: string; requiredChecks: readonly string[] } | string;
|
|
32
|
+
/** The declared instances of the running hub: id to its tier 2 fields. */
|
|
33
|
+
declared: () => Record<string, Record<string, unknown>>;
|
|
34
|
+
/** config.local.json as the file holds it now, parsed once per call (`{}` when it is missing or unreadable). Never sent anywhere: it is read for what is set, never shown. */
|
|
35
|
+
localConfig: () => Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
export interface ConfigEditView {
|
|
38
|
+
revisions: Record<ConfigEditFile, string>;
|
|
39
|
+
instances: { id: string; fields: Record<string, unknown>; tier3: string[] }[];
|
|
40
|
+
/** The instance ids config.local.json holds now, including ones the running hub has not started with yet: the forms change those leaf by leaf. */
|
|
41
|
+
known: string[];
|
|
42
|
+
blocks: { path: string; value: unknown }[];
|
|
43
|
+
sdlc: { mode: string; requiredChecks: readonly string[]; pending: boolean; problem?: string };
|
|
44
|
+
/** Tier 3 keys with the console command that changes them; the dashboard shows exactly this, read-only. */
|
|
45
|
+
tier3: { key: string; set: boolean; command: string }[];
|
|
46
|
+
backups: { name: string; file: ConfigEditFile; at: number; bytes: number }[];
|
|
47
|
+
fields: { instance: readonly string[]; tier3Instance: readonly string[] };
|
|
48
|
+
}
|
|
49
|
+
const REASON_BYTES = 400;
|
|
50
|
+
const BLOCKS: readonly (readonly string[])[] = [["review", "adaptive"], ["review", "min_reviews"], ["review", "pool"], ["review", "rerotate"], ["snapshots", "keep"], ["approvals", "timeout_s"], ["context", "gate"]];
|
|
51
|
+
|
|
52
|
+
export const CONFIG_EDIT_OPS = ["config_edit_get", "config_edit_preview", "config_edit_apply"] as const;
|
|
53
|
+
|
|
54
|
+
export function createConfigEditor(host: ConfigEditHost) {
|
|
55
|
+
const sdlcPending = (): boolean => {
|
|
56
|
+
const file = host.fileSdlc(), running = host.runningSdlc();
|
|
57
|
+
return typeof file !== "string" && (file.mode !== running.mode || JSON.stringify(file.requiredChecks) !== JSON.stringify(running.requiredChecks));
|
|
58
|
+
};
|
|
59
|
+
/** What a changed sdlc.toml waits for: shown with the other pending restarts. */
|
|
60
|
+
const sdlcRestart = (): { key: string; reason: string }[] => {
|
|
61
|
+
const file = host.fileSdlc(), running = host.runningSdlc();
|
|
62
|
+
if (typeof file === "string") return [];
|
|
63
|
+
return [...(file.mode !== running.mode ? [{ key: "sdlc.mode", reason: "the lifecycle is read once at hub start" }] : []),
|
|
64
|
+
...(JSON.stringify(file.requiredChecks) !== JSON.stringify(running.requiredChecks) ? [{ key: "sdlc.required_checks", reason: "the lifecycle is read once at hub start" }] : [])];
|
|
65
|
+
};
|
|
66
|
+
const pendingRestart = (): { key: string; reason: string }[] => sdlcRestart();
|
|
67
|
+
const allPending = (): string[] => [...host.pending().map(row => row.key), ...sdlcRestart().map(row => row.key)];
|
|
68
|
+
function view(): ConfigEditView {
|
|
69
|
+
const file = host.fileSdlc(), running = host.runningSdlc();
|
|
70
|
+
let local: Record<string, unknown> = {};
|
|
71
|
+
try { local = host.localConfig(); } catch { /* the revisions say the file is unavailable */ }
|
|
72
|
+
const fileInstances = local.instances && typeof local.instances === "object" && !Array.isArray(local.instances) ? Object.keys(local.instances) : [];
|
|
73
|
+
const isSet = (path: readonly string[]): boolean => { let node: unknown = local; for (const part of path) { if (!node || typeof node !== "object" || !Object.hasOwn(node, part)) return false; node = (node as Record<string, unknown>)[part]; } return node !== undefined; };
|
|
74
|
+
const declared = host.declared();
|
|
75
|
+
return {
|
|
76
|
+
revisions: viewRevisions(host.cwd),
|
|
77
|
+
known: fileInstances,
|
|
78
|
+
instances: Object.entries(declared).map(([id, fields]) => ({ id, fields: Object.fromEntries(TIER2_INSTANCE_FIELDS.filter(field => fields[field] !== undefined).map(field => [field, fields[field]])),
|
|
79
|
+
tier3: TIER3_INSTANCE_FIELDS.filter(field => fields[field] !== undefined) as string[] })),
|
|
80
|
+
blocks: BLOCKS.map(path => ({ path: path.join("."), value: host.effective(path) ?? null })),
|
|
81
|
+
sdlc: { mode: running.mode, requiredChecks: running.requiredChecks, pending: sdlcPending(), ...(typeof file === "string" ? { problem: file } : {}) },
|
|
82
|
+
tier3: [...TIER3_PREFIXES.map(key => ({ key, set: isSet(key.split(".")), command: `ahub settings edit config.local.json set ${key} <json>` })),
|
|
83
|
+
...Object.entries(declared).flatMap(([id, fields]) => TIER3_INSTANCE_FIELDS.filter(field => fields[field] !== undefined).map(field => ({ key: `instances.${id}.${field}`, set: true, command: `ahub settings edit config.local.json set instances.${id}.${field} <json>` })))],
|
|
84
|
+
backups: configBackups(host.stateDir).slice(0, 10),
|
|
85
|
+
fields: { instance: TIER2_INSTANCE_FIELDS, tier3Instance: TIER3_INSTANCE_FIELDS },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** A refusal before any loader runs: an unauthorized or unclassifiable request learns nothing about the project. */
|
|
89
|
+
function authorize(raw: unknown, authority: EditAuthority): { ok: true; tier3: string[] } | { ok: false; error: string } {
|
|
90
|
+
if (authority === "ordinary") return { ok: false, error: "structural config edits need a settings session: open one with ahub ui --settings, or use the console" };
|
|
91
|
+
const request = parseConfigEditRequest(raw);
|
|
92
|
+
if (typeof request === "string") return { ok: false, error: request };
|
|
93
|
+
const tier3: string[] = [];
|
|
94
|
+
for (const { path, value } of request.set) {
|
|
95
|
+
const tier = configTier(request.file, path, value);
|
|
96
|
+
if ("refused" in tier) return { ok: false, error: tier.refused };
|
|
97
|
+
if (tier.tier === 3) tier3.push(tier.key);
|
|
98
|
+
}
|
|
99
|
+
if (tier3.length && authority !== "terminal") return { ok: false, error: `${tier3[0]} chooses what the hub runs or where secrets go: it is never editable from the dashboard; change it in the console (ahub settings edit config.local.json set ${tier3[0]} <json>)` };
|
|
100
|
+
if (tier3.length > 1) return { ok: false, error: "change one executable or secret-locating key at a time: each needs its own typed confirmation" };
|
|
101
|
+
return { ok: true, tier3 };
|
|
102
|
+
}
|
|
103
|
+
function planned(raw: unknown, authority: EditAuthority): { ok: true; plan: Extract<ConfigEditPlan, { ok: true }> } | { ok: false; error: string; field?: string } {
|
|
104
|
+
const allowed = authorize(raw, authority);
|
|
105
|
+
if (!allowed.ok) return allowed;
|
|
106
|
+
const held = host.held();
|
|
107
|
+
if (held) return { ok: false, error: `${held} is holding config edits` };
|
|
108
|
+
const plan = planConfigEdit(host.cwd, raw, host.check);
|
|
109
|
+
if (!plan.ok) return plan;
|
|
110
|
+
// The plan reads the file itself: if it finds a tier 3 key the request alone did not show, a dashboard still learns nothing.
|
|
111
|
+
if (plan.tier === 3 && authority !== "terminal") return { ok: false, error: `${plan.tier3Keys[0]} chooses what the hub runs or where secrets go: it is never editable from the dashboard; change it in the console` };
|
|
112
|
+
// The request names some tier 3 keys and the file adds others (an instance it holds a home for): one write changes exactly one, and each is typed.
|
|
113
|
+
if (plan.tier3Keys.length > 1) return { ok: false, error: "change one executable or secret-locating key at a time: each needs its own typed confirmation" };
|
|
114
|
+
return { ok: true, plan };
|
|
115
|
+
}
|
|
116
|
+
function diffText(plan: Extract<ConfigEditPlan, { ok: true }>): string[] {
|
|
117
|
+
const show = (path: string, value: unknown): string => { const masked = maskTier3(path.split("."), value); return masked === undefined ? "(unset)" : JSON.stringify(masked); };
|
|
118
|
+
return plan.diff.map(row => `${row.path}: ${show(row.path, row.before)} -> ${show(row.path, row.after)} (${row.applies === "restart" ? "pending restart" : "applies at next use"})`);
|
|
119
|
+
}
|
|
120
|
+
function preview(raw: unknown, authority: EditAuthority): Record<string, unknown> {
|
|
121
|
+
const result = planned(raw, authority);
|
|
122
|
+
if (!result.ok) return { ok: false, error: result.error, ...(result.field ? { field: result.field } : {}) };
|
|
123
|
+
const { plan } = result;
|
|
124
|
+
return { ok: true, file: plan.request.file, revision: plan.request.revision, tier: plan.tier, lines: [...diffText(plan), ...plan.notes],
|
|
125
|
+
diff: plan.diff.map(row => ({ path: row.path, tier: row.tier, applies: row.applies, before: maskTier3(row.path.split("."), row.before) ?? null, after: maskTier3(row.path.split("."), row.after) ?? null })),
|
|
126
|
+
...(plan.tier === 3 ? { confirm: plan.tier3Keys[0], warning: "this runs code with the daemon's rights, or chooses where secrets are read from" } : {}) };
|
|
127
|
+
}
|
|
128
|
+
async function apply(raw: unknown, authority: EditAuthority, confirm?: unknown, rawReason?: unknown): Promise<Record<string, unknown>> {
|
|
129
|
+
let reason: string | undefined;
|
|
130
|
+
if (rawReason !== undefined) {
|
|
131
|
+
if (typeof rawReason !== "string" || !rawReason.trim() || Buffer.byteLength(rawReason) > REASON_BYTES) return { ok: false, error: `reason must contain 1..${REASON_BYTES} UTF-8 bytes` };
|
|
132
|
+
reason = rawReason.trim().replace(/[\x00-\x1f\x7f]/g, " ");
|
|
133
|
+
}
|
|
134
|
+
const result = planned(raw, authority);
|
|
135
|
+
if (!result.ok) return { ok: false, error: result.error, ...(result.field ? { field: result.field } : {}) };
|
|
136
|
+
const { plan } = result;
|
|
137
|
+
if (plan.tier === 3 && confirm !== plan.tier3Keys[0]) return { ok: false, error: `${plan.tier3Keys[0]} runs code with the daemon's rights or locates secrets: type the key name to confirm; nothing was written`, confirm: plan.tier3Keys[0] };
|
|
138
|
+
let written: ReturnType<typeof writeConfigEdit>;
|
|
139
|
+
try { written = writeConfigEdit(host.cwd, host.stateDir, plan); }
|
|
140
|
+
catch (error) { return { ok: false, error: `${(error as Error).message}; nothing was changed` }; }
|
|
141
|
+
const source = authority === "terminal" ? "terminal" as const : "dashboard" as const;
|
|
142
|
+
host.event({ type: "config_edit", file: plan.request.file, paths: written.paths, fileBefore: written.fileBefore, fileAfter: written.fileAfter, tier: plan.tier, by: "person", source, ...(written.backup ? { backup: written.backup } : {}), ...(reason ? { reason } : {}) });
|
|
143
|
+
try { host.notify(`config edit: ${plan.request.file} ${plan.diff.map(row => row.path).join(", ")} (${source})`); } catch { /* the write stands */ }
|
|
144
|
+
const settled = () => { try { host.changed(); } catch { /* the write stands; the next status write shows it */ } };
|
|
145
|
+
if (plan.request.file === "sdlc.toml") { settled(); return { ok: true, saved: true, ...(written.backup ? { backup: written.backup } : {}), lines: diffText(plan), text: `sdlc.toml written; the lifecycle is read once at hub start: restart pending`, restartPending: allPending() }; }
|
|
146
|
+
const failures: string[] = [];
|
|
147
|
+
for (const key of leafKeys(plan)) {
|
|
148
|
+
let reloaded: Record<string, unknown>;
|
|
149
|
+
try { reloaded = await host.reload(authority, key); } catch { reloaded = { ok: false, error: "the change was saved but could not be published; inspect hub.log" }; }
|
|
150
|
+
if (reloaded.ok !== true) failures.push(`${key}: ${String(reloaded.error ?? "not published")}`);
|
|
151
|
+
}
|
|
152
|
+
settled();
|
|
153
|
+
if (failures.length) return { ok: false, saved: true, error: `saved, but not published: ${failures.join("; ")}; run ahub settings reload to review it`, ...(written.backup ? { backup: written.backup } : {}) };
|
|
154
|
+
return { ok: true, saved: true, ...(written.backup ? { backup: written.backup } : {}), lines: diffText(plan), text: `config.local.json written: ${plan.diff.map(row => row.path).join(", ")}${plan.diff.some(row => row.applies === "restart") ? "; restart pending" : ""}`, restartPending: allPending() };
|
|
155
|
+
}
|
|
156
|
+
return { view, preview, apply, pendingRestart };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The leaf keys a plan changed, so each is published through its own scoped reload. */
|
|
160
|
+
function leafKeys(plan: Extract<ConfigEditPlan, { ok: true }>): string[] {
|
|
161
|
+
const out = new Set<string>();
|
|
162
|
+
const walk = (prefix: string, before: unknown, after: unknown): void => {
|
|
163
|
+
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
164
|
+
if (object(before) || object(after)) {
|
|
165
|
+
const old = object(before) ? before : {}, next = object(after) ? after : {};
|
|
166
|
+
for (const key of new Set([...Object.keys(old), ...Object.keys(next)])) walk(`${prefix}.${key}`, old[key], next[key]);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (JSON.stringify(before ?? null) !== JSON.stringify(after ?? null)) out.add(prefix);
|
|
170
|
+
};
|
|
171
|
+
for (const row of plan.diff) walk(row.path, row.before, row.after);
|
|
172
|
+
return [...out];
|
|
173
|
+
}
|