@staix/agent-hub 0.12.44 → 0.12.45

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 (137) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +3 -3
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +102 -14
  5. package/docs/agent-notes/README.md +9 -0
  6. package/docs/agent-notes/adapters.md +58 -44
  7. package/docs/agent-notes/budget.md +1 -1
  8. package/docs/agent-notes/daemon.md +9 -7
  9. package/docs/agent-notes/local-worker.md +4 -3
  10. package/docs/agent-notes/tasks.md +8 -5
  11. package/docs/agent-notes/tests.md +7 -7
  12. package/docs/operations.md +99 -7
  13. package/docs/quickstart.md +5 -1
  14. package/docs/sdlc.md +26 -8
  15. package/docs/security.md +6 -3
  16. package/docs/specs/2026-09-19-agent-hub-design.md +114 -5
  17. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  18. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  19. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  20. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  21. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  22. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  23. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  24. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  25. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  26. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  27. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  28. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  29. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  30. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  31. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  32. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  33. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  34. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  35. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  36. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  37. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  38. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  39. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  40. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  41. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  43. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  44. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  45. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  46. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  47. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  48. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  49. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  51. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  53. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  54. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  55. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  56. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  57. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  58. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  59. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  60. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  61. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  62. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  65. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  66. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  68. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  69. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  70. package/docs/verified.json +19 -16
  71. package/package.json +5 -1
  72. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  73. package/plugins/agent-hub/server.js +9818 -9605
  74. package/src/adapters/adapter-api.ts +2 -0
  75. package/src/adapters/agy/definition.ts +3 -1
  76. package/src/adapters/claude-channel.ts +14 -5
  77. package/src/adapters/codex-appserver.ts +1 -0
  78. package/src/adapters/conformance/acp.ts +114 -0
  79. package/src/adapters/conformance/codex.ts +191 -0
  80. package/src/adapters/conformance/enrollment.ts +43 -0
  81. package/src/adapters/conformance/factories.ts +17 -0
  82. package/src/adapters/conformance/fake-paths.ts +12 -0
  83. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  84. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  85. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  86. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  87. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  88. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  89. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  90. package/src/adapters/conformance/index.ts +11 -0
  91. package/src/adapters/conformance/kit.ts +50 -0
  92. package/src/adapters/conformance/local-pi.ts +100 -0
  93. package/src/adapters/conformance/owned-acp.ts +44 -0
  94. package/src/adapters/definitions/builtin-metadata.ts +23 -6
  95. package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
  96. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  97. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  98. package/src/adapters/definitions/qwen.ts +4 -1
  99. package/src/adapters/external-metadata.ts +48 -0
  100. package/src/adapters/external-module-scan.ts +126 -0
  101. package/src/adapters/external-modules.ts +240 -0
  102. package/src/adapters/pi.ts +2 -0
  103. package/src/adapters/public.ts +7 -0
  104. package/src/adapters/registry-types.ts +10 -0
  105. package/src/adapters/registry.ts +51 -15
  106. package/src/cli/help.ts +21 -16
  107. package/src/cli/identity.ts +1 -1
  108. package/src/cli/main.ts +23 -5
  109. package/src/cli/upgrade-labels.ts +1 -1
  110. package/src/cli/upgrade-presentation.ts +1 -1
  111. package/src/cli/upgrade-restore-state.ts +3 -2
  112. package/src/cli/upgrade-runtime.ts +4 -1
  113. package/src/hub/board.ts +30 -4
  114. package/src/hub/config-apply.ts +18 -10
  115. package/src/hub/config-reload.ts +8 -5
  116. package/src/hub/config-trust.ts +1 -1
  117. package/src/hub/control-client.ts +4 -1
  118. package/src/hub/daemon.ts +95 -39
  119. package/src/hub/facts.ts +76 -13
  120. package/src/hub/hub-tools.ts +21 -5
  121. package/src/hub/instance-config.ts +17 -5
  122. package/src/hub/instance-execution.ts +20 -8
  123. package/src/hub/instances.ts +2 -1
  124. package/src/hub/peers.ts +9 -0
  125. package/src/hub/plan-conformance.ts +83 -0
  126. package/src/hub/review-findings.ts +104 -0
  127. package/src/hub/roster.ts +31 -5
  128. package/src/hub/sdlc-queue.ts +19 -4
  129. package/src/hub/sdlc-release.ts +16 -1
  130. package/src/hub/sdlc.ts +27 -12
  131. package/src/hub/settings.ts +5 -1
  132. package/src/hub/snapshots.ts +8 -6
  133. package/src/hub/tasks.ts +275 -45
  134. package/src/hub/ui.ts +2 -2
  135. package/src/ui/index.html +559 -78
  136. package/src/ui/office-model.ts +3 -1
  137. package/src/ui/peer-palette.ts +44 -0
