@staix/agent-hub 0.16.0 → 0.18.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 (96) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +5 -0
  4. package/docs/agent-notes/adapters.md +20 -5
  5. package/docs/agent-notes/benchmarks.md +1 -0
  6. package/docs/agent-notes/daemon.md +5 -3
  7. package/docs/agent-notes/local-worker.md +1 -0
  8. package/docs/agent-notes/tasks.md +9 -3
  9. package/docs/agent-notes/tests.md +6 -3
  10. package/docs/dogfood.md +21 -4
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +150 -34
  13. package/docs/profiles.md +36 -0
  14. package/docs/quickstart.md +14 -4
  15. package/docs/roles.md +1 -0
  16. package/docs/sdlc.md +39 -13
  17. package/docs/security.md +41 -9
  18. package/docs/smoke.md +51 -3
  19. package/docs/specs/2026-09-19-agent-hub-design.md +56 -10
  20. package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
  21. package/docs/verification/2026-10-25-0.17.0.md +74 -0
  22. package/docs/verification/2026-10-25-0.18.0.md +76 -0
  23. package/docs/verification/2026-10-25-codex-0164-reviewer-tool-call.md +52 -0
  24. package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
  25. package/docs/verification/2026-10-25-kimi-231-reviewer-compat.md +48 -0
  26. package/docs/verification/2026-10-25-kimi-usage-ac11.md +48 -0
  27. package/docs/verification/2026-10-25-qwen-dual-output-hub-e2e.md +23 -0
  28. package/docs/verification/2026-10-25-qwen-dual-output-t0.md +20 -0
  29. package/docs/verification/2026-10-25-remote-docker-transport.md +46 -0
  30. package/docs/verification/2026-10-25-reviewer-no-verdict-diagnostics.md +35 -0
  31. package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
  32. package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
  33. package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
  34. package/docs/verified.json +28 -19
  35. package/package.json +2 -2
  36. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  37. package/plugins/agent-hub/server.js +1115 -192
  38. package/src/adapters/codex-review-isolation.ts +10 -1
  39. package/src/adapters/compat.ts +7 -2
  40. package/src/adapters/conformance/enrollment.ts +18 -4
  41. package/src/adapters/conformance/factories.ts +4 -1
  42. package/src/adapters/conformance/kit.ts +11 -0
  43. package/src/adapters/definitions/builtin-metadata.ts +1 -1
  44. package/src/adapters/definitions/native-cli-entry-qwen.ts +32 -4
  45. package/src/adapters/definitions/native-cli-preview-qwen.ts +9 -1
  46. package/src/adapters/definitions/native-factories.ts +8 -0
  47. package/src/adapters/definitions/native-recovery.ts +8 -0
  48. package/src/adapters/definitions/qwen.ts +7 -4
  49. package/src/adapters/local-worker.ts +5 -3
  50. package/src/adapters/qwen-dual.ts +578 -0
  51. package/src/adapters/registry-types.ts +5 -1
  52. package/src/adapters/registry.ts +9 -1
  53. package/src/adapters/reviewer-mcp.ts +12 -6
  54. package/src/adapters/reviewer-print.ts +100 -17
  55. package/src/cli/console-state.ts +9 -2
  56. package/src/cli/help.ts +29 -28
  57. package/src/cli/identity.ts +87 -6
  58. package/src/cli/main.ts +190 -69
  59. package/src/cli/output.ts +7 -2
  60. package/src/cli/sdlc-output.ts +242 -0
  61. package/src/cli/task-output.ts +212 -0
  62. package/src/cli/upgrade-output.ts +57 -0
  63. package/src/cli/upgrade.ts +4 -3
  64. package/src/hub/board.ts +17 -0
  65. package/src/hub/check-runner.ts +252 -22
  66. package/src/hub/conductor.ts +5 -0
  67. package/src/hub/config-apply.ts +16 -7
  68. package/src/hub/config-edit-host.ts +1 -1
  69. package/src/hub/config-tier.ts +1 -1
  70. package/src/hub/daemon.ts +487 -94
  71. package/src/hub/dogfood-profile.ts +248 -0
  72. package/src/hub/dogfood-read.ts +77 -14
  73. package/src/hub/dogfood.ts +5 -1
  74. package/src/hub/events.ts +1 -1
  75. package/src/hub/host-executable.ts +2 -1
  76. package/src/hub/hub-tools.ts +2 -2
  77. package/src/hub/memory-plan-guard.ts +8 -33
  78. package/src/hub/operational-authority.ts +60 -0
  79. package/src/hub/operational-view.ts +141 -0
  80. package/src/hub/orphan-process.ts +69 -0
  81. package/src/hub/permission-mode.ts +1 -1
  82. package/src/hub/read-only-shell-proof.ts +80 -0
  83. package/src/hub/read-only-shell.ts +162 -0
  84. package/src/hub/reviewer-pool-config.ts +3 -2
  85. package/src/hub/reviewer-pool.ts +3 -0
  86. package/src/hub/routing.ts +23 -6
  87. package/src/hub/sdlc-merge.ts +299 -0
  88. package/src/hub/sdlc-mirror.ts +129 -0
  89. package/src/hub/sdlc.ts +58 -12
  90. package/src/hub/settings.ts +11 -5
  91. package/src/hub/snapshots.ts +60 -0
  92. package/src/hub/task-repository.ts +31 -5
  93. package/src/hub/tasks.ts +266 -28
  94. package/src/local/tools.ts +17 -4
  95. package/src/memory/brief.ts +45 -5
  96. package/src/ui/index.html +2 -1
@@ -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);