hypomnema 1.6.1 → 1.7.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 (62) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +39 -14
  4. package/README.md +39 -14
  5. package/commands/capture.md +8 -6
  6. package/commands/crystallize.md +39 -20
  7. package/docs/ARCHITECTURE.md +49 -14
  8. package/docs/CONTRIBUTING.md +31 -29
  9. package/hooks/base-store.mjs +265 -0
  10. package/hooks/hooks.json +7 -1
  11. package/hooks/hypo-auto-minimal-crystallize.mjs +77 -31
  12. package/hooks/hypo-auto-stage.mjs +30 -0
  13. package/hooks/hypo-cwd-change.mjs +31 -2
  14. package/hooks/hypo-file-watch.mjs +21 -2
  15. package/hooks/hypo-first-prompt.mjs +19 -3
  16. package/hooks/hypo-lookup.mjs +86 -29
  17. package/hooks/hypo-personal-check.mjs +24 -3
  18. package/hooks/hypo-session-record.mjs +2 -3
  19. package/hooks/hypo-session-start.mjs +89 -7
  20. package/hooks/hypo-shared.mjs +944 -147
  21. package/hooks/proposal-store.mjs +513 -0
  22. package/package.json +40 -14
  23. package/scripts/capture.mjs +556 -37
  24. package/scripts/crystallize.mjs +639 -108
  25. package/scripts/doctor.mjs +304 -9
  26. package/scripts/feedback-sync.mjs +515 -44
  27. package/scripts/graph.mjs +9 -2
  28. package/scripts/init.mjs +230 -34
  29. package/scripts/lib/extensions.mjs +656 -1
  30. package/scripts/lib/hypo-ignore.mjs +54 -6
  31. package/scripts/lib/hypo-root.mjs +56 -6
  32. package/scripts/lib/page-usage.mjs +15 -2
  33. package/scripts/lib/pkg-json.mjs +40 -0
  34. package/scripts/lib/plugin-detect.mjs +96 -6
  35. package/scripts/lib/wd-match.mjs +23 -5
  36. package/scripts/lib/wikilink.mjs +32 -6
  37. package/scripts/lint.mjs +20 -4
  38. package/scripts/proposal.mjs +1032 -0
  39. package/scripts/query.mjs +25 -4
  40. package/scripts/resume.mjs +34 -12
  41. package/scripts/stats.mjs +28 -8
  42. package/scripts/uninstall.mjs +141 -6
  43. package/scripts/upgrade.mjs +197 -15
  44. package/skills/crystallize/SKILL.md +44 -7
  45. package/skills/debate/SKILL.md +88 -0
  46. package/skills/debate/references/orchestration-patterns.md +83 -0
  47. package/templates/.hyposcanignore +10 -0
  48. package/templates/SCHEMA.md +12 -0
  49. package/templates/gitignore +5 -0
  50. package/templates/hypo-config.md +1 -1
  51. package/templates/hypo-guide.md +6 -0
  52. package/scripts/.gitkeep +0 -0
  53. package/scripts/check-bilingual.mjs +0 -153
  54. package/scripts/check-readme-version.mjs +0 -126
  55. package/scripts/check-tracker-ids.mjs +0 -426
  56. package/scripts/check-versions.mjs +0 -171
  57. package/scripts/install-git-hooks.mjs +0 -293
  58. package/scripts/lib/changelog-classify.mjs +0 -216
  59. package/scripts/lib/check-bilingual.mjs +0 -244
  60. package/scripts/lib/check-tracker-ids.mjs +0 -217
  61. package/scripts/lib/pre-commit-format.mjs +0 -251
  62. package/scripts/pre-commit-format.mjs +0 -198
@@ -53,7 +53,12 @@ hypomnema/
53
53
  │ ├── Home.md, Overview.md, hypo-automation.md, hypo-help.md
54
54
  │ ├── pages/_index.md
55
55
  │ └── projects/_template/
56
- ├── tests/runner.mjs ← no-dependency test runner
56
+ ├── tests/
57
+ │ ├── harness.mjs ← test()/testAsync()/suite(), selection, reporting
58
+ │ ├── helpers.mjs ← fixtures shared by more than one area file
59
+ │ ├── runner.mjs ← entry: imports every *.test.mjs, owns no tests
60
+ │ ├── parallel.mjs ← runs the entry as N processes (`npm test`)
61
+ │ └── <area>.test.mjs ← one file per production area (26)
57
62
  ├── docs/ ← ARCHITECTURE.md, CONTRIBUTING.md
