@staix/agent-hub 0.26.0 → 0.28.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 (101) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +13 -4
  3. package/docs/agent-notes/adapters.md +4 -2
  4. package/docs/agent-notes/bus.md +4 -4
  5. package/docs/agent-notes/daemon.md +8 -4
  6. package/docs/agent-notes/tasks.md +7 -2
  7. package/docs/agent-notes/tests.md +6 -5
  8. package/docs/design/adapters.md +76 -0
  9. package/docs/design/agent-hub-architecture.md +67 -0
  10. package/docs/design/agent-hub.md +52 -0
  11. package/docs/design/bus.md +48 -0
  12. package/docs/design/task-board.md +86 -0
  13. package/docs/dogfood.md +19 -2
  14. package/docs/operations.md +337 -15
  15. package/docs/profiles.md +2 -0
  16. package/docs/quickstart.md +15 -3
  17. package/docs/sdlc.md +4 -4
  18. package/docs/security.md +9 -4
  19. package/docs/smoke.md +2 -0
  20. package/docs/specs/2026-09-19-agent-hub-design.md +3 -1
  21. package/docs/specs/2026-11-01-research-campaigns-design.md +12 -1
  22. package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
  23. package/docs/verification/2026-11-01-0.27.0.md +49 -0
  24. package/docs/verification/2026-11-02-0.28.0.md +54 -0
  25. package/docs/verification/2026-11-02-autonomy-t4.md +39 -0
  26. package/docs/verification/2026-11-02-codex-0166-reviewer-tool-call.md +53 -0
  27. package/docs/verification/2026-11-02-control-write-after.json +139 -0
  28. package/docs/verification/2026-11-02-control-write-baseline.json +127 -0
  29. package/docs/verification/2026-11-02-control-write-guarded.json +127 -0
  30. package/docs/verification/2026-11-02-control-write-outcomes.md +65 -0
  31. package/docs/verification/2026-11-02-orca-1.4.245-cli-contract.md +51 -0
  32. package/docs/verified.json +13 -13
  33. package/package.json +2 -2
  34. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  35. package/plugins/agent-hub/server.js +344 -116
  36. package/src/adapters/claude-channel.ts +12 -2
  37. package/src/adapters/compat.ts +1 -1
  38. package/src/adapters/definitions/native-cli-claude-setup.ts +31 -9
  39. package/src/adapters/definitions/native-cli-entry-jcode.ts +33 -0
  40. package/src/adapters/definitions/native-cli-entry-qwen.ts +2 -2
  41. package/src/adapters/jcode.ts +37 -4
  42. package/src/adapters/qwen-dual.ts +3 -2
  43. package/src/cli/agents.ts +4 -2
  44. package/src/cli/bench.ts +4 -2
  45. package/src/cli/board-read.ts +25 -0
  46. package/src/cli/claude-homes.ts +2 -2
  47. package/src/cli/console-state.ts +8 -3
  48. package/src/cli/help.ts +8 -6
  49. package/src/cli/identity.ts +14 -6
  50. package/src/cli/init-prompt.ts +179 -0
  51. package/src/cli/init-settings.ts +229 -0
  52. package/src/cli/init.ts +102 -9
  53. package/src/cli/launch.ts +3 -1
  54. package/src/cli/main.ts +112 -36
  55. package/src/cli/output.ts +14 -0
  56. package/src/cli/setup-homes.ts +18 -3
  57. package/src/cli/specs.ts +150 -0
  58. package/src/cli/study-output.ts +56 -0
  59. package/src/cli/study.ts +26 -3
  60. package/src/cli/terminal-recovery.ts +35 -11
  61. package/src/hub/action-policy.ts +5 -3
  62. package/src/hub/board.ts +56 -3
  63. package/src/hub/bus.ts +54 -7
  64. package/src/hub/child-process.ts +6 -4
  65. package/src/hub/config-apply.ts +4 -1
  66. package/src/hub/config-edit.ts +1 -1
  67. package/src/hub/config-tier.ts +1 -0
  68. package/src/hub/control-client.ts +28 -4
  69. package/src/hub/cost-governance.ts +22 -6
  70. package/src/hub/crash.ts +20 -0
  71. package/src/hub/daemon.ts +324 -64
  72. package/src/hub/delivery-hints.ts +6 -3
  73. package/src/hub/delivery-journal.ts +14 -3
  74. package/src/hub/events.ts +2 -1
  75. package/src/hub/hub-tools.ts +15 -9
  76. package/src/hub/no-writes.ts +1 -1
  77. package/src/hub/peers.ts +1 -1
  78. package/src/hub/project-runtime-state.ts +38 -13
  79. package/src/hub/review-brief.ts +18 -0
  80. package/src/hub/routing.ts +7 -3
  81. package/src/hub/sdlc-release-authorization.ts +11 -5
  82. package/src/hub/session-identity.ts +1 -1
  83. package/src/hub/settings.ts +2 -0
  84. package/src/hub/specs-config.ts +43 -0
  85. package/src/hub/specs-task.ts +108 -0
  86. package/src/hub/specs.ts +1008 -0
  87. package/src/hub/task-list-page.ts +75 -0
  88. package/src/hub/task-sweep.ts +10 -1
  89. package/src/hub/tasks.ts +448 -40
  90. package/src/hub/terminal-open.ts +10 -5
  91. package/src/hub/tool-request.ts +131 -0
  92. package/src/ui/office-model.ts +17 -1
  93. package/src/ui/office-space.ts +1 -1
  94. package/templates/config.json +1 -0
  95. package/templates/presets/confirmation-matrix.json +5 -5
  96. package/templates/profiles/dev.json +1 -0
  97. package/templates/profiles/general.json +1 -0
  98. package/templates/profiles/research.json +1 -0
  99. package/templates/profiles/writing.json +1 -0
  100. package/templates/release-guard.block.md +2 -1
  101. package/templates/specs.block.md +5 -0
