@staix/agent-hub 0.17.0 → 0.19.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 (109) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +10 -6
  5. package/docs/agent-notes/benchmarks.md +1 -0
  6. package/docs/agent-notes/daemon.md +6 -2
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +11 -2
  9. package/docs/agent-notes/tests.md +7 -3
  10. package/docs/dogfood.md +22 -2
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +162 -35
  13. package/docs/profiles.md +36 -0
  14. package/docs/quickstart.md +15 -5
  15. package/docs/sdlc.md +29 -3
  16. package/docs/security.md +44 -13
  17. package/docs/smoke.md +3 -3
  18. package/docs/specs/2026-09-19-agent-hub-design.md +31 -10
  19. package/docs/verification/2026-10-25-0.18.0.md +76 -0
  20. package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
  21. package/docs/verification/2026-10-25-remote-docker-transport.md +56 -0
  22. package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
  23. package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
  24. package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
  25. package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
  26. package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
  27. package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
  28. package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
  29. package/docs/verification/2026-10-27-0.19.0.md +86 -0
  30. package/docs/verification/claude-permission-transport-native.py +187 -0
  31. package/docs/verification/claude-shell-wrapper-host.ts +20 -0
  32. package/docs/verification/claude-shell-wrapper-native.py +145 -0
  33. package/docs/verification/pi-140-model-free.ts +57 -0
  34. package/docs/verification/pi-140-native.mjs +21 -0
  35. package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
  36. package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
  37. package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
  38. package/docs/verified.json +24 -18
  39. package/package.json +2 -2
  40. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  41. package/plugins/agent-hub/server.js +364 -98
  42. package/src/adapters/agy/entry.ts +1 -1
  43. package/src/adapters/claude-channel.ts +7 -3
  44. package/src/adapters/claude-implementer.ts +5 -3
  45. package/src/adapters/compat.ts +32 -18
  46. package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
  47. package/src/adapters/definitions/builtin-metadata.ts +3 -3
  48. package/src/adapters/definitions/native-factories.ts +7 -2
  49. package/src/adapters/definitions/reviewer-variants.ts +2 -2
  50. package/src/adapters/local-worker.ts +5 -3
  51. package/src/adapters/pi.ts +39 -3
  52. package/src/adapters/qwen-dual.ts +25 -0
  53. package/src/adapters/registry-types.ts +6 -2
  54. package/src/adapters/registry.ts +1 -0
  55. package/src/cli/agents.ts +69 -7
  56. package/src/cli/claude-shell-wrapper.ts +54 -0
  57. package/src/cli/console-state.ts +9 -2
  58. package/src/cli/help.ts +29 -28
  59. package/src/cli/identity.ts +87 -6
  60. package/src/cli/main.ts +198 -71
  61. package/src/cli/output.ts +11 -2
  62. package/src/cli/sdlc-output.ts +242 -0
  63. package/src/cli/task-output.ts +212 -0
  64. package/src/cli/upgrade-output.ts +57 -0
  65. package/src/hub/agent-drift.ts +61 -0
  66. package/src/hub/board.ts +31 -2
  67. package/src/hub/check-runner.ts +385 -25
  68. package/src/hub/claude-shell-wrapper.ts +349 -0
  69. package/src/hub/conductor.ts +5 -0
  70. package/src/hub/config-apply.ts +16 -7
  71. package/src/hub/config-edit-host.ts +1 -1
  72. package/src/hub/config-edit.ts +2 -2
  73. package/src/hub/config-tier.ts +3 -3
  74. package/src/hub/daemon.ts +530 -87
  75. package/src/hub/dogfood-profile.ts +248 -0
  76. package/src/hub/dogfood-read.ts +77 -14
  77. package/src/hub/dogfood.ts +5 -1
  78. package/src/hub/events.ts +1 -1
  79. package/src/hub/host-executable.ts +21 -1
  80. package/src/hub/hub-tools.ts +2 -2
  81. package/src/hub/instances.ts +18 -2
  82. package/src/hub/lifecycle.ts +1 -1
  83. package/src/hub/operational-authority.ts +67 -0
  84. package/src/hub/operational-view.ts +137 -0
  85. package/src/hub/orphan-process.ts +69 -0
  86. package/src/hub/permission-mode.ts +1 -1
  87. package/src/hub/read-only-shell-proof.ts +81 -0
  88. package/src/hub/read-only-shell.ts +162 -0
  89. package/src/hub/review-scope.ts +13 -0
  90. package/src/hub/reviewer-pool-config.ts +3 -2
  91. package/src/hub/reviewer-pool.ts +3 -0
  92. package/src/hub/routing.ts +23 -6
  93. package/src/hub/sdlc-merge.ts +299 -0
  94. package/src/hub/sdlc-metrics.ts +6 -4
  95. package/src/hub/sdlc-mirror.ts +129 -0
  96. package/src/hub/sdlc.ts +58 -12
  97. package/src/hub/settings.ts +11 -5
  98. package/src/hub/snapshots.ts +60 -0
  99. package/src/hub/task-repository.ts +31 -5
  100. package/src/hub/task-shell-command.ts +85 -0
  101. package/src/hub/task-shell-default.ts +78 -0
  102. package/src/hub/task-shell-proof.ts +171 -0
  103. package/src/hub/task-shell-sandbox.ts +61 -0
  104. package/src/hub/tasks.ts +378 -27
  105. package/src/local/deny.ts +91 -11
  106. package/src/local/sandbox.ts +67 -28
  107. package/src/local/shell-environment.ts +10 -0
  108. package/src/local/tools.ts +17 -4
  109. package/src/ui/index.html +3 -1
