@dalzoubi/dev-agents-sync 2.0.20 → 2.0.22

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.
package/README.md CHANGED
@@ -109,11 +109,12 @@ The CLI seeds a blank steering scaffold under `.claude/steering/` — `decisions
109
109
 
110
110
  ### Hook scripts (managed)
111
111
 
112
- `init` and `update` install three hook scripts into `.claude/hooks/` as marker-managed, lockfile-tracked, executable files:
112
+ `init` and `update` install four hook scripts into `.claude/hooks/` as marker-managed, lockfile-tracked, executable files:
113
113
 
114
114
  | Script | Claude Code event | Purpose |
115
115
  |---|---|---|
116
116
  | `steering-injector.sh` | `SessionStart` | Surfaces steering context at the start of a session. |
117
+ | `rules-injector.sh` | `SessionStart` | Injects the always-on shared rule bodies (`structured-choices`, `tone`) from `.claude/rules/` into every session; fail-safe (skips absent rules, exits 0). |
117
118
  | `managed-file-guard.sh` | `PostToolUse` | Guards managed files against unexpected edits. |
118
119
  | `uncommitted-check.sh` | `Stop` | Flags uncommitted work when a session ends. |
119
120
 
@@ -121,7 +122,7 @@ Hook scripts use the shell-comment marker form `# managed-by: dev-agents-sync vX
121
122
 
122
123
  ### settings.json auto-wiring
123
124
 
124
- By default, `init` and `update` also register the three hooks in `.claude/settings.json` so Claude Code runs them. The merge is:
125
+ By default, `init` and `update` also register the four hooks in `.claude/settings.json` so Claude Code runs them. The merge is:
125
126
 
126
127
  - **Additive** — every existing setting and every consumer-authored hook is preserved untouched.
127
128
  - **Idempotent** — running `update` twice produces no duplicate entries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dalzoubi/dev-agents-sync",
3
- "version": "2.0.20",
3
+ "version": "2.0.22",
4
4
  "type": "module",
5
5
  "description": "CLI that syncs managed dev-agent prompts into consumer repos (.claude/ and/or .cursor/).",
6
6
  "license": "UNLICENSED",