package/src/hub/sdlc.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { planConformanceBlockers, type PlanConformance } from "./plan-conformance.ts";
2
+ import { DEFAULT_MAX_NITS, findingsForHead, findingHead, findingTally, type ReviewFindingSet, type ReviewFindingTally } from "./review-findings.ts";
1
3
  import type { CostPlan } from "./cost-governance.ts";
2
4
  import { criteriaReceipt, type CriteriaReceipt, type CriteriaView, type LegacyPhaseReceipt } from "./workflow.ts";
3
5
  import { constants, openSync, fstatSync, readSync, closeSync } from "node:fs";
@@ -29,7 +31,7 @@ export type StageGate = "issue" | "pr" | "local" | "review" | "checks" | "criter
29
31
  export interface StageDefinition { base: SdlcBaseStage; actor: string; entry_gates: StageGate[]; exit_gates: StageGate[]; after: string[]; }
30
32
  export type SdlcMode = "off" | "advise" | "enforce";
31
33
  /** `workflow: "gsd-lite"` marks the retired setting during its window (#481 S8): every task then needs a criteria checklist. */
32
- export interface SdlcConfig { workflow?: "gsd-lite"; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
34
+ export interface SdlcConfig { workflow?: "gsd-lite"; maxNits?: number; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
33
35
  export interface PlanReviewReceipt {
34
36
  planner: string;
35
37
  planningRevision: number;
@@ -63,6 +65,8 @@ export interface PrEvidence {
63
65
  pendingReviews: number | null;
64
66
  paths: string[];
65
67
  pathsComplete?: boolean;
68
+ /** All affected path identities are represented; rename/copy destinations alone cannot certify S2. */
69
+ typesComplete?: boolean;
66
70
  draft?: boolean;
67
71
  base?: string;
68
72
  baseHead?: string;
@@ -125,7 +129,10 @@ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
125
129
  if (value === undefined) return { mode: "off", requiredChecks: [], stages: [...SDLC_STAGES] };
126
130
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc: expected a table");
127
131
  const raw = value as Record<string, unknown>;
128
- for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
132
+ for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions", "max_nits"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
133
+ const maxNits = raw.max_nits === undefined ? DEFAULT_MAX_NITS : raw.max_nits;
134
+ if (typeof maxNits !== "number" || !Number.isSafeInteger(maxNits) || maxNits < 0 || maxNits > 64) throw new Error("sdlc.max_nits: expected an integer from 0 to 64");
135
+ const findingConfig = raw.max_nits === undefined ? {} : { maxNits };
129
136
  const mode = raw.mode ?? "off";
130
137
  if (mode !== "off" && mode !== "advise" && mode !== "enforce") throw new Error("sdlc.mode: expected off, advise or enforce");
131
138
  // ponytail: the #481 S8 window is a fixed version; delete `workflow`, the phase aliases and their tests once it passes.
@@ -140,7 +147,7 @@ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
140
147
  if (raw.definitions !== undefined) {
141
148
  const custom = definitions(raw.definitions, raw.stages, phaseAlias);
142
149
  if (phaseAlias.used) notes.push(`the sdlc phase gate is deprecated and stops loading in ${RETIRED_WORKFLOW_FROM} (#481): name it criteria`);
143
- return { mode, requiredChecks: [...checks] as string[], ...custom, ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
150
+ return { mode, ...findingConfig, requiredChecks: [...checks] as string[], ...custom, ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
144
151
  }
145
152
  // Off keeps the #257 seven stages byte for byte; an enabled lifecycle defaults to the playbook (#481 S0).
146
153
  const given = raw.stages ?? (mode === "off" ? [...SDLC_STAGES] : [...PLAYBOOK_STAGES]);
@@ -148,7 +155,7 @@ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
148
155
  // An enabled #257 list keeps its order under canonical ids, so implement/verify stay read-only aliases there too.
149
156
  const stages = mode !== "off" && exactly(SDLC_STAGES) ? SDLC_STAGES.map(canonicalStage) : given;
150
157
  if (!exactly(SDLC_STAGES) && !exactly(PLAYBOOK_STAGES)) throw new Error(`sdlc.stages: use ${PLAYBOOK_STAGES.join(", ")} in that order (or the #257 list ${SDLC_STAGES.join(", ")}); other lists need definitions`);
151
- return { mode, requiredChecks: [...checks] as string[], stages: [...stages as string[]], ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
158
+ return { mode, ...findingConfig, requiredChecks: [...checks] as string[], stages: [...stages as string[]], ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
152
159
  }
153
160
 
154
161
  export function loadSdlc(cwd: string, version?: string): SdlcConfig {
@@ -216,7 +223,9 @@ export function parsePrEvidence(value: unknown): PrEvidence {
216
223
  return { name, state } as PrEvidence["checks"][number];
217
224
  });
218
225
  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; });
219
- 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 } : {}),
226
+ const completeTypes = p.files.every((file: Record<string, unknown>) => typeof file.changeType === "string" && ["ADDED", "DELETED", "MODIFIED", "CHANGED"].includes(file.changeType));
227
+ // Rename/copy metadata exposes only its destination, never a complete inventory of affected identities.
228
+ return { head: p.headRefOid, state: p.state, checks, paths, pathsComplete: Number.isSafeInteger(p.changedFiles) && p.changedFiles >= 0 && p.changedFiles === paths.length, typesComplete: completeTypes, ...(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 } : {}),
220
229
  review: p.reviewDecision === "APPROVED" ? "approved" : p.reviewDecision === "CHANGES_REQUESTED" ? "changes_requested" : "unknown",
221
230
  pendingReviews: Array.isArray(p.reviewRequests) && p.reviewRequests.length <= 100 && p.reviewRequests.every((r: unknown) => !!r && typeof r === "object") ? p.reviewRequests.length : null,
222
231
  unresolved: Number.isSafeInteger(p.unresolvedThreads) && p.unresolvedThreads >= 0 ? p.unresolvedThreads : null,
@@ -239,12 +248,16 @@ export const SPEC_STATE_TEXT: Record<Exclude<SpecState, "current">, string> = {
239
248
  changed: "spec changed after acceptance: the person re-accepts with ahub sdlc accept <task> --issue <n>",
240
249
  unknown: "spec edit time unknown: the issue could not be read",
241
250
  };
242
- export interface SdlcStatus { spec?: { issue: number; state: SpecState }; playbook?: string; criteria?: CriteriaView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
251
+ export interface SdlcStatus { planConformance?: PlanConformance & { accepted?: boolean; earlierPushes?: "unknown"; revisionsAfterFirstObservedPush?: number }; findings?: ReviewFindingTally; spec?: { issue: number; state: SpecState }; playbook?: string; criteria?: CriteriaView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
243
252
  /** The acceptance-criteria checklist (#481 S8, Compliance pass) counts once recorded, and on every task under the retired gsd-lite setting. Off keeps #257's output. */
244
253
  export const criteriaRequired = (config: SdlcConfig, receipt: SdlcReceipt | undefined): boolean => config.workflow === "gsd-lite" || (config.mode !== "off" && !!checklistOf(receipt));
245
- export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView, specEdit?: SpecEdit): SdlcStatus {
254
+ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView, specEdit?: SpecEdit, sets: readonly ReviewFindingSet[] = [], commit?: string, plan?: SdlcStatus["planConformance"]): SdlcStatus {
246
255
  const stage = receipt?.stage ?? config.stages[0] ?? "spec", blockers: string[] = [];
247
256
  const definition = stageDefinition(config, stage);
257
+ const items = findingsForHead(sets, findingHead(receipt, evidence, commit));
258
+ const findings = items.length ? findingTally(items) : undefined;
259
+ if (config.mode === "enforce" && findings?.openImportant) blockers.push("open important review findings: " + findings.openImportant);
260
+ if (config.mode === "enforce") blockers.push(...planConformanceBlockers(plan));
248
261
  if (!definition) blockers.push("lifecycle stage definition unknown");
249
262
  if (!receipt?.repo || !receipt.issue) blockers.push("issue identity missing");
250
263
  if (!receipt?.pr) blockers.push("PR identity missing");
@@ -270,7 +283,7 @@ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined,
270
283
  const shown = criteria && (criteriaRequired(config, receipt) || definition && [...definition.entry_gates, ...definition.exit_gates].includes("criteria"));
271
284
  // Shown, not counted as a readiness blocker: the spec gate is build entry (#481 S1).
272
285
  const spec = config.mode !== "off" && receipt?.issue ? { spec: { issue: receipt.issue, state: specState(receipt, specEdit) } } : {};
273
- return { ...spec, ...(config.mode !== "off" && definition && playbookStage(definition.base) ? { playbook: playbookStage(definition.base) } : {}), ...(shown ? { criteria } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
286
+ return { ...spec, ...(config.mode !== "off" && plan ? { planConformance: plan } : {}), ...(findings ? { findings } : {}), ...(config.mode !== "off" && definition && playbookStage(definition.base) ? { playbook: playbookStage(definition.base) } : {}), ...(shown ? { criteria } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
274
287
  actor: !definition ? "person" : shown && criteria.blockers.length ? "reviewer/person" : config.definitions ? definition.actor : baseActor(definition.base),
275
288
  next: blockers[0] ?? "person may inspect merge readiness; no automatic merge" };
276
289
  }
@@ -434,13 +447,15 @@ export async function readReviewReadiness(receipt: SdlcReceipt, expected: string
434
447
  if (thread?.isResolved !== false) { blockers.push("bot review thread evidence unavailable"); continue; }
435
448
  const comments = thread?.comments;
436
449
  if (comments?.pageInfo?.hasNextPage !== false || !Array.isArray(comments.nodes) || comments.nodes.length > 100
437
- || comments.nodes.some((comment: { author?: { __typename?: string }; createdAt?: string }) => !["Bot", "User"].includes(comment?.author?.__typename ?? "") || !Number.isFinite(Date.parse(comment.createdAt ?? "")))) {
450
+ || comments.nodes.some((comment: { createdAt?: string }) => !Number.isFinite(Date.parse(comment?.createdAt ?? "")))) {
438
451
  blockers.push("bot review thread evidence unavailable"); continue;
439
452
  }
453
+ // #230 item 3: only a `User` comment answers. Every other or missing author kind (Bot, null, Organization, Mannequin,
454
+ // EnterpriseUserAccount, a kind GitHub adds later) is a pending bot comment, so an unresolved thread settles by
455
+ // resolution or a later User comment. No weaker than before, and still no body or login is read.
440
456
  let unanswered = false;
441
- for (const comment of comments.nodes) {
442
- if (comment.author.__typename === "Bot") unanswered = true;
443
- else if (comment.author.__typename === "User") unanswered = false;
457
+ for (const comment of comments.nodes as { author?: { __typename?: string | null } | null }[]) {
458
+ unanswered = comment?.author?.__typename !== "User";
444
459
  }
445
460
  if (unanswered) blockers.push("bot review threads unanswered");
446
461
  }
@@ -75,7 +75,7 @@ export const SETTINGS: readonly SettingDef[] = [
75
75
  * Never editable from settings, whatever the session: these choose what the hub executes or where secrets and task
76
76
  * text go. A key equal to an entry, or below it, is refused by name.
77
77
  */
78
- 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", "instances", "adapters"] as const;
78
+ 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", "instances", "adapters", "adapter_modules"] as const;
79
79
 
80
80
  /** Derive project-bound rows from the same builtin templates used by lookup, without registering aliases globally. */
81
81
  export function projectSettings(instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): readonly SettingDef[] {
@@ -384,6 +384,10 @@ export function undoSetting(cwd: string, stateDir: string, validate: (scratch: s
384
384
  export function storedLocally(cwd: string, def: SettingDef): boolean {
385
385
  try { return valueAt(JSON.parse(readFileSync(join(cwd, ".agenthub", CONFIG_LOCAL), "utf8")), def.path) !== undefined; } catch { return false; }
386
386
  }
387
+ /** What the shared `.agenthub/config.json` holds for the key, or undefined: a removal here only ever touches the machine-local file. */
388
+ export function sharedValue(cwd: string, def: SettingDef): unknown {
389
+ try { return valueAt(JSON.parse(readFileSync(join(cwd, ".agenthub", "config.json"), "utf8")), def.path); } catch { return undefined; }
390
+ }
387
391
  /**
388
392
  * For a config setting: the value the config files would hold for it after the pending undo (the previous version of
389
393
  * the machine-local file, else the shared file), or, with `inherit`, after the machine-local value is removed (the
@@ -20,19 +20,21 @@ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
20
20
  * ponytail: the trees are unreferenced, so `git gc` may prune them after gc.pruneExpire (two weeks by default); undo
21
21
  * of an older turn says so. Refs under refs/agenthub/ would keep them, at the cost of showing up in the user's repo.
22
22
  */
23
- export interface SnapshotBudget { deadline: number; }
23
+ /** `now` is a host-only clock (#661): a test holds it fixed so the budget never runs out whatever the runner's speed. Production leaves it unset (performance.now). */
24
+ export interface SnapshotBudget { deadline: number; now?: () => number; }
25
+ export const budgetNow = (budget: SnapshotBudget): number => budget.now ? budget.now() : performance.now();
24
26
  export class SnapshotBudgetExceeded extends Error {
25
27
  constructor() { super("snapshot burst budget exhausted; boundary evidence is unknown"); }
26
28
  }
27
29
  const git = (top: string | Repo, args: string[], env: Record<string, string> = {}, budget?: SnapshotBudget, maxBuffer?: number) => {
28
30
  const argv = repoGitArgs(top, args);
29
- const remaining = budget ? budget.deadline - performance.now() : SNAPSHOT_TIMEOUT_MS;
31
+ const remaining = budget ? budget.deadline - budgetNow(budget) : SNAPSHOT_TIMEOUT_MS;
30
32
  if (remaining <= 0) throw new SnapshotBudgetExceeded();
31
33
  const result = hubGitSync(argv, {
32
34
  encoding: "utf8", env: { ...repoGitEnv(top), ...env }, ...(maxBuffer === undefined ? {} : { maxBuffer }),
33
35
  timeout: Math.max(1, Math.min(SNAPSHOT_TIMEOUT_MS, Math.floor(remaining))),
34
36
  });
35
- if (budget && (performance.now() >= budget.deadline || (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT")) throw new SnapshotBudgetExceeded();
37
+ if (budget && (budgetNow(budget) >= budget.deadline || (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT")) throw new SnapshotBudgetExceeded();
36
38
  return result;
37
39
  };
38
40
  const SNAPSHOT_TIMEOUT_MS = 10_000;
@@ -146,7 +148,7 @@ export function branchHeads(repo: Repo, budget?: SnapshotBudget): string | undef
146
148
  const result = git(repo, ["for-each-ref", "--sort=refname", "--format=%(refname) %(objectname)", "refs/heads/", "refs/remotes/"], {}, budget, 1024 * 1024);
147
149
  if (result.status !== 0 || result.error) return undefined;
148
150
  const digest = createHash("sha256").update(result.stdout).digest("hex");
149
- if (budget && performance.now() >= budget.deadline) throw new SnapshotBudgetExceeded();
151
+ if (budget && budgetNow(budget) >= budget.deadline) throw new SnapshotBudgetExceeded();
150
152
  return digest;
151
153
  }
152
154
 
@@ -158,7 +160,7 @@ export function soleWorktree(repo: Repo, budget?: SnapshotBudget): boolean | und
158
160
  const registeredRoot = repo.shadow ? repo.dir : repo.top;
159
161
  let count = 0;
160
162
  for (const field of result.stdout.split("\0")) {
161
- if (budget && performance.now() >= budget.deadline) throw new SnapshotBudgetExceeded();
163
+ if (budget && budgetNow(budget) >= budget.deadline) throw new SnapshotBudgetExceeded();
162
164
  if (!field.startsWith("worktree ")) continue;
163
165
  if (field.slice(9) !== registeredRoot || ++count > 1) return false;
164
166
  }
@@ -179,7 +181,7 @@ export interface HeadMoveCursor {
179
181
  const HEAD_LOG_TAIL_BYTES = 4096;
180
182
  const HEAD_LOG_MAX_APPEND_BYTES = 128 * 1024;
181
183
  const HEAD_LOG_MAX_MOVES = 64;
182
- const checkSnapshotBudget = (budget?: SnapshotBudget) => { if (budget && performance.now() >= budget.deadline) throw new SnapshotBudgetExceeded(); };
184
+ const checkSnapshotBudget = (budget?: SnapshotBudget) => { if (budget && budgetNow(budget) >= budget.deadline) throw new SnapshotBudgetExceeded(); };
183
185
  const sameLogFile = (a: Stats, b: Stats) => a.dev === b.dev && a.ino === b.ino;
184
186
  function readHeadLog<T>(repo: Repo, budget: SnapshotBudget | undefined, read: (fd: number, stat: Stats) => T | undefined): T | undefined {
185
187
  let fd: number | undefined;