@staix/agent-hub 0.12.43 → 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 (180) hide show
  1. package/CHANGELOG.md +38 -3
  2. package/README.md +3 -3
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +228 -0
  5. package/docs/agent-notes/README.md +14 -0
  6. package/docs/agent-notes/adapters.md +61 -42
  7. package/docs/agent-notes/budget.md +2 -2
  8. package/docs/agent-notes/daemon.md +16 -10
  9. package/docs/agent-notes/local-worker.md +4 -3
  10. package/docs/agent-notes/tasks.md +13 -6
  11. package/docs/agent-notes/tests.md +9 -7
  12. package/docs/operations.md +163 -12
  13. package/docs/quickstart.md +10 -2
  14. package/docs/sdlc.md +26 -8
  15. package/docs/security.md +11 -6
  16. package/docs/specs/2026-09-19-agent-hub-design.md +273 -8
  17. package/docs/verification/2026-10-21-0.12.44.md +54 -0
  18. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  19. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  20. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  21. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  22. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  23. package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
  24. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  25. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  26. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  27. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  28. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  29. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  30. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  31. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  32. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  33. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  34. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  35. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  36. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  37. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  38. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  39. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  40. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  41. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  43. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  44. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  45. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  46. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  47. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  48. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  49. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  51. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  53. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  54. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  55. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  56. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  57. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  58. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  59. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  60. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  61. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  62. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  65. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  66. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  68. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  69. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  70. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  71. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  72. package/docs/verified.json +38 -20
  73. package/package.json +5 -1
  74. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  75. package/plugins/agent-hub/server.js +10287 -9560
  76. package/src/adapters/acp-owned-group.ts +124 -0
  77. package/src/adapters/acp.ts +92 -25
  78. package/src/adapters/adapter-api.ts +2 -0
  79. package/src/adapters/agy/definition.ts +4 -4
  80. package/src/adapters/agy/entry.ts +4 -1
  81. package/src/adapters/channel-peer.ts +143 -0
  82. package/src/adapters/claude-channel.ts +21 -12
  83. package/src/adapters/codex-appserver.ts +17 -2
  84. package/src/adapters/configured-acp.ts +124 -0
  85. package/src/adapters/conformance/acp.ts +114 -0
  86. package/src/adapters/conformance/codex.ts +191 -0
  87. package/src/adapters/conformance/enrollment.ts +43 -0
  88. package/src/adapters/conformance/factories.ts +17 -0
  89. package/src/adapters/conformance/fake-paths.ts +12 -0
  90. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  91. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  92. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  93. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  94. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  95. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  96. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  97. package/src/adapters/conformance/index.ts +11 -0
  98. package/src/adapters/conformance/kit.ts +50 -0
  99. package/src/adapters/conformance/local-pi.ts +100 -0
  100. package/src/adapters/conformance/owned-acp.ts +44 -0
  101. package/src/adapters/definitions/builtin-metadata.ts +60 -39
  102. package/src/adapters/definitions/native-channel-state.ts +41 -0
  103. package/src/adapters/definitions/native-cli-entry-kimi.ts +9 -1
  104. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  105. package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
  106. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  107. package/src/adapters/definitions/native-identity-defaults.ts +14 -0
  108. package/src/adapters/definitions/qwen.ts +7 -9
  109. package/src/adapters/definitions/reviewer-variants.ts +3 -7
  110. package/src/adapters/external-metadata.ts +48 -0
  111. package/src/adapters/external-module-scan.ts +126 -0
  112. package/src/adapters/external-modules.ts +240 -0
  113. package/src/adapters/jcode.ts +4 -0
  114. package/src/adapters/local-worker.ts +4 -0
  115. package/src/adapters/pi.ts +6 -0
  116. package/src/adapters/public.ts +7 -0
  117. package/src/adapters/registry-types.ts +28 -16
  118. package/src/adapters/registry.ts +115 -17
  119. package/src/adapters/reviewer-print.ts +4 -0
  120. package/src/cli/agy-mcp.ts +3 -2
  121. package/src/cli/configured-acp-entry.ts +58 -0
  122. package/src/cli/console-state.ts +9 -4
  123. package/src/cli/facts-hook.ts +2 -1
  124. package/src/cli/help.ts +27 -16
  125. package/src/cli/identity.ts +8 -7
  126. package/src/cli/launch.ts +5 -4
  127. package/src/cli/main.ts +101 -32
  128. package/src/cli/memory.ts +13 -10
  129. package/src/cli/output.ts +1 -0
  130. package/src/cli/preview.ts +9 -0
  131. package/src/cli/recovery-guidance.ts +1 -1
  132. package/src/cli/terminal-recovery.ts +16 -13
  133. package/src/cli/upgrade-labels.ts +1 -1
  134. package/src/cli/upgrade-presentation.ts +1 -1
  135. package/src/cli/upgrade-restore-state.ts +6 -4
  136. package/src/cli/upgrade-runtime.ts +25 -7
  137. package/src/cli/upgrade.ts +3 -2
  138. package/src/hub/board.ts +37 -8
  139. package/src/hub/budget.ts +9 -2
  140. package/src/hub/bus.ts +6 -2
  141. package/src/hub/claude-state.ts +2 -37
  142. package/src/hub/conductor.ts +8 -6
  143. package/src/hub/config-apply.ts +37 -17
  144. package/src/hub/config-reload.ts +42 -11
  145. package/src/hub/config-trust.ts +1 -1
  146. package/src/hub/context-window.ts +6 -0
  147. package/src/hub/control-client.ts +4 -1
  148. package/src/hub/crash.ts +8 -6
  149. package/src/hub/daemon.ts +412 -328
  150. package/src/hub/facts.ts +76 -13
  151. package/src/hub/hub-tools.ts +57 -5
  152. package/src/hub/instance-config.ts +33 -12
  153. package/src/hub/instance-execution.ts +23 -11
  154. package/src/hub/instances.ts +47 -22
  155. package/src/hub/lifecycle-schema.ts +5 -3
  156. package/src/hub/memory-profile.ts +16 -3
  157. package/src/hub/no-writes.ts +50 -0
  158. package/src/hub/peers.ts +9 -0
  159. package/src/hub/permission-mode.ts +9 -7
  160. package/src/hub/plan-conformance.ts +83 -0
  161. package/src/hub/review-findings.ts +104 -0
  162. package/src/hub/roster.ts +35 -6
  163. package/src/hub/routing.ts +21 -20
  164. package/src/hub/sdlc-queue.ts +19 -4
  165. package/src/hub/sdlc-release.ts +16 -1
  166. package/src/hub/sdlc.ts +27 -12
  167. package/src/hub/settings.ts +54 -16
  168. package/src/hub/snapshots.ts +32 -6
  169. package/src/hub/start-mode.ts +10 -8
  170. package/src/hub/task-sweep.ts +8 -2
  171. package/src/hub/tasks.ts +358 -60
  172. package/src/hub/ui.ts +2 -2
  173. package/src/memory/capture.ts +2 -1
  174. package/src/ui/index.html +563 -77
  175. package/src/ui/office-model.ts +3 -1
  176. package/src/ui/peer-palette.ts +44 -0
  177. package/templates/AGENTS.block.md +1 -1
  178. package/templates/profiles/general.json +1 -1
  179. package/templates/profiles/research.json +1 -1
  180. package/templates/profiles/writing.json +1 -1
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
  }
