@dzhechkov/p-replicator 1.5.18 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/.dz-manifest.json +126 -46
  2. package/CHANGELOG.md +135 -0
  3. package/MULTIPLATFORM_ROADMAP.md +1 -1
  4. package/README/eng/01_quickstart.md +2 -2
  5. package/README/eng/02_user_guide.md +1 -1
  6. package/README/eng/03_admin_guide.md +2 -2
  7. package/README/eng/05_architecture.md +6 -2
  8. package/README/eng/README.md +1 -1
  9. package/README/ru/01_quickstart.md +2 -2
  10. package/README/ru/02_user_guide.md +1 -1
  11. package/README/ru/03_admin_guide.md +2 -2
  12. package/README/ru/05_architecture.md +1 -1
  13. package/README/ru/README.md +1 -1
  14. package/README/ru/html/index.html +7 -7
  15. package/README.md +36 -15
  16. package/bin/cli.js +0 -0
  17. package/package.json +11 -10
  18. package/sbom.json +245 -45
  19. package/src/utils.js +2 -0
  20. package/templates/.claude/agents/doc-validator.md +2 -1
  21. package/templates/.claude/agents/product-discoverer.md +1 -1
  22. package/templates/.claude/commands/next.md +16 -0
  23. package/templates/.claude/commands/replicate.md +126 -8
  24. package/templates/.claude/commands/start.md +19 -1
  25. package/templates/.claude/hooks/autocommit-insights.cjs +95 -10
  26. package/templates/.claude/hooks/autocommit-plans.cjs +95 -10
  27. package/templates/.claude/hooks/autocommit-roadmap.cjs +96 -13
  28. package/templates/.claude/hooks/check-ports.cjs +232 -0
  29. package/templates/.claude/hooks/session-insights.cjs +13 -1
  30. package/templates/.claude/hooks/state-update.cjs +13 -1
  31. package/templates/.claude/hooks/statusline.cjs +145 -18
  32. package/templates/.claude/rules/docker-ports.md +123 -0
  33. package/templates/.claude/rules/replicate-pipeline.md +5 -2
  34. package/templates/.claude/settings.json +5 -5
  35. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/01-detect-parse.md +57 -14
  36. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/02-analyze-map.md +9 -7
  37. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/enhanced-recommendations.md +6 -4
  38. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/extended-mapping.md +2 -2
  39. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-agents.md +2 -2
  40. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-skills.md +1 -1
  41. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/enhanced-claude-md.md +2 -2
  42. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md +11 -2
  43. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +1 -1
  44. package/templates/.claude/skills/requirements-validator/SKILL.md +7 -0
  45. package/templates/.claude/skills/requirements-validator/references/scoring-system.md +28 -0
  46. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +143 -1
  47. package/tests/e2e/lifecycle.test.js +21 -10
  48. package/tests/snapshot/baseline.json +29 -27
  49. package/tests/snapshot/update-baseline.js +2 -1
  50. package/tests/unit/adr-decision-coverage.test.js +137 -0
  51. package/tests/unit/adr-scanner-contract.test.js +108 -0
  52. package/tests/unit/autocommit-deletion.test.js +242 -0
  53. package/tests/unit/check-ports.test.js +184 -0
  54. package/tests/unit/db-port-rule.test.js +216 -0
  55. package/tests/unit/detect-parse-anchor.test.js +109 -0
  56. package/tests/unit/external-dependency-check.test.js +209 -0
  57. package/tests/unit/growth-module-b2b-gate.test.js +104 -0
  58. package/tests/unit/hooks-project-anchored.test.js +223 -0
  59. package/tests/unit/hooks-report-failures.test.js +207 -0
  60. package/tests/unit/pipeline-file-ownership.test.js +95 -0
  61. package/tests/unit/roadmap-one-schema.test.js +179 -0
  62. package/tests/unit/sparc-reconciliation.test.js +117 -0
  63. package/tests/unit/spec-pseudocode-traceability.test.js +146 -0
  64. package/tests/unit/statusline-honest-labels.test.js +178 -0
  65. package/tests/unit/statusline-two-roots.test.js +237 -0
  66. package/tests/unit/sync-templates-guard.test.js +209 -0
  67. package/tests/unit/utils.test.js +2 -2
  68. package/tests/unit/validation-gate-teeth.test.js +158 -0
  69. package/LICENSE +0 -21
@@ -166,7 +166,20 @@ Read the skill: `.claude/skills/reverse-engineering-unicorn/SKILL.md`
166
166
  | M2: Product & Customers | Always | JTBD, Value Prop, segments |
167
167
  | M3: Market & Competition | Always | TAM/SAM, competitors, Blue Ocean |
168
168
  | M4: Business & Finance | If monetization | Unit economics |
169
- | M5: Growth Engine | If B2C/PLG | Channels, integrations |
169
+ | M5: Growth Engine | If acquisition/adoption in scope (incl. B2B) | Channels, integrations |
170
+
171
+ M5 is no longer gated on PRODUCT TYPE. The module branches on type itself — *«Если B2B → sales-led
172
+ growth, не product-led»* — so gating it outside disabled the one branch it declares, and every B2B
173
+ project got an empty `### Growth Channels` slot.
174
+
175
+ The condition is APPLICABILITY, not type, and the difference matters in both directions: `Always`
176
+ would over-promise, because M5's outputs are CAC, channels and loops, and an internal tool with no
177
+ acquisition objective has nothing to put in them. So: run it whenever acquisition or adoption is in
178
+ scope — which includes B2B — and skip it when neither is. The cost is visible rather than
179
+ discovered: a B2B run now spends M5's time.
180
+
181
+ Honest limit: line 62 of the module says to *switch* to a sales-led framework; it does not define
182
+ one. A B2B run gets the type-appropriate instruction, not a type-appropriate playbook.
170
183
 
