create-agent-rig 0.3.0 → 0.3.2

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 (28) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +11 -3
  3. package/package.json +1 -1
  4. package/packages/cli/dist/commands/create.js +5 -3
  5. package/packages/cli/dist/commands/init.js +73 -18
  6. package/packages/cli/dist/index.js +11 -1
  7. package/packages/cli/dist/lib/git-env.js +48 -0
  8. package/packages/cli/dist/lib/init-settings.js +52 -0
  9. package/packages/cli/dist/lib/targets.js +15 -1
  10. package/packages/cli/dist/templates.js +8 -0
  11. package/scripts/prepare.mjs +54 -17
  12. package/templates/agent-os/init/CLAUDE.md +139 -0
  13. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +16 -1
  14. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +104 -0
  15. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +20 -0
  16. package/templates/agent-os/universal/.claude/rules/invariants.md +12 -11
  17. package/templates/agent-os/universal/.claude/rules/workflow.md +4 -0
  18. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +32 -4
  19. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +34 -1
  20. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +125 -0
  21. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +6 -0
  22. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +3 -0
  23. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +6 -0
  24. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +125 -0
  25. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +19 -7
  26. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +12 -2
  27. package/templates/agent-os/universal/CLAUDE.md +12 -3
  28. package/templates/agent-os/universal/layers.json +2 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,143 @@ the generator.
