@sayansr26/agent-os 0.5.3 → 0.6.1

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 (34) hide show
  1. package/.claude-plugin/marketplace.json +12 -1
  2. package/CHANGELOG.md +77 -0
  3. package/README.md +49 -7
  4. package/package.json +2 -2
  5. package/plugins/agent-os/.claude-plugin/icon.png +0 -0
  6. package/plugins/agent-os/.claude-plugin/plugin.json +3 -1
  7. package/plugins/agent-os/agents/architect.md +5 -0
  8. package/plugins/agent-os/agents/builder.md +7 -2
  9. package/plugins/agent-os/agents/documenter.md +6 -1
  10. package/plugins/agent-os/agents/feature-cartographer.md +18 -1
  11. package/plugins/agent-os/agents/orchestrator.md +5 -0
  12. package/plugins/agent-os/agents/reviewer.md +5 -0
  13. package/plugins/agent-os/agents/tester.md +5 -0
  14. package/plugins/agent-os/hooks/hooks.json +14 -1
  15. package/plugins/agent-os/hooks/pre-edit.mjs +83 -0
  16. package/plugins/agent-os/hooks/session-resume.mjs +75 -18
  17. package/plugins/agent-os/lib/features.mjs +154 -0
  18. package/plugins/agent-os/skills/init/SKILL.md +72 -32
  19. package/plugins/agent-os/skills/init/references/changing-a-feature.md +7 -0
  20. package/plugins/agent-os/skills/init/references/establishing.md +28 -0
  21. package/plugins/agent-os/skills/init/references/git-permissions.md +5 -2
  22. package/plugins/agent-os/skills/init/scripts/audit.mjs +73 -2
  23. package/plugins/agent-os/skills/init/scripts/settings.mjs +186 -0
  24. package/plugins/agent-os/skills/init/scripts/state.mjs +133 -0
  25. package/plugins/auto-route/.claude-plugin/plugin.json +18 -0
  26. package/plugins/auto-route/hooks/hooks.json +1 -0
  27. package/plugins/auto-route/hooks/register.ts +143 -0
  28. package/plugins/auto-route/tests/route.test.ts +87 -0
  29. package/plugins/auto-route/tsconfig.json +3 -0
  30. package/plugins/auto-route/types/index.d.ts +12 -0
  31. package/src/claude-setup.mjs +9 -78
  32. package/src/cli.mjs +81 -30
  33. package/src/plugin.mjs +91 -35
  34. package/src/selftest.mjs +67 -3
@@ -1,81 +1,12 @@
1
1
  /**
2
- * The two Claude Code settings that are not rules and not a plugin.
2
+ * The Claude Code settings that are not rules and not the plugin: git write
3
+ * protection, the todo tools flag, and the task-tracking rule.
3
4
  *
4
- * Both are written at PROJECT scope only — `.claude/settings.json` and the
5
- * project's `CLAUDE.md`. Nothing here touches `~/.claude/`: a project setup
6
- * tool that edits the machine's global config is overreach, and the point is
7
- * that the setup travels with the repository.
5
+ * The logic lives in the plugin (`skills/init/scripts/settings.mjs`) so that
6
+ * `/agent-os:init` inside Claude Code and `agent-os init` in a terminal write
7
+ * exactly the same thing. This module is the CLI's door to it.
8
8
  */