@@ -0,0 +1,179 @@
1
+ /**
2
+ * #1065 T2: the guided prompt of `ahub init` for a person in a terminal on a new project.
3
+ *
4
+ * It asks one question per setting of the spec's table, in the table's order, one line each: the label, the choices and the default in brackets. Enter keeps the default and leaves
5
+ * that setting out of the request; any other answer is an explicit choice, exactly as the equivalent flag is (so typing the default is `--coordination advisory`, which writes the
6
+ * key). Unknown input asks again and never guesses. When every question is answered it shows the plan, in the words `--dry-run` prints, and asks y/N. N, an empty answer, end of
7
+ * input and Ctrl-C write nothing. What it returns are the values the flags would have set, so the files it leads to are the files the equivalent flags write (AC4); nothing here
8
+ * writes or reads a file.
9
+ *
10
+ * Who is offered it is decided by `promptGate`: a person at a terminal, on a new project, with no setting flag and no `--yes`. A delegated peer, a live test and any run without a
11
+ * terminal never reach `runInitPrompt` and get defaults plus their flags (AC5).
12
+ */
13
+ import { INIT_SETTINGS, SDLC_MODES, SPEC_REVIEW_MODES, COORDINATION_MODES, AUTONOMY_CHOICES, type InitSettingRow, type InitSettingsRequest } from "./init-settings.ts";
14
+ import { readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { PROFILE_NAMES } from "../hub/profiles.ts";
17
+ import { DEFAULT_CONFIG } from "../hub/daemon.ts";
18
+ import { DEFAULT_SPECS_CONFIG } from "../hub/specs-config.ts";
19
+ import { START_AGENTS_ALLOWED, START_AGENTS_DEFAULT } from "../hub/start-agents.ts";
20
+
21
+ /** What a prompt needs from its surroundings. `ask` resolves to `null` at the end of input and after Ctrl-C (the caller turns both into `null`); it never throws for either. */
22
+ export interface InitPromptPorts {
23
+ ask(question: string): Promise<string | null>;
24
+ print(line: string): void;
25
+ }
26
+
27
+ /** Everything the questions set: the settings request the flags build, plus the three choices that are not settings rows. */
28
+ export interface InitAnswers { request: InitSettingsRequest; profile?: string; shadow: boolean; releaseGuard: boolean }
29
+
30
+ /** `declined`: the person answered no. `interrupted`: end of input or Ctrl-C. `refused`: a question got no valid answer in three tries. Nothing is written in any of the three. */
31
+ export type InitPromptResult = { ok: true; answers: InitAnswers } | { ok: false; reason: "declined" | "interrupted" | "refused"; message: string };
32
+
33
+ /** The four questions that are choices of `ahub init` but not rows of the settings table. */
34
+ const NOT_ROWS: ReadonlySet<string> = new Set(["profile", "autonomy", "release-guard", "shadow"]);
35
+ const MAX_TRIES = 3;
36
+ const NOTHING = "nothing was written";
37
+ const yes = (text: string) => /^(y|yes)$/i.test(text.trim());
38
+ const word = (text: string) => text.trim().toLowerCase();
39
+
40
+ export interface InitQuestion {
41
+ /** The `INIT_SETTINGS` id, or `profile`, `autonomy`, `release-guard`, `shadow` for the four choices that are not rows. */
42
+ id: string;
43
+ label: string;
44
+ choices: string;
45
+ /** The default in brackets: what an empty answer keeps. */
46
+ shown: string;
47
+ /** Reads an answer into the value to put in the answers, or throws a reason that names the allowed values. */
48
+ read(text: string): unknown;
49
+ }
50
+
51
+ /**
52
+ * The defaults the brackets show, read from where the writer gets them so a change there cannot leave the prompt showing a wrong one while Enter writes the real one: the shipped
53
+ * `templates/config.json` for the keys it carries (memory, snapshots, each adapter switch, specs), the loader's own `DEFAULT_CONFIG` for coordination, and the specs config defaults. A key neither
54
+ * source has throws, naming the key, so a new row without a known default is a loud failure in `test/init-prompt.test.ts` and not a guess.
55
+ */
56
+ const TEMPLATE = JSON.parse(readFileSync(join(import.meta.dir, "..", "..", "templates", "config.json"), "utf8")) as Record<string, unknown>;
57
+ const at = (doc: unknown, path: readonly string[]): unknown => path.reduce<unknown>((node, key) => node && typeof node === "object" ? (node as Record<string, unknown>)[key] : undefined, doc);
58
+ export function shownDefault(id: string): string {
59
+ const row = INIT_SETTINGS.find(entry => entry.id === id);
60
+ if (!row) throw new Error(`no init setting ${id}`);
61
+ const path = row.path as readonly string[];
62
+ const fromTemplate = row.file === "config.json" ? at(TEMPLATE, path) : undefined;
63
+ const value = fromTemplate !== undefined ? fromTemplate
64
+ : id === "coordination" ? DEFAULT_CONFIG.coordination
65
+ : id === "specs.enabled" ? DEFAULT_SPECS_CONFIG.enabled
66
+ : id === "specs.review" ? DEFAULT_SPECS_CONFIG.review
67
+ : id === "sdlc.mode" ? "off" // no file is written for it: the loader's own default (src/hub/sdlc.ts)
68
+ : row.id.endsWith(".enabled") ? false // a legacy `<kind>.enabled` switch that the template does not carry reads as off
69
+ : undefined;
70
+ if (typeof value === "boolean") return value ? "on" : "off";
71
+ if (typeof value === "string") return value;
72
+ throw new Error(`the shown default of ${id} is unknown: add it to templates/config.json or to shownDefault`);
73
+ }
74
+
75
+ const onOff = (id: string, label: string, shown: "on" | "off"): InitQuestion => ({
76
+ id, label, choices: "on|off", shown,
77
+ read: text => { const w = word(text); if (["on", "y", "yes", "true"].includes(w)) return true; if (["off", "n", "no", "false"].includes(w)) return false; throw new Error("answer on or off"); },
78
+ });
79
+ const oneOf = (id: string, label: string, values: readonly string[], shown: string): InitQuestion => ({
80
+ id, label, choices: values.join("|"), shown,
81
+ read: text => { const w = word(text); if (values.includes(w)) return w; throw new Error(`answer one of ${values.join(", ")}`); },
82
+ });
83
+
84
+ export interface InitQuestionContext {
85
+ /** The agent set `ahub setup` chose on this machine (the default shown for the agents question), or `undefined` for the shipped default. */
86
+ machineAgents?: readonly string[];
87
+ /** Project packs a profile answer may name besides the shipped profiles. */
88
+ packs?: readonly string[];
89
+ /** Shadow history is offered only for a folder with no Git work tree. */
90
+ shadowAvailable?: boolean;
91
+ }
92
+
93
+ /**
94
+ * The questions, in the order of the spec's table: profile, agents, specs, spec review, SDLC, coordination, memory, snapshots, each adapter's enable switch, machine autonomy, release
95
+ * guard and shadow history. A settings row is asked only while `INIT_SETTINGS` has it, and a row the table gains is caught by the test that compares the two.
96
+ */
97
+ export function initQuestions(context: InitQuestionContext = {}): InitQuestion[] {
98
+ const ids = new Set(INIT_SETTINGS.map(row => row.id));
99
+ const packs = context.packs ?? [];
100
+ const profile: InitQuestion = {
101
+ id: "profile", label: "Profile", choices: [...PROFILE_NAMES, ...packs].join("|"), shown: "dev",
102
+ read: text => { const w = text.trim(); if ((PROFILE_NAMES as readonly string[]).includes(w) || packs.includes(w)) return w; throw new Error(`answer one of ${[...PROFILE_NAMES, ...packs].join(", ")}`); },
103
+ };
104
+ const agents: InitQuestion = {
105
+ id: "start.agents", label: "Agents ahub up starts", choices: `${START_AGENTS_ALLOWED.join(",")} as a comma list, or none`, shown: (context.machineAgents ?? START_AGENTS_DEFAULT).join(", ") || "none",
106
+ read: text => INIT_SETTINGS.find(row => row.id === "start.agents")!.parse(text.trim().toLowerCase()),
107
+ };
108
+ const switches = INIT_SETTINGS.filter(row => row.id.endsWith(".enabled") && !["specs.enabled", "memory.enabled", "snapshots.enabled"].includes(row.id));
109
+ const all: InitQuestion[] = [
110
+ profile,
111
+ agents,
112
+ onOff("specs.enabled", "Design specs and their freshness checks", shownDefault("specs.enabled") as "on" | "off"),
113
+ oneOf("specs.review", "Spec review mode", SPEC_REVIEW_MODES, shownDefault("specs.review")),
114
+ oneOf("sdlc.mode", "SDLC mode", SDLC_MODES, shownDefault("sdlc.mode")),
115
+ oneOf("coordination", "Coordination", COORDINATION_MODES, shownDefault("coordination")),
116
+ onOff("memory.enabled", "Shared memory", shownDefault("memory.enabled") as "on" | "off"),
117
+ onOff("snapshots.enabled", "Per-turn snapshots", shownDefault("snapshots.enabled") as "on" | "off"),
118
+ ...switches.map(row => onOff(row.id, row.describe.charAt(0).toUpperCase() + row.describe.slice(1), shownDefault(row.id) as "on" | "off")),
119
+ oneOf("autonomy", "Machine autonomy preset", AUTONOMY_CHOICES, "inherit"),
120
+ onOff("release-guard", "Release guard (AGENTS.md block and pre-push hook)", "off"),
121
+ ...(context.shadowAvailable ? [onOff("shadow", "Shadow history (a folder with no Git work tree)", "off")] : []),
122
+ ];
123
+ return all.filter(question => NOT_ROWS.has(question.id) || ids.has(question.id));
124
+ }
125
+
126
+ /** The question line: `label (a|b|c) [default]: `. */
127
+ export const questionLine = (question: InitQuestion): string => `${question.label} (${question.choices}) [${question.shown}]: `;
128
+
129
+ /**
130
+ * Asks every question, then the confirmation. `preview` turns the answers into the lines of the plan; the caller runs the same planner `--dry-run` runs, so what is shown is what is
131
+ * written, and a throw from it (a custom hooks path under the release guard, a config the loader refuses) ends the prompt with its message and no write.
132
+ */
133
+ export async function runInitPrompt(ports: InitPromptPorts, context: InitQuestionContext, preview: (answers: InitAnswers) => string[]): Promise<InitPromptResult> {
134
+ const answers: InitAnswers = { request: { values: {} }, shadow: false, releaseGuard: false };
135
+ ports.print("ahub init: press Enter to keep the default in brackets. N at the end, Ctrl-C or Ctrl-D writes nothing.");
136
+ for (const question of initQuestions(context)) {
137
+ let settled = false;
138
+ for (let attempt = 0; attempt < MAX_TRIES && !settled; attempt++) {
139
+ const answer = await ports.ask(questionLine(question));
140
+ if (answer === null) return { ok: false, reason: "interrupted", message: NOTHING };
141
+ if (!answer.trim()) { settled = true; break; } // Enter: the default, and nothing is set for this question
142
+ try {
143
+ const value = question.read(answer);
144
+ if (question.id === "profile") answers.profile = value as string;
145
+ else if (question.id === "autonomy") answers.request.autonomy = value as typeof AUTONOMY_CHOICES[number];
146
+ else if (question.id === "release-guard") answers.releaseGuard = value as boolean;
147
+ else if (question.id === "shadow") answers.shadow = value as boolean;
148
+ else answers.request.values[question.id] = value;
149
+ settled = true;
150
+ } catch (error) { ports.print(` ${(error as Error).message}; asking again`); }
151
+ }
152
+ if (!settled) return { ok: false, reason: "refused", message: `no valid answer for "${question.label}" after ${MAX_TRIES} tries, so ${NOTHING}; run ahub init again, or pass the setting as a flag` };
153
+ }
154
+ let lines: string[];
155
+ try { lines = preview(answers); } catch (error) { return { ok: false, reason: "refused", message: `${(error as Error).message}` }; }
156
+ ports.print(lines.length ? `This will be written:\n${lines.join("\n")}` : "Nothing differs from the defaults; the project gets the shipped configuration.");
157
+ const confirm = await ports.ask("Write this? [y/N]: ");
158
+ if (confirm === null) return { ok: false, reason: "interrupted", message: NOTHING };
159
+ if (!yes(confirm)) return { ok: false, reason: "declined", message: NOTHING };
160
+ return { ok: true, answers };
161
+ }
162
+
163
+ /**
164
+ * Whether the shadow-history question is asked: shadow history exists for a folder with no Git work tree, so an absent repository is eligible (that is the case it is for), a shadow repository that is
165
+ * already there is eligible too (`--shadow` accepts it), and a real Git work tree is not (`ahub init --shadow` refuses it).
166
+ */
167
+ export const shadowOffered = (repo: { shadow?: boolean } | undefined): boolean => repo === undefined || repo.shadow === true;
168
+
169
+ /** The rows of the plan in the words `ahub init --dry-run` prints, so the prompt and the preview cannot disagree. */
170
+ export const settingLines = (rows: readonly InitSettingRow[]): string[] =>
171
+ rows.map(row => ` ${row.key}: ${row.from === undefined ? "(not set)" : JSON.stringify(row.from)} -> ${JSON.stringify(row.to)} (${row.file})`);
172
+
173
+ /**
174
+ * Whether a run is offered the prompt: a person (the console role, not a delegated peer or a live test) at a terminal (stdin and stdout), on a new project, with no flag of any kind
175
+ * that sets something and no `--yes`. Every other run gets defaults plus its flags and is never asked anything.
176
+ */
177
+ export function promptGate(input: { newProject: boolean; flagGiven: boolean; yes: boolean; dryRun: boolean; stdinIsTTY: boolean; stdoutIsTTY: boolean; role: string; delegated: boolean; liveTest: boolean }): boolean {
178
+ return input.newProject && !input.flagGiven && !input.yes && !input.dryRun && input.stdinIsTTY && input.stdoutIsTTY && input.role === "console" && !input.delegated && !input.liveTest;
179
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * #1065 T1: the settings `ahub init` can set, as one table. A row names the flag, the file and key it writes, the closed set of values it accepts and how
3
+ * to read it back, so a new setting (the specs rows of #1064 T3) is one more row here and nothing else. Executables, secrets, models, providers, instances
4
+ * and permissions are deliberately not rows: they stay file edits.
5
+ *
6
+ * Pure: this module reads and writes no file. `planInitSettings` takes the current text of the three files and returns the next text of those it changes
7
+ * plus one row per changed key (`{ key, file, from, to }`, values from closed sets, so nothing secret can appear in a preview).
8
+ */
9
+ import { checkStartAgents, START_AGENTS_ALLOWED } from "../hub/start-agents.ts";
10
+ import { editSdlcText } from "../hub/config-edit.ts";
11
+ import { ADAPTERS } from "../adapters/registry.ts";
12
+
13
+ export type InitSettingFile = "config.json" | "config.local.json" | "sdlc.toml";
14
+ export const SDLC_MODES = ["off", "advise", "enforce"] as const;
15
+ export const COORDINATION_MODES = ["advisory", "turn-free"] as const;
16
+ export const AUTONOMY_CHOICES = ["inherit", "off"] as const;
17
+ export const SPEC_REVIEW_MODES = ["off", "report", "require"] as const;
18
+
19
+ /** One changed key, as `ahub init --dry-run --json` shows it. `from` is `undefined` (absent in JSON) when the key was not set before. */
20
+ export interface InitSettingRow { key: string; file: InitSettingFile; from: unknown; to: unknown }
21
+
22
+ interface InitSettingDef {
23
+ /** The request key and the row's `key` (the config path, dotted). */
24
+ id: string;
25
+ /** Flags that set it, with the value each gives: a value flag reads its argument, a switch gives a fixed value. */
26
+ flags: readonly { flag: string; value?: unknown }[];
27
+ file: InitSettingFile;
28
+ /** The key's path inside the file. */
29
+ path: readonly string[];
30
+ /** The closed set shown in a refusal, and in help. */
31
+ allowed: string;
32
+ /** Reads a flag's text into the value to write, or throws a reason that names the allowed values. */
33
+ parse(text: string): unknown;
34
+ /** What the setting is, for a refusal or a plan line. */
35
+ describe: string;
36
+ }
37
+
38
+ const choice = <T extends string>(id: string, flag: string, values: readonly T[]) => (text: string): T => {
39
+ if ((values as readonly string[]).includes(text)) return text as T;
40
+ throw new Error(`${flag}: "${text.slice(0, 48)}" is not ${values.join(", ")}; nothing was written`);
41
+ };
42
+
43
+ export const INIT_SETTINGS: readonly InitSettingDef[] = [
44
+ { id: "start.agents", flags: [{ flag: "--agents" }], file: "config.local.json", path: ["start", "agents"], allowed: `${START_AGENTS_ALLOWED.join(", ")} or none`, describe: "agents ahub up starts",
45
+ parse: text => {
46
+ const words = text.split(/[\s,]+/).filter(Boolean);
47
+ try { return checkStartAgents(words.length === 1 && words[0] === "none" ? [] : words, "--agents"); }
48
+ catch (error) { throw new Error(`${(error as Error).message}; allowed: ${START_AGENTS_ALLOWED.join(", ")} or none; nothing was written`); }
49
+ } },
50
+ { id: "sdlc.mode", flags: [{ flag: "--sdlc" }], file: "sdlc.toml", path: ["mode"], allowed: SDLC_MODES.join("|"), describe: "SDLC mode", parse: choice("sdlc.mode", "--sdlc", SDLC_MODES) },
51
+ { id: "coordination", flags: [{ flag: "--coordination" }], file: "config.json", path: ["coordination"], allowed: COORDINATION_MODES.join("|"), describe: "coordination mode", parse: choice("coordination", "--coordination", COORDINATION_MODES) },
52
+ { id: "memory.enabled", flags: [{ flag: "--memory", value: true }, { flag: "--no-memory", value: false }], file: "config.json", path: ["memory", "enabled"], allowed: "on|off", describe: "shared memory", parse: () => { throw new Error("unreachable"); } },
53
+ { id: "snapshots.enabled", flags: [{ flag: "--snapshots", value: true }, { flag: "--no-snapshots", value: false }], file: "config.json", path: ["snapshots", "enabled"], allowed: "on|off", describe: "per-turn snapshots", parse: () => { throw new Error("unreachable"); } },
54
+ // #1064 T3: the design-specs feature. `--no-specs` also removes the AGENT_HUB_SPECS block from AGENTS.md and `--specs` restores it (init.ts reads the planned `specs.enabled`); the key is shared, so a team commits the choice.
55
+ { id: "specs.enabled", flags: [{ flag: "--specs", value: true }, { flag: "--no-specs", value: false }], file: "config.json", path: ["specs", "enabled"], allowed: "on|off", describe: "design specs and their freshness checks", parse: () => { throw new Error("unreachable"); } },
56
+ { id: "specs.review", flags: [{ flag: "--spec-review" }], file: "config.json", path: ["specs", "review"], allowed: SPEC_REVIEW_MODES.join("|"), describe: "how a task's covering specs are reviewed", parse: choice("specs.review", "--spec-review", SPEC_REVIEW_MODES) },
57
+ // One row per adapter that still has a legacy `<kind>.enabled` switch in config.json (its definition says so with `enabledSwitchStartOnly`): no peer is named here, so a second
58
+ // such adapter gets its `--<kind>` / `--no-<kind>` flags and its row from its own definition (#451: peer policy lives in the registry, not in generic code).
59
+ ...ADAPTERS.filter(definition => definition.enabledSwitchStartOnly === true).map((definition): InitSettingDef => ({
60
+ id: `${definition.kind}.enabled`, flags: [{ flag: `--${definition.kind}`, value: true }, { flag: `--no-${definition.kind}`, value: false }],
61
+ file: "config.json", path: [definition.kind, "enabled"], allowed: "on|off", describe: `the ${definition.displayName} peer`, parse: () => { throw new Error("unreachable"); },
62
+ })),
63
+ ];
64
+
65
+ /** `--autonomy inherit|off` is not a config key: it decides whether a new project inherits the machine's autonomy preset (person-only, main.ts applies it). */
66
+ export interface InitSettingsRequest {
67
+ /** Keyed by `InitSettingDef.id`; absent means "not asked for", which on an existing project changes nothing. */
68
+ values: Record<string, unknown>;
69
+ autonomy?: typeof AUTONOMY_CHOICES[number];
70
+ }
71
+
72
+ /** The usage line `ahub init` prints for a bad argument; help.ts shows the same flags. */
73
+ export const INIT_USAGE = "usage: ahub init [--profile dev|research|writing|general|<pack>] [--agents <a,b,...|none>] [--sdlc off|advise|enforce] [--coordination advisory|turn-free] [--memory|--no-memory] [--snapshots|--no-snapshots] [--pi|--no-pi] [--specs|--no-specs] [--spec-review off|report|require] [--autonomy inherit|off] [--shadow] [--release-guard] [--yes] [--dry-run --json]";
74
+
75
+ /** Every flag the table and the two non-key choices own, for the caller's own argument loop and for help. */
76
+ export const INIT_SETTING_FLAGS: readonly string[] = [...INIT_SETTINGS.flatMap(row => row.flags.map(flag => flag.flag)), "--autonomy"];
77
+
78
+ /**
79
+ * Separates the setting flags from the rest of an `ahub init` argv. A value flag takes the next word; a switch takes none. A flag given twice with different
80
+ * values is refused, so the last one never silently wins. Throws a reason that names the allowed values.
81
+ */
82
+ export function parseInitSettings(args: readonly string[]): { request: InitSettingsRequest; rest: string[] } {
83
+ const request: InitSettingsRequest = { values: {} };
84
+ const rest: string[] = [];
85
+ const set = (id: string, flag: string, value: unknown) => {
86
+ if (Object.hasOwn(request.values, id) && JSON.stringify(request.values[id]) !== JSON.stringify(value)) throw new Error(`${flag} conflicts with another flag for ${id}; give one; nothing was written`);
87
+ request.values[id] = value;
88
+ };
89
+ for (let i = 0; i < args.length; i++) {
90
+ const arg = args[i]!;
91
+ if (arg === "--autonomy") {
92
+ const text = args[++i];
93
+ if (text === undefined) throw new Error(`--autonomy needs a value: ${AUTONOMY_CHOICES.join("|")}; nothing was written`);
94
+ if (!(AUTONOMY_CHOICES as readonly string[]).includes(text)) throw new Error(`--autonomy: "${text.slice(0, 48)}" is not ${AUTONOMY_CHOICES.join(", ")}; nothing was written`);
95
+ if (request.autonomy !== undefined && request.autonomy !== text) throw new Error("--autonomy was given twice with different values; give one; nothing was written");
96
+ request.autonomy = text as typeof AUTONOMY_CHOICES[number];
97
+ continue;
98
+ }
99
+ const row = INIT_SETTINGS.find(def => def.flags.some(flag => flag.flag === arg));
100
+ if (!row) { rest.push(arg); continue; }
101
+ const flag = row.flags.find(entry => entry.flag === arg)!;
102
+ if ("value" in flag) { set(row.id, arg, flag.value); continue; }
103
+ const text = args[++i];
104
+ if (text === undefined) throw new Error(`${arg} needs a value: ${row.allowed}; nothing was written`);
105
+ set(row.id, arg, row.parse(text));
106
+ }
107
+ return { request, rest };
108
+ }
109
+
110
+ const isObject = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
111
+ const valueAt = (doc: Record<string, unknown>, path: readonly string[]): unknown => path.reduce<unknown>((at, key) => isObject(at) ? at[key] : undefined, doc);
112
+
113
+ // ---- A text-level JSON editor: it changes one key and leaves every other byte of the file where it was (AC2: the formatting of untouched keys is preserved). ----
114
+ // The input is always text JSON.parse already accepted, so the scanner only has to find where values start and end.
115
+ const skip = (text: string, at: number): number => { while (at < text.length && /\s/.test(text[at]!)) at++; return at; };
116
+ function stringEnd(text: string, at: number): number { at++; while (text[at] !== '"') { if (text[at] === "\\") at++; at++; } return at + 1; }
117
+ interface Member { key: string; keyStart: number; valueStart: number; valueEnd: number }
118
+ interface ObjectSpan { start: number; end: number; members: Member[] }
119
+ function valueEnd(text: string, from: number): number {
120
+ let at = skip(text, from);
121
+ const c = text[at];
122
+ if (c === '"') return stringEnd(text, at);
123
+ if (c === "{") return objectSpan(text, at).end;
124
+ if (c === "[") {
125
+ at = skip(text, at + 1);
126
+ if (text[at] === "]") return at + 1;
127
+ for (;;) { at = skip(text, valueEnd(text, at)); if (text[at] === ",") { at = skip(text, at + 1); continue; } return at + 1; }
128
+ }
129
+ while (at < text.length && !/[\s,\]}]/.test(text[at]!)) at++;
130
+ return at;
131
+ }
132
+ function objectSpan(text: string, start: number): ObjectSpan {
133
+ const members: Member[] = [];
134
+ let at = skip(text, start + 1);
135
+ if (text[at] === "}") return { start, end: at + 1, members };
136
+ for (;;) {
137
+ at = skip(text, at);
138
+ const keyStart = at, keyEnd = stringEnd(text, at);
139
+ const key = JSON.parse(text.slice(keyStart, keyEnd)) as string;
140
+ at = skip(text, skip(text, keyEnd) + 1);
141
+ const end = valueEnd(text, at);
142
+ members.push({ key, keyStart, valueStart: at, valueEnd: end });
143
+ at = skip(text, end);
144
+ if (text[at] === ",") { at++; continue; }
145
+ return { start, end: at + 1, members };
146
+ }
147
+ }
148
+ const indentOf = (text: string, at: number): string => { const lineStart = text.lastIndexOf("\n", at - 1) + 1; return /^[ \t]*/.exec(text.slice(lineStart))![0]; };
149
+ /** A value as the project's own files write it: scalars as JSON, a list of scalars on one line, an object over lines (or one line when `inline`). */
150
+ function render(value: unknown, indent: string, inline: boolean): string {
151
+ if (Array.isArray(value)) return `[${value.map(item => render(item, indent, true)).join(", ")}]`;
152
+ if (!isObject(value)) return JSON.stringify(value);
153
+ const entries = Object.entries(value);
154
+ if (!entries.length) return "{}";
155
+ if (inline) return `{ ${entries.map(([key, item]) => `${JSON.stringify(key)}: ${render(item, indent, true)}`).join(", ")} }`;
156
+ const inner = `${indent} `;
157
+ return `{\n${entries.map(([key, item]) => `${inner}${JSON.stringify(key)}: ${render(item, inner, false)}`).join(",\n")}\n${indent}}`;
158
+ }
159
+ /** Sets `path` to `value` in JSON `text`, keeping everything else byte for byte. A missing key is added after the last member; a missing parent is created. */
160
+ export function editJsonText(text: string, path: readonly string[], value: unknown): string {
161
+ const [head, ...tail] = path;
162
+ const span = objectSpan(text, skip(text, 0));
163
+ return editObject(text, span, head!, tail, value, indentOf(text, span.start));
164
+ }
165
+ function editObject(text: string, span: ObjectSpan, head: string, tail: readonly string[], value: unknown, parentIndent: string): string {
166
+ // JSON.parse (and so the daemon's loader) keeps the LAST occurrence of a repeated key: edit that one, or the file would read as unchanged (#1065 review).
167
+ const found = span.members.findLast(member => member.key === head);
168
+ if (found) {
169
+ if (!tail.length) return text.slice(0, found.valueStart) + render(value, indentOf(text, found.keyStart), !text.slice(span.start, span.end).includes("\n")) + text.slice(found.valueEnd);
170
+ if (text[found.valueStart] !== "{") throw new Error(`${head} in the project config is not an object, so ${[head, ...tail].join(".")} cannot be set; fix it in your editor, then run ahub init again; nothing was written`);
171
+ return editObject(text, objectSpan(text, found.valueStart), tail[0]!, tail.slice(1), value, indentOf(text, found.keyStart));
172
+ }
173
+ const nested = tail.reduceRight<unknown>((inner, key) => ({ [key]: inner }), value);
174
+ const multiline = text.slice(span.start, span.end).includes("\n") || !span.members.length;
175
+ if (!span.members.length) {
176
+ const inner = `${parentIndent} `;
177
+ return `${text.slice(0, span.start)}{\n${inner}${JSON.stringify(head)}: ${render(nested, inner, false)}\n${parentIndent}}${text.slice(span.end)}`;
178
+ }
179
+ const last = span.members.at(-1)!, indent = indentOf(text, last.keyStart);
180
+ const member = `${JSON.stringify(head)}: ${render(nested, indent, !multiline)}`;
181
+ return `${text.slice(0, last.valueEnd)},${multiline ? `\n${indent}` : " "}${member}${text.slice(last.valueEnd)}`;
182
+ }
183
+
184
+ /** The current text of each file init may write (`null` when absent), and what the plan makes of them. */
185
+ export type InitSettingTexts = Record<InitSettingFile, string | null>;
186
+ export interface InitSettingsPlan { rows: InitSettingRow[]; next: Partial<Record<InitSettingFile, string>>; }
187
+
188
+ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
189
+ function parseJsonObject(file: string, text: string): Record<string, unknown> {
190
+ let parsed: unknown;
191
+ try { parsed = JSON.parse(text); } catch { throw new Error(`.agenthub/${file} is not valid JSON; fix it in your editor, then run ahub init again; nothing was written`); }
192
+ if (!isObject(parsed)) throw new Error(`.agenthub/${file} must hold a JSON object; fix it in your editor, then run ahub init again; nothing was written`);
193
+ return parsed;
194
+ }
195
+
196
+ /**
197
+ * What the requested settings change, given the current files. `texts["config.json"]` is the text the file will have (the template, with the profile applied, for
198
+ * a new project). A key that already holds the requested value makes no row and no write; an absent key is written. Only the named keys change: an existing file is edited in place by
199
+ * `editJsonText` (and `editSdlcText` for sdlc.toml), a missing config.local.json is created with just the keys asked for. The same key given twice is
200
+ * refused by the parser, so each key appears at most once.
201
+ */
202
+ export function planInitSettings(request: InitSettingsRequest, texts: InitSettingTexts): InitSettingsPlan {
203
+ const rows: InitSettingRow[] = [], next: Partial<Record<InitSettingFile, string>> = {};
204
+ const current: Record<"config.json" | "config.local.json", string | null> = { "config.json": texts["config.json"], "config.local.json": texts["config.local.json"] };
205
+ const parsed = (file: "config.json" | "config.local.json") => current[file] === null ? {} : parseJsonObject(file, current[file]!);
206
+ for (const row of INIT_SETTINGS) {
207
+ if (!Object.hasOwn(request.values, row.id)) continue;
208
+ const to = request.values[row.id];
209
+ if (row.file === "sdlc.toml") {
210
+ let from: unknown = "off";
211
+ if (texts["sdlc.toml"] !== null) {
212
+ try { const mode = (Bun.TOML.parse(texts["sdlc.toml"]) as Record<string, unknown>).mode; if (mode !== undefined) from = mode; }
213
+ catch { throw new Error(".agenthub/sdlc.toml is not valid TOML; fix it in your editor, then run ahub init again; nothing was written"); }
214
+ }
215
+ if (same(from, to)) continue; // off with no file is the shipped state: no file is written for it
216
+ const text = editSdlcText(next["sdlc.toml"] ?? texts["sdlc.toml"], [{ path: ["mode"], value: to }]);
217
+ if (text !== null) next["sdlc.toml"] = text;
218
+ rows.push({ key: row.id, file: "sdlc.toml", from, to });
219
+ continue;
220
+ }
221
+ const file = row.file, doc = parsed(file);
222
+ const held = valueAt(doc, row.path);
223
+ if (held !== undefined && same(held, to)) continue; // a key that already holds the value is a no-op; an absent key is written, so the choice is explicit and a team can commit it
224
+ current[file] = current[file] === null ? editJsonText("{}\n", row.path, to) : editJsonText(current[file]!, row.path, to);
225
+ next[file] = current[file]!;
226
+ rows.push({ key: row.id, file, ...(held === undefined ? {} : { from: held }), to } as InitSettingRow);
227
+ }
228
+ return { rows, next };
229
+ }
package/src/cli/init.ts CHANGED
@@ -11,6 +11,10 @@ import { realPath } from "../hub/project.ts";
11
11
  import { hubGitArgv, hubGitEnv, hubGitSync } from "../hub/git.ts";
12
12
  import { KIMI_MCP_FILE } from "./kimi-mcp.ts";
13
13
  import { AGY_PLUGIN_FILES } from "./agy-mcp.ts";
14
+ import { loadConfig } from "../hub/daemon.ts";
15
+ import { sdlcConfig } from "../hub/sdlc.ts";
16
+ import { VERSION } from "../version.ts";
17
+ import { planInitSettings, type InitSettingFile, type InitSettingRow, type InitSettingsRequest } from "./init-settings.ts";
14
18
 
15
19
  const TEMPLATES = join(import.meta.dir, "..", "..", "templates");
16
20
  const BEGIN = "<!-- AGENT_HUB:BEGIN (managed by `ahub init`, edits inside are overwritten) -->";
@@ -41,7 +45,7 @@ export interface InitChange {
41
45
  managedBlock?: "insert" | "replace" | "remove";
42
46
  }
43
47
  /** Content is internal to apply, never included in a public preview. */
44
- interface PlannedWrite extends InitChange { next?: string; shadow?: true; directory?: true; executable?: true; moveFrom?: string; expected?: { dev: number; ino: number }; }
48
+ interface PlannedWrite extends InitChange { privateFile?: true; next?: string; shadow?: true; directory?: true; executable?: true; moveFrom?: string; expected?: { dev: number; ino: number }; }
45
49
  const RELEASE_BEGIN = "<!-- AGENT_HUB_RELEASE_GUARD:BEGIN (managed by `ahub init --release-guard`) -->";
46
50
  const RELEASE_END = "<!-- AGENT_HUB_RELEASE_GUARD:END -->";
47
51
  const HOOK_MARKER = "# agent-hub managed release pre-push guard";
@@ -54,6 +58,41 @@ function releaseBlock(existing: string): string {
54
58
  if (start >= 0 || existing.includes(RELEASE_END)) throw new Error("release guard instruction markers are incomplete");
55
59
  return `${existing.trimEnd()}\n\n${block}\n`;
56
60
  }
61
+ const SPECS_BEGIN = "<!-- AGENT_HUB_SPECS:BEGIN (managed by `ahub init`, edits inside are overwritten; `ahub init --no-specs` removes it) -->";
62
+ const SPECS_END = "<!-- AGENT_HUB_SPECS:END -->";
63
+ /**
64
+ * #1064 T3: the design-specs instruction block, placed the way the release guard's is. On: insert it (at the end of the file) or replace it in place. Off: drop it and the blank
65
+ * lines around it, and nothing else. Text outside the markers is never touched, and incomplete markers are refused (nothing is guessed about where a half block ends).
66
+ */
67
+ export function specsBlock(existing: string, enabled: boolean): string {
68
+ const start = existing.indexOf("<!-- AGENT_HUB_SPECS:BEGIN"), end = existing.indexOf(SPECS_END, Math.max(start, 0));
69
+ if (start >= 0 !== end > start || start < 0 && existing.includes(SPECS_END)) throw new Error("the AGENT_HUB_SPECS markers in AGENTS.md are incomplete; fix or remove the half block in your editor, then run ahub init again; nothing was written");
70
+ if (!enabled) {
71
+ if (start < 0) return existing;
72
+ // Take out the block, the line break that ends its closing marker and one separating blank line, and nothing else: whitespace belonging to the text around it
73
+ // (an indented code block right after the block, trailing spaces, the end of the file) is left byte for byte. With nothing before the block, the blank lines after it go instead.
74
+ let before = existing.slice(0, start), after = existing.slice(end + SPECS_END.length);
75
+ if (before === "") after = after.replace(/^(?:[ \t]*\r?\n)+/, "");
76
+ else { after = after.replace(/^\r?\n/, ""); if (/\r?\n\r?\n$/.test(before)) before = before.replace(/\r?\n$/, ""); }
77
+ const rest = before + after;
78
+ return rest.trim() === "" ? "" : rest;
79
+ }
80
+ const block = `${SPECS_BEGIN}\n${readFileSync(join(TEMPLATES, "specs.block.md"), "utf8").trim()}\n${SPECS_END}`;
81
+ if (start >= 0) return existing.slice(0, start) + block + existing.slice(end + SPECS_END.length);
82
+ return `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${block}\n`;
83
+ }
84
+ /** Whether the specs feature is on for what init is about to leave behind: the flag when given, else the project's own layers (config.local.json over config.json), else the shipped default (on). */
85
+ function specsEnabled(cwd: string, settings: InitSettingsRequest | undefined): boolean {
86
+ const asked = settings?.values["specs.enabled"];
87
+ if (typeof asked === "boolean") return asked;
88
+ let enabled = true;
89
+ for (const name of ["config.json", "config.local.json"]) {
90
+ const path = join(cwd, ".agenthub", name);
91
+ if (!existsSync(path)) continue;
92
+ try { const value = (JSON.parse(readFileSync(path, "utf8")) as { specs?: { enabled?: unknown } }).specs?.enabled; if (typeof value === "boolean") enabled = value; } catch { /* an unreadable layer is refused elsewhere, with its own message */ }
93
+ }
94
+ return enabled;
95
+ }
57
96
  function regularHook(file: string): Stats | undefined {
58
97
  const info = lstatSync(file, { throwIfNoEntry: false });
59
98
  if (info && (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1)) throw new Error("release guard hook must be an unlinked regular file");
@@ -110,7 +149,7 @@ function selection(cwd: string, requested: string | undefined): { profile?: Prof
110
149
  const bad = loaded.errors.filter(error => error.id === requested || error.id === "project").map(error => `${error.id}: ${error.reason}`);
111
150
  throw new Error(`unknown profile or pack "${requested.slice(0, 48)}"; profiles: ${PROFILE_NAMES.join(", ")}; packs in .agenthub/roles: ${[...loaded.packs.keys()].join(", ") || "none"}${bad.length ? `; not loaded: ${bad.join("; ")}` : ""}`);
112
151
  }
113
- function changes(cwd: string, requestedName?: string, shadow = false, releaseGuard = false): PlannedWrite[] {
152
+ function changes(cwd: string, requestedName?: string, shadow = false, releaseGuard = false, settings?: InitSettingsRequest): { writes: PlannedWrite[]; settingRows: InitSettingRow[] } {
114
153
  const { profile: requested, pack } = selection(cwd, requestedName);
115
154
  const originalAgents = existsSync(join(cwd, "AGENTS.md")) ? readFileSync(join(cwd, "AGENTS.md"), "utf8") : "";
116
155
  const guarded = releaseGuard || originalAgents.includes("<!-- AGENT_HUB_RELEASE_GUARD:BEGIN");
@@ -131,7 +170,8 @@ function changes(cwd: string, requestedName?: string, shadow = false, releaseGua
131
170
  for (const name of ["config.json", "config.local.json"]) {
132
171
  const path = join(cwd, ".agenthub", name);
133
172
  if (existsSync(path)) {
134
- const layer = JSON.parse(readFileSync(path, "utf8"));
173
+ let layer: Record<string, unknown>;
174
+ try { layer = JSON.parse(readFileSync(path, "utf8")); } catch { throw new Error(`.agenthub/${name} is not valid JSON; fix it in your editor, then run ahub init again; nothing was written`); }
135
175
  if (layer.profile !== undefined) activeProfile = profileName(layer.profile);
136
176
  resolveProfile(activeProfile, layer.profile_overrides);
137
177
  overrides = mergeProfileOverrides(overrides, layer.profile_overrides);
@@ -170,7 +210,8 @@ function changes(cwd: string, requestedName?: string, shadow = false, releaseGua
170
210
  }
171
211
  const agents = join(cwd, "AGENTS.md");
172
212
  const existing = existsSync(agents) ? readFileSync(agents, "utf8") : "";
173
- const instructions = upsertBlock(existing, overrides && Object.hasOwn(overrides as object, "agents") ? profile.agents : profile.agents || readFileSync(join(TEMPLATES, "AGENTS.block.md"), "utf8"));
213
+ const hubBlock = upsertBlock(existing, overrides && Object.hasOwn(overrides as object, "agents") ? profile.agents : profile.agents || readFileSync(join(TEMPLATES, "AGENTS.block.md"), "utf8"));
214
+ const instructions = specsBlock(hubBlock, specsEnabled(cwd, settings)); // #1064 T3: a project that turned specs off keeps no block
174
215
  write(agents, guarded ? releaseBlock(instructions) : instructions, "refresh hub instructions; preserve surrounding text", existing.includes("<!-- AGENT_HUB:BEGIN") ? "replace" : "insert");
175
216
  const claude = join(cwd, "CLAUDE.md");
176
217
  const own = lstatSync(claude, { throwIfNoEntry: false });
@@ -196,18 +237,69 @@ function changes(cwd: string, requestedName?: string, shadow = false, releaseGua
196
237
  }
197
238
  if (existsSync(join(cwd, JCODE_MCP_FILE)) && hubGitSync(["ls-files", "--error-unmatch", "--", JCODE_MCP_FILE], { cwd, encoding: "utf8", timeout: 3000 }).status !== 0 && !lines.split("\n").includes(JCODE_MCP_FILE)) lines = `${lines}${lines && !lines.endsWith("\n") ? "\n" : ""}${JCODE_MCP_FILE}\n`;
198
239
  write(ignore, lines, "ignore runtime state and machine-local configuration");
199
- return [...hooks, ...changed];
240
+ // #1065 T1: the settings table. config.json is the text it will have after the template and pack writes above; the rest is what is on disk.
241
+ let settingRows: InitSettingRow[] = [];
242
+ if (settings && (Object.keys(settings.values).length)) {
243
+ const planned = (path: string) => changed.find(row => row.path === path && row.next !== undefined)?.next;
244
+ const read = (path: string): string | null => existsSync(path) ? readFileSync(path, "utf8") : null;
245
+ const files: Record<InitSettingFile, string> = { "config.json": join(cwd, ".agenthub", "config.json"), "config.local.json": join(cwd, ".agenthub", "config.local.json"), "sdlc.toml": join(cwd, ".agenthub", "sdlc.toml") };
246
+ const texts = Object.fromEntries((Object.keys(files) as InitSettingFile[]).map(name => [name, planned(files[name]) ?? read(files[name])])) as Record<InitSettingFile, string | null>;
247
+ const result = planInitSettings(settings, texts);
248
+ settingRows = result.rows;
249
+ for (const name of Object.keys(result.next) as InitSettingFile[]) {
250
+ const path = files[name], text = result.next[name]!;
251
+ const row = changed.find(entry => entry.path === path);
252
+ const reason = `set ${result.rows.filter(entry => entry.file === name).map(entry => entry.key).join(", ")} from the init flags`;
253
+ if (row) { row.next = text; row.reason = `${row.reason}; ${reason}`; }
254
+ else changed.push({ action: existsSync(path) ? "modify" : "create", path, next: text, reason, ...(name === "config.local.json" ? { privateFile: true as const } : {}) });
255
+ }
256
+ }
257
+ return { writes: [...hooks, ...changed], settingRows };
258
+ }
259
+ /**
260
+ * #1065: the files init is about to write for the settings flags are checked by the loaders the daemon uses, before anything is written. A refusal
261
+ * names the loader's reason and writes nothing. Only the settings files are judged, so an unrelated project that already has a warning is not blocked.
262
+ */
263
+ function checkCandidate(cwd: string, plan: readonly PlannedWrite[]): void {
264
+ const at = (name: string) => plan.find(row => row.path === join(cwd, ".agenthub", name) && row.next !== undefined)?.next;
265
+ const config = at("config.json"), local = at("config.local.json"), sdlc = at("sdlc.toml");
266
+ if (config === undefined && local === undefined && sdlc === undefined) return;
267
+ const layer = (name: "config.json" | "config.local.json", text: string | undefined) => {
268
+ const raw = text ?? (existsSync(join(cwd, ".agenthub", name)) ? readFileSync(join(cwd, ".agenthub", name), "utf8") : undefined);
269
+ return raw === undefined ? [] : [{ name, raw: JSON.parse(raw) as Record<string, unknown> }];
270
+ };
271
+ if (config !== undefined || local !== undefined) {
272
+ try { loadConfig(cwd, [...layer("config.json", config), ...layer("config.local.json", local)] as never); }
273
+ catch (error) { throw new Error(`the resulting .agenthub config does not pass the hub's config check: ${(error as Error).message}; fix that key in your editor, then run ahub init again; nothing was written`); }
274
+ }
275
+ if (sdlc !== undefined) {
276
+ try { sdlcConfig(Bun.TOML.parse(sdlc), VERSION); }
277
+ catch (error) { throw new Error(`the resulting .agenthub/sdlc.toml does not pass the hub's check: ${(error as Error).message}; fix it in your editor, then run ahub init again; nothing was written`); }
278
+ }
279
+ }
280
+ /**
281
+ * The plan row for the machine autonomy preset a new project inherits after the writes (`inheritMachineAutonomy`, run by `ahub init` once the files exist): `--dry-run` and the guided prompt's
282
+ * confirmation both take it from here, so the plan a person approves cannot omit it. `undefined` when nothing is inherited (the machine default is off, or the person chose `--autonomy off`).
283
+ */
284
+ export function autonomyInheritanceChange(cwd: string, machineEnabled: boolean, autonomy: "inherit" | "off" | undefined): InitChange | undefined {
285
+ if (!machineEnabled || autonomy === "off") return undefined;
286
+ return { action: existsSync(join(cwd, ".agenthub/config.local.json")) ? "modify" : "create", path: join(cwd, ".agenthub/config.local.json"), reason: "inherit the trusted machine autonomy preset; consent is checked at daemon startup" };
200
287
  }
201
288
  /** What a pack adds, by id only: the preview names the criteria and checks, never their text. */
202
289
  const packReason = (pack: DomainPack, how: string) =>
203
290
  `${how} pack ${pack.name} (profile ${pack.profile}): rubric ${pack.rubric.map(item => item.id).join(", ")}; checks ${pack.checks.map(check => check.id).join(", ") || "none"}; expects roles ${pack.roles.join(", ") || "none"} from the catalog`;
204
291
  /** Read-only metadata, deliberately omitting project/user file contents. `profile` is a shipped profile name or a project pack name. */
205
- export function planInit(cwd: string, profile?: string, shadow = false, releaseGuard = false): InitChange[] {
206
- return changes(cwd, profile, shadow, releaseGuard).map(({ next, shadow: _shadow, directory, executable, moveFrom, expected, ...change }) => change);
292
+ export function planInit(cwd: string, profile?: string, shadow = false, releaseGuard = false, settings?: InitSettingsRequest): InitChange[] {
293
+ return changes(cwd, profile, shadow, releaseGuard, settings).writes.map(({ next, shadow: _shadow, directory, executable, moveFrom, expected, privateFile, ...change }) => change);
294
+ }
295
+ /** #1065 T1: what the settings flags change, key by key (`ahub init --dry-run --json` shows it as `settings`). Read-only. */
296
+ export function initSettingRows(cwd: string, profile?: string, shadow = false, releaseGuard = false, settings?: InitSettingsRequest): InitSettingRow[] {
297
+ return changes(cwd, profile, shadow, releaseGuard, settings).settingRows;
207
298
  }
208
299
  /** Returns the paths it changed, applying the same plan as the preview. */
209
- export function init(cwd: string, profile?: string, shadow = false, releaseGuard = false): string[] {
210
- const plan = changes(cwd, profile, shadow, releaseGuard);
300
+ export function init(cwd: string, profile?: string, shadow = false, releaseGuard = false, settings?: InitSettingsRequest): string[] {
301
+ const plan = changes(cwd, profile, shadow, releaseGuard, settings).writes;
302
+ checkCandidate(cwd, plan);
211
303
  // Recheck hook parents and rename identities before any project write.
212
304
  for (const change of plan.filter(change => change.executable || change.moveFrom)) {
213
305
  const parent = lstatSync(dirname(change.path), { throwIfNoEntry: false });
@@ -243,6 +335,7 @@ export function init(cwd: string, profile?: string, shadow = false, releaseGuard
243
335
  throw error;
244
336
  } finally { if (lstatSync(temporary, { throwIfNoEntry: false })) unlinkSync(temporary); }
245
337
  }
338
+ else if (change.privateFile) { const tmp = `${change.path}.${process.pid}.tmp`; try { writeFileSync(tmp, change.next!, { mode: 0o600, flag: "wx" }); renameSync(tmp, change.path); } catch (error) { try { unlinkSync(tmp); } catch { /* nothing was written */ } throw error; } }
246
339
  else writeFileSync(change.path, change.next!);
247
340
  }
248
341
  return plan.map(change => change.path);
package/src/cli/launch.ts CHANGED
@@ -55,7 +55,9 @@ export function requirePeerInstances(features: unknown): void {
55
55
 
56
56
  /** Launch identity belongs to the native child, never to the agent that invoked the wrapper. */
57
57
  export function nativeLaunchEnv(tool: string, source: NodeJS.ProcessEnv, instances?: InstancesConfig): NodeJS.ProcessEnv {
58
- const env = peerChildEnv(tool, source);
58
+ // This native runs in the person's own terminal and keeps that pane's Orca integration.
59
+ // Daemon-owned peers use peerChildEnv's default scrubbed environment.
60
+ const env = peerChildEnv(tool, source, { preserveOrca: true });
59
61
  delete env.AGENTHUB_UNATTENDED;
60
62
  // Channel evidence for the plugin (issue #205): set beside the development-channel flag `buildLaunch` always passes.
61
63
  const definition = definitionOf(tool, instances);