7
7
  Versions are published to npm as [`create-agent-rig`](https://www.npmjs.com/package/create-agent-rig);
8
8
  `npx github:serhii-baksheiev/create-agent-rig` keeps working for either path.
9
9
 
10
+ ## 0.3.2
11
+
12
+ Numbered as a patch by the owner's call; the content below is additive, so
13
+ nothing that shipped in 0.3.1 changed shape.
14
+
15
+ A generated project gains two review gates it did not have — one before the work
16
+ starts, one over the prose that instructs it — and three more queue-hygiene
17
+ checks.
18
+
19
+ **Upgrading an existing rig: `init` alone is not enough, and here is exactly
20
+ why.** `create-agent-rig init` installs files that are not there and **keeps
21
+ every file that is** — `--force` replaces `CLAUDE.md` and nothing else
22
+ (`packages/cli/src/commands/init.ts`). Re-running it on a 0.3.1 rig therefore
23
+ delivers the two new files, `.claude/agents/prose-reviewer.md` and
24
+ `.claude/skills/check-premises/SKILL.md`, and **none of their wiring**: the
25
+ skill arrives with nothing calling it, and the agent arrives with `pr-ship`
26
+ never launching it. Six files below changed rather than appeared, and `init`
27
+ will not touch them:
28
+
29
+ ```
30
+ .claude/agents/code-reviewer.md # the sixth blocking item
31
+ .claude/skills/loop/SKILL.md # calls check-premises, and §3/§6/§8
32
+ .claude/skills/pr-ship/SKILL.md # fans out prose-reviewer, passes the item
33
+ .claude/scripts/queue/core.mjs # the three hygiene checks + Ticket.body
34
+ .claude/scripts/detect-missed-gate.mjs # sees a rulebook outside the repo root
35
+ .claude/hooks/gate-stop-dod.mjs # judges the tree it is in
36
+ ```
37
+
38
+ Delete those six and re-run `init`, or copy them across by hand. A proper
39
+ upgrade command is queued, not shipped — and until it exists this note tells you
40
+ the manual steps rather than an easy sentence that leaves half the release
41
+ inert. That failure mode is the whole subject of 0.3.1, immediately below.
42
+
43
+ ### Added
44
+
45
+ - **`check-premises` skill** — a queue item is a _claim about the code_, written
46
+ by someone who was not reading the code at the time, and nothing downstream
47
+ re-checks it: the failing test is written against the item, the implementation
48
+ against the test, and the reviewer compares the diff to the item. A false
49
+ premise therefore produces work that is correct, tested, reviewed and useless.
50
+ The skill runs between taking the item and the Red step, is read-only by
51
+ frontmatter so it cannot start implementing, and returns `PREMISES HOLD` /
52
+ `PREMISE FALSE` / `UNVERIFIABLE`. Its two boundaries are the point: a false
53
+ load-bearing premise is **stop and report**, never a silent re-aim of the task,
54
+ and only load-bearing claims are checked — an audit is what makes the step
55
+ expensive enough to skip. The `loop` skill calls it, and treats `PREMISE FALSE`
56
+ as a per-task escalation rather than a licence to rewrite the item.
57
+ - **`prose-reviewer` agent** — a fourth gate, read-only. In this layer the prose
58
+ _is_ the implementation: a rule that overstates its own enforcement fails
59
+ exactly like broken code, silently and in the direction of false confidence. It
60
+ blocks on five things — enforcement claimed beyond the mechanism, a dead
61
+ reference, two rules that contradict each other, stated limits gone stale in
62
+ either direction, and domain that must not travel (a vendor name, a host path,
63
+ a tracker key or a credential in a layer meant to be neutral) — and its
64
+ boundary comes before its checklist: it is **not
65
+ a literary editor**, and prose that is merely clumsy is not a finding. Wired
66
+ into the `pr-ship` fan-out and named in both maps.
67
+ - **A sixth blocking item for `code-reviewer`** — a change that contradicts the
68
+ queue item it claims to implement. The instruction is to report the mismatch,
69
+ never to decide which side "must have been meant": a reviewer who reconciles
70
+ the two silently turns a visible mismatch into an invisible one. Where no item
71
+ was supplied, it says so rather than reconstructing one from the PR body —
72
+ which is evidence `autonomy.md` refuses by name. `pr-ship` now passes the item.
73
+ - **Three queue-hygiene checks** — a parent that says it was split up and is
74
+ still open; a dependency line naming a blocker no link carries (worse than a
75
+ stale label: selection reads the item as unblocked); and a document link that
76
+ is broken on its face. The neutral `Ticket` shape gains a **nullable `body`**
77
+ so these live in one pure function instead of once per adapter — and `null`
78
+ means "this adapter cannot answer", never "checked, found nothing".
79
+
80
+ ### Fixed
81
+
82
+ - **The baseline commit of a generated project could land in the caller's
83
+ repository.** Git hands its hooks an absolute `GIT_DIR`, and the CLI spawned
84
+ git with the environment intact — so `git init` re-initialised the caller's
85
+ repo, `add -A` staged its tree, and the commit landed on whatever branch it had
86
+ checked out, while the generated project got no `.git` at all. A redirected
87
+ `git init` can also flip the caller's repository to `core.bare=true`. The path
88
+ that triggers it is a pre-commit hook running a suite that generates projects —
89
+ which is what made the `worktree-task` skill unusable. Every git call site now
90
+ strips the variables that locate a repository, including the shipped
91
+ `gate-stop-dod` hook (which asked git whether _which_ tree was clean) and
92
+ `preflight`.
93
+ - **The Tier-2 gate sweep could not see a rulebook outside the repository root.**
94
+ `detect-missed-gate` exempts the rulebook from its inert-file rule so a merge
95
+ rewriting the autonomy tiers cannot pass as "just prose" — but the exemption
96
+ was anchored at `CLAUDE.md` / `.claude/`. Any project that vendors, templates
97
+ or nests a rig keeps its rulebook elsewhere, and every `.md` there was dropped
98
+ before the elevated-path test ran. It is now recognised wherever it sits, and
99
+ the sweep's verdict vocabulary knows the words `pr-ship` actually emits.
100
+
101
+ ### Deferred, and on what condition
102
+
103
+ Two pieces of the source brief did **not** travel, because shipping an unproven
104
+ gate into other people's projects is worse than not having one:
105
+
106
+ - the queue-closing discipline for blocked dependents — enters when it has been
107
+ merged and used in the project it came from;
108
+ - the clarify-gate (`C-0…C-2`) — enters once that gate has fired at least once
109
+ anywhere. Until then there is nothing to copy but an intention.
110
+
111
+ ## 0.3.1
112
+
113
+ `create-agent-rig init` shipped a rig that looked installed and enforced
114
+ nothing. Everything below is that one failure, in its four parts — a repo
115
+ `init`ed with 0.3.0 should be re-run with this version (`--force` to replace the
116
+ CLAUDE.md it wrote).
117
+
118
+ ### Fixed
119
+
120
+ - **The hooks are wired.** `init` laid the hook files down and stopped there: no
121
+ `.claude/settings.json` meant `guard-bash`, `block-no-verify`, `gate-stop-dod`
122
+ and `inject-rules` were never called, while the installed `CLAUDE.md` claimed
123
+ they were enforced at the tool layer. The wiring is now _derived_ from the
124
+ shipped settings, so it names exactly the hooks that travelled — never one that
125
+ did not. Where the repo already has a `settings.json`, `init` keeps it and
126
+ prints the entries to merge rather than failing silently.
127
+ - **The kill switch works.** `init` copied templates byte-for-byte, leaving
128
+ `__PROJECT_NAME__` in six places — including `stop-flag.mjs`, so the brake
129
+ looked for `~/.claude/__PROJECT_NAME__-loop-STOP` while the operator, following
130
+ the instructions in the same install, created `~/.claude/<repo>-loop-STOP`. It
131
+ never fired, and never said so.
132
+ - **The installed `CLAUDE.md` describes the repo it landed in.** It used to be
133
+ the generated monorepo's map — `packages/core/`, `apps/web/`, links to an
134
+ `architecture.md` and two guards that `init` deliberately does not install. It
135
+ is now its own document: what was installed, what was not, and that the
136
+ architecture rules are yours to write.
137
+ - **The elevated-path block names paths that exist.** It seeded
138
+ `packages/db/src/` into repos that have no such directory, so the Tier-2 gate
139
+ sweep reported "clean" while looking at nothing.
140
+
141
+ ### Added
142
+
143
+ - A template test that fails if anything `init` installs references a `.claude`
144
+ file `init` does not install — the drift that produced three of the four
145
+ findings above, now mechanical.
146
+
10
147
  ## 0.3.0
11
148
 
12
149
  The factory extraction: a scaffolded project now arrives with a working
package/README.md CHANGED
@@ -22,7 +22,15 @@ npx create-agent-rig init --dry-run # print the plan, write nothing
22
22
  ```
23
23
 
24
24
  `init` drops in the autonomy tiers, stop rules, workflow, and the enforcement
25
- hooks, and refuses to clobber an existing `CLAUDE.md`.
25
+ hooks **wired**, in a `.claude/settings.json` that names exactly the hooks it
26
+ installed — plus a `CLAUDE.md` that describes that rig rather than the generated
27
+ monorepo. It refuses to clobber an existing `CLAUDE.md`; if the repo already has
28
+ a `.claude/settings.json`, it keeps it and prints the entries to merge, because a
29
+ hook nothing calls is not enforcement.
30
+
31
+ Two things it deliberately leaves to you, and says so in the installed
32
+ `CLAUDE.md`: the Definition-of-Done gate has no `dod-checks.json` (it cannot know
33
+ your commands), and the elevated-path list names only what every repo has.
26
34
 
27
35
  ## What you get
28
36
 
@@ -75,8 +83,8 @@ never labels**, and **the agent never files its own work items**.
75
83
 
76
84
  Around all of it: **autonomy tiers** (what an agent does alone / after review /
77
85
  never), **stop rules** (three strikes, flaky ≠ retry, session staleness),
78
- **subagent gates** (`test-writer`, `code-reviewer`, `security-scanner`, and
79
- `cdk-diff-reviewer` on the AWS target), **skills** (`pr-ship` pre-merge gate;
86
+ **subagent gates** (`test-writer`, `code-reviewer`, `security-scanner`,
87
+ `prose-reviewer`, and `cdk-diff-reviewer` on the AWS target), **skills** (`pr-ship` pre-merge gate;
80
88
  `loop` queue driver; `worktree-task` for concurrent sessions; `new-invariant`, a
81
89
  generator for the invariant→hook→test pattern; `post-deploy-verify` and
82
90
  `ro-debug` on the AWS target), and a one-page `CLAUDE.md` map a fresh session
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-agent-rig",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Scaffold a new project with an agent operating system (rules, gates, hooks) and a runnable code skeleton",
5
5
  "keywords": [
6
6
  "create",
@@ -5,6 +5,7 @@ import { promisify } from 'node:util';
5
5
  import { copyTree, listTree } from '../lib/copy-tree.js';
6
6
  import { ALLOWED_OVERWRITES, detectCollisions } from '../lib/composition.js';
7
7
  import { substituteContent, substituteFileName } from '../lib/substitute.js';
8
+ import { gitEnv } from '../lib/git-env.js';
8
9
  import { DEFAULT_TARGET, TARGETS, TARGET_NAMES } from '../lib/targets.js';
9
10
  import { agentOsStackDir, agentOsUniversalDir, skeletonDir } from '../templates.js';
10
11
  /** A user-facing failure: message is printed as-is, no stack trace. */
@@ -72,9 +73,10 @@ async function initGitBaseline(projectDir) {
72
73
  // .git/objects/pack after we return — a non-deterministic tail that races any
73
74
  // caller cleaning up the directory, and pointless work on a one-commit repo.
74
75
  const quiet = ['-c', 'gc.auto=0', '-c', 'maintenance.auto=false'];
76
+ const where = { cwd: projectDir, env: gitEnv() };
75
77
  try {
76
- await run('git', [...quiet, 'init', '--quiet'], { cwd: projectDir });
77
- await run('git', [...quiet, 'add', '-A'], { cwd: projectDir });
78
+ await run('git', [...quiet, 'init', '--quiet'], where);
79
+ await run('git', [...quiet, 'add', '-A'], where);
78
80
  // Explicit identity: the baseline must commit even where git has no
79
81
  // global user configured (fresh machines, CI). --no-verify here shields
80
82
  // the baseline from the USER'S global hooks only — the generated
@@ -90,7 +92,7 @@ async function initGitBaseline(projectDir) {
90
92
  '--no-verify',
91
93
  '-m',
92
94
  'Pristine template (create-agent-rig)',
93
- ], { cwd: projectDir });
95
+ ], where);
94
96
  }
95
97
  catch {
96
98
  // git missing or unusable — generation never fails on this.
@@ -1,37 +1,93 @@
1
1
  import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { agentOsUniversalDir } from '../templates.js';
3
+ import { settingsForInstalledHooks } from '../lib/init-settings.js';
4
+ import { substituteContent } from '../lib/substitute.js';
5
+ import { agentOsInitDir, agentOsUniversalDir } from '../templates.js';
4
6
  /** A user-facing failure: message is printed as-is, no stack trace. */
5
7
  export class InitError extends Error {
6
8
  }
9
+ const SETTINGS = '.claude/settings.json';
7
10
  async function loadManifest() {
8
11
  const raw = await readFile(path.join(agentOsUniversalDir(), 'layers.json'), 'utf8');
9
12
  return JSON.parse(raw);
10
13
  }
14
+ async function exists(p) {
15
+ try {
16
+ await access(p);
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ /**
24
+ * The name this repo is known by inside the rig. It ends up in a *filename* —
25
+ * `~/.claude/<name>-loop-STOP`, the kill switch — so it is reduced to
26
+ * characters an operator can type into a shell without quoting.
27
+ */
28
+ export function projectNameFor(repoDir) {
29
+ const base = path.basename(path.resolve(repoDir));
30
+ const slug = base
31
+ .toLowerCase()
32
+ .replace(/[^a-z0-9._-]+/g, '-')
33
+ .replace(/^[-.]+|[-.]+$/g, '');
34
+ return slug === '' ? 'project' : slug;
35
+ }
11
36
  /**
12
37
  * `init` installs only the PROCESS layer (hooks-and-reach brief §3/§4): rules
13
38
  * that assume nothing about the codebase shape. Architecture rules reference
14
39
  * `packages/core` and friends — installing them into an arbitrary repo would
15
40
  * describe a structure that does not exist, which is worse than no rule.
16
41
  *
17
- * CLAUDE.md is the meta file we bring, but never over an existing one.
42
+ * It also installs two things the process manifest does not name, because both
43
+ * are meaningless in the generated shape and load-bearing here:
44
+ *
45
+ * - `CLAUDE.md` — the map, taken from the init override layer, which describes
46
+ * the rig this command installs rather than the generated monorepo;
47
+ * - `.claude/settings.json` — the wiring, derived from the shipped settings so
48
+ * it names exactly the hooks that travelled.
18
49
  */
19
- async function processFiles(manifest) {
20
- // the process layer, plus CLAUDE.md as the map (guarded separately)
21
- return [...manifest.process, 'CLAUDE.md'];
22
- }
23
- async function exists(p) {
24
- try {
25
- await access(p);
26
- return true;
50
+ export async function initManifest() {
51
+ const manifest = await loadManifest();
52
+ const universal = agentOsUniversalDir();
53
+ const override = agentOsInitDir();
54
+ const files = [];
55
+ for (const rel of [...manifest.process, 'CLAUDE.md']) {
56
+ const overridden = path.join(override, rel);
57
+ files.push({
58
+ rel,
59
+ source: (await exists(overridden)) ? overridden : path.join(universal, rel),
60
+ });
27
61
  }
28
- catch {
29
- return false;
62
+ files.push({ rel: SETTINGS, source: null });
63
+ return files;
64
+ }
65
+ /**
66
+ * Exactly the bytes `init` would write, keyed by destination path — the single
67
+ * source the plan, the install and the template tests all read.
68
+ *
69
+ * Every file the process layer carries is text (asserted by a template test),
70
+ * so substitution can be applied unconditionally: an unsubstituted
71
+ * `__PROJECT_NAME__` in `stop-flag.mjs` is a kill switch that silently never
72
+ * fires.
73
+ */
74
+ export async function initFileContents(repoDir) {
75
+ const projectName = projectNameFor(repoDir);
76
+ const ctx = { projectName, projectScope: projectName, region: '' };
77
+ const files = await initManifest();
78
+ const contents = new Map();
79
+ for (const { rel, source } of files) {
80
+ if (source === null)
81
+ continue;
82
+ contents.set(rel, substituteContent(await readFile(source, 'utf8'), ctx));
30
83
  }
84
+ const installedHooks = new Set(files.map((f) => f.rel).filter((rel) => rel.startsWith('.claude/hooks/')));
85
+ const shipped = JSON.parse(await readFile(path.join(agentOsUniversalDir(), SETTINGS), 'utf8'));
86
+ contents.set(SETTINGS, `${JSON.stringify(settingsForInstalledHooks(shipped, installedHooks), null, 2)}\n`);
87
+ return contents;
31
88
  }
32
89
  export async function planInit(repoDir) {
33
- const manifest = await loadManifest();
34
- const files = await processFiles(manifest);
90
+ const files = (await initManifest()).map((f) => f.rel);
35
91
  const conflicts = [];
36
92
  for (const rel of files) {
37
93
  if (await exists(path.join(repoDir, rel)))
@@ -40,9 +96,7 @@ export async function planInit(repoDir) {
40
96
  return { files: files.map((p) => ({ path: p })), conflicts };
41
97
  }
42
98
  export async function initProject(repoDir, options) {
43
- const manifest = await loadManifest();
44
- const files = await processFiles(manifest);
45
- const universal = agentOsUniversalDir();
99
+ const files = (await initManifest()).map((f) => f.rel);
46
100
  // Refuse to clobber an existing CLAUDE.md unless forced — init edits
47
101
  // someone's working repository (brief §4, non-negotiable).
48
102
  if (!options.force && files.includes('CLAUDE.md')) {
@@ -51,6 +105,7 @@ export async function initProject(repoDir, options) {
51
105
  'Merge the agent-os map in by hand, or re-run with --force to replace it.');
52
106
  }
53
107
  }
108
+ const contents = await initFileContents(repoDir);
54
109
  const written = [];
55
110
  const skipped = [];
56
111
  const plannedCount = files.length;
@@ -65,7 +120,7 @@ export async function initProject(repoDir, options) {
65
120
  if (options.dryRun)
66
121
  continue;
67
122
  await mkdir(path.dirname(dest), { recursive: true });
68
- await writeFile(dest, await readFile(path.join(universal, rel)));
123
+ await writeFile(dest, contents.get(rel) ?? '');
69
124
  written.push(rel);
70
125
  }
71
126
  return { written, skipped, plannedCount };
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { parseArgs } from 'node:util';
6
6
  import { CreateError, createProject } from './commands/create.js';
7
- import { InitError, initProject, planInit } from './commands/init.js';
7
+ import { InitError, initFileContents, initProject, planInit } from './commands/init.js';
8
8
  import { makePalette } from './lib/colors.js';
9
9
  import { promptTarget } from './lib/prompts.js';
10
10
  import { collectGovernance, renderSummary } from './lib/summary.js';
@@ -65,6 +65,16 @@ async function runInit(rawArgs) {
65
65
  process.stdout.write(`\nInstalled ${result.written.length} files` +
66
66
  (result.skipped.length ? `, kept ${result.skipped.length} existing` : '') +
67
67
  '.\n');
68
+ // The one kept file that silently disables everything else: without this
69
+ // wiring the hooks sit on disk and are never called, while the rules claim
70
+ // they are enforced. Say so loudly, and hand over the exact entries.
71
+ if (result.skipped.includes('.claude/settings.json')) {
72
+ const wiring = (await initFileContents(cwd)).get('.claude/settings.json') ?? '';
73
+ process.stdout.write(`\n! .claude/settings.json already exists — it was kept, so the rig's hooks are NOT wired.\n` +
74
+ ` Until you merge these entries into it, nothing enforces the rules:\n\n` +
75
+ wiring.replace(/^/gm, ' ') +
76
+ '\n');
77
+ }
68
78
  return 0;