package/src/settings.mjs CHANGED
@@ -263,6 +263,7 @@ export function removeHookSettings(consumerCwd, opts = {}) {
263
263
  */
264
264
  export const DEFAULT_HOOK_SCRIPTS = [
265
265
  { event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
266
+ { event: 'SessionStart', matcher: '.*', command: '.claude/hooks/rules-injector.sh' },
266
267
  { event: 'PostToolUse', matcher: '.*', command: '.claude/hooks/managed-file-guard.sh' },
267
268
  { event: 'Stop', matcher: '.*', command: '.claude/hooks/uncommitted-check.sh' },
268
269
  ];
@@ -0,0 +1,352 @@
1
+ /**
2
+ * tests/rules-injector-registration.test.mjs
3
+ *
4
+ * Slice S2 — SessionStart rules-injector.sh hook (registration half).
5
+ * Spec: docs/specs/260708-claude-rules-enforcement/
6
+ *
7
+ * These tests FAIL before the implement agent registers rules-injector.sh as a
8
+ * managed SessionStart hook and PASS after. They are behavior-anchored (§7):
9
+ * each criterion maps to a named test that would fail if the behavior broke.
10
+ *
11
+ * Behavior anchors:
12
+ *
13
+ * R1. DEFAULT_HOOK_SCRIPTS (packages/dev-agents-sync/src/settings.mjs) includes
14
+ * a SessionStart entry whose command targets
15
+ * `.claude/hooks/rules-injector.sh`.
16
+ * R2. When the fetched FileMap ships `claude/hooks/rules-injector.sh` (as S3's
17
+ * build.mjs will once the canonical `.agents/hooks/rules-injector.sh`
18
+ * exists), `runInit` wires a HOOK_TAG-tagged SessionStart registration for
19
+ * it into the consumer's `.claude/settings.json`, additively alongside the
20
+ * three pre-existing managed hooks (steering-injector.sh,
21
+ * managed-file-guard.sh, uncommitted-check.sh).
22
+ * R3. `runUpdate` does the same on a pre-initialized consumer repo.
23
+ * R4. Running update twice produces no duplicate rules-injector.sh
24
+ * registration (idempotent, §9).
25
+ *
26
+ * Test runner: `node --test packages/dev-agents-sync/tests/rules-injector-registration.test.mjs`
27
+ * No third-party dependencies. All tests use isolated tmp dirs.
28
+ */
29
+
30
+ import { describe, it, beforeEach, afterEach } from 'node:test';
31
+ import assert from 'node:assert/strict';
32
+ import {
33
+ mkdtempSync,
34
+ mkdirSync,
35
+ writeFileSync,
36
+ readFileSync,
37
+ existsSync,
38
+ rmSync,
39
+ readdirSync,
40
+ } from 'node:fs';
41
+ import { tmpdir } from 'node:os';
42
+ import path from 'node:path';
43
+ import { fileURLToPath } from 'node:url';
44
+
45
+ import { runInit } from '../src/commands/init.mjs';
46
+ import { runUpdate } from '../src/commands/update.mjs';
47
+ import { writeLockfile } from '../src/lockfile.mjs';
48
+ import { DEFAULT_HOOK_SCRIPTS, HOOK_TAG } from '../src/settings.mjs';
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Paths / fixture helpers (mirrors tests/hooks-cli-install.test.mjs)
52
+ // ---------------------------------------------------------------------------
53
+
54
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
55
+ const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'release-v1.0.0');
56
+
57
+ function collectFiles(baseDir, currentDir, map, prefix) {
58
+ const entries = readdirSync(currentDir, { withFileTypes: true });
59
+ for (const entry of entries) {
60
+ const full = path.join(currentDir, entry.name);
61
+ if (entry.isDirectory()) {
62
+ collectFiles(baseDir, full, map, prefix);
63
+ } else {
64
+ const rel = path.relative(baseDir, full).replace(/\\/g, '/');
65
+ map[`${prefix}/${rel}`] = readFileSync(full, 'utf8');
66
+ }
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Builds a fixture FileMap that includes the four managed hook scripts —
72
+ * the three pre-existing ones plus rules-injector.sh (as S3's build.mjs will
73
+ * emit once the canonical `.agents/hooks/rules-injector.sh` source exists).
74
+ */
75
+ function buildFixtureFileMap() {
76
+ const map = {};
77
+ const targetDir = path.join(FIXTURE_DIR, 'claude');
78
+ if (existsSync(targetDir)) {
79
+ collectFiles(targetDir, targetDir, map, 'claude');
80
+ }
81
+ map['claude/hooks/steering-injector.sh'] =
82
+ '#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# SessionStart hook.\nexit 0\n';
83
+ map['claude/hooks/managed-file-guard.sh'] =
84
+ '#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# PostToolUse hook.\nexit 0\n';
85
+ map['claude/hooks/uncommitted-check.sh'] =
86
+ '#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# Stop hook.\nexit 0\n';
87
+ map['claude/hooks/rules-injector.sh'] =
88
+ '#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# SessionStart hook.\nexit 0\n';
89
+ return map;
90
+ }
91
+
92
+ function makeFixtureFetcher() {
93
+ return async (_repo, _tag, _token) => buildFixtureFileMap();
94
+ }
95
+
96
+ const AVAILABLE_TAGS = ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'];
97
+
98
+ function makeTmpDir() {
99
+ return mkdtempSync(path.join(tmpdir(), 'das-rules-injector-reg-test-'));
100
+ }
101
+
102
+ function setupInitializedRepo(consumerDir, opts = {}) {
103
+ const { targets = ['claude'], resolvedVersion = '1.0.0', range = '^1' } = opts;
104
+
105
+ writeLockfile(consumerDir, {
106
+ source: 'github:dalzoubi/dev-agents',
107
+ range,
108
+ resolvedVersion,
109
+ targets,
110
+ lastUpdated: '2026-06-01T00:00:00Z',
111
+ });
112
+
113
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
114
+ mkdirSync(agentsDir, { recursive: true });
115
+ const fixtureContent = readFileSync(
116
+ path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'),
117
+ 'utf8',
118
+ );
119
+ writeFileSync(path.join(agentsDir, 'define.md'), fixtureContent, 'utf8');
120
+ }
121
+
122
+ /** Collects every hook `command` string registered anywhere in settings.hooks. */
123
+ function allHookCommands(settings) {
124
+ const hooks = settings.hooks ?? {};
125
+ const commands = [];
126
+ for (const entries of Object.values(hooks)) {
127
+ if (!Array.isArray(entries)) continue;
128
+ for (const entry of entries) {
129
+ const hookList = Array.isArray(entry.hooks) ? entry.hooks : [];
130
+ for (const h of hookList) {
131
+ if (h.command) commands.push({ command: h.command, tagged: entry[HOOK_TAG] === true });
132
+ }
133
+ }
134
+ }
135
+ return commands;
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // R1 — DEFAULT_HOOK_SCRIPTS registers rules-injector.sh on SessionStart
140
+ // ---------------------------------------------------------------------------
141
+
142
+ describe('S2 R1 — DEFAULT_HOOK_SCRIPTS includes rules-injector.sh', () => {
143
+ it('R1a: DEFAULT_HOOK_SCRIPTS has an entry whose command is .claude/hooks/rules-injector.sh', () => {
144
+ const entry = DEFAULT_HOOK_SCRIPTS.find(
145
+ (h) => h.command === '.claude/hooks/rules-injector.sh',
146
+ );
147
+ assert.ok(
148
+ entry,
149
+ `DEFAULT_HOOK_SCRIPTS must register .claude/hooks/rules-injector.sh; ` +
150
+ `got commands: ${DEFAULT_HOOK_SCRIPTS.map((h) => h.command).join(', ')}`,
151
+ );
152
+ });
153
+
154
+ it('R1b: the rules-injector.sh registration fires on SessionStart', () => {
155
+ const entry = DEFAULT_HOOK_SCRIPTS.find(
156
+ (h) => h.command === '.claude/hooks/rules-injector.sh',
157
+ );
158
+ assert.ok(entry, 'rules-injector.sh must be registered in DEFAULT_HOOK_SCRIPTS');
159
+ assert.equal(
160
+ entry.event,
161
+ 'SessionStart',
162
+ `rules-injector.sh must be registered on the SessionStart event; got: ${entry?.event}`,
163
+ );
164
+ });
165
+
166
+ it('R1c: the three pre-existing managed hooks are still registered (additive, not replaced)', () => {
167
+ const commands = DEFAULT_HOOK_SCRIPTS.map((h) => h.command);
168
+ for (const expected of [
169
+ '.claude/hooks/steering-injector.sh',
170
+ '.claude/hooks/managed-file-guard.sh',
171
+ '.claude/hooks/uncommitted-check.sh',
172
+ ]) {
173
+ assert.ok(
174
+ commands.includes(expected),
175
+ `registering rules-injector.sh must not drop the existing ${expected} registration`,
176
+ );
177
+ }
178
+ });
179
+ });
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // R2 — init wires a tagged SessionStart registration for rules-injector.sh
183
+ // ---------------------------------------------------------------------------
184
+
185
+ describe('S2 R2 — init wires rules-injector.sh into settings.json', () => {
186
+ let consumerDir;
187
+
188
+ beforeEach(() => {
189
+ consumerDir = makeTmpDir();
190
+ });
191
+
192
+ afterEach(() => {
193
+ rmSync(consumerDir, { recursive: true, force: true });
194
+ });
195
+
196
+ it('R2a: init registers a SessionStart entry for rules-injector.sh', async () => {
197
+ await runInit(consumerDir, {
198
+ targets: ['claude'],
199
+ fetcher: makeFixtureFetcher(),
200
+ availableTags: AVAILABLE_TAGS,
201
+ });
202
+
203
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
204
+ assert.ok(existsSync(settingsPath), '.claude/settings.json must exist after init');
205
+
206
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
207
+ const sessionStart = settings.hooks?.SessionStart ?? [];
208
+ const found = sessionStart.some(
209
+ (e) =>
210
+ Array.isArray(e.hooks) &&
211
+ e.hooks.some((h) => typeof h.command === 'string' && h.command.includes('rules-injector.sh')),
212
+ );
213
+ assert.ok(
214
+ found,
215
+ 'settings.json must contain a SessionStart hook entry for rules-injector.sh after init',
216
+ );
217
+ });
218
+
219
+ it('R2b: the injected rules-injector.sh entry carries the HOOK_TAG', async () => {
220
+ await runInit(consumerDir, {
221
+ targets: ['claude'],
222
+ fetcher: makeFixtureFetcher(),
223
+ availableTags: AVAILABLE_TAGS,
224
+ });
225
+
226
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
227
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
228
+ const sessionStart = settings.hooks?.SessionStart ?? [];
229
+ const taggedEntry = sessionStart.find(
230
+ (e) =>
231
+ e[HOOK_TAG] === true &&
232
+ Array.isArray(e.hooks) &&
233
+ e.hooks.some((h) => typeof h.command === 'string' && h.command.includes('rules-injector.sh')),
234
+ );
235
+ assert.ok(
236
+ taggedEntry,
237
+ `rules-injector.sh SessionStart entry must carry HOOK_TAG ("${HOOK_TAG}") so it is identifiable as CLI-managed`,
238
+ );
239
+ });
240
+
241
+ it('R2c: rules-injector.sh registration coexists with the three pre-existing managed hooks', async () => {
242
+ await runInit(consumerDir, {
243
+ targets: ['claude'],
244
+ fetcher: makeFixtureFetcher(),
245
+ availableTags: AVAILABLE_TAGS,
246
+ });
247
+
248
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
249
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
250
+ const commands = allHookCommands(settings).map((c) => c.command);
251
+
252
+ for (const expected of [
253
+ 'steering-injector.sh',
254
+ 'managed-file-guard.sh',
255
+ 'uncommitted-check.sh',
256
+ 'rules-injector.sh',
257
+ ]) {
258
+ assert.ok(
259
+ commands.some((cmd) => cmd.includes(expected)),
260
+ `settings.json must register ${expected} alongside the other managed hooks after init`,
261
+ );
262
+ }
263
+ });
264
+ });
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // R3 — update wires rules-injector.sh on a pre-initialized consumer repo
268
+ // ---------------------------------------------------------------------------
269
+
270
+ describe('S2 R3 — update wires rules-injector.sh into settings.json', () => {
271
+ let consumerDir;
272
+
273
+ beforeEach(() => {
274
+ consumerDir = makeTmpDir();
275
+ });
276
+
277
+ afterEach(() => {
278
+ rmSync(consumerDir, { recursive: true, force: true });
279
+ });
280
+
281
+ it('R3a: update registers a SessionStart entry for rules-injector.sh', async () => {
282
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
283
+
284
+ await runUpdate(consumerDir, {
285
+ fetcher: makeFixtureFetcher(),
286
+ availableTags: AVAILABLE_TAGS,
287
+ });
288
+
289
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
290
+ assert.ok(existsSync(settingsPath), '.claude/settings.json must exist after update');
291
+
292
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
293
+ const sessionStart = settings.hooks?.SessionStart ?? [];
294
+ const found = sessionStart.some(
295
+ (e) =>
296
+ Array.isArray(e.hooks) &&
297
+ e.hooks.some((h) => typeof h.command === 'string' && h.command.includes('rules-injector.sh')),
298
+ );
299
+ assert.ok(
300
+ found,
301
+ 'settings.json must contain a SessionStart hook entry for rules-injector.sh after update',
302
+ );
303
+ });
304
+
305
+ it('R3b: rules-injector.sh script itself is installed by update', async () => {
306
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
307
+
308
+ await runUpdate(consumerDir, {
309
+ fetcher: makeFixtureFetcher(),
310
+ availableTags: AVAILABLE_TAGS,
311
+ });
312
+
313
+ assert.ok(
314
+ existsSync(path.join(consumerDir, '.claude', 'hooks', 'rules-injector.sh')),
315
+ 'update must write .claude/hooks/rules-injector.sh',
316
+ );
317
+ });
318
+ });
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // R4 — idempotent: running update twice adds no duplicate registration
322
+ // ---------------------------------------------------------------------------
323
+
324
+ describe('S2 R4 — repeated update produces no duplicate rules-injector.sh registration', () => {
325
+ let consumerDir;
326
+
327
+ beforeEach(() => {
328
+ consumerDir = makeTmpDir();
329
+ });
330
+
331
+ afterEach(() => {
332
+ rmSync(consumerDir, { recursive: true, force: true });
333
+ });
334
+
335
+ it('R4: rules-injector.sh command appears at most once after two updates', async () => {
336
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
337
+
338
+ const opts = { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS };
339
+ await runUpdate(consumerDir, opts);
340
+ await runUpdate(consumerDir, opts);
341
+
342
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
343
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
344
+ const commands = allHookCommands(settings).map((c) => c.command);
345
+ const occurrences = commands.filter((cmd) => cmd.includes('rules-injector.sh')).length;
346
+
347
+ assert.ok(
348
+ occurrences <= 1,
349
+ `rules-injector.sh hook command must appear at most once after two updates (found: ${occurrences})`,
350
+ );
351
+ });
352
+ });
@@ -0,0 +1,313 @@
1
+ /**
2
+ * tests/rules-injector-runtime.test.mjs
3
+ *
4
+ * Slice S2 — SessionStart rules-injector.sh hook (runtime behavior half).
5
+ * Spec: docs/specs/260708-claude-rules-enforcement/
6
+ *
7
+ * Pins the runtime behavior of the shipped `.claude/hooks/rules-injector.sh`
8
+ * script (mirrored from the canonical `.agents/hooks/rules-injector.sh`, which
9
+ * does not exist yet — these tests are the RED anchor for that build step).
10
+ * Follows the steering-injector.sh convention: the hook is invoked with the
11
+ * consumer repo root as its cwd and looks for its inputs relative to that cwd,
12
+ * so we exercise it against an isolated tmp fixture dir rather than depending
13
+ * on this repo's real `.claude/rules/` contents (which may change).
14
+ *
15
+ * Behavior anchors:
16
+ *
17
+ * T1. Executing the script against a fixture dir containing
18
+ * `.claude/rules/structured-choices.md` and `.claude/rules/tone.md`
19
+ * emits both rule BODIES to stdout, with the YAML frontmatter and the
20
+ * `<!-- managed-by: ... -->` marker line stripped.
21
+ * T2. The script exits 0 on that happy path.
22
+ * T3. When one rule file is absent, the script still exits 0 (fail-safe),
23
+ * emits at most a stderr note, and still surfaces the present rule's body.
24
+ * T4. When both rule files are absent (no .claude/rules/ dir at all), the
25
+ * script still exits 0.
26
+ *
27
+ * Test runner: `node --test packages/dev-agents-sync/tests/rules-injector-runtime.test.mjs`
28
+ * No third-party dependencies. All tests use isolated tmp dirs; none of them
29
+ * depend on this repo's actual `.claude/rules/*.md` content.
30
+ */
31
+
32
+ import { describe, it, beforeEach, afterEach } from 'node:test';
33
+ import assert from 'node:assert/strict';
34
+ import { spawnSync } from 'node:child_process';
35
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
36
+ import { tmpdir } from 'node:os';
37
+ import path from 'node:path';
38
+ import { fileURLToPath } from 'node:url';
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Paths
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
45
+ // packages/dev-agents-sync/tests -> packages/dev-agents-sync -> packages -> repo root
46
+ const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
47
+ const SCRIPT_PATH = path.join(REPO_ROOT, '.claude', 'hooks', 'rules-injector.sh');
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Fixture content — self-contained, does NOT depend on this repo's actual
51
+ // .claude/rules/*.md bodies so the test stays stable if those rules are edited.
52
+ // ---------------------------------------------------------------------------
53
+
54
+ const STRUCTURED_CHOICES_BODY_MARKER = 'FIXTURE_STRUCTURED_CHOICES_BODY_9f3a1';
55
+ const TONE_BODY_MARKER = 'FIXTURE_TONE_BODY_7c22e';
56
+ const FRONTMATTER_NAME_MARKER = 'name: "structured-choices"';
57
+ const MARKDOWN_MANAGED_MARKER = '<!-- managed-by: dev-agents-sync v0.1.0 -->';
58
+
59
+ function structuredChoicesFixture() {
60
+ return [
61
+ '---',
62
+ 'name: "structured-choices"',
63
+ 'description: "fixture rule for rules-injector runtime tests"',
64
+ '---',
65
+ MARKDOWN_MANAGED_MARKER,
66
+ '# Structured choices',
67
+ '',
68
+ STRUCTURED_CHOICES_BODY_MARKER,
69
+ '',
70
+ ].join('\n');
71
+ }
72
+
73
+ function toneFixture() {
74
+ return [
75
+ '---',
76
+ 'name: "tone"',
77
+ 'description: "fixture rule for rules-injector runtime tests"',
78
+ '---',
79
+ MARKDOWN_MANAGED_MARKER,
80
+ '# Tone',
81
+ '',
82
+ TONE_BODY_MARKER,
83
+ '',
84
+ ].join('\n');
85
+ }
86
+
87
+ function makeTmpDir() {
88
+ return mkdtempSync(path.join(tmpdir(), 'das-rules-injector-runtime-test-'));
89
+ }
90
+
91
+ /** Runs the shipped rules-injector.sh with `cwd` as its working directory. */
92
+ function runScript(cwd) {
93
+ return spawnSync('sh', [SCRIPT_PATH], { cwd, encoding: 'utf8' });
94
+ }
95
+
96
+ let fixtureDir;
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // T1/T2 — happy path: both rules present
100
+ // ---------------------------------------------------------------------------
101
+
102
+ describe('S2 T1/T2 — rules-injector.sh emits both rule bodies, exits 0', () => {
103
+ beforeEach(() => {
104
+ fixtureDir = makeTmpDir();
105
+ const rulesDir = path.join(fixtureDir, '.claude', 'rules');
106
+ mkdirSync(rulesDir, { recursive: true });
107
+ writeFileSync(path.join(rulesDir, 'structured-choices.md'), structuredChoicesFixture(), 'utf8');
108
+ writeFileSync(path.join(rulesDir, 'tone.md'), toneFixture(), 'utf8');
109
+ });
110
+
111
+ afterEach(() => {
112
+ rmSync(fixtureDir, { recursive: true, force: true });
113
+ });
114
+
115
+ it('T2: exits 0 when both rule files are present', () => {
116
+ const result = runScript(fixtureDir);
117
+ assert.equal(
118
+ result.status,
119
+ 0,
120
+ `rules-injector.sh must exit 0 on the happy path; got status=${result.status}, stderr=${result.stderr}, error=${result.error}`,
121
+ );
122
+ });
123
+
124
+ it('T1a: stdout includes the structured-choices.md body', () => {
125
+ const result = runScript(fixtureDir);
126
+ assert.ok(
127
+ (result.stdout ?? '').includes(STRUCTURED_CHOICES_BODY_MARKER),
128
+ `stdout must include the structured-choices.md body; got stdout=${JSON.stringify(result.stdout)}`,
129
+ );
130
+ });
131
+
132
+ it('T1b: stdout includes the tone.md body', () => {
133
+ const result = runScript(fixtureDir);
134
+ assert.ok(
135
+ (result.stdout ?? '').includes(TONE_BODY_MARKER),
136
+ `stdout must include the tone.md body; got stdout=${JSON.stringify(result.stdout)}`,
137
+ );
138
+ });
139
+
140
+ it('T1c: stdout strips the YAML frontmatter (no "name:" front-matter key leaks through)', () => {
141
+ const result = runScript(fixtureDir);
142
+ const stdout = result.stdout ?? '';
143
+ // Anchored to the body actually being emitted — otherwise "frontmatter is
144
+ // absent" would pass vacuously against empty stdout (e.g. the script not
145
+ // existing yet) without proving anything about stripping behavior.
146
+ assert.ok(
147
+ stdout.includes(STRUCTURED_CHOICES_BODY_MARKER),
148
+ `precondition failed: stdout must carry the rule body before frontmatter-stripping can be checked; got stdout=${JSON.stringify(stdout)}`,
149
+ );
150
+ assert.ok(
151
+ !stdout.includes(FRONTMATTER_NAME_MARKER),
152
+ `stdout must not include raw frontmatter (${FRONTMATTER_NAME_MARKER}); got stdout=${JSON.stringify(stdout)}`,
153
+ );
154
+ });
155
+
156
+ it('T1d: stdout strips the managed-by marker line', () => {
157
+ const result = runScript(fixtureDir);
158
+ const stdout = result.stdout ?? '';
159
+ // Same anchoring as T1c: require the body to be present so this cannot
160
+ // pass vacuously against empty output.
161
+ assert.ok(
162
+ stdout.includes(TONE_BODY_MARKER),
163
+ `precondition failed: stdout must carry the rule body before marker-stripping can be checked; got stdout=${JSON.stringify(stdout)}`,
164
+ );
165
+ assert.ok(
166
+ !stdout.includes(MARKDOWN_MANAGED_MARKER),
167
+ `stdout must not include the raw managed-by marker line; got stdout=${JSON.stringify(stdout)}`,
168
+ );
169
+ });
170
+ });
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // T3 — fail-safe: one rule file absent
174
+ // ---------------------------------------------------------------------------
175
+
176
+ describe('S2 T3 — rules-injector.sh is fail-safe when one rule file is absent', () => {
177
+ beforeEach(() => {
178
+ fixtureDir = makeTmpDir();
179
+ const rulesDir = path.join(fixtureDir, '.claude', 'rules');
180
+ mkdirSync(rulesDir, { recursive: true });
181
+ // Only tone.md exists; structured-choices.md is missing.
182
+ writeFileSync(path.join(rulesDir, 'tone.md'), toneFixture(), 'utf8');
183
+ });
184
+
185
+ afterEach(() => {
186
+ rmSync(fixtureDir, { recursive: true, force: true });
187
+ });
188
+
189
+ it('T3a: still exits 0 when structured-choices.md is absent', () => {
190
+ const result = runScript(fixtureDir);
191
+ assert.equal(
192
+ result.status,
193
+ 0,
194
+ `rules-injector.sh must exit 0 even when a rule file is absent (fail-safe); got status=${result.status}, stderr=${result.stderr}, error=${result.error}`,
195
+ );
196
+ });
197
+
198
+ it('T3b: still surfaces the present rule (tone.md) body on stdout', () => {
199
+ const result = runScript(fixtureDir);
200
+ assert.ok(
201
+ (result.stdout ?? '').includes(TONE_BODY_MARKER),
202
+ `stdout must still include the present tone.md body; got stdout=${JSON.stringify(result.stdout)}`,
203
+ );
204
+ });
205
+
206
+ it('T3c: emits at most a stderr note about the missing rule (does not crash silently on stdout)', () => {
207
+ const result = runScript(fixtureDir);
208
+ const stdout = result.stdout ?? '';
209
+ // Anchored to exit 0 + the present rule's body actually being emitted —
210
+ // otherwise "no fabricated content" would pass vacuously (e.g. against
211
+ // empty stdout from a script that doesn't exist yet or crashed outright)
212
+ // without proving the fail-safe skip behavior.
213
+ assert.equal(
214
+ result.status,
215
+ 0,
216
+ `precondition failed: script must exit 0 before the fail-safe skip behavior can be checked; got status=${result.status}`,
217
+ );
218
+ assert.ok(
219
+ stdout.includes(TONE_BODY_MARKER),
220
+ `precondition failed: stdout must carry the present rule's body before checking the missing rule was skipped cleanly; got stdout=${JSON.stringify(stdout)}`,
221
+ );
222
+ // "at most a stderr note" — we don't require stderr, but if the script
223
+ // emits anything about the skip, it must go to stderr, not stdout, and
224
+ // must not fabricate content for the missing rule on stdout.
225
+ assert.ok(
226
+ !stdout.includes('structured-choices'),
227
+ `stdout must not fabricate content for the missing structured-choices.md; got stdout=${JSON.stringify(stdout)}`,
228
+ );
229
+ });
230
+ });
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // T5 — CRLF robustness: frontmatter fences with a trailing CR are still stripped
234
+ // (regression anchor for M1 — the awk fence match must tolerate `---\r`, else
235
+ // on a CRLF checkout the whole YAML frontmatter leaks into injected context).
236
+ // ---------------------------------------------------------------------------
237
+
238
+ describe('S2 T5 — rules-injector.sh strips frontmatter on a CRLF checkout', () => {
239
+ beforeEach(() => {
240
+ fixtureDir = makeTmpDir();
241
+ const rulesDir = path.join(fixtureDir, '.claude', 'rules');
242
+ mkdirSync(rulesDir, { recursive: true });
243
+ // Re-encode both fixtures with CRLF line endings, as a CRLF checkout would.
244
+ const toCRLF = (s) => s.replace(/\n/g, '\r\n');
245
+ writeFileSync(path.join(rulesDir, 'structured-choices.md'), toCRLF(structuredChoicesFixture()), 'utf8');
246
+ writeFileSync(path.join(rulesDir, 'tone.md'), toCRLF(toneFixture()), 'utf8');
247
+ });
248
+
249
+ afterEach(() => {
250
+ rmSync(fixtureDir, { recursive: true, force: true });
251
+ });
252
+
253
+ it('T5a: exits 0 and still emits the rule bodies on a CRLF checkout', () => {
254
+ const result = runScript(fixtureDir);
255
+ assert.equal(
256
+ result.status,
257
+ 0,
258
+ `rules-injector.sh must exit 0 on a CRLF checkout; got status=${result.status}, stderr=${result.stderr}, error=${result.error}`,
259
+ );
260
+ assert.ok(
261
+ (result.stdout ?? '').includes(STRUCTURED_CHOICES_BODY_MARKER),
262
+ `stdout must still include the structured-choices.md body on a CRLF checkout; got stdout=${JSON.stringify(result.stdout)}`,
263
+ );
264
+ });
265
+
266
+ it('T5b: strips the CRLF frontmatter (no "name:" key and no bare "---" fence leaks)', () => {
267
+ const result = runScript(fixtureDir);
268
+ const stdout = result.stdout ?? '';
269
+ // Anchor to the body actually being emitted, so this cannot pass vacuously.
270
+ assert.ok(
271
+ stdout.includes(STRUCTURED_CHOICES_BODY_MARKER),
272
+ `precondition failed: stdout must carry the rule body before frontmatter-stripping can be checked; got stdout=${JSON.stringify(stdout)}`,
273
+ );
274
+ assert.ok(
275
+ !stdout.includes(FRONTMATTER_NAME_MARKER),
276
+ `stdout must not leak raw frontmatter (${FRONTMATTER_NAME_MARKER}) on a CRLF checkout; got stdout=${JSON.stringify(stdout)}`,
277
+ );
278
+ // No line should be a bare YAML fence (with or without a trailing CR).
279
+ assert.ok(
280
+ !/^---\r?$/m.test(stdout),
281
+ `stdout must not leak a bare "---" frontmatter fence on a CRLF checkout; got stdout=${JSON.stringify(stdout)}`,
282
+ );
283
+ // The managed-by marker must also be gone even with the trailing CR.
284
+ assert.ok(
285
+ !stdout.includes(MARKDOWN_MANAGED_MARKER),
286
+ `stdout must not leak the managed-by marker line on a CRLF checkout; got stdout=${JSON.stringify(stdout)}`,
287
+ );
288
+ });
289
+ });
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // T4 — fail-safe: no .claude/rules/ directory at all
293
+ // ---------------------------------------------------------------------------
294
+
295
+ describe('S2 T4 — rules-injector.sh is fail-safe when no rule files exist', () => {
296
+ beforeEach(() => {
297
+ fixtureDir = makeTmpDir();
298
+ // Deliberately do NOT create .claude/rules/ at all.
299
+ });
300
+
301
+ afterEach(() => {
302
+ rmSync(fixtureDir, { recursive: true, force: true });
303
+ });
304
+
305
+ it('T4: exits 0 when .claude/rules/ does not exist', () => {
306
+ const result = runScript(fixtureDir);
307
+ assert.equal(
308
+ result.status,
309
+ 0,
310
+ `rules-injector.sh must exit 0 when .claude/rules/ is entirely absent; got status=${result.status}, stderr=${result.stderr}, error=${result.error}`,
311
+ );
312
+ });
313
+ });