58
63
  ├── .claude-plugin/plugin.json← plugin manifest
59
64
  └── package.json ← npm metadata, no runtime deps
@@ -395,19 +400,49 @@ Default patterns: `*.pdf`, `*.zip`, `*.pem`, `*.env`, `*.key`, `*.crt`, `*creden
395
400
 
396
401
  ## Testing
397
402
 
398
- | Layer | Coverage |
403
+ ```
404
+ tests/
405
+ ├── harness.mjs ← test() / testAsync() / suite(), and how a run is selected
406
+ ├── helpers.mjs ← fixtures used by more than one area file
407
+ ├── runner.mjs ← the entry: imports every *.test.mjs, owns no tests itself
408
+ ├── parallel.mjs ← runs the entry as N concurrent processes (`npm test`)
409
+ └── <area>.test.mjs ← one file per production area (26 of them)
410
+ ```
411
+
412
+ Tests live next to the thing they test: `extensions.test.mjs`, `lint.test.mjs`,
413
+ `proposal-base.test.mjs`, and so on. Two branches adding tests to different areas never touch
414
+ the same file, which is the point of the layout. It is not about speed.
415
+
416
+ Everything uses Node built-ins only. Fixtures are built in code with scoped helpers
417
+ (`withTmpDir`, `withWiki`, `withCleanWiki`, `gitRepo`, …), each cleaning up in a `finally`.
418
+
419
+ **Selection is by suite, never by test.** `suite(label)` opens a selection unit, and
420
+ `--shard=i/n` keeps a whole suite in one process, in order. So tests inside one suite may build
421
+ on each other, and suites may not build on each other. `node tests/parallel.mjs --shards=237`
422
+ runs every suite alone in a fresh process and is the proof of that invariant. Run it whenever
423
+ you add or split a suite.
424
+
425
+ | Command | What it does |
399
426
  |---|---|
400
- | `hypo-shared.mjs` utilities | 7 unit tests |
401
- | `hypo-compact-guard.mjs` contract | 8 input/output tests |
402
- | `hypo-personal-check.mjs` contract | 6 input/output tests |
403
- | `init.mjs` smoke | ~6 (idempotency, dry-run, hook merge) |
404
- | `doctor.mjs` smoke | ~5 |
405
- | `upgrade.mjs` regression | ~10 (includes migration fixture) |
406
- | `lint.mjs` (fix / json / session-state) | ~10 |
407
- | Misc (`expandHome`, `resolveHypoRoot`, …) | ~remainder |
408
- | **Total** | Run `npm test` for the live count |
409
-
410
- Run with `npm test`. The runner uses only Node.js built-ins; tests create scoped temp dirs and clean up after themselves. The count above is a layout sketch — exact totals shift as lanes ship, so `npm test` is the source of truth.
427
+ | `npm test` | `parallel.mjs`, cpu-count processes, suites round-robined across them |
428
+ | `npm run test:serial` | the whole suite in one process; verdict-identical |
429
+ | `node tests/runner.mjs --grep=<regex>` | just the matching tests, in seconds |
430
+ | `node tests/runner.mjs --file=lint` | one area file |
431
+ | `node tests/parallel.mjs --by-file` | one process per area file |
432
+ | `node tests/parallel.mjs --shards=237` | every suite alone: the order-independence proof |
433
+
434
+ Round-robin is the default rather than one-process-per-file, and the reason is measured, not
435
+ assumed: on a 2026 laptop the whole suite runs in about 70s round-robined and about 100s
436
+ `--by-file`. The areas differ by an order of magnitude in size, and `--by-file` starts all 26
437
+ at once, which oversubscribes the cores. The big files (`extensions`, `proposal-base`,
438
+ `close-global`) then finish last under contention while the small ones exit in seconds and
439
+ leave the tail of the run half idle. Reach for `--by-file` to read one area's output, not to go
440
+ fast.
441
+
442
+ ` ✓ name` and ` ✗ name` are a contract, not decoration.
443
+ `scripts/lib/fix-status-verify.mjs` parses them to map a fix number to its verdict, and reads
444
+ the `// @fix #N:` anchors as a union across every `tests/*.mjs`, so the file an anchor sits in
445
+ does not matter.
411
446
 
412
447
  ---
413
448
 
@@ -476,4 +511,4 @@ Requires `NPM_TOKEN` secret.
476
511
 
