hypomnema 1.6.2 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +39 -14
- package/README.md +39 -14
- package/commands/capture.md +8 -6
- package/commands/crystallize.md +39 -20
- package/docs/ARCHITECTURE.md +49 -14
- package/docs/CONTRIBUTING.md +31 -29
- package/hooks/base-store.mjs +265 -0
- package/hooks/hooks.json +7 -1
- package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
- package/hooks/hypo-auto-stage.mjs +30 -0
- package/hooks/hypo-cwd-change.mjs +31 -2
- package/hooks/hypo-file-watch.mjs +21 -2
- package/hooks/hypo-first-prompt.mjs +19 -3
- package/hooks/hypo-lookup.mjs +86 -29
- package/hooks/hypo-personal-check.mjs +24 -3
- package/hooks/hypo-session-record.mjs +2 -3
- package/hooks/hypo-session-start.mjs +89 -7
- package/hooks/hypo-shared.mjs +904 -128
- package/hooks/proposal-store.mjs +513 -0
- package/package.json +40 -14
- package/scripts/capture.mjs +556 -37
- package/scripts/crystallize.mjs +639 -108
- package/scripts/doctor.mjs +304 -9
- package/scripts/feedback-sync.mjs +515 -44
- package/scripts/graph.mjs +9 -2
- package/scripts/init.mjs +230 -34
- package/scripts/lib/extensions.mjs +656 -1
- package/scripts/lib/hypo-ignore.mjs +54 -6
- package/scripts/lib/hypo-root.mjs +56 -6
- package/scripts/lib/page-usage.mjs +15 -2
- package/scripts/lib/pkg-json.mjs +40 -0
- package/scripts/lib/plugin-detect.mjs +96 -6
- package/scripts/lib/wd-match.mjs +23 -5
- package/scripts/lib/wikilink.mjs +32 -6
- package/scripts/lint.mjs +20 -4
- package/scripts/proposal.mjs +1032 -0
- package/scripts/query.mjs +25 -4
- package/scripts/resume.mjs +34 -12
- package/scripts/stats.mjs +28 -8
- package/scripts/uninstall.mjs +141 -6
- package/scripts/upgrade.mjs +197 -15
- package/skills/crystallize/SKILL.md +44 -7
- package/skills/debate/SKILL.md +88 -0
- package/skills/debate/references/orchestration-patterns.md +83 -0
- package/templates/.hyposcanignore +10 -0
- package/templates/SCHEMA.md +12 -0
- package/templates/gitignore +5 -0
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +6 -0
- package/scripts/.gitkeep +0 -0
- package/scripts/check-bilingual.mjs +0 -153
- package/scripts/check-readme-version.mjs +0 -126
- package/scripts/check-tracker-ids.mjs +0 -426
- package/scripts/check-versions.mjs +0 -171
- package/scripts/install-git-hooks.mjs +0 -293
- package/scripts/lib/changelog-classify.mjs +0 -216
- package/scripts/lib/check-bilingual.mjs +0 -244
- package/scripts/lib/check-tracker-ids.mjs +0 -217
- package/scripts/lib/pre-commit-format.mjs +0 -251
- package/scripts/pre-commit-format.mjs +0 -198
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
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
|
-
| `
|
|
401
|
-
| `
|
|
402
|
-
| `
|
|
403
|
-
| `
|
|
404
|
-
| `
|
|
405
|
-
| `
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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/
|
|
514
|
+
Breaking schema or hook-format changes require a major bump and a corresponding `upgrade.mjs` migration fixture in `tests/upgrade.test.mjs`.
|
package/docs/CONTRIBUTING.md
CHANGED
|
@@ -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
|
|
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
|
|
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/
|
|
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/
|
|
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
|
|
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
|
|
126
|
-
# tests
|
|
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).
|
|
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
|
|
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`.
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
#
|
|
364
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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`)
|
|
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/
|
|
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": [
|
|
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
|
}
|
|
@@ -123,10 +123,20 @@ function emitBlock(sessionId, transcriptPath, gate = null, opts = {}) {
|
|
|
123
123
|
// an install/transcript path contains spaces (display text only — never exec'd
|
|
124
124
|
// here).
|
|
125
125
|
const transcriptHint = transcriptPath ? ` --transcript-path="${transcriptPath}"` : '';
|
|
126
|
+
// Recovery command carries EVIDENCE, never the recency-derived close.project.
|
|
127
|
+
// The evidence-backed attribution is a singleton close scope (transcript
|
|
128
|
+
// close-files ∪ this marker's projects); when it is unambiguous, embed it as
|
|
129
|
+
// --project so the re-run is one-shot instead of failing closed for lack of
|
|
130
|
+
// evidence. The session cwd (from the payload) rides along so the re-run's own
|
|
131
|
+
// session-cwd close check runs against the same project this Stop evaluated.
|
|
132
|
+
const scope = gate?.close?.scope || [];
|
|
133
|
+
const evidenceProject = scope.length === 1 ? scope[0] : null;
|
|
134
|
+
const projectHint = evidenceProject ? ` --project=${evidenceProject}` : '';
|
|
135
|
+
const cwdHint = opts.sessionCwd ? ` --session-cwd="${opts.sessionCwd}"` : '';
|
|
126
136
|
const cliBase = PKG_ROOT ? `node "${join(PKG_ROOT, 'scripts', 'crystallize.mjs')}"` : null;
|
|
127
137
|
const markCmd = cliBase
|
|
128
|
-
? `${cliBase} --mark-session-closed --session-id=${sessionId}${transcriptHint}`
|
|
129
|
-
: `/hypo:crystallize (session_id=${sessionId}${transcriptHint})`;
|
|
138
|
+
? `${cliBase} --mark-session-closed --session-id=${sessionId}${projectHint}${transcriptHint}${cwdHint}`
|
|
139
|
+
: `/hypo:crystallize (session_id=${sessionId}${projectHint}${transcriptHint})`;
|
|
130
140
|
// The log-only escape hatch for a non-project (wiki/tooling-only)
|
|
131
141
|
// session. Offered ONLY as an explicit alternative when a close blocker is
|
|
132
142
|
// present — never as the default recovery, so a real project session is not
|
|
@@ -150,7 +160,9 @@ function emitBlock(sessionId, transcriptPath, gate = null, opts = {}) {
|
|
|
150
160
|
// Only when a project-close blocker is what's holding the session: a
|
|
151
161
|
// non-project session has nothing to close, so offer log-only as the way out
|
|
152
162
|
// (Claude decides whether this session is project-scoped — no auto-attribution).
|
|
153
|
-
|
|
163
|
+
// A session-cwd close blocker (the session's own cwd project is unstarted) is
|
|
164
|
+
// the same kind of project-close hold, so it gets the same escape.
|
|
165
|
+
if (gate.blockers.some((b) => b.type === 'close' || b.type === 'close-cwd')) {
|
|
154
166
|
reason += ` If this was a non-project (wiki/tooling-only) session with no project to close, run \`${logOnlyCmd}\` instead (log-only close, no project attribution).`;
|
|
155
167
|
}
|
|
156
168
|
} else {
|
|
@@ -202,6 +214,9 @@ process.stdin.on('end', () => {
|
|
|
202
214
|
|
|
203
215
|
const sessionId = payload.session_id || payload.sessionId || null;
|
|
204
216
|
const transcriptPath = payload.transcript_path || payload.transcriptPath || null;
|
|
217
|
+
// Authoritative session cwd (the one verified cwd source) for the session-cwd
|
|
218
|
+
// close check below. Absent on older Claude Code payloads → the check is skipped.
|
|
219
|
+
const sessionCwd = payload.cwd || null;
|
|
205
220
|
|
|
206
221
|
// 3. substantial-session gate. Pure Q&A / incidental-lookup sessions skip
|
|
207
222
|
// the block; mutating sessions AND high-volume read-only investigations
|
|
@@ -220,10 +235,49 @@ process.stdin.on('end', () => {
|
|
|
220
235
|
return;
|
|
221
236
|
}
|
|
222
237
|
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
238
|
+
// Read-only /compact gate (same precompactGateStatus the real PreCompact hook
|
|
239
|
+
// uses) sharpens the block message and, with sessionCwd, backs the session-cwd
|
|
240
|
+
// close check below. The hook NEVER writes the marker here (file-header
|
|
241
|
+
// invariant); this is read-only. Any error → null → emitBlock falls back to the
|
|
242
|
+
// generic message (fail-open). Computed lazily: the common closed-session path
|
|
243
|
+
// (a project marker whose cwd project is complete) still short-circuits without
|
|
244
|
+
// paying the gate cost, unless a cwd signal makes the cwd check meaningful.
|
|
245
|
+
let gate = null;
|
|
246
|
+
const computeGate = () => {
|
|
247
|
+
try {
|
|
248
|
+
return precompactGateStatus(HYPO_DIR, {
|
|
249
|
+
...(transcriptPath ? { transcriptPath } : {}),
|
|
250
|
+
...(sessionCwd ? { sessionCwd } : {}),
|
|
251
|
+
...(sessionId ? { sessionId } : {}),
|
|
252
|
+
});
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// 5. close already verified for this session_id — but a project marker only
|
|
259
|
+
// attests the project(s) it recorded. If THIS session's cwd project still has
|
|
260
|
+
// an unstarted close, honoring the marker would end the session green while
|
|
261
|
+
// that project stays open (the session-cwd false-green). So re-check the cwd
|
|
262
|
+
// project before accepting a project marker. A log-only marker (non-project
|
|
263
|
+
// session) is exempt, and without a cwd signal we accept the marker as before
|
|
264
|
+
// (back-compat with payloads that carry no cwd).
|
|
265
|
+
if (sessionId) {
|
|
266
|
+
const marker = readSessionClosedMarker(HYPO_DIR, sessionId);
|
|
267
|
+
if (marker) {
|
|
268
|
+
if (marker.scope === 'log-only' || !sessionCwd) {
|
|
269
|
+
emitContinue();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
gate = computeGate();
|
|
273
|
+
const cwdBlocked = !!gate?.blockers?.some((b) => b.type === 'close-cwd');
|
|
274
|
+
if (!cwdBlocked) {
|
|
275
|
+
emitContinue();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// marker present but the session's cwd project close is incomplete: fall
|
|
279
|
+
// through to block, reusing the gate computed above.
|
|
280
|
+
}
|
|
227
281
|
}
|
|
228
282
|
|
|
229
283
|
// 6. block — but only when we have a session_id to address the recovery
|
|
@@ -234,18 +288,7 @@ process.stdin.on('end', () => {
|
|
|
234
288
|
return;
|
|
235
289
|
}
|
|
236
290
|
|
|
237
|
-
|
|
238
|
-
// PreCompact hook uses) sharpens the block message — distinguishes "close
|
|
239
|
-
// is compact-ready, only the marker is missing" from "there are real
|
|
240
|
-
// blockers". The hook NEVER writes the marker here (file-header invariant);
|
|
241
|
-
// this is read-only. Any error → null → emitBlock falls back to the generic
|
|
242
|
-
// message (fail-open).
|
|
243
|
-
let gate = null;
|
|
244
|
-
try {
|
|
245
|
-
gate = precompactGateStatus(HYPO_DIR, transcriptPath ? { transcriptPath } : {});
|
|
246
|
-
} catch {
|
|
247
|
-
gate = null;
|
|
248
|
-
}
|
|
291
|
+
if (!gate) gate = computeGate();
|
|
249
292
|
|
|
250
293
|
// Reconfirm decision (conditional-close-reconfirm): "close" and
|
|
251
294
|
// "work-incomplete" together are exactly the case where the transcript's
|
|
@@ -269,7 +312,7 @@ process.stdin.on('end', () => {
|
|
|
269
312
|
return;
|
|
270
313
|
}
|
|
271
314
|
|
|
272
|
-
emitBlock(sessionId, transcriptPath, gate, { reconfirm: workIncomplete });
|
|
315
|
+
emitBlock(sessionId, transcriptPath, gate, { reconfirm: workIncomplete, sessionCwd });
|
|
273
316
|
} catch (err) {
|
|
274
317
|
// Fail-open on any unexpected error.
|
|
275
318
|
process.stderr.write(`[hypo-auto-minimal-crystallize] error: ${err?.message ?? String(err)}\n`);
|