9
- import { existsSync, readFileSync } from "node:fs";
10
- import { join } from "node:path";
11
- import { write } from "./source.mjs";
12
-
13
- export const TODO_ENV = "CLAUDE_CODE_ENABLE_TODO_TOOLS";
14
-
15
- export const TASK_RULE = `- **Always track work with the task tool (TaskCreate / TaskUpdate).** Any request
16
- with more than one step gets a task list before work starts: one task per
17
- deliverable, marked \`in_progress\` when started and \`completed\` only when
18
- verified. Keep it current as scope changes, including work delegated to
19
- subagents, so I can see what is done, running and left at any moment.`;
20
-
21
- /**
22
- * Turn on the todo tools in project settings, preserving every other key.
23
- * Returns "added" | "present" | "conflict" | "invalid".
24
- */
25
- export function ensureTodoEnv(root, { dry = false } = {}) {
26
- const rel = ".claude/settings.json";
27
- const p = join(root, rel);
28
- let settings = {};
29
- if (existsSync(p)) {
30
- try { settings = JSON.parse(readFileSync(p, "utf8")); }
31
- catch { return { status: "invalid", rel }; }
32
- }
33
- const env = settings.env || {};
34
- const current = env[TODO_ENV];
35
-
36
- // "true" and "1" are both truthy to Claude Code; an existing truthy value is
37
- // the user's choice and is left alone rather than normalised.
38
- if (current !== undefined) {
39
- const on = current === "1" || current === "true" || current === true;
40
- return { status: on ? "present" : "conflict", rel, current };
41
- }
42
-
43
- settings.env = { ...env, [TODO_ENV]: "1" };
44
- if (!dry) write(root, rel, JSON.stringify(settings, null, 2) + "\n");
45
- return { status: "added", rel };
46
- }
47
-
48
- /**
49
- * Put the task-tracking rule in CLAUDE.md if it is not already there.
50
- * Never creates CLAUDE.md — that file is the project's own always-loaded
51
- * context, and inventing one from a CLI is how a project ends up with a stub
52
- * nobody owns. Returns "added" | "present" | "no-file".
53
- */
54
- export function ensureTaskRule(root, { dry = false } = {}) {
55
- const rel = "CLAUDE.md";
56
- const p = join(root, rel);
57
- if (!existsSync(p)) return { status: "no-file", rel };
58
-
59
- const text = readFileSync(p, "utf8");
60
- if (/TaskCreate/.test(text)) return { status: "present", rel };
61
-
62
- // Prefer the operator-preferences section if the project keeps one, so the
63
- // rule lands with the other instructions about how to work rather than
64
- // orphaned at the bottom of the file.
65
- const heading = text.match(/^##\s+Operator preferences\s*$/m);
66
- let next;
67
- if (heading) {
68
- const start = heading.index + heading[0].length;
69
- const rest = text.slice(start);
70
- const nextHeading = rest.search(/^##\s+/m);
71
- const end = nextHeading === -1 ? text.length : start + nextHeading;
72
- const before = text.slice(0, end).replace(/\s*$/, "");
73
- next = `${before}\n\n${TASK_RULE}\n\n${text.slice(end)}`;
74
- } else {
75
- next = `${text.replace(/\s*$/, "")}\n\n## Working agreement\n\n${TASK_RULE}\n`;
76
- }
77
- next = next.replace(/\n{4,}/g, "\n\n\n");
78
-
79
- if (!dry) write(root, rel, next);
80
- return { status: "added", rel, section: heading ? "Operator preferences" : "Working agreement" };
81
- }
9
+ export {
10
+ TODO_ENV, TASK_RULE, GIT_DENY, userDir, paths,
11
+ mergeSettings, addTaskRule, applyScope, describe,
12
+ } from "../plugins/agent-os/skills/init/scripts/settings.mjs";
package/src/cli.mjs CHANGED
@@ -5,8 +5,10 @@ import { join } from "node:path";
5
5
  import { detect, summarise, TOOLS } from "./detect.mjs";
6
6
  import { load, write, readIfExists, matches, BANNER, DIR } from "./source.mjs";
7
7
  import { adopt } from "./adopt.mjs";
8
- import { installPlugin, MARKETPLACE, MARKETPLACE_NAME, PLUGIN } from "./plugin.mjs";
9
- import { ensureTodoEnv, ensureTaskRule, TODO_ENV } from "./claude-setup.mjs";
8
+ import { ensurePlugin, MARKETPLACE_NAME, PLUGIN, PLUGINS } from "./plugin.mjs";
9
+ import { setupState, describeState } from "../plugins/agent-os/skills/init/scripts/state.mjs";
10
+ import { applyScope, describe } from "./claude-setup.mjs";
11
+ import { createInterface } from "node:readline/promises";
10
12
  import { compile, TARGETS } from "./targets.mjs";
11
13
 
12
14
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
@@ -15,12 +17,14 @@ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
15
17
  const USAGE = `
16
18
  ${bold("agent-os")} — one source of truth for AI coding agent config
17
19
 
18
- agent-os init detect your tools, create .agent-os/, compile
20
+ agent-os init set up a fresh project, or repair and update an existing one
19
21
  agent-os sync recompile after editing .agent-os/
20
22
  agent-os check verify nothing drifted (exit 1 if it has) — for CI
21
23
  agent-os detect list which tools this project is set up for
22
24
  agent-os audit inspect the project's context layer and report findings
23
25
  agent-os memory inspect and health-check every memory store
26
+ agent-os settings preview git write protection + task tools for Claude Code
27
+ (--scope project|user|both, --apply to write)
24
28
 
25
29
  Without a global install, prefix any of these with
26
30
  ${dim("npx @sayansr26/agent-os")}
@@ -29,7 +33,11 @@ Options
29
33
  --root <dir> project directory (default: cwd)
30
34
  --dry-run print what would change, write nothing
31
35
  --force overwrite files agent-os did not generate (it refuses by default)
32
- --no-plugin skip installing the Claude Code plugin during init
36
+ --no-plugin skip installing or updating the Claude Code plugins during init
37
+ --no-auto-route
38
+ init: install agent-os but not the auto-route model router
39
+ --global init: also write ~/.claude settings without asking
40
+ --no-global init: leave ~/.claude alone
33
41
  `;
34
42
 
35
43
  function scaffold(root, found) {
@@ -116,6 +124,13 @@ export async function main(argv) {
116
124
  return;
117
125
  }
118
126
 
127
+ if (cmd === "settings") {
128
+ const i = argv.indexOf("--scope");
129
+ runScript("plugins/agent-os/skills/init/scripts/settings.mjs",
130
+ ["--root", root, "--scope", i === -1 ? "both" : argv[i + 1], ...(argv.includes("--apply") ? ["--apply"] : [])]);
131
+ return;
132
+ }
133
+
119
134
  if (cmd === "memory") {
120
135
  runScript("plugins/agent-os/skills/memory/scripts/memory.mjs", argv.includes("--stale") ? [root, "--stale"] : [root]);
121
136
  return;
@@ -123,14 +138,28 @@ export async function main(argv) {
123
138
 
124
139
  let adopted = new Set();
125
140
 
141
+ let before = null;
126
142
  if (cmd === "init") {
127
143
  console.log(`\n${bold("agent-os init")} ${root}\n`);
144
+ // Decide fresh vs repair before touching anything, and say which, so a
145
+ // second run on a set-up project reads as a repair rather than a re-init.
146
+ before = setupState(root);
147
+ for (const l of describeState(before)) console.log(` ${l}`);
148
+ console.log({
149
+ fresh: `\n ${dim("Fresh setup: creating everything.")}`,
150
+ repair: `\n ${dim("Existing setup: fixing the items marked MISSING, leaving the rest as it is.")}`,
151
+ healthy: `\n ${dim("Setup is complete: re-syncing and checking the plugin for updates.")}`,
152
+ }[before.status]);
153
+ console.log("");
128
154
  console.log(summarise(found));
155
+ const hadSource = existsSync(join(root, DIR));
129
156
  const { present, taken } = scaffold(root, found);
130
157
  adopted = taken.paths;
131
158
  if (taken.from.length) {
132
159
  console.log(`\n adopted into ${DIR}/ ${dim("— your existing files are now the source")}`);
133
160
  for (const f of taken.from) console.log(` ${f}`);
161
+ } else if (hadSource) {
162
+ console.log(`\n kept ${DIR}/ ${dim("— already the source; nothing re-scaffolded")}`);
134
163
  } else {
135
164
  console.log(`\n created ${DIR}/ ${dim("(config.json, AGENTS.md, rules/example.md)")}`);
136
165
  }
@@ -214,36 +243,58 @@ Pick one:
214
243
  if (cmd === "init" && targets.includes("claude-code")) {
215
244
  console.log(`\n${bold("Claude Code setup")}\n`);
216
245
 
217
- const e = ensureTodoEnv(root, { dry });
218
- const eMsg = {
219
- added: ` ${e.rel} ${dim(`env.${TODO_ENV} = "1"`)}`,
220
- present: ` ${e.rel} ${dim(`env.${TODO_ENV} already set`)}`,
221
- conflict: ` ${e.rel} ${dim(`env.${TODO_ENV} is "${e.current}" — left as you set it`)}`,
222
- invalid: ` ${e.rel} ${dim("is not valid JSON — left alone, fix it and re-run")}`,
223
- }[e.status];
224
- console.log(eMsg);
225
-
226
- const t = ensureTaskRule(root, { dry });
227
- const tMsg = {
228
- added: ` ${t.rel} ${dim(`task-tracking rule added under ${t.section}`)}`,
229
- present: ` ${t.rel} ${dim("task-tracking rule already there")}`,
230
- "no-file": ` ${t.rel} ${dim("absent — run /agent-os:init inside Claude Code to build it")}`,
231
- }[t.status];
232
- console.log(tMsg);
246
+ // Project scope always: it travels with the repo. User scope protects
247
+ // every other project on the machine too, so it is asked once rather
248
+ // than assumed — unless --global / --no-global already answered.
249
+ for (const l of describe(applyScope("project", root, { dry }), { dry })) console.log(` ${l}`);
250
+
251
+ let global = argv.includes("--global") ? true : argv.includes("--no-global") ? false : null;
252
+ if (global === null && !dry && process.stdin.isTTY && process.stdout.isTTY) {
253
+ console.log("");
254
+ for (const l of describe(applyScope("user", root, { dry: true }), { dry: true })) console.log(` ${l}`);
255
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
256
+ const a = (await rl.question(`\n Apply the same to ~/.claude (every project on this machine)? [Y/n] `)).trim().toLowerCase();
257
+ rl.close();
258
+ global = a === "" || a === "y" || a === "yes";
259
+ }
260
+ if (global) {
261
+ console.log("");
262
+ for (const l of describe(applyScope("user", root, { dry }), { dry })) console.log(` ${l}`);
263
+ } else if (global === null) {
264
+ console.log(`\n ${dim("~/.claude left alone (not a terminal). To protect every project:")}`);
265
+ console.log(` ${dim("npx @sayansr26/agent-os settings --scope user --apply")}`);
266
+ }
233
267
  }
234
268
 
235
269
  if (cmd === "init" && targets.includes("claude-code") && !argv.includes("--no-plugin")) {
236
- console.log(`\n${bold("Claude Code plugin")} ${dim(`${PLUGIN}@${MARKETPLACE_NAME}`)}\n`);
237
- const r = installPlugin({ dry });
238
- for (const line of r.done) console.log(` ${dim(line)}`);
239
- if (r.ok && r.reason === "installed") {
240
- console.log(`\n installed at project scope ${dim("— .claude/settings.json, so it travels with the repo")}`);
241
- console.log(` ${dim("restart Claude Code, or /reload-plugins, then run /agent-os:init")}`);
242
- } else if (!r.ok) {
243
- console.log("");
244
- for (const line of r.hint) console.log(` ${line}`);
270
+ // Both come from the same marketplace, so it is added or refreshed once:
271
+ // by the first plugin whose run gets that far.
272
+ let refresh = true;
273
+ for (const plugin of PLUGINS.filter((p) => p === PLUGIN || !argv.includes(`--no-${p}`))) {
274
+ console.log(`\n${bold("Claude Code plugin")} ${dim(`${plugin}@${MARKETPLACE_NAME}`)}\n`);
275
+ const r = ensurePlugin({ root, dry, plugin, refresh });
276
+ if (r.done.length || r.reason === "no-cli") refresh = false;
277
+ for (const line of r.done) console.log(` ${dim(line)}`);
278
+ if (r.ok) console.log({
279
+ installed: `\n installed ${r.to || ""} at project scope ${dim("— .claude/settings.json, so it travels with the repo")}`,
280
+ updated: `\n updated ${r.from} → ${r.to}`,
281
+ current: `\n already the latest (${r.to})`,
282
+ "dry-run": `\n ${dim(r.from ? `installed: ${r.from} — would refresh and update` : "not installed — would install")}`,
283
+ }[r.reason]);
284
+ for (const line of r.hint) console.log(` ${r.ok ? dim(line) : line}`);
285
+ if (r.reason === "installed" || r.reason === "updated")
286
+ console.log(` ${dim("restart Claude Code, or /reload-plugins, to load it")}`);
287
+ }
288
+ console.log(`\n ${dim("--no-plugin skips this; --no-auto-route skips only the model router")}`);
289
+ }
290
+
291
+ if (cmd === "init" && !dry) {
292
+ const after = setupState(root);
293
+ console.log(`\n${bold("Result")} ${before.status} → ${after.status}`);
294
+ if (after.missing.length) {
295
+ console.log(dim(" still to do:"));
296
+ for (const i of after.missing) console.log(` ${i.label} → ${i.fix}`);
245
297
  }
246
- console.log(`\n ${dim("--no-plugin skips this")}`);
247
298
  }
248
299
  console.log("");
249
300
  return;
package/src/plugin.mjs CHANGED
@@ -1,23 +1,46 @@
1
1
  /**
2
- * Install the Claude Code half of agent-os during `init`.
2
+ * Install or update the Claude Code half of agent-os during `init`.
3
3
  *
4
4
  * The CLI compiles rules for every tool. Claude Code additionally gets the
5
- * agents, skills, per-agent memory and session hook, which ship as a plugin —
6
- * so `init` should install it rather than printing two slash commands and
7
- * hoping. `claude plugin ...` with `--yes` is the documented automation path.
5
+ * agents, skills, per-agent memory and hooks, which ship as a plugin, and
6
+ * auto-route, a mod that picks the model and effort per turn. Both live in
7
+ * this repo's marketplace. `init` brings each to the latest version whatever
8
+ * state it finds:
8
9
  *
9
- * Everything here is best effort and reversible: if the `claude` CLI is not on
10
- * PATH, or a command fails, `init` reports it and carries on. The rules are the
11
- * part that must not depend on this.
10
+ * not installed marketplace add + install, at project scope
11
+ * installed, disabled enable
12
+ * installed refresh the marketplace, then update
13
+ *
14
+ * An installed plugin is never left behind: Claude Code pins the cached
15
+ * version, so a project installed once stays on that version until something
16
+ * updates it — which is how a project ended up running 0.4.1 hooks.
17
+ *
18
+ * Every command runs with the project as its working directory, so project
19
+ * scope means *this* project even with --root. Best effort throughout: if the
20
+ * `claude` CLI is missing or a command fails, `init` reports it and carries
21
+ * on. The rules are the part that must not depend on this.
12
22
  */
13
23
  import { spawnSync } from "node:child_process";
24
+ import { readFileSync, realpathSync } from "node:fs";
25
+ import { cmpVersion } from "../plugins/agent-os/skills/init/scripts/state.mjs";
14
26
 
15
27
  export const MARKETPLACE = "sayansr26/agent-os";
16
28
  export const MARKETPLACE_NAME = "sayan-plugins";
17
29
  export const PLUGIN = "agent-os";
30
+ // Installed by `init` in this order; each can be skipped with --no-<name>.
31
+ export const PLUGINS = [PLUGIN, "auto-route"];
32
+ const idOf = (plugin) => `${plugin}@${MARKETPLACE_NAME}`;
18
33
 
19
- const run = (args) =>
20
- spawnSync("claude", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
34
+ const run = (args, cwd) =>
35
+ spawnSync("claude", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000 });
36
+ const json = (r) => { try { return JSON.parse(r.stdout); } catch { return null; } };
37
+ const real = (p) => { try { return realpathSync(p); } catch { return p; } };
38
+ /** The version this package ships for a plugin, from its plugin.json. */
39
+ const bundled = (plugin) => {
40
+ try { return JSON.parse(readFileSync(new URL(`../plugins/${plugin}/.claude-plugin/plugin.json`, import.meta.url), "utf8")).version || null; }
41
+ catch { return null; }
42
+ };
43
+ const tail = (r) => (r.stderr || r.stdout || String(r.error || "")).trim().split("\n").slice(-3);
21
44
 
22
45
  /** Is the Claude Code CLI usable from here? */
23
46
  export function claudeAvailable() {
@@ -25,50 +48,83 @@ export function claudeAvailable() {
25
48
  return !r.error && r.status === 0;
26
49
  }
27
50
 
51
+ /** This project's install of a plugin, per `claude plugin list --json`, or null. */
52
+ export function installed(root, plugin = PLUGIN) {
53
+ const list = json(run(["plugin", "list", "--json"], root));
54
+ if (!Array.isArray(list)) return null;
55
+ const mine = list.filter((p) => p.id === idOf(plugin) &&
56
+ (p.scope === "user" || (p.projectPath && real(p.projectPath) === real(root))));
57
+ return mine.find((p) => p.scope === "project" || p.scope === "local") || mine[0] || null;
58
+ }
59
+
28
60
  /**
29
- * Add the marketplace and install the plugin at project scope, so the setup
30
- * travels with the repository instead of living on one machine.
61
+ * Bring a plugin to the latest version for this project.
62
+ * Returns { ok, reason, from, to, done, hint } with reason one of
63
+ * "installed" | "updated" | "current" | "dry-run" | "no-cli" | "failed".
64
+ * `refresh: false` skips the marketplace step when an earlier call this run
65
+ * already added or updated it.
31
66
  */
32
- export function installPlugin({ dry = false, scope = "project" } = {}) {
33
- const steps = [
34
- ["marketplace", ["plugin", "marketplace", "add", MARKETPLACE, "--scope", scope]],
35
- ["plugin", ["plugin", "install", `${PLUGIN}@${MARKETPLACE_NAME}`, "--scope", scope, "--yes"]],
36
- ];
37
-
67
+ export function ensurePlugin({ root = process.cwd(), dry = false, scope = "project", plugin = PLUGIN, refresh = true } = {}) {
68
+ const ID = idOf(plugin);
38
69
  if (!claudeAvailable())
39
70
  return {
40
- ok: false,
41
- reason: "no-cli",
42
- done: [],
71
+ ok: false, reason: "no-cli", done: [],
43
72
  hint: [
44
- "The `claude` CLI is not on PATH, so the plugin was not installed.",
45
- "Inside Claude Code, run:",
46
- ` /plugin marketplace add ${MARKETPLACE}`,
47
- ` /plugin install ${PLUGIN}@${MARKETPLACE_NAME}`,
73
+ ...(refresh ? [
74
+ "The `claude` CLI is not on PATH, so the plugin was not installed or updated.",
75
+ "Inside Claude Code, run:",
76
+ ` /plugin marketplace add ${MARKETPLACE}`,
77
+ ] : []),
78
+ ` /plugin install ${ID} (or /plugin update ${ID} if it is already there)`,
48
79
  ],
49
80
  };
50
81
 
82
+ const before = installed(root, plugin);
83
+ const steps = [];
84
+ if (refresh) {
85
+ const markets = json(run(["plugin", "marketplace", "list", "--json"], root));
86
+ const haveMarket = Array.isArray(markets) && markets.some((m) => m.name === MARKETPLACE_NAME);
87
+ steps.push(haveMarket
88
+ ? ["plugin", "marketplace", "update", MARKETPLACE_NAME]
89
+ : ["plugin", "marketplace", "add", MARKETPLACE, "--scope", scope]);
90
+ }
91
+ if (!before) steps.push(["plugin", "install", ID, "--scope", scope, "--yes"]);
92
+ else {
93
+ if (before.enabled === false) steps.push(["plugin", "enable", ID, "--scope", before.scope]);
94
+ steps.push(["plugin", "update", ID, "--scope", before.scope, "--yes"]);
95
+ }
96
+
51
97
  if (dry)
52
- return { ok: true, reason: "dry-run", done: steps.map(([, a]) => `claude ${a.join(" ")}`), hint: [] };
98
+ return { ok: true, reason: "dry-run", from: before?.version || null, done: steps.map((a) => `claude ${a.join(" ")}`), hint: [] };
53
99
 
54
100
  const done = [];
55
- for (const [what, args] of steps) {
56
- const r = run(args);
101
+ for (const args of steps) {
102
+ const r = run(args, root);
57
103
  if (r.status !== 0) {
58
- const err = (r.stderr || r.stdout || "").trim().split("\n").slice(-3).join("\n");
59
104
  return {
60
- ok: false,
61
- reason: what,
62
- done,
105
+ ok: false, reason: "failed", from: before?.version || null, done,
63
106
  hint: [
64
107
  `\`claude ${args.join(" ")}\` failed:`,
65
- ...err.split("\n").map((l) => ` ${l}`),
66
- "The rules above were still written. Install the plugin by hand inside",
67
- `Claude Code: /plugin install ${PLUGIN}@${MARKETPLACE_NAME}`,
108
+ ...tail(r).map((l) => ` ${l}`),
109
+ "The rules and settings above were still written. Inside Claude Code:",
110
+ before ? ` /plugin update ${ID}, then /reload-plugins` : ` /plugin install ${ID}`,
68
111
  ],
69
112
  };
70
113
  }
71
114
  done.push(`claude ${args.join(" ")}`);
72
115
  }
73
- return { ok: true, reason: "installed", done, hint: [] };
116
+
117
+ const after = installed(root, plugin);
118
+ const to = after?.version || null;
119
+ const from = before?.version || null;
120
+ const want = bundled(plugin);
121
+ const hint = [];
122
+ // The marketplace serves what is pushed to GitHub; this package may be newer.
123
+ if (to && want && cmpVersion(to, want) < 0)
124
+ hint.push(`Marketplace still serves ${plugin} ${to}; this agent-os ships ${want}. It updates once ${MARKETPLACE} is pushed.`);
125
+ return {
126
+ ok: true,
127
+ reason: !before ? "installed" : from !== to ? "updated" : "current",
128
+ from, to, done, hint,
129
+ };
74
130
  }
package/src/selftest.mjs CHANGED
@@ -8,6 +8,12 @@ import { spawnSync } from "node:child_process";
8
8
  import { fileURLToPath } from "node:url";
9
9
 
10
10
  const PKG = join(fileURLToPath(new URL(".", import.meta.url)), "..");
11
+ // Every child inherits this, so nothing here can ever touch the real ~/.claude.
12
+ const HOME = mkdtempSync(join(tmpdir(), "agent-os-home-"));
13
+ process.env.CLAUDE_CONFIG_DIR = HOME;
14
+ const PLUGIN = join(PKG, "plugins/agent-os");
15
+ const node = (script, args, opts = {}) =>
16
+ spawnSync(process.execPath, [join(PLUGIN, script), ...args], { encoding: "utf8", ...opts });
11
17
  const cli = (args, cwd) => spawnSync(process.execPath, [join(PKG, "bin/agent-os.mjs"), ...args, "--root", cwd], { encoding: "utf8" });
12
18
  let fail = 0;
13
19
  const ok = (c, m) => { console.log(` ${c ? "ok " : "FAIL"} ${m}`); if (!c) fail++; };
@@ -85,15 +91,21 @@ try {
85
91
  ok(existsSync(join(r2, ".agent-os/rules/theming.md")), "existing .claude/rules/ are adopted");
86
92
  ok(!existsSync(join(r2, ".agent-os/rules/example.md")), "no example.md when real rules were adopted");
87
93
  ok(!existsSync(join(r2, ".claude/rules/example.md")), "no example.md compiled into the project");
88
- ok(!i.stdout.includes("Claude Code plugin"), "--no-plugin skips the plugin install");
94
+ ok(!i.stdout.includes("--no-plugin skips this"), "--no-plugin skips the plugin install");
89
95
 
90
96
  // init offers the plugin when not told otherwise; --dry-run proves the
91
97
  // commands without running them against the machine's real config.
92
98
  const r4 = mkdtempSync(join(tmpdir(), "agent-os-plug-"));
93
99
  const pi = cli(["init", "--dry-run"], r4);
94
- ok(pi.stdout.includes("Claude Code plugin"), "init sets up the Claude Code plugin by default");
100
+ ok(pi.stdout.includes("--no-plugin skips this"), "init sets up the Claude Code plugin by default");
101
+ ok(pi.stdout.includes("SETUP FRESH"), "init on an empty project reports a fresh setup");
95
102
  ok(/marketplace add sayansr26\/agent-os|plugin marketplace add|not on PATH/.test(pi.stdout),
96
103
  "init names the marketplace step or says why it could not run it");
104
+ ok(pi.stdout.includes("auto-route@sayan-plugins"), "init sets up auto-route by default");
105
+ ok((pi.stdout.match(/marketplace add/g) || []).length <= 1, "the marketplace is added once, not per plugin");
106
+ const na = cli(["init", "--dry-run", "--no-auto-route"], r4);
107
+ ok(!/install auto-route@sayan-plugins/.test(na.stdout) && /install agent-os@sayan-plugins/.test(na.stdout),
108
+ "--no-auto-route keeps agent-os and skips auto-route");
97
109
  rmSync(r4, { recursive: true, force: true });
98
110
 
99
111
  // A hand-written file at a generated path must survive a sync.
@@ -129,6 +141,20 @@ try {
129
141
  ok(st.env.CLAUDE_CODE_ENABLE_TODO_TOOLS === "1", "todo tools enabled in project settings");
130
142
  ok(st.env.FOO === "bar", "existing env keys preserved");
131
143
  ok(st.permissions?.deny?.[0] === "Bash(git push *)", "existing permissions preserved");
144
+ ok(st.permissions.deny.length === 42, "git write protection merged into project settings, no duplicate push rule");
145
+ ok(!existsSync(join(HOME, "settings.json")), "without --global (and no terminal) ~/.claude is left alone");
146
+ ok(!existsSync(join(r5, ".claude/settings.json.agent-os.bak")), "no backup litter inside the project");
147
+
148
+ const again = cli(["init", "--no-plugin", "--global"], r5).stdout;
149
+ ok(again.includes("SETUP REPAIR") && again.includes("MISSING ~/.claude/settings.json git write protection"),
150
+ "init on a set-up project reports a repair and names what is missing");
151
+ ok(again.includes("kept .agent-os/"), "repair keeps the existing source instead of re-scaffolding");
152
+ const us = JSON.parse(readFileSync(join(HOME, "settings.json"), "utf8"));
153
+ ok(us.permissions.deny.includes("Bash(git commit *)") && us.env.CLAUDE_CODE_ENABLE_TODO_TOOLS === "1",
154
+ "--global writes git protection and task tools to ~/.claude/settings.json");
155
+ ok(readFileSync(join(HOME, "CLAUDE.md"), "utf8").includes("TaskCreate"), "--global adds the task rule to ~/.claude/CLAUDE.md");
156
+ cli(["init", "--no-plugin", "--global"], r5);
157
+ ok(JSON.parse(readFileSync(join(HOME, "settings.json"), "utf8")).permissions.deny.length === 42, "second --global run adds nothing");
132
158
 
133
159
  const cm = readFileSync(join(r5, "CLAUDE.md"), "utf8");
134
160
  ok(cm.includes("TaskCreate / TaskUpdate"), "task-tracking rule added to CLAUDE.md");
@@ -155,13 +181,51 @@ try {
155
181
  rmSync(r6, { recursive: true, force: true });
156
182
  }
157
183
 
184
+ console.log("\n plugin hooks");
185
+ {
186
+ const r7 = mkdtempSync(join(tmpdir(), "agent-os-hooks-"));
187
+ const mem = join(r7, ".claude/agent-memory/agent-os-feature-cartographer");
188
+ mkdirSync(mem, { recursive: true });
189
+ for (const f of ["billing", "orders", "search"]) mkdirSync(join(r7, "src/features", f), { recursive: true });
190
+ writeFileSync(join(mem, "_architecture.md"), "# Architecture\n");
191
+ writeFileSync(join(mem, "billing.md"), "---\nmapped: 2026-01-01\nentry: src/features/billing/index.ts\n---\n# billing\n");
192
+ writeFileSync(join(mem, "MEMORY.md"), "- billing — src/features/billing — mapped 2026-01-01\n");
193
+ mkdirSync(join(r7, ".agent-os/rules"), { recursive: true });
194
+ writeFileSync(join(r7, ".agent-os/rules/api.md"), '---\npaths:\n - "src/**"\n---\n\nSource body.\n');
195
+ mkdirSync(join(r7, ".claude/rules"), { recursive: true });
196
+ writeFileSync(join(r7, ".claude/rules/api.md"), "---\n---\n\n<!-- agent-os: generated from .agent-os/ — edit the source -->\nEdited in place.\n");
197
+ const env = { ...process.env, CLAUDE_PROJECT_DIR: r7 };
198
+
199
+ const start = node("hooks/session-resume.mjs", [], { cwd: r7, env }).stdout;
200
+ ok(start.includes("agent-os is active") && start.includes("agent-os:feature-cartographer"), "session start announces the agents");
201
+ ok(start.includes("Mapped: 1 of 3 features") && start.includes("billing"), "session start says what is mapped");
202
+
203
+ const run = `selftest-${process.pid}-${Date.now()}`;
204
+ const edit = (file, session = run) => node("hooks/pre-edit.mjs", [], {
205
+ env, input: JSON.stringify({ session_id: session, cwd: r7, tool_input: { file_path: join(r7, file) } }),
206
+ }).stdout;
207
+ const denied = edit(".claude/rules/api.md");
208
+ ok(denied.includes('"deny"') && denied.includes(".agent-os/rules/api.md"), "edit to a generated rule is blocked, source named");
209
+ ok(edit("src/features/orders/a.ts").includes("no cartographer map"), "first edit to an unmapped feature reminds");
210
+ ok(edit("src/features/orders/b.ts") === "", "reminder shows once per feature per session");
211
+ ok(edit("src/features/billing/a.ts") === "", "mapped feature stays silent");
212
+ ok(edit("src/lib/x.ts") === "", "non-feature path stays silent");
213
+
214
+ mkdirSync(join(mem, ".claude/agent-memory"), { recursive: true });
215
+ const audit = node("skills/init/scripts/audit.mjs", [r7]).stdout;
216
+ ok(/api\.md was edited in place/.test(audit), "audit flags a generated rule that drifted from its source");
217
+ ok(audit.includes("folder inside agent memory"), "audit flags a nested folder in agent memory");
218
+ ok(audit.includes("1 of 3 (33%)"), "audit reports map coverage, not just that a folder exists");
219
+ rmSync(r7, { recursive: true, force: true });
220
+ }
221
+
158
222
  console.log("\n merge, not overwrite");
159
223
  writeFileSync(join(root, "opencode.json"), JSON.stringify({ model: "anthropic/x", instructions: ["KEEP.md"] }, null, 2));
160
224
  cli(["sync"], root);
161
225
  const oc = JSON.parse(read("opencode.json"));
162
226
  ok(oc.model === "anthropic/x", "existing keys preserved");
163
227
  ok(oc.instructions.includes("KEEP.md"), "existing instructions preserved");
164
- } finally { rmSync(root, { recursive: true, force: true }); }
228
+ } finally { rmSync(root, { recursive: true, force: true }); rmSync(HOME, { recursive: true, force: true }); }
165
229
 
166
230
  console.log(`\n${fail ? `FAILED (${fail})` : "PASSED"}\n`);
167
231
  process.exit(fail ? 1 : 0);