477
512
  Hypomnema follows semver. The release tooling (`scripts/bump-version.mjs`, `CHANGELOG.md`, `release.yml`) is documented in [CONTRIBUTING.md](CONTRIBUTING.md#release-process).
478
513
 
479
- Breaking schema or hook-format changes require a major bump and a corresponding `upgrade.mjs` migration fixture in `tests/runner.mjs`.
514
+ Breaking schema or hook-format changes require a major bump and a corresponding `upgrade.mjs` migration fixture in `tests/upgrade.test.mjs`.
@@ -52,7 +52,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the full breakdown. Short version:
52
52
  | `hooks/hypo-shared.mjs` | Shared hook utilities — read the deployment constraint below |
53
53
  | `skills/<name>/SKILL.md` | Agent Skills (auto-trigger via description match) |
54
54
  | `templates/` | Files copied into new wiki vaults on init |
55
- | `tests/runner.mjs` | Test runner (no external deps) |
55
+ | `tests/` | Test suite (no external deps): `harness.mjs`, `helpers.mjs`, `runner.mjs` (entry), and one `<area>.test.mjs` per production area |
56
56
  | `docs/` | ARCHITECTURE, CONTRIBUTING |
57
57
  | `.claude-plugin/plugin.json` | Plugin manifest |
58
58
 
@@ -79,14 +79,14 @@ Scripts under `scripts/` are not deployed and may freely import from `scripts/li
79
79
  2. Edit `scripts/<name>.mjs` — the Node.js logic.
80
80
  3. If the command is new and synthesis-heavy, add `skills/<name>/SKILL.md`.
81
81
  4. Update the command table in `README.md` and `README.ko.md`.
82
- 5. Add coverage to `tests/runner.mjs`.
82
+ 5. Add coverage to the matching `tests/<area>.test.mjs` (a new command usually means a new area file).
83
83
 
84
84
  ### Adding or modifying a hook
85
85
 
86
86
  1. Edit the hook file in `hooks/`.
87
87
  2. If it's new, register it in `hooks/hooks.json` under the correct event key.
88
88
  3. Shared utilities go in `hooks/hypo-shared.mjs`.
89
- 4. Add a contract test in `tests/runner.mjs` (input → expected `additionalContext` shape).
89
+ 4. Add a contract test in the hook's area file, e.g. `tests/session-hooks.test.mjs` (input → expected `additionalContext` shape).
90
90
  5. After your change, run `/hypo:upgrade` in a real Claude Code session and verify the hook fires.
91
91
 
92
92
  ### Modifying `hypo-shared.mjs`
@@ -105,7 +105,7 @@ If you need to share new logic, prefer extending an existing helper over adding
105
105
  1. Drop the file under `templates/`.
106
106
  2. Update `scripts/init.mjs` so `init` copies it.
107
107
  3. Update the templates section of [ARCHITECTURE.md](ARCHITECTURE.md#package-layout).
108
- 4. Add a test in `tests/runner.mjs` that asserts the file lands in a freshly initialized vault.
108
+ 4. Add a test in `tests/init.test.mjs` that asserts the file lands in a freshly initialized vault.
109
109
 
110
110
  ### Adding an Agent Skill
111
111
 
@@ -119,13 +119,14 @@ If you need to share new logic, prefer extending an existing helper over adding
119
119
  ## Testing
120
120
 
121
121
  ```bash
122
- npm test # tests/runner.mjs — unit + smoke + contract tests
122
+ npm test # tests/*.test.mjs, sharded across processes — unit + smoke + contract
123
123
  npm run lint # scripts/lint.mjs — frontmatter + wikilink validation + W8 (design-history stale vs session-log)
124
124
  npm run fix:verify # Phase 1 of learned_behavior #6 — verifies fix #N status claims in
125
- # a wiki spec against `// @fix #N: <test-name>` anchors in
126
- # tests/runner.mjs. Maintainer dogfood; needs a local wiki at
125
+ # a wiki spec against `// @fix #N: <test-name>` anchors, read as a
126
+ # union across every tests/*.mjs. Maintainer dogfood; needs a wiki at
127
127
  # $HYPO_DIR or ~/hypomnema (source checkout only; not shipped in
128
- # the npm package). Does NOT grep ADR core decision lines.
128
+ # the npm package). Phase 2 also greps each manifest adrKeyLine
129
+ # against the production corpus.
129
130
  ```
130
131
 
131
132
  > **`fix:verify` needs an explicit `--spec`.** The default path
@@ -243,7 +244,7 @@ After both blocks, language-neutral:
243
244
 
244
245
  - **PR title**: Conventional Commits plus a scope, e.g. `feat(feedback): add failure_type enum`. The type drives the CHANGELOG section (see the classification table below).
245
246
  - **Merge commit**: the squash-merge subject carries the PR number (`#123`). That is where `#N` comes from, not the PR title. The two conventions stay separate.
246
- - Internal tracker ids (`FEAT-`, `IMPR-`, `ISSUE-`, `PRAC-`) may appear in your local notes and in `tests/` / `qa-runs/` (where they aid test-to-issue traceability and never reach an installed user), but not in shipped code or workflow comments, and never on the published changelog and release surface: not in the CHANGELOG body, not in the PR `## Changelog` block, not in a tag annotation, not in a GitHub Release. The only identifier that ships in those is the PR number `#N`. `check-tracker-ids` gates every surface (`--all`/`--staged` for files, `--commit-msg` for messages, `--tag` for the tag body); the migration keeps the CHANGELOG body clean.
247
+ - Internal tracker ids (`FEAT-`, `IMPR-`, `ISSUE-`, `PRAC-`, `fix #N`) may appear in your local notes and in `tests/` / `qa-runs/` (where they aid test-to-issue traceability and never reach an installed user), but not in shipped code or workflow comments, and never on the published changelog and release surface: not in the CHANGELOG body, not in the PR `## Changelog` block, not in a tag annotation, not in a GitHub Release. The only tracker identifier that ships in those is the PR number `#N`; the lone exception is the ADR carve-out noted below. `check-tracker-ids` gates the file, message, and tag surfaces (`--all`/`--staged` for files, `--commit-msg` for messages, `--tag` for the tag body), and `check-pr-surface` gates the PR title and body; the migration keeps the CHANGELOG body clean. `ADR NNNN` / `decisions/NNNN` are exempt on the changelog surfaces (the CHANGELOG body, the tag body, and the PR `## Changelog` block), where a release line legitimately cites the decision behind it.
247
248
 
248
249
  ### The `## Changelog` block
249
250
 
@@ -323,10 +324,13 @@ command.
323
324
  Every Hypomnema release must carry Korean alongside the English body
324
325
  in **both** the CHANGELOG section AND the git tag annotation. The release
325
326
  workflow enforces this with `scripts/check-bilingual.mjs`; a lightweight tag, or
326
- a gated CHANGELOG section missing its `#### 한국어` sub-block, will block `npm publish`. It also requires the
327
- release version to appear in **both** `README.md` and `README.ko.md`
328
- (`scripts/check-readme-version.mjs`) the floor that stops the README reconcile
329
- from being dropped.
327
+ a gated CHANGELOG section missing its `#### 한국어` sub-block, will block `npm publish`.
328
+
329
+ The READMEs are not part of a release. They describe what Hypomnema does now, not
330
+ what each version added, so cutting a release never edits them. Version history
331
+ lives in `CHANGELOG.md` alone. (A gate used to require the release version to
332
+ appear in both READMEs, back when they carried a rolling version narrative. That
333
+ narrative is gone, and the gate with it.)
330
334
 
331
335
  Prerequisite: the changelog collector in step 2 reads merged-PR data through the GitHub CLI, so have `gh` installed and authenticated (`gh auth status`) before you start. It is a maintainer-only release script and is not shipped to npm.
332
336
 
@@ -360,32 +364,31 @@ node scripts/collect-changelog.mjs --strict # maintainer-only; not shipped to
360
364
  # "#### English" and a "#### 한국어" sub-block; check:bilingual enforces it.
361
365
  $EDITOR CHANGELOG.md
362
366
 
363
- # 3. Reconcile BOTH READMEs add a v<version> sentence to the rolling version
364
- # narrative in README.md AND README.ko.md, and update the first-viewport
365
- # "current release" pointer. (Dropped 3x historically; check:readme is the floor.)
366
- $EDITOR README.md README.ko.md
367
+ # The READMEs need no edit here. They describe current behavior, so a release
368
+ # only touches them when the behavior itself changed.
367
369
 
368
- # 4. Verify locally before tagging (the full gate set CI + prepublishOnly run)
370
+ # 3. Verify locally before tagging (the full gate set CI + prepublishOnly run)
369
371
  npm test # unit suite
370
372
  npm run lint # vault linter
371
373
  npm run check:versions # all version-carrying files (incl. package-lock) agree
372
374
  npm run check:bilingual # each gated CHANGELOG section has #### English + #### 한국어
373
- npm run check:readme # v<version> present in BOTH READMEs
374
375
  npm run smoke:plugin # plugin manifest + hooks/commands/skills load-valid
375
376
  npm run smoke-pack # packed tarball installs and resolves
376
377
  npm run check:tracker-ids # no private-tracker pointers leaked into shipped files
377
378
 
378
- # 5. Commit include EVERY file the bump touched, the READMEs, and the lockfile.
379
+ # 4. Commit every file the bump touched, plus the lockfile.
379
380
  git add package.json package-lock.json .claude-plugin/ templates/hypo-config.md \
380
- CHANGELOG.md README.md README.ko.md
381
+ CHANGELOG.md
381
382
  git commit -m "chore: release v<version>"
382
383
  # Then open the release PR, pass review + green CI, and squash-merge to main.
383
384
 
384
- # 6. Tag the merge commit with an ANNOTATED tag, never a lightweight tag.
385
+ # 5. Tag the merge commit with an ANNOTATED tag, never a lightweight tag.
385
386
  # Annotation body shape: English summary, then "---" on its own line,
386
387
  # then a Korean summary block. The GitHub Release republishes this body
387
388
  # verbatim, so keep it on the same public surface as the CHANGELOG: PR
388
- # numbers (#N) only, no internal tracker ids (check-tracker-ids --tag gates it).
389
+ # numbers (#N) only, no `FEAT-`/`IMPR-`/`ISSUE-`/`PRAC-`/`fix #N` tracker
390
+ # ids (check-tracker-ids --tag gates it). Like the CHANGELOG, the tag body MAY cite
391
+ # `ADR NNNN` / `decisions/NNNN` for the decision behind a release.
389
392
  git tag -a v<version> -m "$(cat <<'EOF'
390
393
  Hypomnema v<version>: <one-line English summary>
391
394
 
@@ -399,17 +402,17 @@ Hypomnema v<version>: <한 줄 한글 요약>
399
402
  EOF
400
403
  )"