171
184
  **Output:** Product Discovery Brief → passed as pre-filled context to Phase 1
172
185
 
@@ -255,6 +268,10 @@ Read the skill: `.claude/skills/requirements-validator/SKILL.md`
255
268
  | `validator-architecture` | Architecture.md | Target constraints, completeness |
256
269
  | `validator-pseudocode` | Pseudocode.md | Story coverage, implementability |
257
270
  | `validator-coherence` | Cross-document | Consistency, no contradictions |
271
+ | `validator-dependencies` | `Architecture.md` → `## External Dependencies` | Every external capability a requirement relies on has a verdict and, where CONFIRMED, evidence that names that capability |
272
+
273
+ The sixth lens is the only one that looks OUTSIDE the documents. The other five compare our own
274
+ output with our own output, which cannot discover that a service does not do what we assumed.
258
275
 
259
276
  **Process (iterative, max 3 iterations):**
260
277
 
@@ -270,7 +287,16 @@ Read the skill: `.claude/skills/requirements-validator/SKILL.md`
270
287
  - Happy path (1-2), Error handling (2-3), Edge cases (1-2), Security
271
288
  - Save as `docs/test-scenarios.md`
272
289
 
273
- **Save validation report:** `docs/validation-report.md`
290
+ **Save validation report:** `docs/validation-report.md`. Its **first line** must be exactly one of
291
+
292
+ ```
293
+ **Verdict:** 🟢 READY
294
+ **Verdict:** 🟡 CAVEATS
295
+ **Verdict:** 🔴 NEEDS WORK
296
+ ```
297
+
298
+ and no other line in the file may begin with `**Verdict:**`. Phase 3 reads that one line and nothing
299
+ else — an unanchored verdict is a verdict Phase 3 can find in an example or a quoted history.
274
300
 
275
301
  Git commit: `docs: validation report and BDD scenarios`
276
302
 
@@ -278,9 +304,85 @@ Git commit: `docs: validation report and BDD scenarios`
278
304
 
279
305
  | Verdict | Conditions | Action |
280
306
  |---------|-----------|--------|
