@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.
Files changed (120) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +23 -9
  3. package/docs/agent-notes/README.md +8 -0
  4. package/docs/agent-notes/adapters.md +21 -4
  5. package/docs/agent-notes/budget.md +6 -4
  6. package/docs/agent-notes/bus.md +1 -0
  7. package/docs/agent-notes/daemon.md +20 -9
  8. package/docs/agent-notes/local-worker.md +2 -2
  9. package/docs/agent-notes/tasks.md +21 -4
  10. package/docs/agent-notes/tests.md +6 -3
  11. package/docs/dogfood.md +19 -5
  12. package/docs/events.md +21 -7
  13. package/docs/memory.md +78 -10
  14. package/docs/operations.md +197 -39
  15. package/docs/profiles.md +1 -0
  16. package/docs/quickstart.md +16 -6
  17. package/docs/sdlc.md +17 -0
  18. package/docs/security.md +110 -21
  19. package/docs/smoke.md +7 -3
  20. package/docs/specs/2026-09-19-agent-hub-design.md +156 -16
  21. package/docs/verification/2026-10-23-0.15.0.md +80 -0
  22. package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
  23. package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
  24. package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
  25. package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
  26. package/docs/verification/2026-10-24-amem-runtime.md +69 -0
  27. package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
  28. package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
  29. package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
  30. package/docs/verification/2026-10-25-0.16.0.md +82 -0
  31. package/docs/verified.json +40 -19
  32. package/package.json +2 -2
  33. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  34. package/plugins/agent-hub/server.js +3109 -2046
  35. package/src/adapters/acp.ts +46 -7
  36. package/src/adapters/claude-channel.ts +26 -4
  37. package/src/adapters/claude-implementer.ts +243 -0
  38. package/src/adapters/claude-print-child.ts +90 -0
  39. package/src/adapters/claude-task-sessions.ts +80 -0
  40. package/src/adapters/codex-appserver.ts +69 -1
  41. package/src/adapters/compat.ts +6 -2
  42. package/src/adapters/conformance/enrollment.ts +2 -1
  43. package/src/adapters/conformance/fakes/acp-server.ts +3 -1
  44. package/src/adapters/definitions/builtin-metadata.ts +17 -5
  45. package/src/adapters/definitions/native-channel-state.ts +1 -1
  46. package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
  47. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  48. package/src/adapters/definitions/native-factories.ts +8 -0
  49. package/src/adapters/kimi-wire-usage.ts +303 -0
  50. package/src/adapters/local-worker.ts +5 -4
  51. package/src/adapters/registry-types.ts +8 -4
  52. package/src/adapters/registry.ts +14 -2
  53. package/src/adapters/reviewer-print.ts +30 -53
  54. package/src/cli/console-config.ts +123 -0
  55. package/src/cli/console-state.ts +48 -3
  56. package/src/cli/console-symbols.ts +3 -2
  57. package/src/cli/console-usage.ts +5 -2
  58. package/src/cli/console.ts +30 -2
  59. package/src/cli/facts-hook.ts +9 -5
  60. package/src/cli/help.ts +10 -7
  61. package/src/cli/identity.ts +3 -1
  62. package/src/cli/launch.ts +10 -4
  63. package/src/cli/main.ts +91 -19
  64. package/src/cli/memory.ts +119 -7
  65. package/src/cli/output.ts +40 -4
  66. package/src/cli/reset.ts +1 -1
  67. package/src/cli/settings-edit.ts +89 -0
  68. package/src/cli/terminal-recovery.ts +1 -6
  69. package/src/hub/action-class.ts +211 -17
  70. package/src/hub/action-enforce.ts +90 -0
  71. package/src/hub/action-policy.ts +40 -17
  72. package/src/hub/ask.ts +16 -4
  73. package/src/hub/board.ts +110 -31
  74. package/src/hub/budget-notes.ts +65 -0
  75. package/src/hub/budget.ts +44 -7
  76. package/src/hub/bus.ts +6 -6
  77. package/src/hub/claude-launch-proof.ts +33 -0
  78. package/src/hub/code-facts.ts +56 -16
  79. package/src/hub/conductor.ts +5 -2
  80. package/src/hub/config-apply.ts +12 -0
  81. package/src/hub/config-edit-host.ts +173 -0
  82. package/src/hub/config-edit.ts +282 -0
  83. package/src/hub/config-tier.ts +89 -0
  84. package/src/hub/control-client.ts +24 -3
  85. package/src/hub/daemon.ts +614 -104
  86. package/src/hub/deliverable-files.ts +131 -0
  87. package/src/hub/deliverables.ts +125 -0
  88. package/src/hub/delivery-journal.ts +18 -2
  89. package/src/hub/envelope.ts +1 -1
  90. package/src/hub/events.ts +5 -3
  91. package/src/hub/hub-tools.ts +22 -6
  92. package/src/hub/instances.ts +7 -4
  93. package/src/hub/kimi-usage.ts +156 -0
  94. package/src/hub/lifecycle.ts +10 -5
  95. package/src/hub/manager.ts +1 -1
  96. package/src/hub/memory-code-head.ts +44 -0
  97. package/src/hub/memory-code-overlay.ts +119 -0
  98. package/src/hub/memory-plan-guard.ts +10 -1
  99. package/src/hub/memory-stale-anchors.ts +159 -0
  100. package/src/hub/memory-symbols.ts +74 -0
  101. package/src/hub/peers.ts +7 -0
  102. package/src/hub/permission-payload.ts +15 -0
  103. package/src/hub/planning.ts +10 -4
  104. package/src/hub/read-tools.ts +21 -3
  105. package/src/hub/routing.ts +118 -3
  106. package/src/hub/sdlc.ts +34 -0
  107. package/src/hub/settings.ts +18 -3
  108. package/src/hub/settled-roles.ts +15 -3
  109. package/src/hub/task-sweep.ts +25 -3
  110. package/src/hub/tasks.ts +551 -75
  111. package/src/hub/token-window.ts +46 -0
  112. package/src/hub/usage-rollup.ts +16 -6
  113. package/src/hub/window-kind.ts +13 -0
  114. package/src/hub/workflow.ts +16 -0
  115. package/src/local/tools.ts +5 -5
  116. package/src/memory/brief.ts +22 -12
  117. package/src/memory/capture.ts +26 -40
  118. package/src/memory/client.ts +137 -65
  119. package/src/memory/recall.ts +13 -22
  120. package/src/ui/index.html +215 -19
@@ -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 Promise.resolve(unknown());
169
- let repo:string;try{repo=realPath(at.repo);}catch{return Promise.resolve(unknown());}
170
- const id=JSON.stringify([repo,at.head,key]),hit=this.cache.get(id);if(hit)return (hit as Promise<CodeFactResult<T>>).then(value=>structuredClone(value));
171
- if(this.busy)return Promise.resolve(unknown());
172
- this.busy=true;this.failedChild=undefined;
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{if(tree && this.failedChild){this.retained={tree,child:this.failedChild};answer=unknown();}else try{tree?.close();}catch{answer=unknown();}this.busy=false;}
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=>{if(answer.state==="unknown" && this.cache.get(id)===job)this.cache.delete(id);});
189
- return job.then(value=>structuredClone(value));
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")return{exists:false,ambiguous:false};throw Error();}
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}>> {
@@ -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
- export function publicPeerBudget(status: ReturnType<Budget["status"]>, nameable: (text: string) => boolean): Record<string, unknown> {
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]",
@@ -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
+ }