401
404
 
402
- # 7. Rehearse the release locally (same checks CI runs against the tag)
405
+ # 6. Rehearse the release locally (same checks CI runs against the tag)
403
406
  node scripts/check-bilingual.mjs --tag v<version>
404
407
  node scripts/check-versions.mjs --tag v<version>
405
408
  node scripts/check-tracker-ids.mjs --tag v<version> # no tracker ids leak into the tag body
406
409
  npm publish --dry-run --access public # packs + prepublishOnly, NO registry PUT
407
410
 
408
- # 8. Push the SPECIFIC tag alone — NOT --tags (that would push stale local tags
411
+ # 7. Push the SPECIFIC tag alone — NOT --tags (that would push stale local tags
409
412
  # and could trigger releases for versions you did not intend).
410
413
  git push origin v<version>
411
414
 
412
- # 9. (minor+ releases only) After the release workflow succeeds, close the spec
415
+ # 8. (minor+ releases only) After the release workflow succeeds, close the spec
413
416
  # lifecycle IN PLACE (no path move): set status: archived and
414
417
  # archived_date: <release date> in the same spec, run the wiki lint, then
415
418
  # commit and push the vault. Do this only after the release is green, never at
@@ -425,8 +428,7 @@ The `release.yml` workflow then:
425
428
  2. Smokes the plugin channel — manifest, `hooks/hooks.json` targets, and