@@ -1,4 +1,4 @@
1
- import { definitionOf, type KindMap, ADAPTERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
1
+ import { definitionOf, type KindMap, type ProjectAdapterCatalog, ADAPTERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
2
2
  import { createHash } from "node:crypto";
3
3
  import { types } from "node:util";
4
4
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
@@ -19,6 +19,8 @@ export const SETTING_PEERS = SUPPORTED_PEERS;
19
19
  /** `null` removes the machine-local value, so the shared file's or the default applies again. */
20
20
  export type SettingValue = string | boolean | number | string[] | null;
21
21
  export interface SettingDef {
22
+ /** Fixed host policy; no authority can edit or undo this row. */
23
+ locked?: string;
22
24
  key: string;
23
25
  group: "Permissions" | "Start" | "Routing" | "Switches" | "Roster" | "Roles";
24
26
  label: string;
@@ -38,6 +40,7 @@ export interface SettingDef {
38
40
  path: readonly string[];
39
41
  }
40
42
 
43
+ const READ_ONLY_LOCK = "read-only instance: permission mode is locked by its declaration";
41
44
  const PEER = /^[a-z][a-z0-9-]{0,31}$/;
42
45
  const PEERS_MAX = 8;
43
46
  const perClass = (name: TaskClass): SettingDef[] => [
@@ -48,6 +51,7 @@ const perClass = (name: TaskClass): SettingDef[] => [
48
51
  ];
49
52
  export const SETTINGS: readonly SettingDef[] = [
50
53
  ...["enabled", "auto_reassign"].map((key): SettingDef => ({ key: `task_sweep.${key}`, group: "Switches", label: `Task sweep: ${key}`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["task_sweep", key] })),
54
+ { key: "task_sweep.no_writes_turns", group: "Switches", label: "Consecutive owner turns without writes (0 disables)", type: "integer", min: 0, max: Number.MAX_SAFE_INTEGER, store: "config", applies: "live", risk: "raises", path: ["task_sweep", "no_writes_turns"] },
51
55
  ...["interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"].map((key): SettingDef => ({ key: `task_sweep.${key}`, group: "Switches", label: `Task sweep: ${key}`, type: "number", min: 1, max: 2_147_483_647 / (key === "interval_s" ? 1000 : 60_000), store: "config", applies: "live", risk: "raises", path: ["task_sweep", key] })),
52
56
  ...SETTING_PEERS.map((peer): SettingDef => ({ key: `tasks.wip_limits.${peer}`, group: "Routing", label: `${peer}: planner WIP limit`, type: "integer", min: 0, max: 32, store: "config", applies: "live", risk: "raises", floor: 0, path: ["tasks", "wip_limits", peer] })),
53
57
  { key: "tasks.escalate_after", group: "Routing", label: "Escalate after N consecutive changes_requested", type: "integer", min: 1, max: 10, store: "config", applies: "live", risk: "raises", path: ["tasks", "escalate_after"] },
@@ -71,37 +75,61 @@ export const SETTINGS: readonly SettingDef[] = [
71
75
  * Never editable from settings, whatever the session: these choose what the hub executes or where secrets and task
72
76
  * text go. A key equal to an entry, or below it, is refused by name.
73
77
  */
74
- 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"] 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;
75
79
 
76
80
  /** Derive project-bound rows from the same builtin templates used by lookup, without registering aliases globally. */
77
- export function projectSettings(instances?: KindMap): readonly SettingDef[] {
78
- if (!instances || typeof instances !== "object" || types.isProxy(instances) || Array.isArray(instances)
79
- || ![Object.prototype, null].includes(Object.getPrototypeOf(instances)) || Object.getOwnPropertySymbols(instances).length) return SETTINGS;
81
+ export function projectSettings(instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): readonly SettingDef[] {
82
+ const resolve = adapterCatalog?.definitionOf ?? definitionOf;
80
83
  const extra: SettingDef[] = [];
81
- for (const peer of Object.keys(instances)) {
82
- if (definitionOf(peer)) continue; // An explicit declaration never changes a builtin's setting definition.
83
- const adapter = definitionOf(peer, instances);
84
+ const headless = (peer: string): SettingDef => Object.freeze({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (headless only)`,
85
+ type: "enum", values: Object.freeze(["headless"]), store: "config", applies: "next use", risk: "raises", floor: "headless", path: Object.freeze(["peers", peer, "start_mode"]) });
86
+ for (const adapter of adapterCatalog?.definitions ?? []) {
87
+ if (definitionOf(adapter.kind)) continue;
88
+ const peer = adapter.kind;
89
+ extra.push(
90
+ { key: `roster.${peer}`, group: "Roster", label: `${adapter.displayName} (${peer}): participates`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", peer] },
91
+ { ...SETTINGS.find(def => def.key.startsWith("tasks.wip_limits."))!, key: `tasks.wip_limits.${peer}`, label: `${peer}: planner WIP limit`, path: ["tasks", "wip_limits", peer] },
92
+ headless(peer),
93
+ );
94
+ const values = Object.keys(adapter.permission.genericAcp?.modes ?? {});
95
+ if (!adapter.permission.unmanaged && values.length) for (const runtime of [true, false]) extra.push({
96
+ key: `${runtime ? "permission" : "permission_modes"}.${peer}`, group: "Permissions", label: `${peer}: mode ${runtime ? "now" : "at next peer start"}`,
97
+ type: "enum", values, store: runtime ? "runtime" : "config", applies: runtime ? "live" : "next use", risk: "raises", ...(values.includes("ask") ? { floor: "ask" } : {}), path: runtime ? [peer] : ["permission_modes", peer],
98
+ });
99
+ }
100
+ const baseSettings = [...SETTINGS, ...extra];
101
+ const validInstances = instances && typeof instances === "object" && !types.isProxy(instances) && !Array.isArray(instances)
102
+ && [Object.prototype, null].includes(Object.getPrototypeOf(instances)) && !Object.getOwnPropertySymbols(instances).length;
103
+ for (const peer of validInstances ? Object.keys(instances!) : []) {
104
+ if (resolve(peer)) continue; // An explicit declaration never changes a canonical setting definition.
105
+ const adapter = resolve(peer, instances);
84
106
  if (!adapter) continue;
107
+ const entry = Object.getOwnPropertyDescriptor(instances!, peer)?.value;
108
+ const readOnly = entry && typeof entry === "object" && !types.isProxy(entry) && Object.getOwnPropertyDescriptor(entry, "readOnly")?.value === true && !!adapter.permission.genericAcp?.readOnly;
85
109
  const copy = (base: SettingDef, key: string): SettingDef => Object.freeze({ ...base, key,
86
110
  label: base.label.replace(`${adapter.kind}:`, `${peer}:`),
87
111
  path: Object.freeze(base.path.map(part => part === adapter.kind ? peer : part)),
88
112
  ...(base.values ? { values: Object.freeze([...base.values]) } : {}),
89
113
  });
90
114
  for (const prefix of ["roster", "permission", "permission_modes", "tasks.wip_limits"]) {
91
- const base = SETTINGS.find(def => def.key === `${prefix}.${adapter.kind}`);
115
+ if (readOnly && (prefix === "permission" || prefix === "permission_modes")) continue;
116
+ const base = baseSettings.find(def => def.key === `${prefix}.${adapter.kind}`);
92
117
  if (base) extra.push(copy(base, `${prefix}.${peer}`));
93
118
  }
94
- // I2 declarations select the headless engine; settings cannot opt an extra into a native TUI.
95
- extra.push(Object.freeze({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (headless only)`,
96
- type: "enum", values: Object.freeze(["headless"]), store: "config", applies: "next use", risk: "raises", floor: "headless", path: Object.freeze(["peers", peer, "start_mode"]) } satisfies SettingDef));
119
+ if (readOnly) for (const runtime of [true, false]) extra.push(Object.freeze({
120
+ key: `${runtime ? "permission" : "permission_modes"}.${peer}`, group: "Permissions", label: `${peer}: read-only (locked)`,
121
+ type: "enum", values: Object.freeze(["read-only"]), store: runtime ? "runtime" : "config", applies: runtime ? "live" : "next use", risk: "safe",
122
+ path: Object.freeze(runtime ? [peer] : ["permission_modes", peer]), locked: READ_ONLY_LOCK,
123
+ } satisfies SettingDef));
124
+ extra.push(headless(peer));
97
125
  }
98
126
  return extra.length ? Object.freeze([...SETTINGS, ...extra]) : SETTINGS;
99
127
  }
100
128
 
101
129
  /** The definition of a key, or why there is none. */
102
- export function settingDef(key: unknown, instances?: KindMap): SettingDef | string {
130
+ export function settingDef(key: unknown, instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): SettingDef | string {
103
131
  if (typeof key !== "string" || key.length > 80) return "unknown setting";
104
- const found = projectSettings(instances).find((s) => s.key === key);
132
+ const found = projectSettings(instances, adapterCatalog).find((s) => s.key === key);
105
133
  if (found) return found;
106
134
  if (NEVER_EDITABLE.some((never) => key === never || key.startsWith(`${never}.`))) return `${key} chooses what the hub runs or where secrets and task text go: it is never editable from settings; edit .agenthub/config.local.json in your editor`;
107
135
  return "unknown setting; run ahub settings list";
@@ -109,6 +137,7 @@ export function settingDef(key: unknown, instances?: KindMap): SettingDef | stri
109
137
 
110
138
  /** The checked value, or a thrown reason. `routes`: the route ids the project's routing defines. */
111
139
  export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonly string[] = []): SettingValue {
140
+ if (def.locked) throw new Error(def.locked);
112
141
  if (raw === null) {
113
142
  if (def.store === "runtime") throw new Error(`${def.key} has no stored value to remove`);
114
143
  return null;
@@ -151,6 +180,7 @@ export type SettingAuthority = "ordinary" | "settings" | "terminal";
151
180
  /** Why this authority may not set this value, or undefined. Removing a stored value can raise, so it counts as one. */
152
181
  export function settingRefusal(def: SettingDef | string, value: SettingValue, authority: SettingAuthority): string | undefined {
153
182
  if (typeof def === "string") return def || "invalid setting definition";
183
+ if (def.locked) return def.locked;
154
184
  if (def.risk === "safe" || authority !== "ordinary") return undefined;
155
185
  if (value !== null && value === def.floor) return undefined;
156
186
  return `${def.key} can widen what agents do without asking: open a settings session with ahub ui --settings, or use ahub settings in a terminal`;
@@ -354,6 +384,10 @@ export function undoSetting(cwd: string, stateDir: string, validate: (scratch: s
354
384
  export function storedLocally(cwd: string, def: SettingDef): boolean {
355
385
  try { return valueAt(JSON.parse(readFileSync(join(cwd, ".agenthub", CONFIG_LOCAL), "utf8")), def.path) !== undefined; } catch { return false; }
356
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
+ }
357
391
  /**
358
392
  * For a config setting: the value the config files would hold for it after the pending undo (the previous version of
359
393
  * the machine-local file, else the shared file), or, with `inherit`, after the machine-local value is removed (the
@@ -390,6 +424,7 @@ export function ignoredLine(ignored: readonly string[] | undefined, def: Setting
390
424
  });
391
425
  }
392
426
  export interface SettingRow {
427
+ locked?: string;
393
428
  key: string; group: SettingDef["group"]; label: string; type: SettingDef["type"]; values?: readonly string[]; min?: number; max?: number;
394
429
  /** The file a write goes to, or "this hub run". */
395
430
  file: string;
@@ -418,6 +453,7 @@ export function settingRows(input: {
418
453
  cwd: string; routing: Routing;
419
454
  /** Captured trusted declarations for this daemon/project, never taken from a settings request. */
420
455
  instances?: KindMap;
456
+ adapterCatalog: ProjectAdapterCatalog;
421
457
  running: (path: readonly string[]) => unknown;
422
458
  next: ((path: readonly string[]) => unknown) | undefined;
423
459
  mode: (peer: string) => { value?: string; note?: string };
@@ -432,9 +468,11 @@ export function settingRows(input: {
432
468
  const projectRouting = existsSync(join(input.cwd, ".agenthub", "routing.toml"));
433
469
  const routes = Object.keys(input.routing.hub_routes ?? {});
434
470
  const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
435
- return projectSettings(input.instances).map((def): SettingRow => {
471
+ return projectSettings(input.instances, input.adapterCatalog).map((def): SettingRow => {
436
472
  const base = { key: def.key, group: def.group, label: def.label, type: def.type, ...(["integer", "number"].includes(def.type) ? { min: def.min, max: def.max } : {}), applies: def.applies, ...(def.restartReason ? { restartReason: def.restartReason } : {}), risk: def.risk, ...(def.floor === undefined ? {} : { floor: def.floor }),
437
- ...(def.key === "scoped_grants.protect_instructions" ? { hint: "AGENTS.md, CLAUDE.md and QWEN.md remain grantable by default; enabling this asks before scoped writes and edits" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!, input.instances) } : {}) };
473
+ ...(def.key === "scoped_grants.protect_instructions" ? { hint: "AGENTS.md, CLAUDE.md and QWEN.md remain grantable by default; enabling this asks before scoped writes and edits" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!, input.instances, input.adapterCatalog) } : {}) };
474
+ if (def.locked) return { ...base, values: def.values, file: def.store === "runtime" ? "this hub run" : CONFIG_LOCAL,
475
+ source: "instance declaration", value: "read-only", fileValue: "read-only", locked: def.locked, note: def.locked };
438
476
  if (def.store === "runtime") {
439
477
  const mode = input.mode(def.path[0]!);
440
478
  return { ...base, values: def.values!, file: "this hub run", value: mode.value ?? null, fileValue: mode.value ?? null, source: "this hub run", ...(mode.note ? { note: mode.note } : {}) };
@@ -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
- const git = (top: string | Repo, args: string[], env: Record<string, string> = {}, budget?: SnapshotBudget) => {
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
- encoding: "utf8", env: { ...repoGitEnv(top), ...env },
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;
@@ -141,6 +143,30 @@ export function headState(repo: Repo, budget?: SnapshotBudget): { head: string |
141
143
  return current.status === 1 ? { head: null, tree: null } : undefined;
142
144
  }
143
145
 
146
+ /** One bounded probe covers shared local/remote branch refs, including commit and push progress. Names never escape. */
147
+ export function branchHeads(repo: Repo, budget?: SnapshotBudget): string | undefined {
148
+ const result = git(repo, ["for-each-ref", "--sort=refname", "--format=%(refname) %(objectname)", "refs/heads/", "refs/remotes/"], {}, budget, 1024 * 1024);
149
+ if (result.status !== 0 || result.error) return undefined;
150
+ const digest = createHash("sha256").update(result.stdout).digest("hex");
151
+ if (budget && budgetNow(budget) >= budget.deadline) throw new SnapshotBudgetExceeded();
152
+ return digest;
153
+ }
154
+
155
+ /** Negative canonical evidence needs exclusive scope, including detached linked worktrees. Paths stay host-only. */
156
+ export function soleWorktree(repo: Repo, budget?: SnapshotBudget): boolean | undefined {
157
+ const result = git(repo, ["worktree", "list", "--porcelain", "-z"], {}, budget, 1024 * 1024);
158
+ if (result.status !== 0 || result.error) return undefined;
159
+ // A project-bound shadow registers its private bare Git directory, not its explicit --work-tree root.
160
+ const registeredRoot = repo.shadow ? repo.dir : repo.top;
161
+ let count = 0;
162
+ for (const field of result.stdout.split("\0")) {
163
+ if (budget && budgetNow(budget) >= budget.deadline) throw new SnapshotBudgetExceeded();
164
+ if (!field.startsWith("worktree ")) continue;
165
+ if (field.slice(9) !== registeredRoot || ++count > 1) return false;
166
+ }
167
+ return count === 1 ? true : undefined;
168
+ }
169
+
144
170
  /** A bounded append cursor; raw reflog identities and messages never leave the reader. */
145
171
  export interface HeadMoveCursor {
146
172
  readonly head: string | null;
@@ -155,7 +181,7 @@ export interface HeadMoveCursor {
155
181
  const HEAD_LOG_TAIL_BYTES = 4096;
156
182
  const HEAD_LOG_MAX_APPEND_BYTES = 128 * 1024;
157
183
  const HEAD_LOG_MAX_MOVES = 64;
158
- 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(); };
159
185
  const sameLogFile = (a: Stats, b: Stats) => a.dev === b.dev && a.ino === b.ino;
160
186
  function readHeadLog<T>(repo: Repo, budget: SnapshotBudget | undefined, read: (fd: number, stat: Stats) => T | undefined): T | undefined {
161
187
  let fd: number | undefined;
@@ -1,4 +1,4 @@
1
- import { definitionOf, SUPPORTED_PEERS, START_MODE_PEERS as REGISTRY_START_MODE_PEERS, type KindMap } from "../adapters/registry.ts";
1
+ import { definitionOf, SUPPORTED_PEERS, START_MODE_PEERS as REGISTRY_START_MODE_PEERS, type ProjectAdapterCatalog, type KindMap } from "../adapters/registry.ts";
2
2
 
3
3
  /**
4
4
  * #269: how a peer comes up when the hub starts it: its own TUI in a terminal, or headless. A person who types
@@ -20,27 +20,29 @@ export const isStartablePeer = (value: unknown): value is StartablePeer => typeo
20
20
 
21
21
  export type PeerStartConfig = Record<string, { start_mode?: StartMode }>;
22
22
  /** The checked per-peer start modes; only peers with both forms may be configured. */
23
- export function peerStartConfig(value: unknown, instances?: KindMap): PeerStartConfig {
23
+ export function peerStartConfig(value: unknown, instances?: KindMap, catalog?: ProjectAdapterCatalog): PeerStartConfig {
24
24
  if (value === undefined) return {};
25
25
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("peers must be an object of per-peer settings");
26
26
  const result: PeerStartConfig = {};
27
27
  for (const [peer, block] of Object.entries(value)) {
28
- const extra = definitionOf(peer, instances)?.kind !== peer && !!definitionOf(peer, instances);
29
- if (!(REGISTRY_START_MODE_PEERS as readonly string[]).includes(peer) && !extra) throw new Error(`peers.${peer}: only ${REGISTRY_START_MODE_PEERS.join(" and ")} have a start mode`);
28
+ const configured = !!catalog?.definitionOf(peer) && !definitionOf(peer);
29
+ const extra = (catalog?.definitionOf ?? definitionOf)(peer, instances)?.kind !== peer && !!(catalog?.definitionOf ?? definitionOf)(peer, instances);
30
+ if (!(REGISTRY_START_MODE_PEERS as readonly string[]).includes(peer) && !extra && !configured) throw new Error(`peers.${peer}: only ${REGISTRY_START_MODE_PEERS.join(" and ")} have a start mode`);
30
31
  if (!block || typeof block !== "object" || Array.isArray(block)) throw new Error(`peers.${peer} must be an object`);
32
+ if (Object.keys(block).some(key => key !== "start_mode")) throw new Error(`peers.${peer}: unsupported setting`);
31
33
  const mode = (block as { start_mode?: unknown }).start_mode;
32
34
  if (mode !== undefined && !(START_MODES as readonly unknown[]).includes(mode)) throw new Error(`peers.${peer}.start_mode must be tui or headless`);
33
- if (extra && mode !== undefined && mode !== "headless") throw new Error(`peers.${peer}.start_mode: extra instances require headless mode in I2`);
35
+ if ((extra || configured) && mode !== undefined && mode !== "headless") throw new Error(`peers.${peer}.start_mode: extra instances require headless mode in I2`);
34
36
  result[peer] = mode === undefined ? {} : { start_mode: mode as StartMode };
35
37
  }
36
38
  return result;
37
39
  }
38
40
 
39
41
  /** The mode a start of `peer` takes when nothing more specific was asked: `tui` wherever the peer has one. */
40
- export function startModeOf(peers: PeerStartConfig | undefined, peer: string, instances?: KindMap): StartMode {
41
- if (definitionOf(peer, instances)?.kind !== peer && definitionOf(peer, instances)) return peers?.[peer]?.start_mode ?? "headless";
42
+ export function startModeOf(peers: PeerStartConfig | undefined, peer: string, instances?: KindMap, catalog?: ProjectAdapterCatalog): StartMode {
43
+ if ((catalog?.definitionOf ?? definitionOf)(peer, instances)?.kind !== peer && (catalog?.definitionOf ?? definitionOf)(peer, instances)) return peers?.[peer]?.start_mode ?? "headless";
42
44
  if ((REGISTRY_START_MODE_PEERS as readonly string[]).includes(peer)) return peers?.[peer]?.start_mode ?? definitionOf(peer)?.start.defaultForm ?? "tui";
43
- return definitionOf(peer)?.start.defaultForm ?? "headless";
45
+ return (catalog?.definitionOf ?? definitionOf)(peer)?.start.defaultForm ?? "headless";
44
46
  }
45
47
 
46
48
  /**
@@ -10,6 +10,7 @@ export interface SweepRecord {
10
10
  }
11
11
  export interface TaskSweepConfig {
12
12
  enabled: boolean;
13
+ no_writes_turns?: number;
13
14
  interval_s: number;
14
15
  unaccepted_min: number;
15
16
  idle_min: number;
@@ -19,7 +20,7 @@ export interface TaskSweepConfig {
19
20
  auto_reassign: boolean;
20
21
  }
21
22
  export const DEFAULT_TASK_SWEEP: TaskSweepConfig = {
22
- enabled: false, ready_min: 60, interval_s: 300, unaccepted_min: 60, idle_min: 120, review_min: 120, ladder_min: 30, auto_reassign: false,
23
+ enabled: false, no_writes_turns: 2, ready_min: 60, interval_s: 300, unaccepted_min: 60, idle_min: 120, review_min: 120, ladder_min: 30, auto_reassign: false,
23
24
  };
24
25
 
25
26
  /** Config cannot silently enable a sweep or reassignment through a truthy string. */
@@ -33,6 +34,11 @@ export function taskSweepConfig(input: unknown): TaskSweepConfig {
33
34
  if (typeof values[key] !== "boolean") throw new Error(`task_sweep.${key} must be a boolean`);
34
35
  out[key] = values[key];
35
36
  }
37
+ if (values.no_writes_turns !== undefined) {
38
+ const limit = values.no_writes_turns;
39
+ if (typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 0) throw new Error("task_sweep.no_writes_turns must be a safe nonnegative integer");
40
+ out.no_writes_turns = limit;
41
+ }
36
42
  for (const key of ["interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"] as const) {
37
43
  if (values[key] === undefined) continue;
38
44
  const value = values[key];
@@ -77,7 +83,7 @@ export interface PlannerGroomingOptions {
77
83
  acceptedIssues?: ReadonlyMap<number, { repo: string; issue: number; lastEditedAt: string | null }>;
78
84
  }
79
85
  const GROOMING_TASK_LIMIT = 500, GROOMING_ITEM_LIMIT = 256;
80
- const GROOMING_CODES = new Set(["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "runner", "approvals", "quota", "dependency"]);
86
+ const GROOMING_CODES = new Set(["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "runner", "approvals", "quota", "dependency", "no-writes"]);
81
87
  const groomingRepo = (value: unknown): string | undefined => {
82
88
  if (typeof value !== "string") return undefined;
83
89
  if (value.startsWith("path:/") && value.length <= 4096 && !/\p{Cc}/u.test(value)) return value;