package/src/hub/sdlc.ts CHANGED
@@ -33,7 +33,12 @@ export type StageGate = "issue" | "pr" | "local" | "review" | "checks" | "criter
33
33
  export interface StageDefinition { base: SdlcBaseStage; actor: string; entry_gates: StageGate[]; exit_gates: StageGate[]; after: string[]; }
34
34
  export type SdlcMode = "off" | "advise" | "enforce";
35
35
  /** `workflow: "gsd-lite"` marks the retired setting during its window (#481 S8): every task then needs a criteria checklist. */
36
- export interface SdlcConfig { workflow?: "gsd-lite"; maxNits?: number; /** #481 S7: optional control bands per metric; absent means none. */ bands?: BandsConfig; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
36
+ /** #786: `off` never merges, `dry-run` reports the decision and runs nothing, `on` runs the guarded `gh pr merge`. */
37
+ export type AutoMergeMode = "off" | "dry-run" | "on";
38
+ export const AUTO_MERGE_MODES: readonly AutoMergeMode[] = ["off", "dry-run", "on"];
39
+ /** #786: one repository's own gate: its required checks (before the top-level list) and its own merge mode. */
40
+ export interface RepoMergePolicy { requiredChecks?: string[]; autoMerge?: AutoMergeMode }
41
+ export interface SdlcConfig { workflow?: "gsd-lite"; maxNits?: number; /** #481 S7: optional control bands per metric; absent means none. */ bands?: BandsConfig; mode: SdlcMode; requiredChecks: string[]; /** #786: absent reads as "off"; written only when the file names it. */ autoMerge?: AutoMergeMode; /** #786: per-repository tables, keyed by lowercase owner/name. */ repos?: Record<string, RepoMergePolicy>; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
37
42
  export interface PlanReviewReceipt {
38
43
  planner: string;
39
44
  planAuthor?: string | null;
@@ -43,7 +48,17 @@ export interface PlanReviewReceipt {
43
48
  scope?: CostPlan;
44
49
  issue?: PlanIssueMetadata;
45
50
  }
51
+ /** Host-observed guarded merge evidence. Never accepted from a tool or restored across an artifact relink. */
52
+ export interface MergeRecord {
53
+ repo: string; pr: number; head: string; at: number;
54
+ state: "merged" | "not_merged" | "unknown";
55
+ exit: number; mergeCommit?: string;
56
+ cleanup: "not_started" | "claimed" | "deleted" | "already_gone" | "skipped" | "unknown";
57
+ reason?: "not_merged" | "missing_commit" | "metadata_unknown" | "identity_changed" | "protected_branch" | "default_branch" | "head_changed" | "authority_changed" | "delete_unconfirmed";
58
+ ownerNotice: "not_started" | "claimed" | "sent" | "unknown";
59
+ }
46
60
  export interface SdlcReceipt {
61
+ merge?: MergeRecord;
47
62
  stage: SdlcStage;
48
63
  planReview?: PlanReviewReceipt;
49
64
  criteria?: CriteriaReceipt;
@@ -78,6 +93,8 @@ export interface PrEvidence {
78
93
  base?: string;
79
94
  baseHead?: string;
80
95
  mergeCommit?: string;
96
+ /** #786: the PR's head branch name, read so a release branch is refused; never used as an identity. */
97
+ headBranch?: string;
81
98
  }
82
99
  const sha = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{40}$/.test(value);
83
100
  const repo = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith("-");
@@ -131,12 +148,37 @@ function definitions(raw: unknown, stages: unknown, phaseAlias: { allowed: boole
131
148
  return { stages: [...stages], definitions: result };
132
149
  }
133
150
 
151
+ const checkList = (value: unknown, what: string): string[] => {
152
+ if (!Array.isArray(value) || value.length > 32 || value.some(c => typeof c !== "string" || !c.trim() || c.length > 120 || /[\r\n\x00-\x1f]/.test(c)) || new Set(value).size !== value.length) throw new Error(`${what}: expected at most 32 unique nonempty names`);
153
+ return [...value] as string[];
154
+ };
155
+ const autoMergeMode = (value: unknown, what: string): AutoMergeMode => {
156
+ if (!AUTO_MERGE_MODES.includes(value as AutoMergeMode)) throw new Error(`${what}: expected off, dry-run or on`);
157
+ return value as AutoMergeMode;
158
+ };
159
+ /** #786: `[repos."owner/name"]` tables. Unknown keys are refused like everywhere else in this file. */
160
+ function mergeRepos(value: unknown): Record<string, RepoMergePolicy> | undefined {
161
+ if (value === undefined) return undefined;
162
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length > 16) throw new Error("sdlc.repos: expected at most 16 tables named owner/repository");
163
+ const result: Record<string, RepoMergePolicy> = Object.create(null);
164
+ for (const [name, table] of Object.entries(value)) {
165
+ if (!repo(name)) throw new Error(`sdlc.repos: "${String(name).slice(0, 60)}" is not an owner/repository name`);
166
+ if (!table || typeof table !== "object" || Array.isArray(table)) throw new Error(`sdlc.repos.${name}: expected a table`);
167
+ const row = table as Record<string, unknown>;
168
+ for (const key of Object.keys(row)) if (!["required_checks", "auto_merge"].includes(key)) throw new Error(`sdlc.repos.${name}.${key}: unknown field`);
169
+ const key = name.toLowerCase();
170
+ if (result[key]) throw new Error(`sdlc.repos.${name}: repeats another table (names are case-insensitive)`);
171
+ result[key] = { ...(row.required_checks === undefined ? {} : { requiredChecks: checkList(row.required_checks, `sdlc.repos.${name}.required_checks`) }), ...(row.auto_merge === undefined ? {} : { autoMerge: autoMergeMode(row.auto_merge, `sdlc.repos.${name}.auto_merge`) }) };
172
+ }
173
+ return result;
174
+ }
175
+
134
176
  /** `version` is the running hub's, which closes the #481 S8 window; the daemon and Tasks pass it. Without one the window is open. */
135
177
  export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
136
178
  if (value === undefined) return { mode: "off", requiredChecks: [], stages: [...SDLC_STAGES] };
137
179
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc: expected a table");
138
180
  const raw = value as Record<string, unknown>;
139
- for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions", "max_nits", "bands"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
181
+ for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions", "max_nits", "bands", "auto_merge", "repos"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
140
182
  const maxNits = raw.max_nits === undefined ? DEFAULT_MAX_NITS : raw.max_nits;
141
183
  if (typeof maxNits !== "number" || !Number.isSafeInteger(maxNits) || maxNits < 0 || maxNits > 64) throw new Error("sdlc.max_nits: expected an integer from 0 to 64");
142
184
  const bands = bandsConfig(raw.bands), bandConfig = Object.keys(bands).length ? { bands } : {};
@@ -150,12 +192,13 @@ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
150
192
  if (workflow !== "none" && workflow !== "gsd-lite") throw new Error("sdlc.workflow: expected none or gsd-lite");
151
193
  const notes = workflow === "gsd-lite" ? [`sdlc.workflow = "gsd-lite" is deprecated and stops loading in ${RETIRED_WORKFLOW_FROM} (#481): remove it; every task then needs a criteria checklist recorded with hub_sdlc_record`] : [];
152
194
  const phaseAlias = { allowed: window, used: false };
153
- const checks = raw.required_checks ?? [];
154
- if (!Array.isArray(checks) || checks.length > 32 || checks.some(c => typeof c !== "string" || !c.trim() || c.length > 120 || /[\r\n\x00-\x1f]/.test(c)) || new Set(checks).size !== checks.length) throw new Error("sdlc.required_checks: expected at most 32 unique nonempty names");
195
+ const checks = checkList(raw.required_checks ?? [], "sdlc.required_checks");
196
+ // #786: absent keys add nothing to the config, so the off-mode default keeps its #257 shape byte for byte.
197
+ const mergeConfig = { ...(raw.auto_merge === undefined ? {} : { autoMerge: autoMergeMode(raw.auto_merge, "sdlc.auto_merge") }), ...(raw.repos === undefined ? {} : { repos: mergeRepos(raw.repos)! }) };
155
198
  if (raw.definitions !== undefined) {
156
199
  const custom = definitions(raw.definitions, raw.stages, phaseAlias);
157
200
  if (phaseAlias.used) notes.push(`the sdlc phase gate is deprecated and stops loading in ${RETIRED_WORKFLOW_FROM} (#481): name it criteria`);
158
- return { mode, ...findingConfig, requiredChecks: [...checks] as string[], ...custom, ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
201
+ return { mode, ...findingConfig, ...mergeConfig, requiredChecks: [...checks] as string[], ...custom, ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
159
202
  }
160
203
  // Off keeps the #257 seven stages byte for byte; an enabled lifecycle defaults to the playbook (#481 S0).
161
204
  const given = raw.stages ?? (mode === "off" ? [...SDLC_STAGES] : [...PLAYBOOK_STAGES]);
@@ -163,7 +206,7 @@ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
163
206
  // An enabled #257 list keeps its order under canonical ids, so implement/verify stay read-only aliases there too.
164
207
  const stages = mode !== "off" && exactly(SDLC_STAGES) ? SDLC_STAGES.map(canonicalStage) : given;
165
208
  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`);
166
- return { mode, ...findingConfig, requiredChecks: [...checks] as string[], stages: [...stages as string[]], ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
209
+ return { mode, ...findingConfig, ...mergeConfig, requiredChecks: [...checks] as string[], stages: [...stages as string[]], ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
167
210
  }
168
211
 
169
212
  export function loadSdlc(cwd: string, version?: string): SdlcConfig {
@@ -213,6 +256,7 @@ export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined =
213
256
 
214
257
  function receiptIdentity(next: SdlcReceipt, previous: SdlcReceipt, raw: Record<string, unknown>): SdlcReceipt {
215
258
  const checklist = raw.criteria ?? raw.phase;
259
+ if (next.repo !== previous.repo || next.pr !== previous.pr || next.head !== previous.head || next.reviewedHead !== previous.reviewedHead || next.localHead !== previous.localHead) delete next.merge;
216
260
  // A test receipt never carries across a different linked issue or artifact.
217
261
  if (raw.redFirst === undefined && (next.repo !== previous.repo || next.issue !== previous.issue || next.pr !== previous.pr)) delete next.redFirst;
218
262
  // An acceptance names one spec issue; relinking the receipt to another drops it (#481 S1).
@@ -272,7 +316,8 @@ export function parsePrEvidence(value: unknown): PrEvidence {
272
316
  review: p.reviewDecision === "APPROVED" ? "approved" : p.reviewDecision === "CHANGES_REQUESTED" ? "changes_requested" : "unknown",
273
317
  pendingReviews: Array.isArray(p.reviewRequests) && p.reviewRequests.length <= 100 && p.reviewRequests.every((r: unknown) => !!r && typeof r === "object") ? p.reviewRequests.length : null,
274
318
  unresolved: Number.isSafeInteger(p.unresolvedThreads) && p.unresolvedThreads >= 0 ? p.unresolvedThreads : null,
275
- ...(sha(p.mergeCommit?.oid) ? { mergeCommit: p.mergeCommit.oid } : {}) };
319
+ ...(sha(p.mergeCommit?.oid) ? { mergeCommit: p.mergeCommit.oid } : {}),
320
+ ...(typeof p.headRefName === "string" && p.headRefName && p.headRefName.length <= 200 && !/[\x00-\x1f\x7f]/.test(p.headRefName) ? { headBranch: p.headRefName } : {}) };
276
321
  }
277
322
 
278
323
  /** Issue identity and edit times only; no body is fetched or stored. `lastEditedAt` moves on a body edit, not on comments. */
@@ -351,7 +396,7 @@ export function touchingPrs(prs: { pr: number; paths: string[] }[]): { before: n
351
396
  }
352
397
 
353
398
  export type SdlcGh = (args: string[], timeoutMs?: number, cwd?: string, maxOutputBytes?: number) => Promise<{ code: number; stdout: string }>;
354
- /** Read-only, fixed argv. Output and time are bounded; diagnostics never expose credentials or remote text. */
399
+ /** Fixed argv and bounded output and time; diagnostics never expose credentials or remote text. Reads, except the one guarded `gh pr merge` that `sdlc-merge.ts` builds (#786). */
355
400
  export const sdlcGh: SdlcGh = (args, timeoutMs = 10_000, cwd, maxOutputBytes = 256 * 1024) => new Promise((resolve) => {
356
401
  const outputLimit = Number.isSafeInteger(maxOutputBytes) ? Math.min(2 * 1024 * 1024, Math.max(1, maxOutputBytes)) : 256 * 1024;
357
402
  let child: ReturnType<typeof spawn>;
@@ -409,7 +454,7 @@ export function linkedReceipt(previous: SdlcReceipt | undefined, link: { repo: s
409
454
  export async function readSdlcPr(receipt: SdlcReceipt, run: SdlcGh = sdlcGh, withIntegrity = false): Promise<PrEvidence | undefined> {
410
455
  if (!repo(receipt.repo) || !id(receipt.pr)) return undefined;
411
456
  try {
412
- const view = await run(["pr", "view", String(receipt.pr), "--repo", receipt.repo, "--json", "headRefOid,state,statusCheckRollup,reviewDecision,reviewRequests,files,changedFiles,mergeCommit,isDraft,baseRefName,baseRefOid"]);
457
+ const view = await run(["pr", "view", String(receipt.pr), "--repo", receipt.repo, "--json", "headRefOid,state,statusCheckRollup,reviewDecision,reviewRequests,files,changedFiles,mergeCommit,isDraft,baseRefName,baseRefOid,headRefName"]);
413
458
  if (view.code !== 0) return undefined;
414
459
  const [owner, name] = receipt.repo.split("/");
415
460
  // The enabled S4 label read shares the existing thread snapshot, never a second readback for a known non-bug.
@@ -468,10 +513,11 @@ export function reviewReadinessFromEvidence(evidence: PrEvidence | undefined, ex
468
513
  else if (evidence.head !== expected) blockers.push("review head changed");
469
514
  if (evidence.state !== "OPEN") blockers.push("review PR is not open");
470
515
  if (!checks.every(name => { const matched = evidence.checks.filter(check => check.name === name); return matched.length === 1 && matched[0]?.state === "passed"; })) blockers.push("exact-head full CI is not green");
471
- return { head: evidence.head, ready: blockers.length === 0, blockers };
516
+ return { head: evidence.head, ready: blockers.length === 0, blockers, checks };
472
517
  }
473
518
 
474
- export interface ReviewReadiness { head?: string; ready: boolean; blockers: string[]; reusedRun?: number }
519
+ /** `checks` (#824) names the required checks that were judged, so a fresh reviewer can be told which ones passed at this head; absent when the read was unknown. */
520
+ export interface ReviewReadiness { head?: string; ready: boolean; blockers: string[]; reusedRun?: number; checks?: string[] }
475
521
  /** Metadata-only pre-review admission; no remote body or comment text is requested. */
476
522
  export async function readReviewReadiness(receipt: SdlcReceipt, expected: string | undefined, run: SdlcGh = sdlcGh, requiredChecks: readonly string[] = []): Promise<ReviewReadiness> {
477
523
  const unknown = (): ReviewReadiness => ({ ready: false, blockers: ["review readiness unavailable"] });
@@ -533,7 +579,7 @@ export async function readReviewReadiness(receipt: SdlcReceipt, expected: string
533
579
  }
534
580
  if (unanswered) blockers.push("bot review threads unanswered");
535
581
  }
536
- return { head: pr.headRefOid, ready: blockers.length === 0, blockers: [...new Set(blockers)], ...(reusedRun !== undefined ? { reusedRun } : {}) };
582
+ return { head: pr.headRefOid, ready: blockers.length === 0, blockers: [...new Set(blockers)], checks: [...full], ...(reusedRun !== undefined ? { reusedRun } : {}) };
537
583
  } catch { return unknown(); }
538
584
  }
539
585
 
@@ -1,3 +1,4 @@
1
+ import { operationalRosterSetting, operationalSetting } from "./operational-authority.ts";
1
2
  import { ACTION_CLASSES } from "./action-class.ts";
2
3
  import { definitionOf, type KindMap, type ProjectAdapterCatalog, ADAPTERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
3
4
  import { createHash } from "node:crypto";
@@ -193,12 +194,15 @@ export function parseSettingText(def: SettingDef, text: string): unknown {
193
194
  }
194
195
  export const settingText = (value: unknown): string => value === null || value === undefined ? "(not set)" : Array.isArray(value) ? value.join(",") || "(none)" : String(value);
195
196
 
197
+ export type SettingSource = "dashboard" | "terminal" | "conductor";
198
+ export type SettingWriteOptions = { preserveUndo?: boolean };
196
199
  /** `ordinary`: a dashboard session from `ahub ui`. `settings`: one from `ahub ui --settings`. `terminal`: `ahub settings`. */
197
- export type SettingAuthority = "ordinary" | "settings" | "terminal";
200
+ export type SettingAuthority = "ordinary" | "settings" | "terminal" | "conductor";
198
201
  /** Why this authority may not set this value, or undefined. Removing a stored value can raise, so it counts as one. */
199
202
  export function settingRefusal(def: SettingDef | string, value: SettingValue, authority: SettingAuthority): string | undefined {
200
203
  if (typeof def === "string") return def || "invalid setting definition";
201
204
  if (def.locked) return def.locked;
205
+ if (authority === "conductor") return operationalSetting(def) || operationalRosterSetting(def) ? undefined : `${def.key} is not an operational conductor setting`;
202
206
  if (def.risk === "safe" || authority !== "ordinary") return undefined;
203
207
  if (value !== null && value === def.floor) return undefined;
204
208
  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`;
@@ -244,10 +248,12 @@ function refuseTracked(cwd: string, file: StoredFile): void {
244
248
  // Unknown is not untracked where there is a repository to ask; only a project outside git has nothing tracked.
245
249
  if (tracked === undefined && configRepositoryPresent(cwd)) throw new Error(`git could not confirm that .agenthub/${file} is untracked; nothing was written`);
246
250
  }
247
- /** Keeps the one previous version, then replaces (or removes) the file. */
248
- function store(cwd: string, stateDir: string, file: StoredFile, key: string, previous: string | null, next: string | null): void {
251
+ /** Person writes keep the one previous version; conductor writes preserve that existing receipt. */
252
+ function store(cwd: string, stateDir: string, file: StoredFile, key: string, previous: string | null, next: string | null, options: SettingWriteOptions = {}): void {
249
253
  refuseTracked(cwd, file);
250
254
  replaceFile(join(cwd, ".agenthub", file), file, next);
255
+ // A conductor write never replaces, deletes or rebases the person's undo receipt. The ordinary digest fence still applies.
256
+ if(options.preserveUndo)return;
251
257
  // The record follows the file: a write that failed leaves the earlier record valid, because the file is as it left it.
252
258
  const record: UndoRecord = { file, key, previous, written: digest(next), at: Date.now() };
253
259
  try {
@@ -282,7 +288,7 @@ export function valueAt(doc: unknown, path: readonly string[]): unknown {
282
288
  * Sets (or with null removes) one key of `.agenthub/config.local.json`. `validate` gets a scratch project that holds
283
289
  * the shared config and the candidate, and throws when the hub's own loader would refuse it: nothing is written then.
284
290
  */
285
- export function writeConfigSetting(cwd: string, stateDir: string, def: SettingDef, value: SettingValue, validate: (scratch: string) => unknown): boolean {
291
+ export function writeConfigSetting(cwd: string, stateDir: string, def: SettingDef, value: SettingValue, validate: (scratch: string) => unknown, options: SettingWriteOptions = {}): boolean {
286
292
  const file = join(cwd, ".agenthub", CONFIG_LOCAL);
287
293
  const before = readText(file);
288
294
  const doc = before === null ? {} : parseObject(before, CONFIG_LOCAL);
@@ -315,7 +321,7 @@ export function writeConfigSetting(cwd: string, stateDir: string, def: SettingDe
315
321
  } else node[leaf] = value;
316
322
  const text = `${JSON.stringify(doc, null, 2)}\n`;
317
323
  checkConfigText(cwd, text, validate);
318
- store(cwd, stateDir, CONFIG_LOCAL, def.key, before, text);
324
+ store(cwd, stateDir, CONFIG_LOCAL, def.key, before, text, options);
319
325
  return true;
320
326
  }
321
327
  /** Runs `validate` on a scratch project holding the shared config and this candidate for the machine-local one. */
@@ -546,6 +546,66 @@ export interface TurnRecord {
546
546
  native: string | null;
547
547
  }
548
548
 
549
+ /**
550
+ * Parse `git status --porcelain=v2 -z` output into the paths of dirty submodules (#798 D2). A submodule's
551
+ * uncommitted edits never move the superproject's gitlink, so a diff of two snapshot trees cannot see them.
552
+ * Submodule entries are the only ones whose mode field starts with `S`; `N` (normal) entries never qualify.
553
+ */
554
+ export function parseDirtySubmodules(z: string): string[] {
555
+ const entries = z.split("\0");
556
+ const found = new Set<string>();
557
+ for (let i = 0; i < entries.length; i++) {
558
+ const entry = entries[i]!;
559
+ if (entry === "") continue;
560
+ const kind = entry[0];
561
+ if (kind === "1") {
562
+ // `1 XY SUB mH mI mW hH hI PATH`: the rest carries 7 spaces, so the path starts after the last one.
563
+ const rest = entry.slice(2);
564
+ const cut = nthSpace(rest, 6);
565
+ const path = cut < 0 ? "" : rest.slice(cut + 1);
566
+ if (path && submoduleField(rest.slice(0, cut))) found.add(path);
567
+ } else if (kind === "2") {
568
+ // `2 XY SUB mH mI mW hH hI Xscore PATH`, then the next entry is the original path: skip it.
569
+ const rest = entry.slice(2);
570
+ const cut = nthSpace(rest, 7);
571
+ const path = cut < 0 ? "" : rest.slice(cut + 1);
572
+ if (path && submoduleField(rest.slice(0, cut))) found.add(path);
573
+ i++;
574
+ } else if (kind === "u") {
575
+ // `u XY SUB m1 m2 m3 mW h1 h2 h3 PATH`: nine spaces in the rest, then the path.
576
+ const rest = entry.slice(2);
577
+ const cut = nthSpace(rest, 8);
578
+ const path = cut < 0 ? "" : rest.slice(cut + 1);
579
+ if (path && submoduleField(rest.slice(0, cut))) found.add(path);
580
+ }
581
+ // `?`, `!` and `#` entries carry no mode field: ignore them.
582
+ }
583
+ return [...found].sort();
584
+ }
585
+
586
+ /** porcelain-v2 fields are `XY SUB ...`: the submodule marker is the first character of the second field. */
587
+ const submoduleField = (fields: string): boolean => fields.slice(3, 4) === "S";
588
+ /** Index of the nth space (0-based) after the leading kind character, or -1 when the entry is malformed. */
589
+ function nthSpace(rest: string, n: number): number {
590
+ let at = -1;
591
+ for (let seen = 0; seen <= n; seen++) {
592
+ at = rest.indexOf(" ", at + 1);
593
+ if (at < 0) return -1;
594
+ }
595
+ return at;
596
+ }
597
+
598
+ /**
599
+ * Dirty submodules of a repository: modified, untracked or new commits inside a submodule worktree are invisible
600
+ * to snapshot tree diffs (#798 D2). Returns undefined when git cannot answer. A project without `.gitmodules`
601
+ * pays no git call at all (D4).
602
+ */
603
+ export function dirtySubmodules(repo: Repo, budget?: SnapshotBudget): string[] | undefined {
604
+ if (repo.shadow || !existsSync(join(repo.top, ".gitmodules"))) return [];
605
+ const result = git(repo, ["status", "--porcelain=v2", "-z", "--ignore-submodules=none", "--", scope(repo)], { GIT_OPTIONAL_LOCKS: "0" }, budget, 1024 * 1024);
606
+ return result.status === 0 ? parseDirtySubmodules(result.stdout) : undefined;
607
+ }
608
+
549
609
  /** Turn records in hub.db: the daemon writes them, `ahub turns` and `ahub undo` read them. */
550
610
  export class Turns {
551
611
  private readonly db: Database;
@@ -1,7 +1,9 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { statSync } from "node:fs";
2
3
  import { dirname, isAbsolute, relative, resolve } from "node:path";
3
4
  import type { TaskPlan, TaskRefs } from "./board.ts";
4
- import { hubGitSync } from "./git.ts";
5
+ import { hubGitArgv, hubGitEnv, hubGitSync } from "./git.ts";
6
+ import { hostExecutable } from "./host-executable.ts";
5
7
  import { canonicalPath, realPath } from "./project.ts";
6
8
  import { isGitHubRepository } from "./spec-read.ts";
7
9
 
@@ -22,6 +24,33 @@ function slug(value: string): string | undefined {
22
24
  } catch { return undefined; }
23
25
  }
24
26
 
27
+ /** Share the advisory reader's bounds; only authority reads pin Git and isolate the actual checkout. */
28
+ function boundedGit(dir: string, args: string[], isolated = false): string | undefined {
29
+ try {
30
+ const options = { encoding: "utf8" as const, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BYTES };
31
+ const argv = ["-C", dir, ...args];
32
+ const found = isolated
33
+ ? spawnSync(hostExecutable("git", dir), hubGitArgv(argv).slice(1), { ...options, cwd: dir, env: hubGitEnv(process.env, { repositoryIsolation: true }) })
34
+ : hubGitSync(argv, options);
35
+ return found.status === 0 && typeof found.stdout === "string" && found.stdout.trim() ? isolated ? found.stdout : found.stdout.trim() : undefined;
36
+ } catch { return undefined; } // Unknown identity never grants; never log remote URLs or stderr.
37
+ }
38
+
39
+ /** Actual project-local GitHub origin only, never task refs, a bare slug or global-config fallback. */
40
+ export function githubOriginRepository(cwd: string): string | undefined {
41
+ let root: string;
42
+ try { root = realPath(cwd); } catch { return undefined; }
43
+ const reply = boundedGit(root, ["config", "--show-scope", "--get", "remote.origin.url"], true);
44
+ const remote = reply && /^(?:local|worktree)\t([^\r\n]+)\n$/.exec(reply)?.[1];
45
+ if (!remote || /[\s?#]/.test(remote)) return undefined;
46
+ const scp = /^(?:[^/@:]+@)?github\.com:[^/]+\/[^/]+\/?$/i.test(remote);
47
+ if (!scp) {
48
+ if (!/^(?:https?|ssh):\/\/(?:[^/@]+@)?github\.com(?::[0-9]+)?\//i.test(remote)) return undefined;
49
+ try { const url = new URL(remote); if (url.search || url.hash) return undefined; } catch { return undefined; }
50
+ }
51
+ return slug(remote);
52
+ }
53
+
25
54
  /** Repository metadata is advisory, never an execution root or a grant. Git reads are bounded, local and cached. */
26
55
  export class TaskRepositories {
27
56
  readonly project: TaskRepository;
@@ -37,10 +66,7 @@ export class TaskRepositories {
37
66
  this.roots.set(root, this.project);
38
67
  }
39
68
  private git(dir: string, args: string[]): string | undefined {
40
- try {
41
- const found = hubGitSync(["-C", dir, ...args], { encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BYTES });
42
- return found.status === 0 && typeof found.stdout === "string" && found.stdout.trim() ? found.stdout.trim() : undefined;
43
- } catch { return undefined; } // Unknown identity keeps a path key; never log remote URLs or stderr.
69
+ return boundedGit(dir, args);
44
70
  }
45
71
  private directory(path: string): string {
46
72
  let cursor = resolve(path);
@@ -0,0 +1,85 @@
1
+ import { parseSimpleShell } from "./read-only-shell.ts";
2
+
3
+ /** Short branch names from host evidence, never from the permission request. */
4
+ export interface TaskShellBinding { branch: string; defaultBranch: string }
5
+ export interface TaskShellCommand {
6
+ kind: "check" | "build" | "test" | "add" | "commit" | "push" | "pr-view" | "pr-checks" | "pr-create";
7
+ program: "scripts/check.sh" | "bun" | "git" | "gh";
8
+ argv: readonly string[];
9
+ /** Original literal path operands. The host must guard them under its verified execution root. */
10
+ paths: readonly string[];
11
+ }
12
+
13
+ export function shortBranch(value: unknown): value is string {
14
+ return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/.test(value)
15
+ && value !== "HEAD" && !value.startsWith("refs/") && !value.includes("..") && !value.includes("//")
16
+ && !value.endsWith("/") && !value.endsWith(".")
17
+ && value.split("/").every(part => !part.startsWith(".") && !part.endsWith(".lock"));
18
+ }
19
+
20
+ function literalPath(value: string): boolean {
21
+ if (!value || value.length > 512 || value.startsWith("-") || !/^[A-Za-z0-9_./-]+$/.test(value)) return false;
22
+ const relative = value.startsWith("./") ? value.slice(2) : value;
23
+ return !relative.startsWith("-") && relative.split("/").every(part => !!part && part !== "." && part !== ".."
24
+ && ![".git", ".agenthub"].includes(part.toLowerCase()));
25
+ }
26
+
27
+ const data = (value: string | undefined, limit: number): value is string =>
28
+ typeof value === "string" && value.length <= limit && !!value.trim() && !/[\p{Cc}\p{Cf}]/u.test(value);
29
+
30
+ export function taskShellBindingValid(binding: TaskShellBinding): boolean {
31
+ return !!binding && shortBranch(binding.branch) && shortBranch(binding.defaultBranch)
32
+ && ![binding.defaultBranch.toLowerCase(), "main", "master"].includes(binding.branch.toLowerCase());
33
+ }
34
+
35
+ /**
36
+ * Lexical data only: this does not authorize execution, bind a task, resolve a program or read Git/files.
37
+ * The caller supplies authoritative native cwd/environment, current child/task/worktree/branch evidence and
38
+ * action/privacy policy, then guards every returned path and executable before answering the native prompt.
39
+ * Quoted punctuation is data only in commit messages and PR title/body values; path/ref operands stay plain.
40
+ */
41
+ export function taskShellCommand(command: unknown, binding: TaskShellBinding): TaskShellCommand | undefined {
42
+ if (!taskShellBindingValid(binding)) return undefined;
43
+ const parsed = parseSimpleShell(command);
44
+ if (!parsed) return undefined;
45
+ const argv = parsed.words;
46
+ const result = (kind: TaskShellCommand["kind"], program: TaskShellCommand["program"], paths: readonly string[] = []): TaskShellCommand =>
47
+ ({ kind, program, argv: [...argv], paths: [...paths] });
48
+ if (argv.length === 2 && argv[0] === "scripts/check.sh" && argv[1] === "--changed") return result("check", "scripts/check.sh", ["scripts/check.sh"]);
49
+ if (argv[0] === "bun") {
50
+ if (argv.length === 3 && argv[1] === "run" && argv[2] === "build") return result("build", "bun");
51
+ const paths = argv.slice(2);
52
+ if (argv[1] === "test" && paths.length >= 1 && paths.length <= 16 && paths.every(path => {
53
+ const relative = path.startsWith("./") ? path.slice(2) : path;
54
+ return literalPath(path) && relative.startsWith("test/") && /^[A-Za-z0-9_][A-Za-z0-9_.-]*\.test\.ts$/.test(relative.split("/").at(-1)!);
55
+ })) return result("test", "bun", paths);
56
+ return undefined;
57
+ }
58
+ if (argv[0] === "git") {
59
+ if (argv[1] === "add") {
60
+ const paths = argv.slice(argv[2] === "--" ? 3 : 2);
61
+ if (paths.length >= 1 && paths.length <= 64 && paths.every(literalPath)) return result("add", "git", paths);
62
+ }
63
+ if (argv.length === 4 && argv[1] === "commit" && argv[2] === "-m" && data(argv[3], 1000)) return result("commit", "git");
64
+ if (argv[1] === "push" && (argv.length === 4 && argv[2] === "origin" && argv[3] === binding.branch
65
+ || argv.length === 5 && argv[2] === "-u" && argv[3] === "origin" && argv[4] === binding.branch)) return result("push", "git");
66
+ return undefined;
67
+ }
68
+ if (argv[0] !== "gh" || argv[1] !== "pr") return undefined;
69
+ if (argv.length === 4 && argv[3] === binding.branch) {
70
+ if (argv[2] === "view") return result("pr-view", "gh");
71
+ if (argv[2] === "checks") return result("pr-checks", "gh");
72
+ }
73
+ if (argv[2] !== "create" || argv.length !== 11) return undefined;
74
+ const allowed = new Set(["--base", "--head", "--title", "--body", "--body-file"]), flags = new Map<string, string>();
75
+ for (let index = 3; index < argv.length; index += 2) {
76
+ const flag = argv[index]!, value = argv[index + 1];
77
+ if (!allowed.has(flag) || value === undefined || flags.has(flag)) return undefined;
78
+ flags.set(flag, value);
79
+ }
80
+ if (flags.get("--base") !== binding.defaultBranch || flags.get("--head") !== binding.branch || !data(flags.get("--title"), 1000)) return undefined;
81
+ if (flags.has("--body") === flags.has("--body-file")) return undefined;
82
+ if (flags.has("--body")) return data(flags.get("--body"), 3000) ? result("pr-create", "gh") : undefined;
83
+ const file = flags.get("--body-file")!;
84
+ return literalPath(file) ? result("pr-create", "gh", [file]) : undefined;
85
+ }
@@ -0,0 +1,78 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { chmodSync, lstatSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { isAbsolute, join, relative } from "node:path";
4
+ import { stopOwnedProcess, trackGroup } from "./child-process.ts";
5
+ import { hostExecutable } from "./host-executable.ts";
6
+ import { hubGitArgv, hubGitEnv } from "./git.ts";
7
+ import { realPath } from "./project.ts";
8
+ import { isGitHubRepository } from "./spec-read.ts";
9
+ import { shortBranch } from "./task-shell-command.ts";
10
+
11
+ const OUTPUT_LIMIT = 64 * 1024;
12
+ function remoteHead(bytes: Buffer): string | undefined {
13
+ const output = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
14
+ if (!output.endsWith("\n")) return;
15
+ const lines = output.slice(0, -1).split("\n");
16
+ if (lines.length !== 2) return;
17
+ const symbolic = lines.filter(line => /^ref: refs\/heads\/.+\tHEAD$/.test(line));
18
+ const oid = lines.filter(line => /^[0-9a-f]{40}\tHEAD$/.test(line));
19
+ if (symbolic.length !== 1 || oid.length !== 1) return;
20
+ const branch = symbolic[0]!.slice("ref: refs/heads/".length, -"\tHEAD".length);
21
+ return shortBranch(branch) ? branch : undefined;
22
+ }
23
+
24
+ /** Startup authority comes from the fixed GitHub remote, never a mutable local origin/HEAD or repository config.
25
+ * No task/native environment or account credentials are inherited. Unknown remote evidence keeps automatic execution unavailable. */
26
+ export async function readTaskShellDefaultBranch(root: string, env: Record<string, string>, current: () => boolean, projectRoot: string, repository?: string, timeoutMs = 5000): Promise<string | undefined> {
27
+ let temporary: string | undefined, proc: ChildProcess | undefined;
28
+ try {
29
+ if (!isGitHubRepository(repository) || !current() || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000
30
+ || !env.PATH || env.PATH.length > 16384 || env.PATH.split(":").some(dir => !isAbsolute(dir) || dir.split("/").includes(".."))) return;
31
+ root = realPath(root);
32
+ const project = realPath(projectRoot), executable = hostExecutable("git", root, env.PATH), rel = relative(project, executable);
33
+ if (!rel || rel !== ".." && !rel.startsWith("../") && !isAbsolute(rel) || !lstatSync(executable).isFile()) return;
34
+ temporary = realPath(mkdtempSync(join(realPath("/tmp"), "ahub-default-"))); chmodSync(temporary, 0o700);
35
+ const own = temporary, stat = lstatSync(own), identity = JSON.stringify([stat.dev, stat.ino, stat.mode]);
36
+ const remove = () => {
37
+ const held = lstatSync(own);
38
+ if (!held.isDirectory() || realPath(own) !== own || JSON.stringify([held.dev, held.ino, held.mode]) !== identity) throw new Error("unavailable");
39
+ rmSync(own, { recursive: true, force: true }); temporary = undefined;
40
+ };
41
+ const environment = hubGitEnv({ PATH: env.PATH, HOME: own, XDG_CONFIG_HOME: own, LANG: env.LANG ?? "C", TERM: "dumb", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CEILING_DIRECTORIES: own }, { repositoryIsolation: true });
42
+ const argv = hubGitArgv(["-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "ls-remote", "--symref", "--exit-code", `https://github.com/${repository}.git`, "HEAD"]);
43
+ if (!current()) { remove(); return; }
44
+ return await new Promise<string | undefined>(resolve => {
45
+ let done = false, bytes = 0, overflowed = false;
46
+ const output: Buffer[] = [];
47
+ let timer: ReturnType<typeof setTimeout> | undefined, watcher: ReturnType<typeof setInterval> | undefined;
48
+ const finish = async (code: number | null) => {
49
+ if (done) return; done = true; clearTimeout(timer); clearInterval(watcher);
50
+ // Do not remove a private cwd/HOME while an owned group may still use it. The existing helper refuses unknown ownership.
51
+ try { if (proc) await stopOwnedProcess(proc, { group: true }); remove(); }
52
+ catch { resolve(undefined); return; }
53
+ try { resolve(code === 0 && !overflowed && current() ? remoteHead(Buffer.concat(output)) : undefined); }
54
+ catch { resolve(undefined); }
55
+ };
56
+ try { proc = spawn(executable, argv.slice(1), { cwd: own, env: environment, detached: true, stdio: ["ignore", "pipe", "pipe"] }); }
57
+ catch { void finish(null); return; }
58
+ timer = setTimeout(() => void finish(null), timeoutMs);
59
+ watcher = setInterval(() => { try { if (!current()) void finish(null); } catch { void finish(null); } }, 50); watcher.unref();
60
+ proc.once("spawn", () => trackGroup(proc!));
61
+ const take = (chunk: Buffer, stdout: boolean) => {
62
+ if (done) return;
63
+ bytes += chunk.length;
64
+ if (bytes > OUTPUT_LIMIT) { overflowed = true; void finish(null); return; }
65
+ if (stdout) output.push(Buffer.from(chunk));
66
+ };
67
+ proc.stdout!.on("data", (chunk: Buffer) => take(chunk, true));
68
+ proc.stderr!.on("data", (chunk: Buffer) => take(chunk, false));
69
+ proc.once("error", () => void finish(null));
70
+ proc.once("exit", code => { if (code !== 0) void finish(null); });
71
+ proc.once("close", code => void finish(code));
72
+ });
73
+ } catch {
74
+ // A spawn/setup failure owns no live group; otherwise only confirmed stop above permits removing the private directory.
75
+ if (!proc && temporary) { try { rmSync(temporary, { recursive: true, force: true }); } catch { /* unknown cleanup remains private */ } }
76
+ return undefined;
77
+ }
78
+ }