69
79
  }
70
80
  async function main() {
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Variables that point git at a repository other than the one at `cwd`.
3
+ *
4
+ * Inherited, they silently redirect a git command into the CALLER's repository:
5
+ * `git init` re-initialises it, `add -A` stages the caller's tree, and a commit
6
+ * lands on whatever branch the caller has checked out — while the directory the
7
+ * command was aimed at ends up with no `.git` at all.
8
+ *
9
+ * This is not hypothetical. Git sets `GIT_DIR` and `GIT_INDEX_FILE` — absolute —
10
+ * for the hooks it runs, so a `git commit` from a linked worktree whose
11
+ * pre-commit runs a suite that shells out to git writes one junk commit per
12
+ * invocation onto the branch being committed. Observed twice in this repo, from
13
+ * two different call sites, which is why this lives in one module: a second copy
14
+ * of this list is a second chance to fix one and forget the other.
15
+ *
16
+ * The list is explicit rather than a `GIT_*` sweep on purpose — `GIT_SSH_COMMAND`
17
+ * or `GIT_TERMINAL_PROMPT` are the caller's environment and none of our business.
18
+ * Only repository *location* is stripped.
19
+ *
20
+ * 🔴 Limit, stated: this strips repository *location*, not every way git can be
21
+ * redirected. The config-injection family (`GIT_CONFIG_COUNT`/`_KEY_n`/`_VALUE_n`,
22
+ * `GIT_CONFIG_PARAMETERS`, `GIT_CONFIG_GLOBAL`/`_SYSTEM`) can carry `core.worktree`
23
+ * or `core.bare` and is deliberately out of scope: git never sets those for the
24
+ * hooks that cause this problem, and stripping a caller's deliberate config
25
+ * overrides would be its own surprise.
26
+ *
27
+ * 🔴 And it protects exactly the call sites that use it. It does not make git
28
+ * safe to call from a hook-invoked process in general, and a spawn that forgets
29
+ * `gitEnv()` is unprotected — nothing in this module can detect that. The sweep
30
+ * in `test/template/git-env.test.ts` is what watches for it.
31
+ */
32
+ export const GIT_LOCATION_VARS = [
33
+ 'GIT_DIR',
34
+ 'GIT_WORK_TREE',
35
+ 'GIT_INDEX_FILE',
36
+ 'GIT_COMMON_DIR',
37
+ 'GIT_OBJECT_DIRECTORY',
38
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
39
+ 'GIT_NAMESPACE',
40
+ 'GIT_PREFIX',
41
+ ];
42
+ /** The caller's environment minus anything that re-points git at another repo. */
43
+ export function gitEnv(env = process.env) {
44
+ const sanitised = { ...env };
45
+ for (const key of GIT_LOCATION_VARS)
46
+ delete sanitised[key];
47
+ return sanitised;
48
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `init` installs the PROCESS layer only — a subset of the hooks the generated
3
+ * shape gets. The wiring it writes has to match that subset exactly:
4
+ *
5
+ * - wiring a hook file that was not installed makes every matching tool call
6
+ * fail on a missing module;
7
+ * - wiring nothing at all is worse and quieter — the hooks sit on disk, the
8
+ * rules claim they are enforced, and nothing ever calls them.
9
+ *
10
+ * So the wiring is *derived* from the shipped settings.json rather than
11
+ * maintained as a second copy: add a process hook and wire it once, upstream,
12
+ * and init picks it up.
13
+ */
14
+ /** Matches the hook file a wired command runs, e.g. `.claude/hooks/guard-bash.mjs`. */
15
+ const HOOK_REFERENCE = /\.claude\/hooks\/[A-Za-z0-9._-]+\.mjs/;
16
+ function isRecord(value) {
17
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
18
+ }
19
+ /** An entry survives unless it names a hook file that is not installed. */
20
+ function keepEntry(entry, installed) {
21
+ if (!isRecord(entry) || typeof entry.command !== 'string')
22
+ return true;
23
+ const referenced = HOOK_REFERENCE.exec(entry.command);
24
+ return referenced === null || installed.has(referenced[0]);
25
+ }
26
+ /** A group survives only with at least one entry left — never as an empty shell. */
27
+ function keepGroup(group, installed) {
28
+ if (!isRecord(group) || !Array.isArray(group.hooks))
29
+ return group;
30
+ const hooks = group.hooks.filter((entry) => keepEntry(entry, installed));
31
+ return hooks.length === 0 ? null : { ...group, hooks };
32
+ }
33
+ /**
34
+ * The shipped settings, narrowed to the hooks actually installed. Shapes this
35
+ * function does not understand are passed through untouched — it filters, it
36
+ * never rewrites.
37
+ */
38
+ export function settingsForInstalledHooks(settings, installed) {
39
+ if (!isRecord(settings) || !isRecord(settings.hooks))
40
+ return settings;
41
+ const events = {};
42
+ for (const [event, groups] of Object.entries(settings.hooks)) {
43
+ if (!Array.isArray(groups)) {
44
+ events[event] = groups;
45
+ continue;
46
+ }
47
+ const kept = groups.map((group) => keepGroup(group, installed)).filter((g) => g !== null);
48
+ if (kept.length > 0)
49
+ events[event] = kept;
50
+ }
51
+ return { ...settings, hooks: events };
52
+ }
@@ -10,5 +10,19 @@ export const TARGETS = {
10
10
  },
11
11
  };
12
12
  export const TARGET_NAMES = Object.keys(TARGETS);
13
- /** Zero options at the personal stage: one implicit target (PLAN.md §6). */
13
+ /**
14
+ * The pre-selected entry in the interactive menu, and the API-level default.
15
+ *
16
+ * 🔴 **Not the fallback for a non-interactive run.** A run with no TTY and no
17
+ * `--target` is *refused* (`index.ts`) — never prompt into a pipe, and never
18
+ * guess a whole project shape for a script that did not say. This constant is
19
+ * what `Enter`, an out-of-range number or an unrecognised name resolve to at the
20
+ * prompt (`prompts.ts`), plus what `createProject` uses when called as a library
21
+ * with no target.
22
+ *
23
+ * The comment this replaces said "one implicit target", which was stale from
24
+ * when there was one. Its first correction claimed the CLI defaults silently in
25
+ * CI — the opposite of what it does, in the file a maintainer would open to
26
+ * check exactly that.
27
+ */
14
28
  export const DEFAULT_TARGET = 'aws-serverless';
@@ -19,3 +19,11 @@ export function agentOsUniversalDir() {
19
19
  export function agentOsStackDir(stack) {
20
20
  return path.join(templatesRoot(), 'agent-os', 'stack', stack);
21
21
  }
22
+ /**
23
+ * Overrides `init` applies on top of the universal layer. A file here replaces
24
+ * its universal namesake when the rig is installed into an existing repo whose
25
+ * shape the generator knows nothing about. `create` never reads this directory.
26
+ */
27
+ export function agentOsInitDir() {
28
+ return path.join(templatesRoot(), 'agent-os', 'init');
29
+ }
@@ -5,25 +5,62 @@ import { spawnSync } from 'node:child_process';
5
5
  import { existsSync } from 'node:fs';
6
6
  import { createRequire } from 'node:module';
7
7
  import path from 'node:path';
8
- import { fileURLToPath } from 'node:url';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
9
 
10
10
  const root = path.dirname(fileURLToPath(import.meta.url)) + '/..';
11
11
 
12
- // 1. Wire up the pre-commit hook when working inside the git checkout.
13
- if (existsSync(path.join(root, '.git'))) {
14
- spawnSync('git', ['config', 'core.hooksPath', '.husky'], { cwd: root, stdio: 'inherit' });
12
+ /**
13
+ * The environment `git config` runs under, minus anything that could point it
14
+ * at another repository's config file.
15
+ *
16
+ * This script runs from `pnpm install`, and a pre-commit hook can reach an
17
+ * install — a hook-started process inherits an absolute `GIT_DIR`, and
18
+ * `core.hooksPath` would then be written into somebody ELSE's repository.
19
+ *
20
+ * ⚠ The canonical list lives in `packages/cli/src/lib/git-env.ts` and this is a
21
+ * deliberate second copy, because `prepare` step 1 runs *before* step 2 builds
22
+ * the TypeScript that would make it importable. The subset is not a smaller
23
+ * opinion about that list: `git config --local` resolves its target file only
24
+ * through `GIT_DIR`/`GIT_COMMON_DIR`, and `GIT_CONFIG` names a config file
25
+ * outright. `GIT_WORK_TREE` and `GIT_INDEX_FILE` cannot move it, so stripping
26
+ * them here would be noise.
27
+ *
28
+ * Exported so the behaviour is testable — importing this module must not build
29
+ * anything, hence the entry-point guard at the bottom.
30
+ */
31
+ export const gitConfigEnv = (env = process.env) => {
32
+ const sanitised = { ...env };
33
+ for (const key of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_CONFIG']) delete sanitised[key];
34
+ return sanitised;
35
+ };
36
+
37
+ function main() {
38
+ // 1. Wire up the pre-commit hook when working inside the git checkout.
39
+ if (existsSync(path.join(root, '.git'))) {
40
+ spawnSync('git', ['config', 'core.hooksPath', '.husky'], {
41
+ cwd: root,
42
+ env: gitConfigEnv(),
43
+ stdio: 'inherit',
44
+ });
45
+ }
46
+
47
+ // 2. Build the CLI so the `bin` entry exists (required for git/tarball installs).
48
+ const require = createRequire(import.meta.url);
49
+ const tscPath = path.join(
50
+ path.dirname(require.resolve('typescript/package.json')),
51
+ 'lib',
52
+ 'tsc.js',
53
+ );
54
+ const result = spawnSync(
55
+ process.execPath,
56
+ [tscPath, '-p', path.join(root, 'packages/cli/tsconfig.build.json')],
57
+ { cwd: root, stdio: 'inherit' },
58
+ );
59
+ process.exit(result.status ?? 1);
15
60
  }
16
61
 
17
- // 2. Build the CLI so the `bin` entry exists (required for git/tarball installs).
18
- const require = createRequire(import.meta.url);
19
- const tscPath = path.join(
20
- path.dirname(require.resolve('typescript/package.json')),
21
- 'lib',
22
- 'tsc.js',
23
- );
24
- const result = spawnSync(
25
- process.execPath,
26
- [tscPath, '-p', path.join(root, 'packages/cli/tsconfig.build.json')],
27
- { cwd: root, stdio: 'inherit' },
28
- );
29
- process.exit(result.status ?? 1);
62
+ // Run only when executed, never when imported: a test that imports this module
63
+ // to check one exported function must not trigger a build or touch git config.
64
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
65
+ main();
66
+ }