281
- | 🟢 READY | All scores ≥50, average ≥70, no contradictions | → Phase 3 |
282
- | 🟡 CAVEATS | Warnings exist, no blocked, limitations described | → Phase 3 with notes |
283
- | 🔴 NEEDS WORK | Blocked items exist | → Return to Phase 1 |
307
+ | 🟢 READY | All scores ≥50, average ≥70, no contradictions, **no item on the blocking floor**, **no external dependency `UNCONFIRMED` or `CONTRADICTED`** | → Phase 3 |
308
+ | 🟡 CAVEATS | Warnings exist, no blocked, limitations described, **every `UNCONFIRMED` dependency NAMED row by row** | → Phase 3 with notes |
309
+ | 🔴 NEEDS WORK | Blocked items exist, **or any item has `Testable = 0` or `Completeness = 0`**, **or any external dependency is `CONTRADICTED`** | → Return to Phase 1 |
310
+
311
+ **The blocking floor** (`skills/requirements-validator/references/scoring-system.md` → "Blocking
312
+ floor"): the weakest link decides, never the average. An item with no acceptance criteria totals
313
+ 72/100 and would otherwise read as READY.
314
+
315
+ **Шаг 2.9 — ПОКРЫТИЕ РЕШЕНИЙ (обязательный, до чекпойнта).**
316
+
317
+ Every decision in `docs/ADR.md` carries an id `ADR-<nnn>` — three digits, assigned in order, never
318
+ reused even after a decision is superseded.
319
+
320
+ **Where to look, named file by file.** «Across the docs» is not an instruction. Search EXACTLY these,
321
+ and no others:
322
+
323
+ ```
324
+ docs/PRD.md · docs/Solution_Strategy.md · docs/Specification.md · docs/Pseudocode.md
325
+ docs/Architecture.md · docs/Refinement.md · docs/Completion.md · docs/C4_Diagrams.md
326
+ ```
327
+
328
+ **Two files are EXCLUDED, and the first exclusion is the one that makes this check work at all:**
329
+
330
+ - `docs/ADR.md` itself. Its own headings contain every id, so counting them would make every decision
331
+ appear named and the check would pass by construction — always, on any project.
332
+ - `docs/validation-report.md`. This step WRITES into it. Counting it would let the previous run's
333
+ output satisfy the next run: the check would start proving itself.
334
+
335
+ **What counts as a mention.** The exact token `ADR-<nnn>`, case-sensitive, in one of the files above.
336
+ Not a title, not a paraphrase, not a link whose text merely resembles it. One occurrence is enough;
337
+ repeats are not counted twice.
338
+
339
+ **A superseded decision needs no current mention.** If a decision's own entry says it is superseded,
340
+ list it in a third column rather than as a gap — it was replaced, not forgotten.
341
+
342
+ Write a `## Decision Coverage` block into `docs/validation-report.md` — **in every case**, including
343
+ the one where everything is covered, because an absent block and a block saying "all covered" are
344
+ indistinguishable to the next reader:
345
+
346
+ ```
347
+ ## Decision Coverage
348
+
349
+ Decisions in docs/ADR.md: [N] · named downstream: [M] · superseded: [S]
350
+
351
+ Recorded but named nowhere:
352
+ | Decision | Title |
353
+ |---|---|
354
+ | ADR-… | … |
355
+
356
+ Named downstream but absent from docs/ADR.md:
357
+ | Reference | Where |
358
+ |---|---|
359
+ | ADR-… | docs/… |
360
+ ```
361
+
362
+ **Both tables are required, and both may be the single word `none`.** A one-way check is half a
363
+ check: without the second table, a document referring to `ADR-009` that nobody ever wrote reads
364
+ exactly like coverage. `none` is written out rather than left blank, because an empty table and a
365
+ forgotten table look identical.
366
+
367
+ **Three states of the ADR file, and each has its own line — the block is written in all three:**
368
+
369
+ | State | What to write in the block |
370
+ |---|---|
371
+ | `docs/ADR.md` absent | *"docs/ADR.md is absent, so no decision ids were collected from it. The second table below still applies."* |
372
+ | present but containing no `ADR-<nnn>` id | *"docs/ADR.md exists but records no decision ids."* |
373
+ | present with ids | the counts and the two tables above |
374
+
375
+ Note what the first line does NOT say. It says the FILE is absent — not that the project recorded no
376
+ architectural decisions. Decisions may live somewhere this pipeline does not look, and claiming
377
+ otherwise would be asserting something this step cannot see. **In all three states the second table
378
+ still runs**: a downstream reference to a decision that does not exist is a defect whether or not an
379
+ ADR file was ever written.
380
+
381
+ **What this establishes, and what it does not.** It establishes that a decision is NAMED somewhere
382
+ downstream. It does NOT establish that the decision was implemented — no comparison of identifiers
383
+ can. So it catches *"the decision was written down and then forgotten"*; it does not catch *"someone
384
+ mentioned it in a sentence and built something else"*. Say so here rather than letting a later reader
385
+ assume the stronger thing.
284
386
 
285
387
  **Checkpoint:**
286
388
  ```
@@ -295,6 +397,18 @@ Iterations: N/3
295
397
 
296
398
  ### Phase 3: TOOLKIT GENERATION
297
399
 
400
+ **Precondition — check it before reading anything else. The toolkit is built ON the validated docs,
401
+ so an unvalidated input is not a smaller toolkit, it is a wrong one:**
402
+
403
+ 1. `docs/validation-report.md` must EXIST. If it is absent, Phase 2 did not run or did not finish —
404
+ do NOT generate anything; return to Phase 2 and say so.
405
+ 2. Its **first line** must be `**Verdict:**` followed by 🟢 READY or 🟡 CAVEATS. Read ONLY that
406
+ line: a verdict word anywhere else in the document — an example, a quoted history, a summary —
407
+ is NOT the verdict. On 🔴 NEEDS WORK, on no such first line, or on more than one line starting
408
+ with `**Verdict:**`, do NOT generate anything; return to Phase 2 and say which of the four it was.
409
+ 3. On 🟡, carry the report's stated limitations into the toolkit's own notes — a caveat that stops at
410
+ the phase boundary was never recorded.
411
+
298
412
  Read the skill: `.claude/skills/cc-toolkit-generator-enhanced/SKILL.md`
299
413
 
300
414
  **Goal:** Generate project-specific Claude Code instruments IN-PLACE.
@@ -305,7 +419,7 @@ Read the skill: `.claude/skills/cc-toolkit-generator-enhanced/SKILL.md`
305
419
  - **Pre-shipped by `npx p-replicator init` — do NOT overwrite or regenerate:**
306
420
  - All 10 skills in `.claude/skills/`
307
421
  - All 11 commands: `/replicate`, `/harvest`, `/start`, `/plan`, `/feature`, `/go`, `/run`, `/next`, `/myinsights`, `/docs`, `/deploy`
308
- - All 5 rules: `replicate-pipeline`, `skill-interface-protocol`, `git-workflow`, `insights-capture`, `feature-lifecycle`
422
+ - All 6 rules: `replicate-pipeline`, `skill-interface-protocol`, `git-workflow`, `insights-capture`, `feature-lifecycle`, `docker-ports`
309
423
  - All 4 pipeline agents: `replicate-coordinator`, `product-discoverer`, `doc-validator`, `harvest-coordinator`
310
424
  - `.claude/settings.json` + cross-platform Node hook scripts in `.claude/hooks/`
311
425
  - Phase 3 generates ONLY project-specific artifacts derived from SPARC docs (see below).
@@ -369,11 +483,15 @@ Git commit: `feat: Claude Code toolkit for [project-name]`
369
483
 
370
484
  **Generate scaffold files:**
371
485
 
372
- 1. `docker-compose.yml` — from Architecture.md services
486
+ 1. `docker-compose.yml` — from Architecture.md services, **if not exists** (a run over a tree that already has one must not discard it either — the guard is symmetric)
373
487
  2. `Dockerfile` — from Architecture.md tech stack
374
488
  3. `.gitignore` — if not exists
375
489
  4. `docs/features/` — create empty directory for future features
376
490
 
491
+ > `docker-compose.yml`, `.gitignore` и `README.md` позже читает `/start` (Phase 1). Он обязан их СОХРАНИТЬ и
492
+ > менять только по названной причине — правило `if not exists` записано на его стороне. Оба конца
493
+ > рекомендованной последовательности договорены в тексте, а не совпадают по случайности.
494
+
377
495
  **Git operations:**
378
496
  ```bash
379
497
  git add .
@@ -467,7 +585,7 @@ PRINCIPLE: User enters keys via UI → stored encrypted in browser → NEVER sen
467
585
 
468
586
  ### NEVER
469
587
  - Don't duplicate explore/research phases — sparc-prd-mini does this internally
470
- - Never skip validation — toolkit is built on validated docs
588
+ - Never skip validation — toolkit is built on validated docs. The enforcing check is Phase 3's precondition, which reads the first `**Verdict:**` line of `docs/validation-report.md`
471
589
  - Never use base cc-toolkit-generator — only enhanced version
472
590
  - Don't overwrite template files (generic commands, rules, settings.json)
473
591
 
@@ -33,7 +33,25 @@ via `Task` tool, commits per logical change for safe error recovery.
33
33
  2. **Generate root configs:** `package.json` (monorepo workspaces),
34
34
  `docker-compose.yml`, `.env.example`, `.gitignore`, `tsconfig.base.json`.
35
35
 
36
- 3. **Git commit:** `chore: project root configuration`
36
+ **Два из них `/replicate` уже мог записать.** Штатная последовательность — `/replicate`, затем
37
+ `/start`, а Phase 4 команды `/replicate` создаёт `docker-compose.yml` и `.gitignore` из того же
38
+ `Architecture.md`. Значит это не два разных файла, а один, выведенный дважды, и слепая
39
+ перегенерация молча выбросит всё, что между двумя запусками правили руками.
40
+
41
+ Для `docker-compose.yml`, `.gitignore` и `README.md` действует правило `if not exists`:
42
+ - файла НЕТ → создать, как обычно;
43
+ - файл ЕСТЬ → **не перегенерировать**. Прочитать его и оставить; внести только МИНИМАЛЬНУЮ
44
+ точечную правку, которой требует названное требование из документов (появился пакет, которого
45
+ в файле нет; порт из `Architecture.md` не проброшен). Всё, что к этому требованию не относится,
46
+ сохраняется без изменений.
47
+ - Перед коммитом ПОСМОТРЕТЬ ДИФ и НАЗВАТЬ каждый изменённый фрагмент и его причину. «Добавил
48
+ недостающий порт», под которым лежит переписанный целиком файл, — это и есть та потеря,
49
+ которую правило предотвращает; диф отличает одно от другого, а обещание не отличает.
50
+
51
+ 3. **Git commit:** `chore: project root configuration`. Если на шаге 2 менялся уже существовавший
52
+ `docker-compose.yml`, `.gitignore` или `README.md` — тело коммита перечисляет каждый изменённый
53
+ фрагмент и причину. Фиксированный заголовок не отменяет этого перечня: заголовок говорит, ЧТО за
54
+ шаг, тело — что именно он тронул в чужом файле.
37
55
 
38
56
  ### Phase 2: Packages (PARALLEL via Task tool ⚡)
39
57
 
@@ -10,30 +10,115 @@ const fs = require('node:fs');
10
10
  const path = require('node:path');
11
11
  const { execFileSync } = require('node:child_process');
12
12
 
13
- const TARGET_DIR = path.resolve(process.cwd(), '.claude', 'insights');
14
- const RELATIVE = path.relative(process.cwd(), TARGET_DIR);
15
- const SILENT = { stdio: 'ignore' };
13
+ // The project root, never the process cwd: a `cd` inside any tool call moves cwd for the rest of
14
+ // the session, and these hooks are non-blocking, so a wrong anchor fails SILENTLY. CLAUDE_PROJECT_DIR
15
+ // first the host is authoritative about what the project is. `__dirname` second: a hook always
16
+ // lives at <project>/.claude/hooks/<x>.cjs, so its own location settles the root with no cooperation
17
+ // from anyone, which is what keeps this working when the variable is absent (hand-run, older host).
18
+ const ENV_ROOT = process.env.CLAUDE_PROJECT_DIR;
19
+ // isAbsolute, not just truthy: a RELATIVE value would still be resolved against the drifting
20
+ // cwd, which is the very bug this anchor exists to remove.
21
+ const ROOT = (ENV_ROOT && path.isAbsolute(ENV_ROOT))
22
+ ? ENV_ROOT
23
+ : path.resolve(__dirname, '..', '..');
24
+
25
+ const TARGET_DIR = path.resolve(ROOT, '.claude', 'insights');
26
+ const RELATIVE = path.relative(ROOT, TARGET_DIR);
27
+ // cwd: ROOT — the paths below are relative to ROOT, so git must run there too.
28
+ // stderr is PIPED, not ignored: on success nothing is printed anyway, and on failure git's
29
+ // own words are the only thing that tells a reader WHY. Discarding them leaves a report that
30
+ // names what failed and not why, which is half a report.
31
+ const SILENT = { stdio: ['ignore', 'pipe', 'pipe'], cwd: ROOT };
32
+
33
+ // What the hook was DOING when it failed. The outer catch also sees staging failures and a
34
+ // missing git binary, and reporting either of those as "could not commit" would send the reader
35
+ // looking in the wrong place.
36
+ let stage = 'start';
16
37
 
17
38
  function git(args) {
18
39
  return execFileSync('git', args, SILENT);
19
40
  }
20
41
 
21
42
  try {
22
- if (!fs.existsSync(TARGET_DIR)) process.exit(0);
23
- try { git(['rev-parse', '--git-dir']); } catch { process.exit(0); }
43
+ // The repository probe comes FIRST now, because the absence check below needs to ask git a
44
+ // question and there is no point asking outside a repository.
45
+ stage = 'inspect the repository';
46
+ try {
47
+ git(['rev-parse', '--git-dir']);
48
+ } catch (probeErr) {
49
+ // "Not a git repository" is the ordinary case and stays silent. Everything else — no git on
50
+ // PATH, dubious ownership, a permission error — is a real failure that used to look exactly
51
+ // like it, which is how a broken machine and an ordinary directory became indistinguishable.
52
+ const why = String((probeErr && probeErr.stderr) || '').trim();
53
+ if (!why || /not a git repository/i.test(why)) process.exit(0);
54
+ throw probeErr;
55
+ }
56
+
57
+ // Absent — but absent HOW? A path that never existed is nothing to do, as before. A path git
58
+ // still tracks is a DELETION, and a deletion is exactly the change this hook exists to record.
59
+ //
60
+ // `git ls-files` is the discriminator, and it must run BEFORE staging: staging a deletion
61
+ // removes the entry from the index, after which the same question answers "not tracked".
62
+ // CORRECTION, measured: `--error-unmatch` also discriminates correctly here, for files AND for
63
+ // directories — 0 for a deleted-but-tracked path, 1 for one that never existed. An earlier note
64
+ // here claimed otherwise; that claim came from measuring it AFTER staging, which is what made it
65
+ // fail, not the directory-ness. `ls-files --` is preferred only because it answers with DATA
66
+ // (empty or not) instead of by throwing, so the ordinary case needs no exception handling.
67
+ //
68
+ // Simply DELETING the existence guard would have been the naive fix and would have broken the
69
+ // reporting that shipped yesterday: `git add` on a path that never existed exits 128, so every
70
+ // session stop in every project without this artifact would print a failure line. MEASURED.
71
+ if (!fs.existsSync(TARGET_DIR)) {
72
+ // A bare `catch { tracked = '' }` here would turn a REAL git failure into "never existed"
73
+ // and skip a deletion that should have been recorded — a silent bypass of the very thing
74
+ // this feature adds. A failure is rethrown into the reporting path; only a genuinely
75
+ // empty answer means the artifact never lived here.
76
+ const tracked = String(git(['ls-files', '--', RELATIVE]) || '').trim();
77
+ if (!tracked) process.exit(0); // never existed here — silent, exactly as before
78
+ }
24
79
 
80
+ stage = 'stage the change';
25
81
  git(['add', '--', RELATIVE]);
26
82
 
27
- let hasDiff = false;
83
+ // Ask git WHAT it staged, not merely WHETHER something changed — same subprocess, strictly
84
+ // more information. Deriving "this is a removal" from whether the path still exists was
85
+ // wrong for a directory: deleting ONE file inside it, or deleting every tracked file while
86
+ // an ignored one keeps the directory present, would have been committed as an update and
87
+ // defeated the very search this feature promises.
88
+ let staged = '';
28
89
  try {
29
- git(['diff', '--cached', '--quiet', '--', RELATIVE]);
90
+ staged = String(git(['diff', '--cached', '--name-status', '--', RELATIVE]) || '').trim();
30
91
  } catch {
31
- hasDiff = true;
92
+ // No HEAD yet (an unborn repository): there is no history to record a removal against.
93
+ staged = '';
32
94
  }
95
+ const deleted = staged.split('\n').some((l) => /^D/.test(l.trim()));
96
+ const hasDiff = staged.length > 0;
33
97
 
34
98
  if (hasDiff) {
35
- git(['commit', '--only', '--', RELATIVE, '-m', 'docs(insights): auto-capture']);
99
+ // -m BEFORE the `--`: everything after `--` is a PATHSPEC, so the old order made git
100
+ // look for files literally named '-m' and 'docs(insights): auto-capture' — it failed every
101
+ // time, from every directory, and this hook exits 0 on failure, so nobody saw it.
102
+ stage = 'commit';
103
+ // A deletion gets its own subject, so the event is findable in `git log` without
104
+ // reading diffs — which is the whole point of recording it.
105
+ git(['commit', '-m', deleted ? 'docs(insights): auto-remove' : 'docs(insights): auto-capture', '--only', '--', RELATIVE]);
36
106
  }
37
- } catch (_err) {
107
+ } catch (err) {
108
+ // Best-effort — never break the Claude session on commit failures. But "not breaking the session"
109
+ // and "saying nothing" are different things, and only the first one is the contract: it is about
110
+ // the EXIT CODE. Silence is what let a permanent defect live here undetected — `-m` after `--`
111
+ // made every commit fail, from every directory, forever, and nothing said so.
112
+ //
113
+ // A held index.lock, a missing user.email, a repository pre-commit hook that rejected the commit,
114
+ // an ignored target: each now costs one line and still exits 0. The ordinary "nothing to commit"
115
+ // path never reaches here, deliberately — a notice that fires when nothing is wrong trains people
116
+ // to ignore notices, and the next real failure scrolls past with them.
117
+ const artifactName = '.claude/insights/';
118
+ const gitSaid = err && err.stderr ? String(err.stderr).trim() : '';
119
+ const detail = (gitSaid || String((err && err.message) || err)).split('\n')
120
+ .map((l) => l.trim()).filter(Boolean)[0] || 'unknown error';
121
+ process.stdout.write('[autocommit-insights] could not ' + stage + ' — ' + artifactName + ': '
122
+ + detail.slice(0, 200) + '\n');
38
123
  process.exit(0);
39
124
  }
@@ -10,30 +10,115 @@ const fs = require('node:fs');
10
10
  const path = require('node:path');
11
11
  const { execFileSync } = require('node:child_process');
12
12
 
13
- const TARGET_DIR = path.resolve(process.cwd(), 'docs', 'plans');
14
- const RELATIVE = path.relative(process.cwd(), TARGET_DIR);
15
- const SILENT = { stdio: 'ignore' };
13
+ // The project root, never the process cwd: a `cd` inside any tool call moves cwd for the rest of
14
+ // the session, and these hooks are non-blocking, so a wrong anchor fails SILENTLY. CLAUDE_PROJECT_DIR
15
+ // first the host is authoritative about what the project is. `__dirname` second: a hook always
16
+ // lives at <project>/.claude/hooks/<x>.cjs, so its own location settles the root with no cooperation
17
+ // from anyone, which is what keeps this working when the variable is absent (hand-run, older host).
18
+ const ENV_ROOT = process.env.CLAUDE_PROJECT_DIR;
19
+ // isAbsolute, not just truthy: a RELATIVE value would still be resolved against the drifting
20
+ // cwd, which is the very bug this anchor exists to remove.
21
+ const ROOT = (ENV_ROOT && path.isAbsolute(ENV_ROOT))
22
+ ? ENV_ROOT
23
+ : path.resolve(__dirname, '..', '..');
24
+
25
+ const TARGET_DIR = path.resolve(ROOT, 'docs', 'plans');
26
+ const RELATIVE = path.relative(ROOT, TARGET_DIR);
27
+ // cwd: ROOT — the paths below are relative to ROOT, so git must run there too.
28
+ // stderr is PIPED, not ignored: on success nothing is printed anyway, and on failure git's
29
+ // own words are the only thing that tells a reader WHY. Discarding them leaves a report that
30
+ // names what failed and not why, which is half a report.
31
+ const SILENT = { stdio: ['ignore', 'pipe', 'pipe'], cwd: ROOT };
32
+
33
+ // What the hook was DOING when it failed. The outer catch also sees staging failures and a
34
+ // missing git binary, and reporting either of those as "could not commit" would send the reader
35
+ // looking in the wrong place.
36
+ let stage = 'start';
16
37
 
17
38
  function git(args) {
18
39
  return execFileSync('git', args, SILENT);
19
40
  }
20
41
 
21
42
  try {
22
- if (!fs.existsSync(TARGET_DIR)) process.exit(0);
23
- try { git(['rev-parse', '--git-dir']); } catch { process.exit(0); }
43
+ // The repository probe comes FIRST now, because the absence check below needs to ask git a
44
+ // question and there is no point asking outside a repository.
45
+ stage = 'inspect the repository';
46
+ try {
47
+ git(['rev-parse', '--git-dir']);
48
+ } catch (probeErr) {
49
+ // "Not a git repository" is the ordinary case and stays silent. Everything else — no git on
50
+ // PATH, dubious ownership, a permission error — is a real failure that used to look exactly
51
+ // like it, which is how a broken machine and an ordinary directory became indistinguishable.
52
+ const why = String((probeErr && probeErr.stderr) || '').trim();
53
+ if (!why || /not a git repository/i.test(why)) process.exit(0);
54
+ throw probeErr;
55
+ }
56
+
57
+ // Absent — but absent HOW? A path that never existed is nothing to do, as before. A path git
58
+ // still tracks is a DELETION, and a deletion is exactly the change this hook exists to record.
59
+ //
60
+ // `git ls-files` is the discriminator, and it must run BEFORE staging: staging a deletion
61
+ // removes the entry from the index, after which the same question answers "not tracked".
62
+ // CORRECTION, measured: `--error-unmatch` also discriminates correctly here, for files AND for
63
+ // directories — 0 for a deleted-but-tracked path, 1 for one that never existed. An earlier note
64
+ // here claimed otherwise; that claim came from measuring it AFTER staging, which is what made it
65
+ // fail, not the directory-ness. `ls-files --` is preferred only because it answers with DATA
66
+ // (empty or not) instead of by throwing, so the ordinary case needs no exception handling.
67
+ //
68
+ // Simply DELETING the existence guard would have been the naive fix and would have broken the
69
+ // reporting that shipped yesterday: `git add` on a path that never existed exits 128, so every
70
+ // session stop in every project without this artifact would print a failure line. MEASURED.
71
+ if (!fs.existsSync(TARGET_DIR)) {
72
+ // A bare `catch { tracked = '' }` here would turn a REAL git failure into "never existed"
73
+ // and skip a deletion that should have been recorded — a silent bypass of the very thing
74
+ // this feature adds. A failure is rethrown into the reporting path; only a genuinely
75
+ // empty answer means the artifact never lived here.
76
+ const tracked = String(git(['ls-files', '--', RELATIVE]) || '').trim();
77
+ if (!tracked) process.exit(0); // never existed here — silent, exactly as before
78
+ }
24
79
 
80
+ stage = 'stage the change';
25
81
  git(['add', '--', RELATIVE]);
26
82
 
27
- let hasDiff = false;
83
+ // Ask git WHAT it staged, not merely WHETHER something changed — same subprocess, strictly
84
+ // more information. Deriving "this is a removal" from whether the path still exists was
85
+ // wrong for a directory: deleting ONE file inside it, or deleting every tracked file while
86
+ // an ignored one keeps the directory present, would have been committed as an update and
87
+ // defeated the very search this feature promises.
88
+ let staged = '';
28
89
  try {
29
- git(['diff', '--cached', '--quiet', '--', RELATIVE]);
90
+ staged = String(git(['diff', '--cached', '--name-status', '--', RELATIVE]) || '').trim();
30
91
  } catch {
31
- hasDiff = true;
92
+ // No HEAD yet (an unborn repository): there is no history to record a removal against.
93
+ staged = '';
32
94
  }
95
+ const deleted = staged.split('\n').some((l) => /^D/.test(l.trim()));
96
+ const hasDiff = staged.length > 0;
33
97
 
34
98
  if (hasDiff) {
35
- git(['commit', '--only', '--', RELATIVE, '-m', 'docs(plan): auto-save']);
99
+ // -m BEFORE the `--`: everything after `--` is a PATHSPEC, so the old order made git
100
+ // look for files literally named '-m' and 'docs(plan): auto-save' — it failed every
101
+ // time, from every directory, and this hook exits 0 on failure, so nobody saw it.
102
+ stage = 'commit';
103
+ // A deletion gets its own subject, so the event is findable in `git log` without
104
+ // reading diffs — which is the whole point of recording it.
105
+ git(['commit', '-m', deleted ? 'docs(plan): auto-remove' : 'docs(plan): auto-save', '--only', '--', RELATIVE]);
36
106
  }
37
- } catch (_err) {
107
+ } catch (err) {
108
+ // Best-effort — never break the Claude session on commit failures. But "not breaking the session"
109
+ // and "saying nothing" are different things, and only the first one is the contract: it is about
110
+ // the EXIT CODE. Silence is what let a permanent defect live here undetected — `-m` after `--`
111
+ // made every commit fail, from every directory, forever, and nothing said so.
112
+ //
113
+ // A held index.lock, a missing user.email, a repository pre-commit hook that rejected the commit,
114
+ // an ignored target: each now costs one line and still exits 0. The ordinary "nothing to commit"
115
+ // path never reaches here, deliberately — a notice that fires when nothing is wrong trains people
116
+ // to ignore notices, and the next real failure scrolls past with them.
117
+ const artifactName = 'docs/plans/';
118
+ const gitSaid = err && err.stderr ? String(err.stderr).trim() : '';
119
+ const detail = (gitSaid || String((err && err.message) || err)).split('\n')
120
+ .map((l) => l.trim()).filter(Boolean)[0] || 'unknown error';
121
+ process.stdout.write('[autocommit-plans] could not ' + stage + ' — ' + artifactName + ': '
122
+ + detail.slice(0, 200) + '\n');
38
123
  process.exit(0);
39
124
  }
@@ -10,35 +10,118 @@ const fs = require('node:fs');
10
10
  const path = require('node:path');
11
11
  const { execFileSync } = require('node:child_process');
12
12
 
13
- const TARGET = path.resolve(process.cwd(), '.claude', 'feature-roadmap.json');
14
- const RELATIVE = path.relative(process.cwd(), TARGET);
15
- const SILENT = { stdio: 'ignore' };
13
+ // The project root, never the process cwd: a `cd` inside any tool call moves cwd for the rest of
14
+ // the session, and these hooks are non-blocking, so a wrong anchor fails SILENTLY. CLAUDE_PROJECT_DIR
15
+ // first the host is authoritative about what the project is. `__dirname` second: a hook always
16
+ // lives at <project>/.claude/hooks/<x>.cjs, so its own location settles the root with no cooperation
17
+ // from anyone, which is what keeps this working when the variable is absent (hand-run, older host).
18
+ const ENV_ROOT = process.env.CLAUDE_PROJECT_DIR;
19
+ // isAbsolute, not just truthy: a RELATIVE value would still be resolved against the drifting
20
+ // cwd, which is the very bug this anchor exists to remove.
21
+ const ROOT = (ENV_ROOT && path.isAbsolute(ENV_ROOT))
22
+ ? ENV_ROOT
23
+ : path.resolve(__dirname, '..', '..');
24
+
25
+ const TARGET = path.resolve(ROOT, '.claude', 'feature-roadmap.json');
26
+ const RELATIVE = path.relative(ROOT, TARGET);
27
+ // cwd: ROOT — the paths below are relative to ROOT, so git must run there too.
28
+ // stderr is PIPED, not ignored: on success nothing is printed anyway, and on failure git's
29
+ // own words are the only thing that tells a reader WHY. Discarding them leaves a report that
30
+ // names what failed and not why, which is half a report.
31
+ const SILENT = { stdio: ['ignore', 'pipe', 'pipe'], cwd: ROOT };
32
+
33
+ // What the hook was DOING when it failed. The outer catch also sees staging failures and a
34
+ // missing git binary, and reporting either of those as "could not commit" would send the reader
35
+ // looking in the wrong place.
36
+ let stage = 'start';
16
37
 
17
38
  function git(args) {
18
39
  return execFileSync('git', args, SILENT);
19
40
  }
20
41
 
21
42
  try {
22
- // Skip if file doesn't exist or we're not in a git repo.
23
- if (!fs.existsSync(TARGET)) process.exit(0);
24
- try { git(['rev-parse', '--git-dir']); } catch { process.exit(0); }
43
+ // The repository probe comes FIRST now, because the absence check below needs to ask git a
44
+ // question and there is no point asking outside a repository.
45
+ stage = 'inspect the repository';
46
+ try {
47
+ git(['rev-parse', '--git-dir']);
48
+ } catch (probeErr) {
49
+ // "Not a git repository" is the ordinary case and stays silent. Everything else — no git on
50
+ // PATH, dubious ownership, a permission error — is a real failure that used to look exactly
51
+ // like it, which is how a broken machine and an ordinary directory became indistinguishable.
52
+ const why = String((probeErr && probeErr.stderr) || '').trim();
53
+ if (!why || /not a git repository/i.test(why)) process.exit(0);
54
+ throw probeErr;
55
+ }
56
+
57
+ // Absent — but absent HOW? A path that never existed is nothing to do, as before. A path git
58
+ // still tracks is a DELETION, and a deletion is exactly the change this hook exists to record.
59
+ //
60
+ // `git ls-files` is the discriminator, and it must run BEFORE staging: staging a deletion
61
+ // removes the entry from the index, after which the same question answers "not tracked".
62
+ // CORRECTION, measured: `--error-unmatch` also discriminates correctly here, for files AND for
63
+ // directories — 0 for a deleted-but-tracked path, 1 for one that never existed. An earlier note
64
+ // here claimed otherwise; that claim came from measuring it AFTER staging, which is what made it
65
+ // fail, not the directory-ness. `ls-files --` is preferred only because it answers with DATA
66
+ // (empty or not) instead of by throwing, so the ordinary case needs no exception handling.
67
+ //
68
+ // Simply DELETING the existence guard would have been the naive fix and would have broken the
69
+ // reporting that shipped yesterday: `git add` on a path that never existed exits 128, so every
70
+ // session stop in every project without this artifact would print a failure line. MEASURED.
71
+ if (!fs.existsSync(TARGET)) {
72
+ // A bare `catch { tracked = '' }` here would turn a REAL git failure into "never existed"
73
+ // and skip a deletion that should have been recorded — a silent bypass of the very thing
74
+ // this feature adds. A failure is rethrown into the reporting path; only a genuinely
75
+ // empty answer means the artifact never lived here.
76
+ const tracked = String(git(['ls-files', '--', RELATIVE]) || '').trim();
77
+ if (!tracked) process.exit(0); // never existed here — silent, exactly as before
78
+ }
25
79
 
26
- // Stage only the target file.
80
+ // Stage the target (or its removal).
81
+ stage = 'stage the change';
27
82
  git(['add', '--', RELATIVE]);
28
83
 
29
84
  // Check whether anything is staged for THIS path.
30
85
  // `git diff --cached --quiet -- <path>` exits 0 = no diff, 1 = diff exists.
31
- let hasDiff = false;
86
+ // Ask git WHAT it staged, not merely WHETHER something changed — same subprocess, strictly
87
+ // more information. Deriving "this is a removal" from whether the path still exists was
88
+ // wrong for a directory: deleting ONE file inside it, or deleting every tracked file while
89
+ // an ignored one keeps the directory present, would have been committed as an update and
90
+ // defeated the very search this feature promises.
91
+ let staged = '';
32
92
  try {
33
- git(['diff', '--cached', '--quiet', '--', RELATIVE]);
93
+ staged = String(git(['diff', '--cached', '--name-status', '--', RELATIVE]) || '').trim();
34
94
  } catch {
35
- hasDiff = true;
95
+ // No HEAD yet (an unborn repository): there is no history to record a removal against.
96
+ staged = '';
36
97
  }
98
+ const deleted = staged.split('\n').some((l) => /^D/.test(l.trim()));
99
+ const hasDiff = staged.length > 0;
37
100
 
38
101
  if (hasDiff) {
39
- git(['commit', '--only', '--', RELATIVE, '-m', 'docs(roadmap): auto-update']);
102
+ // -m BEFORE the `--`: everything after `--` is a PATHSPEC, so the old order made git
103
+ // look for files literally named '-m' and 'docs(roadmap): auto-update' — it failed every
104
+ // time, from every directory, and this hook exits 0 on failure, so nobody saw it.
105
+ stage = 'commit';
106
+ // A deletion gets its own subject, so the event is findable in `git log` without
107
+ // reading diffs — which is the whole point of recording it.
108
+ git(['commit', '-m', deleted ? 'docs(roadmap): auto-remove' : 'docs(roadmap): auto-update', '--only', '--', RELATIVE]);
40
109
  }
41
- } catch (_err) {
42
- // Best-effort — never break Claude session on commit failures.
110
+ } catch (err) {
111
+ // Best-effort — never break the Claude session on commit failures. But "not breaking the session"
112
+ // and "saying nothing" are different things, and only the first one is the contract: it is about
113
+ // the EXIT CODE. Silence is what let a permanent defect live here undetected — `-m` after `--`
114
+ // made every commit fail, from every directory, forever, and nothing said so.
115
+ //
116
+ // A held index.lock, a missing user.email, a repository pre-commit hook that rejected the commit,
117
+ // an ignored target: each now costs one line and still exits 0. The ordinary "nothing to commit"
118
+ // path never reaches here, deliberately — a notice that fires when nothing is wrong trains people
119
+ // to ignore notices, and the next real failure scrolls past with them.
120
+ const artifactName = '.claude/feature-roadmap.json';
121
+ const gitSaid = err && err.stderr ? String(err.stderr).trim() : '';
122
+ const detail = (gitSaid || String((err && err.message) || err)).split('\n')
123
+ .map((l) => l.trim()).filter(Boolean)[0] || 'unknown error';
124
+ process.stdout.write('[autocommit-roadmap] could not ' + stage + ' — ' + artifactName + ': '
125
+ + detail.slice(0, 200) + '\n');
43
126
  process.exit(0);
44
127
  }