426
429
  command/skill component files (`scripts/smoke-plugin.mjs`).
427
430
  3. Validates the CHANGELOG section AND the tag annotation are bilingual
428
- (`scripts/check-bilingual.mjs`), and that the version is present in both
429
- READMEs (`scripts/check-readme-version.mjs`).
431
+ (`scripts/check-bilingual.mjs`).
430
432
  4. Runs `npm test` and `npm run lint`.
431
433
  5. Publishes to npm with `npm publish --access public --provenance` and creates
432
434
  the GitHub Release from the tag body.
@@ -446,7 +448,7 @@ token authenticates without publishing anything.
446
448
  | New `init` template file | minor |
447
449
  | Bug fix, doc-only change, internal refactor | patch |
448
450
 
449
- Major bumps must include an `upgrade.mjs` migration fixture in `tests/runner.mjs`.
451
+ Major bumps must include an `upgrade.mjs` migration fixture in `tests/upgrade.test.mjs`.
450
452
 
451
453
  ---
452
454
 
@@ -0,0 +1,265 @@
1
+ // base-store.mjs: per-session observed-base hash snapshot
2
+ //
3
+ // Lives in hooks/ rather than scripts/lib/ because hypo-session-start.mjs must
4
+ // stay self-contained within this directory (an npm consumer may vendor hooks/
5
+ // alone). scripts/ already imports from hooks/, never the reverse.
6
+ //
7
+ // The write=proposal gate needs to know what a session OBSERVED on disk when it
8
+ // started, so crystallize can tell "nobody touched this page" from "someone else
9
+ // wrote it while this session was alive". crystallize runs as a separate process
10
+ // from the session, so the observation has to be parked somewhere both can read:
11
+ // `<hypoDir>/.cache/sessions/<sessionId>/base.json` (gitignored, never synced).
12
+ //
13
+ // SessionStart writes it, crystallize reads it. Two invariants carry the design:
14
+ //
15
+ // 1. Existence-check, not overwrite. SessionStart fires again on resume and on
16
+ // compact with the SAME session_id (verified by spike). Re-snapshotting
17
+ // there would advance the base to whatever another session had just written,
18
+ // so close would compare base-to-itself, see no drift, and clobber the other
19
+ // session's edits. Single-session tests pass either way, which is exactly
20
+ // why this is pinned by a regression test and not left to reviewer memory.
21
+ // `/clear` mints a NEW session_id, so it gets a fresh snapshot, which is right:
22
+ // a cleared session restarts its observation from disk.
23
+ //
24
+ // 2. Advance after a successful direct write. Once crystallize legitimately
25
+ // overwrites a target, that content IS the new observed base. Without this,
26
+ // a second close in the same session would diff against the stale original
27
+ // and raise a false-positive proposal against its own first write.
28
+ //
29
+ // Everything here is best-effort: a hook must never fail a session start because
30
+ // a cache write did not land. Read failures degrade to "base unknown", which the
31
+ // caller treats as fail-safe (proposal), never as "no conflict".
32
+
33
+ import { createHash } from 'node:crypto';
34
+ import {
35
+ existsSync,
36
+ mkdirSync,
37
+ readFileSync,
38
+ writeFileSync,
39
+ renameSync,
40
+ closeSync,
41
+ openSync,
42
+ writeSync,
43
+ } from 'node:fs';
44
+ import { join, dirname } from 'node:path';
45
+
46
+ /** sha256 of a UTF-8 string, hex. */
47
+ export function hashContent(content) {
48
+ return createHash('sha256').update(content, 'utf-8').digest('hex');
49
+ }
50
+
51
+ /**
52
+ * Hash of a file's bytes. Absent and unreadable are different answers: an absent
53
+ * file was genuinely observed as absent, while an unreadable one was not observed
54
+ * at all, and only the first makes "create it at close" safe.
55
+ * @returns {string|null|undefined} hex hash, `null` if absent, `undefined` if unreadable
56
+ */
57
+ export function hashFile(path) {
58
+ if (!existsSync(path)) return null;
59
+ try {
60
+ return hashContent(readFileSync(path, 'utf-8'));
61
+ } catch {
62
+ return undefined;
63
+ }
64
+ }
65
+
66
+ /** `<hypoDir>/.cache/sessions/<sessionId>/base.json`. */
67
+ export function basePath(hypoDir, sessionId) {
68
+ return join(hypoDir, '.cache', 'sessions', String(sessionId), 'base.json');
69
+ }
70
+
71
+ /** Atomic overwrite via tmp+rename, mirroring crystallize's atomicWrite. */
72
+ function atomicWrite(path, content) {
73
+ mkdirSync(dirname(path), { recursive: true });
74
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
75
+ writeFileSync(tmp, content);
76
+ renameSync(tmp, path);
77
+ }
78
+
79
+ /** Read and parse base.json. Returns null when absent, unreadable, or malformed. */
80
+ function readBaseFile(hypoDir, sessionId) {
81
+ const path = basePath(hypoDir, sessionId);
82
+ if (!existsSync(path)) return null;
83
+ try {
84
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
85
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
86
+ if (!parsed.targets || typeof parsed.targets !== 'object') return null;
87
+ return parsed;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Snapshot the observed base hashes for `relPaths`, ONCE per session.
95
+ *
96
+ * Existence-check (invariant 1): when base.json already exists for this session,
97
+ * this is a no-op: resume and compact must not move the base.
98
+ *
99
+ * A target that does not exist on disk is recorded as `null` (observed-absent),
100
+ * which is distinct from having no entry at all (observed-nothing). Close treats
101
+ * the first as "I saw no file, creating it is safe" and the second as fail-safe.
102
+ *
103
+ * @param {string} hypoDir
104
+ * @param {string} sessionId
105
+ * @param {string[]} relPaths vault-relative target paths
106
+ * @returns {{created: boolean, reason?: string}}
107
+ */
108
+ export function snapshotBase(hypoDir, sessionId, relPaths) {
109
+ if (!sessionId) return { created: false, reason: 'no-session-id' };
110
+ const path = basePath(hypoDir, sessionId);
111
+ if (existsSync(path)) return { created: false, reason: 'already-snapshotted' };
112
+
113
+ const targets = {};
114
+ for (const rel of relPaths) {
115
+ if (!rel) continue;
116
+ const h = hashFile(join(hypoDir, rel));
117
+ // `undefined` (unreadable) is left OUT of the map on purpose: no entry means
118
+ // "unknown", and close fails safe into a proposal rather than assuming a
119
+ // file it could not read was unchanged.
120
+ if (h !== undefined) targets[rel] = h;
121
+ }
122
+
123
+ const body = JSON.stringify(
124
+ { session_id: String(sessionId), created_at: new Date().toISOString(), targets },
125
+ null,
126
+ 2,
127
+ );
128
+
129
+ try {
130
+ mkdirSync(dirname(path), { recursive: true });
131
+ // Exclusive create IS the existence-check, closing the gap between the
132
+ // existsSync above and the write below when two hooks race on one session.
133
+ const fd = openSync(path, 'wx');
134
+ try {
135
+ writeSync(fd, body);
136
+ } finally {
137
+ closeSync(fd);
138
+ }
139
+ return { created: true };
140
+ } catch (e) {
141
+ if (e && e.code === 'EEXIST') return { created: false, reason: 'already-snapshotted' };
142
+ // best-effort: a hook must never break a session start over a cache write
143
+ return { created: false, reason: `write-failed: ${e && e.message}` };
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Look up one target's observed base, as a discriminated state.
149
+ *
150
+ * 'hash' this session observed content; `hash` holds it
151
+ * 'absent' this session observed the file missing, so creating it is safe
152
+ * 'unknown' this session never observed it: no snapshot, wrong session,
153
+ * unreadable at snapshot time, or a target set that shifted
154
+ * mid-session because cwd moved
155
+ *
156
+ * `state` is the discriminator on purpose. An earlier shape returned
157
+ * `{known, hash}` where BOTH 'absent' and 'unknown' carried `hash: null`, so a
158
+ * consumer branching on `if (!entry.hash)` would read never-observed as
159
+ * safe-to-write and quietly defeat the guard. Branch on `state`, never on the
160
+ * truthiness of `hash`.
161
+ *
162
+ * @returns {{state: 'hash'|'absent'|'unknown', hash: string|null}}
163
+ */
164
+ export function readBaseEntry(hypoDir, sessionId, relPath) {
165
+ const unknown = { state: 'unknown', hash: null };
166
+ if (!sessionId) return unknown;
167
+ const parsed = readBaseFile(hypoDir, sessionId);
168
+ if (!parsed) return unknown;
169
+ if (!Object.prototype.hasOwnProperty.call(parsed.targets, relPath)) return unknown;
170
+ const hash = parsed.targets[relPath];
171
+ if (hash === null) return { state: 'absent', hash: null };
172
+ if (typeof hash !== 'string' || hash === '') return unknown;
173
+ return { state: 'hash', hash };
174
+ }
175
+
176
+ /**
177
+ * Move one target's base to `hash` after this session legitimately wrote it
178
+ * (invariant 2). No-op when the session has no snapshot: with no base there is
179
+ * no guard to keep honest.
180
+ *
181
+ * @returns {boolean} true when the base file was updated
182
+ */
183
+ export function advanceBase(hypoDir, sessionId, relPath, hash) {
184
+ if (!sessionId) return false;
185
+ const parsed = readBaseFile(hypoDir, sessionId);
186
+ if (!parsed) return false;
187
+ parsed.targets[relPath] = hash;
188
+ try {
189
+ atomicWrite(basePath(hypoDir, sessionId), JSON.stringify(parsed, null, 2));
190
+ return true;
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Advance a target's base to its current on-disk bytes after the session edited
198
+ * it DIRECTLY (Write/Edit tool), not through crystallize. Invariant 2 covers
199
+ * crystallize's own overwrites; this covers the other way a session legitimately
200
+ * changes a guarded target.
201
+ *
202
+ * Without it, a direct edit looks — at close time — exactly like a DIFFERENT
203
+ * session having written the page: base != disk, so the guard fails safe into a
204
+ * false proposal against the session's own work. `open-questions.md` is the most
205
+ * exposed target, because `/hypo:crystallize` tells the model to fold same-session
206
+ * edits into the close payload. A PostToolUse hook calls this after each wiki
207
+ * write to give the session's own edits provenance.
208
+ *
209
+ * Scoped by tracked-ness, NOT by a target list: `relPath` advances only when the
210
+ * session already has a base entry for it (one of the four overwrite targets
211
+ * snapshotted at start, for the active project). A write to any other wiki file
212
+ * is a no-op, so this never mints a new base key and cannot widen the guard's
213
+ * surface. The file is hashed only once the target is confirmed tracked, so an
214
+ * unrelated write costs one small base.json read and no content hash.
215
+ *
216
+ * An absent or unreadable post-write file leaves the base untouched (returns
217
+ * false) rather than advancing it to null: a target that vanished is a real
218
+ * divergence the close should still fail safe on, not a provenance claim.
219
+ *
220
+ * `knownHash`: when the caller already has the exact bytes the tool wrote (the
221
+ * Write tool carries its full `content`), pass their hash. The base then advances
222
+ * to what the SESSION wrote, not to a fresh disk read — race-safe: if another
223
+ * session overwrote the target in the window between the tool and this call,
224
+ * base = my-bytes ≠ disk, so the close still sees drift and preserves the other
225
+ * write. Callers without the full bytes (Edit/MultiEdit) pass null and take a
226
+ * post-write disk read, which carries a narrow tool→hook race (documented
227
+ * residual in the spec's 보증 범위).
228
+ *
229
+ * This does not weaken the base contract's "no read-just-before-write as base"
230
+ * rule (spec line 40): only the session's OWN writes advance, so a concurrent
231
+ * writer's change to the same target is still observed as drift.
232
+ *
233
+ * @returns {boolean} true when the base file was updated
234
+ */
235
+ export function advanceBaseForWrite(hypoDir, sessionId, relPath, absPath, knownHash = null) {
236
+ if (!sessionId) return false;
237
+ const parsed = readBaseFile(hypoDir, sessionId);
238
+ if (!parsed) return false;
239
+ // Only a tracked target advances. hasOwnProperty, not truthiness: an
240
+ // observed-absent entry is `null` but still a legitimate key to advance from.
241
+ if (!Object.prototype.hasOwnProperty.call(parsed.targets, relPath)) return false;
242
+ const hash = typeof knownHash === 'string' ? knownHash : hashFile(absPath);
243
+ if (typeof hash !== 'string') return false; // absent/unreadable → leave base as-is
244
+ parsed.targets[relPath] = hash;
245
+ try {
246
+ atomicWrite(basePath(hypoDir, sessionId), JSON.stringify(parsed, null, 2));
247
+ return true;
248
+ } catch {
249
+ return false;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * The four overwrite targets crystallize replaces wholesale. `project` may be
255
+ * null when cwd resolves to no project; the two project-scoped paths are then
256
+ * omitted and close falls back to proposal for them.
257
+ */
258
+ export function overwriteTargets(project) {
259
+ const targets = ['hot.md', join('pages', 'open-questions.md')];
260
+ if (project) {
261
+ targets.unshift(join('projects', project, 'session-state.md'));
262
+ targets.unshift(join('projects', project, 'hot.md'));
263
+ }
264
+ return targets;
265
+ }
package/hooks/hooks.json CHANGED
@@ -143,5 +143,11 @@
143
143
  }
144
144
  ]
145
145
  },
146
- "shared": ["hypo-shared.mjs", "version-check.mjs", "version-check-fetch.mjs"]
146
+ "shared": [
147
+ "hypo-shared.mjs",
148
+ "version-check.mjs",
149
+ "version-check-fetch.mjs",
150
+ "base-store.mjs",
151
+ "proposal-store.mjs"
152
+ ]
147
153
  }