hypomnema 1.8.4 → 1.9.1
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/CHANGELOG.md +74 -0
- package/README.ko.md +17 -16
- package/README.md +15 -14
- package/commands/crystallize.md +89 -34
- package/commands/doctor.md +3 -1
- package/commands/feedback.md +10 -0
- package/docs/ARCHITECTURE.md +32 -9
- package/docs/CONTRIBUTING.md +6 -1
- package/hooks/atomic-write.mjs +44 -0
- package/hooks/base-store.mjs +409 -90
- package/hooks/close-gate-store.mjs +131 -16
- package/hooks/close-journal.mjs +141 -19
- package/hooks/close-receipt.mjs +564 -0
- package/hooks/hooks.json +2 -29
- package/hooks/hypo-auto-commit.mjs +80 -10
- package/hooks/hypo-auto-minimal-crystallize.mjs +129 -25
- package/hooks/hypo-auto-stage.mjs +13 -1
- package/hooks/hypo-close-guard.mjs +2 -1
- package/hooks/hypo-cwd-change.mjs +45 -20
- package/hooks/hypo-file-watch.mjs +44 -6
- package/hooks/hypo-first-prompt.mjs +15 -9
- package/hooks/hypo-hot-rebuild.mjs +169 -112
- package/hooks/hypo-personal-check.mjs +85 -7
- package/hooks/hypo-session-record.mjs +1 -1
- package/hooks/hypo-session-start.mjs +381 -18
- package/hooks/hypo-shared.mjs +2235 -214
- package/hooks/hypo-stop.mjs +207 -0
- package/hooks/proposal-store.mjs +166 -34
- package/hooks/shared.json +7 -1
- package/hooks/version-check.mjs +9 -7
- package/package.json +3 -1
- package/scripts/crystallize.mjs +6 -7
- package/scripts/doctor.mjs +529 -37
- package/scripts/feedback-sync.mjs +1786 -162
- package/scripts/feedback.mjs +16 -2
- package/scripts/init.mjs +32 -3
- package/scripts/lib/code-fence.mjs +197 -0
- package/scripts/lib/crystallize-close-apply.mjs +2023 -320
- package/scripts/lib/crystallize-close-check.mjs +62 -41
- package/scripts/lib/design-history-stale.mjs +117 -24
- package/scripts/lib/frontmatter.mjs +39 -0
- package/scripts/lib/git-hooks-dir.mjs +1 -1
- package/scripts/lib/hook-inventory.mjs +31 -1
- package/scripts/lib/page-usage.mjs +1 -1
- package/scripts/lib/pkg-provenance.mjs +7 -1
- package/scripts/lib/project-create.mjs +40 -44
- package/scripts/lib/schema-vocab.mjs +22 -11
- package/scripts/lib/template-schema-version.mjs +1 -0
- package/scripts/lib/time.mjs +46 -0
- package/scripts/lint.mjs +141 -27
- package/scripts/proposal.mjs +423 -18
- package/scripts/resume.mjs +3 -2
- package/scripts/session-audit.mjs +2 -1
- package/scripts/uninstall.mjs +15 -1
- package/scripts/upgrade.mjs +281 -90
- package/scripts/verify.mjs +2 -1
- package/scripts/weekly-report.mjs +2 -1
- package/templates/SCHEMA.md +27 -7
- package/templates/gitignore +11 -0
- package/templates/hypo-automation.md +11 -14
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +58 -26
- package/templates/hot.md +0 -24
package/commands/crystallize.md
CHANGED
|
@@ -19,7 +19,7 @@ If `/hypo:crystallize` was invoked to close a session (via an explicit close sig
|
|
|
19
19
|
|
|
20
20
|
Before composing the payload (Step 2), run these four reflections and surface each to the user. Every one is **advisory** (identity guard): the user confirms or declines, and none performs an automatic action, writes a file on its own, or bypasses the mandatory gate.
|
|
21
21
|
|
|
22
|
-
1. **Trivial-session check (#44)
|
|
22
|
+
1. **Trivial-session check (#44).** Was this session trivial (a single bug fix, a single-file edit, or Q&A with no durable artifact)? If so, recommend skipping session-close: *"이 세션은 trivial해 보입니다. session-close를 건너뛸까요?"* and proceed only if the user wants a close. A trivial skip is a recommendation, **not** a bypass: it must not mark the session closed, must not run `--mark-session-closed`, and must not claim the close check is green. Any real close still requires all 4 mandatory files.
|
|
23
23
|
2. **ADR-candidate check (#41).** Did this session make an architectural or design decision (a new pattern, a tradeoff chosen, a convention established)? If yes, ask whether it warrants an ADR and, if so, capture that intent in the `sessionLog` entry you compose in Step 2. If nothing rose to ADR level, you may record `ADR 없음: <one-line reason>` in that same `sessionLog` entry, but gate it on #42's bar: the marker is machine-read and W8 excludes an entry carrying `ADR 없음` (with no ADR reference) from the design-history staleness check. Write it only when the session had **no design change at all**; a sub-ADR design shift takes #42a (append) instead, since the marker would suppress the W8 nudge it needs. **Never auto-write an ADR file.** Recording the decision (or its absence) in the session-log payload is the only action here. This check carries no `decisions/` directory precondition: run it whether or not that directory exists.
|
|
24
24
|
3. **design-history staleness check (#42).** Two branches, so a stale W8 never blocks a clean close: (a) if this session changed design decisions `projects/<name>/design-history.md` does not yet reflect (including sub-ADR background, tradeoff, or differentiation shifts), recommend appending now: the W8 lint warning flags this mechanically, and an active-project W8 still counts as a `--check-session-close` blocker even though the PreCompact hook no longer stops `/compact` on it, so append before you commit. **If the file does not exist yet and this session had a design change, recommend creating it now** with that change as the first entry; lint separately flags a missing-but-needed file as W14, a warning that never blocks. (b) only if the session made **no** design change does the `ADR 없음` marker (#41) exempt the entry from W8; do not touch design-history, and do not create the file just to satisfy this branch's check. `ADR 없음` means "no design change," a stricter bar than "no ADR-level decision." Never auto-write the file yourself in either branch: recommend it, and let the user decide.
|
|
25
25
|
4. **Ingest check (#43)** — Did this session consume trustworthy external knowledge (a fetched URL, official docs, or code you verified directly)? If so, recommend running `/hypo:ingest` to capture it under `sources/`. Proceed only on the user's confirmation.
|
|
@@ -30,9 +30,9 @@ These are judgment calls; when uncertain, surface the question rather than skip
|
|
|
30
30
|
|
|
31
31
|
## Step 2 — Compose the session-close payload
|
|
32
32
|
|
|
33
|
-
The session-close path is **payload-driven**. Instead of writing the
|
|
33
|
+
The session-close path is **payload-driven**. Instead of writing the 4 mandatory files one-by-one, you compose a single JSON payload that describes the full session-close state, then hand it to the `--apply-session-close` apply path (the Step 3 command), which performs idempotent atomic writes and gates the result with lint.
|
|
34
34
|
|
|
35
|
-
Payload shape (`project` +
|
|
35
|
+
Payload shape (`project` + 3 content fields required; `log` optional/derived; `openQuestions` conditional, per Spec §5.2.7 / §8.3):
|
|
36
36
|
|
|
37
37
|
```json
|
|
38
38
|
{
|
|
@@ -41,7 +41,6 @@ Payload shape (`project` + 4 content fields required; `log` optional/derived; `o
|
|
|
41
41
|
"date": "YYYY-MM-DD",
|
|
42
42
|
"sessionState": { "content": "<full body of projects/<name>/session-state.md>" },
|
|
43
43
|
"projectHot": { "content": "<full body of projects/<name>/hot.md>" },
|
|
44
|
-
"rootHot": { "content": "<full body of <hypo-root>/hot.md>" },
|
|
45
44
|
"sessionLog": { "entry": "<entry to append to projects/<name>/session-log/YYYY-MM-DD.md (daily shard)>" },
|
|
46
45
|
"log": { "entry": "<entry to append to <hypo-root>/log.md>" },
|
|
47
46
|
"openQuestions": { "content": "<full body of pages/open-questions.md>" }
|
|
@@ -54,16 +53,17 @@ Field rules:
|
|
|
54
53
|
|
|
55
54
|
- `project`: **required**. Slug of the project being closed (matches a `projects/<slug>/` directory). Must be a single path segment, charset `A-Za-z0-9._-`, with at least one alphanumeric and not a dot-only name (`.`, `..`, `...`). Apply never infers the target from recency; a same-date pointer-table tie could otherwise write the close into the wrong project (B-3). A missing, malformed, or non-existent value fails the apply before any write.
|
|
56
55
|
- `sessionId`: recommended. The current session id, the same value you pass to `--session-id`. When present, apply refuses (`stage='session-id-mismatch'`, exit 1, nothing written) if it does not equal `--session-id`, so a payload file another session overwrote cannot be applied under this session's marker. It is a string self-check, not the transcript resolver; authority still comes from `--session-id`. Optional for backward-compat: omitting it or setting it to `null` skips the check (fail-open), but an empty string `""` counts as a mismatch and is refused. Omit only if you genuinely cannot determine the id.
|
|
57
|
-
- `date
|
|
56
|
+
- `date`: optional. Defaults to today (local). If supplied, it must be a real calendar date in `YYYY-MM-DD` form; a day that does not exist, such as `2026-09-31`, fails the apply before any write.
|
|
58
57
|
- `openQuestions` — optional. Include only when `pages/open-questions.md` exists and changed this session.
|
|
59
58
|
- `log`: optional. Omit it by default (apply derives the canonical `## [date] session | <project>` line from your `sessionLog` heading). Supply it only for a custom log.md line, which must still be a canonical `session | <project>` heading, or the apply fails at `stage='pre-apply-verification'`.
|
|
60
|
-
- The remaining fields (`sessionState`, `projectHot`, `
|
|
59
|
+
- The remaining fields (`sessionState`, `projectHot`, `sessionLog`) are required.
|
|
60
|
+
- `rootHot` is **gone from this payload.** The root `hot.md` pointer table is regenerated by the SessionStart and Stop hooks from each `projects/*/hot.md`, so a table composed here is overwritten by this same turn's Stop hook before it ever reaches git. It is not an omission: do not add it back. An older installed copy of this command still sends it, so apply does not fail on it: it reports one line in `notices[]` saying the field was ignored, and writes nothing for it.
|
|
61
61
|
|
|
62
62
|
Notes:
|
|
63
63
|
|
|
64
|
-
- `sessionState` / `projectHot` / `
|
|
64
|
+
- `sessionState` / `projectHot` / `openQuestions` are **overwrite** (full-file content); `sessionLog` / `log` are **append** (entry-level idempotency, exact-entry dedup, safe to re-run).
|
|
65
65
|
- **Overwrite means overwrite: whatever you did not carry into the payload is gone.** A single session's close can legitimately touch only one thread of a file that tracks several (a project running two or three tracks in parallel is normal). Before composing an overwrite field, read the CURRENT on-disk file and carry forward every `##` section this session did not touch, verbatim; do not reconstruct a section from memory or drop it because this session had nothing new to add to it. This session's own edits go on top of that, not in place of it.
|
|
66
|
-
- Apply enforces the same rule mechanically: if an overwrite field's payload drops 2 or more of the file's existing `##` sections, apply withholds that field (parks it exactly like a base conflict, as a proposal artifact a human reviews and can force through) rather than writing it. If you are deliberately consolidating or retiring sections on purpose (the user asked for it, or the sections genuinely no longer apply), add `"restructure": true` next to that field's `"content"` to say so
|
|
66
|
+
- Apply enforces the same rule mechanically: if an overwrite field's payload drops 2 or more of the file's existing `##` sections, apply withholds that field (parks it exactly like a base conflict, as a proposal artifact a human reviews and can force through) rather than writing it. If you are deliberately consolidating or retiring sections on purpose (the user asked for it, or the sections genuinely no longer apply), add `"restructure": true` next to that field's `"content"` to say so. **This changed: the flag used to write the bytes straight through, and it no longer does.** It records your claim for whoever reviews the park, and it changes the park reason so they can see the drop was deliberate rather than an omission. The bytes still land only once a human approves the proposal. Nothing you can put in a payload approves your own destructive overwrite. A flag that waived a real trip, as opposed to a `true` set on a field that never dropped anything, shows up in the result's `restructureWaivers[]` (see Step 4's close-result reporting).
|
|
67
67
|
- Frontmatter `updated:` is NOT auto-fixed. If your payload's `updated:` is stale, the post-apply verification gate will fail with `stage='post-apply-verification'` and you must fix the payload and retry.
|
|
68
68
|
- Write the payload to a **session-scoped** temp path, e.g. `/tmp/hypo-session-close-<session-id>.json`, using the same `<session-id>` you pass below. A date-based path (`<YYYY-MM-DD>`) collides when two sessions close on the same day: the second write silently overwrites the first, and the wrong payload gets applied. The `sessionId` field above is the second line of defense if a path is ever reused anyway.
|
|
69
69
|
|
|
@@ -71,10 +71,11 @@ Content guidance for each slot:
|
|
|
71
71
|
|
|
72
72
|
1. **sessionState** — next tasks list for the upcoming session (what to tackle first next time).
|
|
73
73
|
2. **projectHot** — session snapshot under 500 words: what changed and decisions made. Do **not** put next-step tasks here; those belong in `sessionState`.
|
|
74
|
-
3. **
|
|
75
|
-
4. **
|
|
76
|
-
5. **
|
|
77
|
-
|
|
74
|
+
3. **sessionLog**: one session entry to append to `projects/<name>/session-log/YYYY-MM-DD.md` (daily shard).
|
|
75
|
+
4. **log** (optional): a custom `session` entry for `<hypo-root>/log.md`. Omit it to let apply derive the canonical line from `sessionLog`.
|
|
76
|
+
5. **openQuestions** (conditional): only if `pages/open-questions.md` exists and questions were raised or resolved this session.
|
|
77
|
+
|
|
78
|
+
There is no slot for the root `hot.md` here. Its `Last Session` dates come from the project `hot.md` files themselves, and the hooks rebuild the table from those on every session start and every session end. Writing it into `projectHot` is what makes it appear in the root table; writing it by hand is what gets thrown away.
|
|
78
79
|
|
|
79
80
|
---
|
|
80
81
|
|
|
@@ -108,11 +109,30 @@ and nothing on disk, in three cases:
|
|
|
108
109
|
A refusal is not a failure to route around. It means the close should not happen: ask
|
|
109
110
|
the user, and re-run only after they say so.
|
|
110
111
|
|
|
111
|
-
On a verified close (`ok: true
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
On a verified close (`ok: true`, no uncommitted file in the project folder being closed, and no uncommitted file elsewhere that this session wrote through Write or Edit since its last auto-commit)
|
|
113
|
+
the apply first files a commit-backed close checkpoint receipt at
|
|
114
|
+
`HYPO_DIR/.cache/sessions/<id>/close-receipt.json`, then writes the per-session compat marker
|
|
115
|
+
`HYPO_DIR/.cache/session-closed-<id>.marker` as a projection of it. If the marker does not land,
|
|
116
|
+
the receipt is withdrawn again, so a run that reports the marker as failed never leaves a valid
|
|
117
|
+
receipt behind. The checkpoint gate reads git more narrowly than the PreCompact gate and `--check-session-close` do. An uncommitted
|
|
118
|
+
file inside the project folder being closed (`projects/<project>/`) blocks whether or not this
|
|
119
|
+
session has a record of writing it. An uncommitted file at the vault root or in another project's
|
|
120
|
+
folder blocks only when this session wrote it through Write or Edit since its last auto-commit;
|
|
121
|
+
otherwise it is reported as a notice. If the record of what this session wrote cannot be read, the
|
|
122
|
+
gate does not block on it: it treats the session as having no record and adds a notice that it could
|
|
123
|
+
not determine who owns those files. So the checkpoint can land while `--check-session-close` stays red on
|
|
124
|
+
someone else's dirty file. Neither one stops `/compact`: the PreCompact hook only shows a notice. The checkpoint receipt is the thing the
|
|
125
|
+
Stop-chain Layer 3 hook (`hypo-auto-minimal-crystallize`) actually checks: it proves only that
|
|
126
|
+
the file versions it names are in a specific commit, never that every change this session made
|
|
127
|
+
is saved. (It has nothing to do with the `close-receipt-failed` result of `proposal resolve`
|
|
128
|
+
below, which concerns the close-journal's handoff receipt.) Stop stops re-prompting for the close
|
|
129
|
+
procedure itself, and still surfaces any other uncommitted vault change separately, once per
|
|
130
|
+
receipt, as a notice rather than a repeat of "session-close incomplete". A shared append
|
|
131
|
+
target (`log.md`, a session-log shard) is checked by containment, not byte-for-byte: the
|
|
132
|
+
receipt only confirms this close's own entry is in the file, and the commit that lands it
|
|
133
|
+
carries the whole file as it stood at commit time, including any other session's
|
|
134
|
+
already-dirty bytes in the same file. Running crystallize purely for **synthesis** carries
|
|
135
|
+
no payload and needs no session id.
|
|
116
136
|
|
|
117
137
|
> **Source rule for `--session-id`:** use only the main conversation's session id
|
|
118
138
|
> (the id shown in the `[WIKI_AUTOCLOSE]` block reason, or the injected
|
|
@@ -128,7 +148,7 @@ no session id.
|
|
|
128
148
|
|
|
129
149
|
| Invocation | Behavior |
|
|
130
150
|
|---|---|
|
|
131
|
-
| `--apply-session-close` (no `--payload`) | **Probe mode
|
|
151
|
+
| `--apply-session-close` (no `--payload`) | **Probe mode**: exits 0 with "오늘 이미 close 완료로 보임" if all 4 files are fresh today; exits 1 with "payload is required" otherwise. Writes nothing, so it needs no session id. |
|
|
132
152
|
| `--apply-session-close --payload=<path>` | **Refused**, exit 1, `reason: 'session-id-required'`. Nothing is written and nothing is committed. |
|
|
133
153
|
| `--apply-session-close --payload=<path> --session-id=<id>` | The only apply path. Verifies close authority against that session's transcript **first**; on a refusal nothing is written. On success: per-field idempotent writes (no-op when bytes match), strict verification, lint gate, commit, and the per-session closed marker. Safe to re-run. |
|
|
134
154
|
| `--apply-session-close --force` | Skips the probe early-exit. It does **not** skip the authority check, and `--payload` plus `--session-id` are still required to apply anything. |
|
|
@@ -140,22 +160,43 @@ transcript holds no user close signal at all), `transcript-rewrite-detected`
|
|
|
140
160
|
close signal predates a resolution already recorded). The collapsed `reason`
|
|
141
161
|
stays the same string in all three cases, so read `gateReason` rather than
|
|
142
162
|
parsing the `Gate detail:` fragment out of `error`. The field is absent for
|
|
143
|
-
every other refusal.
|
|
163
|
+
every other refusal. `markerGateReason` on a successful apply is the same
|
|
164
|
+
diagnostic one stage later: the apply itself went through, and only the marker
|
|
165
|
+
was withheld for lack of a signal, so it carries the same three values and is
|
|
166
|
+
read the same way (see the `markerSkipReason` branches below).
|
|
144
167
|
|
|
145
168
|
**Two lint gates run automatically, scoped to the files this close writes:**
|
|
146
169
|
|
|
147
|
-
Both gates judge only the **payload files** (the
|
|
170
|
+
Both gates judge only the **payload files** (the 4 mandatory close files + `open-questions.md`). Lint debt this close did not author is never gated, so an unrelated broken page elsewhere cannot block your close. It is reported as a non-blocking notice, scoped to the close-target project: debt under `projects/<project>/` is listed by file in `notices[]`; debt elsewhere (other projects, shared `pages/`, root files) folds into the `otherDebtCount` integer so the same untouched-file debt does not re-list its filenames on every close (run `/hypo:lint` for the full list).
|
|
148
171
|
|
|
149
|
-
1. **Preflight
|
|
172
|
+
1. **Preflight**: an internal `lint --json` preflight runs **before** any payload bytes are written. Errors in overwrite targets (sessionState / projectHot / openQuestions) are filtered (about to be replaced). Errors in an **append target** (session-log / log.md) still block (appending can't repair existing corruption) → exit 1 with `stage='preflight-lint'`. Errors outside the payload files → `notices[]`, apply proceeds.
|
|
150
173
|
2. **Post-apply** — lint re-runs after the writes. Blocks only on **errors** in payload files (a payload-introduced malformed body / bad frontmatter); pre-existing errors elsewhere → `notices[]`. A lint crash (unparseable output) always blocks. Broken wikilinks are lint **warnings** (W4 — forward references to planned pages are normal) and are not gated here. Surfaces as `stage='post-apply-lint'` (or `'post-apply-verification+lint'` if freshness also fails).
|
|
151
174
|
|
|
152
175
|
> **Manual close (direct Write tool calls)** clears the Stop-chain block via `--mark-session-closed --session-id=<id>`. Both marker writers apply a **user-close hard gate**: the marker is written only when the session's transcript carries a genuine user close signal — an NL close phrase, a `/compact`, or an accepted AskUserQuestion [세션 마무리] answer. The transcript is resolved **strictly from `--session-id`** (a globally-unique id, globbed under `~/.claude/projects/`), never from a CLI arg, so a model that runs the writer on its own — without the user ever signalling close — is refused, and a forged path cannot point the gate at someone else's close-intent. The lint scope is widened from that same resolved transcript. `--transcript-path` is **not** consulted by the marker gate; it survives only to scope `--check-session-close`'s lint (which writes no marker).
|
|
176
|
+
>
|
|
177
|
+
> `--mark-session-closed` files the checkpoint receipt only when session-state.md, hot.md, the session-log entry and log.md of every project it attributes are already **committed**, so commit them first. On any failure it prints `ok: false` and exits 1 without a marker.
|
|
178
|
+
>
|
|
179
|
+
> The `stage` table in Step 4 belongs to `--apply-session-close`. `--mark-session-closed` has no `stage` field. A refusal after the receipt proof carries a `reason` instead (and `error` in prose), and so does `prior-checkpoint-rewritten`, which comes before the gate runs. Branch on that:
|
|
180
|
+
>
|
|
181
|
+
> | `reason` | What broke | How to recover |
|
|
182
|
+
> |---|---|---|
|
|
183
|
+
> | `incomplete` | `incompleteProjects[]` names each project whose session-state.md, hot.md, session-log evidence file or log.md is missing, not fresh, or not committed. | Commit the close files of the projects named, or finish writing them, then re-run the same command. |
|
|
184
|
+
> | `mismatch` | The version of a proof file committed at HEAD differs from what is in the working tree. `mismatches[]` lists each `{path, reason}` (for example a file with an uncommitted edit, or one a `.hypoignore` rule keeps out of the commit). | Run `git status` in the vault, commit the reviewed paths, then re-run. |
|
|
185
|
+
> | `no-commit-identity` | The vault has no readable commit to certify against (not a git repository, or no commit exists yet). | Commit the close files so the vault has a HEAD, then re-run. |
|
|
186
|
+
> | `write-failed` | Writing the checkpoint receipt under `.cache/sessions/<session-id>/` failed; `writeReason` names the error. If `retractFailed` is also present, taking back what the write may have left failed too and a stale receipt may remain there. | Fix what blocks that directory (a file where the directory should be, permissions, disk space), remove any stale `close-receipt.json` it names, then re-run. |
|
|
187
|
+
> | `marker-did-not-land` | The receipt was filed but the marker file itself did not land. The receipt is withdrawn again; `retractFailed` is present only when that failed too. | Fix what blocks the marker path under `.cache/` (permissions, a directory sitting where the marker goes, disk space), then re-run. |
|
|
188
|
+
> | `prior-checkpoint-rewritten` | This session had filed a close receipt earlier, and the commit it proved is no longer in the branch history (a hard reset or a rebase dropped it). The command refuses before it touches anything: the old receipt and marker stay where they are, and the result carries `prior_commit`. Issuing a new receipt over today's other close files would report "closed" while the original close record stays lost. A receipt that is missing or unparsable does not trigger this, and neither does one whose commit is still in the history but whose marker is gone (that one recovers with this command as usual). | Restore the dropped commit (for example from `git reflog`), or ask the user to request the close again and run `--apply-session-close` with a new payload. Do not retry `--mark-session-closed`: it refuses the same way until the history holds that commit again. |
|
|
189
|
+
> | `vault-commit-lock-timeout` | Another close or commit held the vault-commit lock past the timeout. | Re-run the same command a moment later. |
|
|
190
|
+
>
|
|
191
|
+
> Three refusals come before the receipt proof and carry no `reason`. A gate refusal (`blockers[]` plus `missing` and `stale`, with `error: 'session-close gate not satisfied'`) means the same close-file, lint or feedback checks as `--check-session-close` found something this session owns: fix it and re-run. A `skipReason` of `no-user-close-signal` (with `gateReason` when the gate has one) or `no-attribution-evidence` means the transcript shows no close request, or nothing ties this session to a project (pass `--project=<slug>` or `--log-only`). A bare `error` with none of these fields means the prior receipt or marker could not be set aside; fix the permission or disk problem under `.cache/` it names.
|
|
192
|
+
>
|
|
193
|
+
> The close checkpoint is recorded once the receipt is issued: `ok: true` from this command, and `close_state: closed` from `--check-session-close --session-id=<id>`. The Stop hook still checks this session's own project folder and has the final say. The final "Close check" line of `--check-session-close` covers the whole vault, so another session's uncommitted file can keep it red after your close has landed. A red line never stops `/compact`: PreCompact only shows a notice.
|
|
153
194
|
|
|
154
195
|
---
|
|
155
196
|
|
|
156
197
|
## Step 4 — Stage-based recovery
|
|
157
198
|
|
|
158
|
-
The result JSON includes a `stage` field when `ok: false`. Branch on it
|
|
199
|
+
The result JSON of `--apply-session-close` includes a `stage` field when `ok: false`. Branch on it. (`--mark-session-closed` reports `reason` instead; see the table in Step 3.)
|
|
159
200
|
|
|
160
201
|
| `stage` | What broke | How to recover |
|
|
161
202
|
|---|---|---|
|
|
@@ -165,22 +206,34 @@ The result JSON includes a `stage` field when `ok: false`. Branch on it:
|
|
|
165
206
|
| `post-apply-verification` | A mandatory file's `updated:` frontmatter is stale (≠ today) after apply. | Edit the payload's stale `content` (or supply correct `date`), then re-run. Writes are idempotent — re-applying a corrected payload is safe. |
|
|
166
207
|
| `post-apply-lint` | The payload introduced an error-level lint blocker in a payload file (malformed body / bad frontmatter), or lint crashed. | Fix the offending content in the payload, then re-run. (Broken wikilinks are W4 warnings — not gated.) |
|
|
167
208
|
| `post-apply-verification+lint` | Both above. | Fix both; re-run. |
|
|
168
|
-
| `proposal-pending` (overwrite park) | At least one overwrite field drifted from this session's observed base, or its payload dropped 2 or more of an existing file's `##` sections
|
|
209
|
+
| `proposal-pending` (overwrite park) | At least one overwrite field drifted from this session's observed base, or its payload dropped 2 or more of an existing file's `##` sections. `restructure: true` does not exempt a field from this; it only changes which park reason is recorded (`section-loss-guard-restructure-pending` instead of `section-loss-guard`). One reason, `will-overwrite-local-change`, fires whenever disk has moved away from what this session's own close last applied to that target (almost always a hand edit through Write/Edit after that apply), regardless of whether the new payload is the exact old bytes or something else entirely: there is no way from here to tell a payload that folds the hand edit back in from one that ignores it, so both park the same way and both need a human's `proposal challenge`/`proposal resolve` to land. Each withheld field appears in `conflicts[]` with a `reason` code and a `why` (a ready-to-read sentence naming the cause, reuse it verbatim, don't reword it), and is parked as a `.cache/proposals/<id>.json` artifact; `proposals[]` lists the id, target, and path for every one. The target on disk is untouched. Other, non-conflicting fields in the same payload may already be on disk but uncommitted (`partialConflict: true`, `appliedUncommitted`). | Do not touch the parked file yourself, and do not tell the user to run `hypomnema proposal apply <id>`: that command reads from a TTY and is refused outright in this environment (no terminal attached). Use the transcript-approved path instead. Run `node ${CLAUDE_PLUGIN_ROOT}/scripts/proposal.mjs challenge --session-id=<current-session-id> --ids=<comma-separated ids from proposals[]>`. If the session resolving this approval is NOT the same session that ran this close (the original session ended and this is a fresh one finishing its park), also pass `--close-session-id=<the original close's session id>`, so the approved bytes get credited back to THAT close's own retry instead of this one's. It prints a diff per target and mints a nonce; tell the user the exact line it prints (`apply-proposals <nonce>`, read the nonce off that output, never invent or reuse one), and once they have typed it back in the conversation, run `node ${CLAUDE_PLUGIN_ROOT}/scripts/proposal.mjs resolve --session-id=<current-session-id>` to apply the approved batch. `node ${CLAUDE_PLUGIN_ROOT}/scripts/proposal.mjs list` and `... discard <id>` (drop a proposal without applying it) both work with no TTY. Once resolved, re-run the **original** close (the one named by `--close-session-id`, or this same session if you omitted it); it is idempotent. If `resolve` itself reports `close-receipt-failed` (the close-journal's handoff receipt, unrelated to the close checkpoint receipt described above), do NOT re-run the close: the approved bytes landed on the page, but the close whose retry needs to see them was not told. Fix whatever is blocking `.cache/close-journal/` and run `node ${CLAUDE_PLUGIN_ROOT}/scripts/proposal.mjs reconcile` instead, which recovers the missing receipt from the audit log without writing any page, then re-run the original close. |
|
|
169
210
|
| `proposal-pending` (append lock timeout) | An append field (`sessionLog` or `log`) could not take its file lock within the timeout (default 5s), usually a concurrent close holding the same target. Nothing is parked for it: it carries `kind: 'append'` in `conflicts[]` and never appears in `proposals[]`. | This is transient, not a review case. Do not run any `proposal` subcommand for it, there is nothing there to challenge or apply. Just re-run the same close; the next attempt normally clears once the other close releases the lock. |
|
|
211
|
+
| `invalidate-failed` | A new close was authorized, and before writing anything the apply tried to set aside this session's prior close checkpoint receipt and marker, because they must not keep certifying a close that is being redone. That rename failed, usually a permission or disk problem under `.cache/`. Nothing was written and nothing was committed (`applied: []`, `committed: null`). The JSON carries `error` but no `mismatches[]`. | Fix the permission or disk problem named in `error`, then re-run the same close. No fresh close phrase is needed: nothing was recorded as resolved. |
|
|
212
|
+
| `receipt-proof-mismatch` | The writes and the commit went through, but the committed bytes do not match what this close meant to certify, so the checkpoint receipt was withheld and no marker was written. `ok: false`, exit 1, `committed: true`, and `mismatches[]` lists each `{path, reason}` that failed (for example a target that a `.hypoignore` rule keeps out of the commit, or a file whose committed version differs from the payload). A `path` of `(commit)` with `no-commit-identity` means the vault has no readable commit to certify against. | Run `git status` in the vault and look at each path in `mismatches[]` to see why that file is not committed as written. Once you have reviewed those paths, commit them, then re-run the same close. The retry needs **no fresh close phrase**, because a close signal is spent only once the receipt and marker land. |
|
|
213
|
+
| `receipt-write-failed` | Same point as above (committed, proof verified), but writing the checkpoint receipt file itself failed. `ok: false`, exit 1, and `mismatches[]` holds one entry `{path: '(receipt)', reason}` naming the write error. When the file was written but could not be read back, the receipt is withdrawn again if it is this close's own; if that fails too, a second entry `{path: '(receipt)', reason: 'retract-failed: …'}` follows and a stale receipt may remain. | Fix what is blocking `.cache/sessions/<session-id>/` (a file sitting where the directory should be, permissions, disk space), then re-run the same close. No fresh close phrase is needed. |
|
|
214
|
+
| `marker-did-not-land` | Every close precondition cleared (the gate was satisfied, the writes landed, the commit succeeded, the checkpoint receipt was filed) and only the session-close marker's own write to disk failed. This is the one marker failure that is a disk problem rather than a policy withhold, so it comes back `ok: false` and exit 1 instead of a `markerSkipReason` on a successful result. The receipt just filed is withdrawn again, so no reader sees this session as closed. If withdrawing it failed too, `mismatches[]` carries `{path: '(receipt)', reason: 'retract-failed: …'}` and a stale receipt may remain under `.cache/sessions/<session-id>/`. | Fix what is blocking the marker path under `.cache/` (permissions, a directory sitting where the marker file goes, disk space), then re-run the same close. **No fresh close phrase is needed**: a close signal is spent only once the marker lands, so this run left it unspent. Everything else is already idempotent, so the retry re-applies cleanly. |
|
|
170
215
|
| `proposal-store-failed` | A conflict was correctly withheld from disk, but writing its `.cache/proposals/` artifact also failed (see `proposalStoreFailures[]` and the loud stderr). The payload bytes for that field are on neither disk nor a proposal artifact right now. | Fix whatever is blocking `.cache/proposals/` (permissions, a file sitting where the directory should be, disk space), then re-run the exact same payload. Nothing was lost: the payload still holds the bytes, only the parking step failed. |
|
|
171
216
|
|
|
172
217
|
Once `ok: true`, report from the result JSON's `applied` and `skipped` arrays together, not `applied` alone. `applied` lists only the fields this run actually wrote bytes for; `skipped` lists the fields that already matched what was on disk (a re-run of an already-applied payload). An idempotent re-run legitimately reports `applied: []`, and that is success, not a failure to report on: check `skipped` for the same 4-6 entries instead. Read `committed` the same way: `true` covers both a real commit and the case where nothing needed staging (a full no-op re-run); `false` means the commit itself ran and failed (see `markerSkipReason`); `null` means apply never reached the commit step at all, because `ok` was already false (an authority refusal before any write, or a verification/lint failure, or a withheld conflict, per `stage`). `null` does NOT mean nothing was written: `applied` can be non-empty (bytes landed on disk) while `committed` stays `null`, because those bytes were never staged into git.
|
|
173
218
|
|
|
174
219
|
- ✓ session-state.md applied (or already current, per `skipped`)
|
|
175
|
-
- ✓ hot.md (project
|
|
220
|
+
- ✓ hot.md (project) applied (or already current). The root `hot.md` pointer table is not part of this apply: it's a separate projection a hook rebuilds from every project's own `hot.md`. A `rootHot` field in an older payload is not applied here either; it's ignored, and the result JSON reports that under `notices[]` (see below).
|
|
176
221
|
- ✓ session-log entry appended (or already present)
|
|
177
222
|
- ✓ open-questions applied (or skipped if unchanged)
|
|
178
223
|
- ✓ log.md entry appended (or already present)
|
|
179
|
-
- ✓ post-apply lint clean
|
|
180
|
-
- **`
|
|
224
|
+
- ✓ post-apply lint: no blockers (say "lint clean" only when `lintNotices` is empty and `notices[]` and `otherDebtCount` show no lint debt; otherwise say "no lint blockers")
|
|
225
|
+
- **`lintNotices[]`** (report any entries): non-blocking W19 findings, one `{id, file, message}` per project that has calendar-overflow session-log headings (a date like `2026-02-30` that is not on the calendar). The message lists every overflowing heading and its session-log file; the `file` field is that project's `design-history.md`. Report each entry's message verbatim. They never block the close or flip `ok`; the fix is to correct the date in that session-log heading.
|
|
226
|
+
- **`notices[]`** (report any entries): non-blocking messages, including the obsolete-field notice above when a payload sent `rootHot`. Report them alongside the checklist so whoever sent an older payload knows their root table field was ignored, not silently dropped.
|
|
227
|
+
- **`hostTagWarning`** (report it verbatim whenever present): this close was granted while a queued item shaped like a known host tag stayed neutral instead of retracting it. The host mints that shape, but a person can type or paste it too, so the close may not reflect a decision the user actually made. Print the string as it comes, including the undo instructions it carries, and print it once per run of the command. A close that keeps failing and gets retried is expected to print it again each time, because each run writes its own bytes; what must not happen is the same run repeating it, or a later, unrelated run inheriting it. It is **not** limited to `ok: true`: a run that committed the payload and then failed its marker write (`stage: 'marker-did-not-land'`) is exactly the case where it matters most, because those bytes are already in the vault's history. The undo instructions differ per run and name only what that run actually did, so never reword them and never swap in instructions you remember from another run. `--mark-session-closed` carries the same field, on the same terms, for a manual close.
|
|
181
228
|
- **marker written?** (required check): if `markerWritten: true`, report "session-close marker written"; if `markerWritten: false`, report "session-close marker NOT written (reason: `<markerSkipReason>`)" and do NOT declare the session "closed" or "complete". A missing marker means the Stop-chain is still open; recover per the `markerSkipReason` branch below.
|
|
182
229
|
|
|
183
|
-
|
|
230
|
+
**Check these three regardless of `ok`.** The result JSON carries them on a failed apply exactly as it does on a successful one: the apply script reports them outside its success branch on purpose. A park most often lands on exactly the run where `ok: false`, `stage: 'proposal-pending'`, since that is the run where the backlog just grew and a damaged artifact is most likely sitting next to the new one. A payload that never reaches `ok: true` still needs these three reported.
|
|
231
|
+
|
|
232
|
+
- **`restructureWaivers[]`** (report it if non-empty): an overwrite field carried `"restructure": true` on a field that really did drop `##` sections. Report each entry verbatim: the target file and the section names. **Nothing was let through.** The field is parked like any other section-loss trip and appears in `conflicts[]` too. This list is the claim, not the outcome, and it is set by the same party (a model composing the payload) the guard exists to check, so it must never pass silently.
|
|
233
|
+
- **`parkedTotal`** (report it if not `null` and > 0): the vault-wide count of parked write-proposal artifacts, not just what this close just parked (`proposals[]` above is only this run's own). It mixes pending, already-approved-but-unreconciled, and evidence-broken artifacts; the breakdown by state lives in doctor, not here. Tell the user, in one line, that N parked write-proposal artifact(s) exist vault-wide and that doctor shows what state each one is in: `/hypo:doctor` in Claude Code, or `hypomnema doctor` from a shell with the npm CLI (a plugin-only install has no `hypomnema` command). Do not repeat the per-id detail already covered by `proposals[]`. `null` means `.cache/proposals` itself could not be listed: report that as a measurement failure, never as "no parked proposals."
|
|
234
|
+
- **`parkedUnreadable`** (report it whenever non-empty, regardless of `parkedTotal`): filenames under `.cache/proposals` that exist as `.json` candidates but could not be parsed into an artifact (corrupt, permission-denied, or hand-edited into an unrecognizable shape). These are **not** counted in `parkedTotal` above; do not add the two numbers together. Tell the user, by name, which file(s) are unreadable and that doctor is where to inspect them (`/hypo:doctor` in Claude Code, or `hypomnema doctor` with the npm CLI); neither `proposal list` nor `proposal reconcile` can see these. This can be non-empty even when `parkedTotal` is `0`: a vault whose only proposal file is broken must never read as "nothing parked."
|
|
235
|
+
|
|
236
|
+
If `markerWritten: true`: ask: "The close checkpoint is recorded. Would you like to also run knowledge synthesis now, or stop here?"
|
|
184
237
|
|
|
185
238
|
**If `ok: false` with an authority `reason`, the close did not happen at all.** Nothing was written, nothing was committed. Do not report a partial close, and do not go looking for another way in.
|
|
186
239
|
|
|
@@ -188,17 +241,17 @@ If `markerWritten: true`: ask: "Session closed. Would you like to also run knowl
|
|
|
188
241
|
- `transcript-unresolved`: the id resolved no transcript, so it is almost certainly not the main conversation's (a background-task or Agent-thread uuid, most often). Get the right one and re-run.
|
|
189
242
|
- `no-user-close-signal`: the transcript is this session's, but no close authority is in force. **Branch on `gateReason` before doing anything.** The three cases need three different responses, and treating them alike is what made this refusal look like it had a new cause every time it appeared.
|
|
190
243
|
- `no-open`: the user genuinely never asked to close in wording the gate recognizes (e.g. "세션 마무리까지 진행해줘" falls outside the close-signal set). Re-running the same id changes nothing, because the transcript is unchanged. Confirm intent once with `AskUserQuestion`, header "세션", a single option labelled **세션 마무리** (설명: "이 세션을 마무리하고 close 마커를 기록"). If the user picks it, that answer lands in the transcript as a recognized close signal, so re-running the exact same command now applies **everything**: the writes, the commit, and the marker. If the user declines, the session stays open and nothing is written.
|
|
191
|
-
- `no-new-open-since-resolution`: the user DID ask, and that request was already resolved by an earlier close. Asking again makes them answer a question they have already answered.
|
|
244
|
+
- `no-new-open-since-resolution`: the user DID ask, and that request was already resolved by an earlier close. Asking again makes them answer a question they have already answered. Before you say anything, read `close_state` from `--check-session-close --session-id=<id>`. If it is `closed` (or `legacy-closed`), report that this session is already closed and stop; do not re-prompt. If it is `broken`, do **not** say the session is already closed: the earlier close's proof no longer holds (a commit it named was dropped by a reset or rebase, or the receipt and marker disagree). First run `--mark-session-closed --session-id=<id>`: when only the marker is missing, that recovers the close without asking the user anything. Only when it refuses with `prior-checkpoint-rewritten` (the commit its receipt named left the history) tell the user so and ask them to request the close again, then run `--apply-session-close` with a new payload. On the apply path, a resolution is recorded only once the session-close marker itself lands, so this reason cannot appear for a session whose apply was never marked closed. `/clear` is the one exception: it ends the session as surely as a close apply does, so `hypo-session-end.mjs` records the same resolution on a `/clear` directly, with no marker involved at all. So a session that never ran an apply, but was ended with `/clear`, can still carry a recorded resolution.
|
|
192
245
|
- `transcript-rewrite-detected`: the transcript changed underneath the gate, so the earlier signal can no longer be attested. This is not a statement about what the user wants. Say what happened rather than asking them to repeat themselves, and let a human decide.
|
|
193
246
|
|
|
194
247
|
In all three: do NOT touch the close-signal matcher itself, and do not hand-write the files to work around the refusal.
|
|
195
248
|
|
|
196
|
-
If the apply succeeded but `markerWritten: false`, do NOT say "session closed." Branch on `markerSkipReason`, which carries one of
|
|
249
|
+
If the apply succeeded but `markerWritten: false`, do NOT say "session closed." Branch on `markerSkipReason`, which carries one of four values here. Three more (`marker-did-not-land`, `receipt-proof-mismatch`, `receipt-write-failed`) no longer reach this branch: when every other precondition cleared and the close still could not certify itself, that is a failure and not a policy withhold, so the result comes back `ok: false` with that value as `stage` and exit 1. Each has its own row in the stage table above.
|
|
197
250
|
|
|
198
|
-
- `compact-gate-not-ok`, `commit-failed:
|
|
199
|
-
**A `commit-failed:` retry can need a hand first.** The payload writes land before the commit does, so after a failed commit those files already match what the retry would write. The retry skips them as already current, which leaves them out of the commit it makes, and they stay uncommitted and keep blocking the marker. If a retry reports `commit-failed:` or `compact-gate-not-ok` a second time on the same close, stop retrying and look at `git status` in the vault: stage and commit the close files yourself, or ask the user to, then run the close again.
|
|
251
|
+
- `compact-gate-not-ok`, `commit-failed: …`: surface the reason verbatim and address it (resolve the gate blocker, fix the git issue) before re-running. The JSON's `gateBlockers[]` names what the checkpoint gate refused on; read the cause there, not from `--check-session-close`, which judges the whole vault on a wider git axis and also lists other sessions' files that did not block this close. One `compact-gate-not-ok` blocker is an uncommitted file in the project folder being closed, which blocks even when this session has no record of writing it: commit it or revert it. **The re-run needs no fresh close phrase from the user for these two.** The resolution that spends a close signal is written only once the marker lands, so a run denied its marker at this stage leaves that signal unspent and the retry is authorized by the same one.
|
|
252
|
+
**A `commit-failed:` retry can need a hand first.** The payload writes land before the commit does, so after a failed commit those files already match what the retry would write. The retry skips them as already current, which leaves them out of the commit it makes, and they stay uncommitted and keep blocking the marker. If a retry reports `commit-failed:` or `compact-gate-not-ok` a second time on the same close, or comes back with `stage: 'receipt-proof-mismatch'` naming those close files, stop retrying and look at `git status` in the vault: stage and commit the close files yourself, or ask the user to, then run the close again.
|
|
200
253
|
- `transcript-unresolved`: the marker writer could not resolve any transcript for this `--session-id` at all, so it never got as far as checking for a close signal. Passing the exact same id again changes nothing; get the main conversation's real session id (not a background-task or Agent-thread uuid) and re-run.
|
|
201
|
-
- `no-user-close-signal`: a transcript was found, but it carries no close signal the gate recognizes. This is the one branch where the re-run DOES need a fresh signal: confirm intent once with `AskUserQuestion` (the same 세션 마무리 flow described under Step 3's `no-user-close-signal` table above), then re-run only after the user confirms.
|
|
254
|
+
- `no-user-close-signal`: a transcript was found, but it carries no close signal the gate recognizes. This is the one branch where the re-run DOES need a fresh signal: confirm intent once with `AskUserQuestion` (the same 세션 마무리 flow described under Step 3's `no-user-close-signal` table above), then re-run only after the user confirms. **Read `markerGateReason` first.** It carries the same three values as the authority refusal's `gateReason` (`no-open`, `transcript-rewrite-detected`, `no-new-open-since-resolution`), and only `no-open` means the user never asked. It also names a queued item shaped like an unregistered host tag when one retracted the close, which is a stale allowlist rather than a change of mind: report that name to the user instead of asking them to close again.
|
|
202
255
|
|
|
203
256
|
If the user says stop, end here. Otherwise continue to Step 5.
|
|
204
257
|
|
|
@@ -287,7 +340,7 @@ Show what was created or modified, and offer to run `/hypo:lint` to verify all n
|
|
|
287
340
|
`--check-session-close` (read-only strict gate, same check PreCompact runs) is still supported as a probe-only verification. Use it when you only want to verify that today's session-close is complete without applying anything:
|
|
288
341
|
|
|
289
342
|
```bash
|
|
290
|
-
node ${CLAUDE_PLUGIN_ROOT}/scripts/crystallize.mjs --check-session-close
|
|
343
|
+
node ${CLAUDE_PLUGIN_ROOT}/scripts/crystallize.mjs --check-session-close --session-id=<current-session-id>
|
|
291
344
|
```
|
|
292
345
|
|
|
293
346
|
Add `--hypo-dir="<path>"` only when the user specified a Hypomnema directory explicitly;
|
|
@@ -295,8 +348,10 @@ otherwise omit it.
|
|
|
295
348
|
|
|
296
349
|
An unrecognized flag exits 2 instead of being ignored.
|
|
297
350
|
|
|
351
|
+
Its verdict covers the whole vault (with `--session-id` for a session whose close was log-only, the project-close checks are skipped), so it can stay red on another session's uncommitted file after your own close has landed. Whether this session is closed is decided by the close checkpoint receipt: pass `--session-id=<id>` and read `close_state` (`closed` once the receipt is valid, or `legacy-closed` for an older marker written before receipts existed), not the vault-wide verdict. The Stop hook still checks this session's own project folder and has the final say.
|
|
352
|
+
|
|
298
353
|
It reports any file as `missing` or `stale`. For an actual close, prefer `--apply-session-close --payload=<path>` (Step 3): it bundles freshness and lint into one gate and is the documented dogfood path. `parseArgs` only accepts the `--payload=<path|->` spelling (a path, or `-` for stdin); a space-separated `--payload <path>` is rejected outright with exit 2, not silently dropped.
|
|
299
354
|
|
|
300
|
-
Add `--project=<slug>` to scope the check to one project (close status + lint scope) when recency picks the wrong one. This is a project-scoped diagnostic only: a green scoped result (JSON `scope: "project"`) attests that slug is close-complete, **not** that
|
|
355
|
+
Add `--project=<slug>` to scope the check to one project (close status + lint scope) when recency picks the wrong one. This is a project-scoped diagnostic only: a green scoped result (JSON `scope: "project"`) attests that slug is close-complete, **not** that the whole vault is clean.
|
|
301
356
|
|
|
302
357
|
On the marker writer (`--mark-session-closed --project=<slug>`), `--project` names the project this session closed: it sets the marker's attribution slug and enters the close scope, so another session's incomplete close is reported as `close_debt` (a notice) instead of refusing your marker. Every other gate check stays global.
|
package/commands/doctor.md
CHANGED
|
@@ -51,6 +51,7 @@ Then show the summary line: `Result: N passed, N warnings, N failed`
|
|
|
51
51
|
For any `✗` failures:
|
|
52
52
|
- Missing Hypomnema root or required directories/files → run `/hypo:init`.
|
|
53
53
|
- 0 hook files installed → run `/hypo:init`.
|
|
54
|
+
- Partial hook files on an npm or manual install (some missing) → run `/hypo:init`; it copies the missing ones. The detail names up to three of them, so report those names rather than only the count.
|
|
54
55
|
- 0 hook registrations in settings.json → run `/hypo:init`.
|
|
55
56
|
- `Hook execution smoke test` failed → show the stderr excerpt in the detail
|
|
56
57
|
verbatim; it names the cause. The repair depends on the channel, and
|
|
@@ -65,7 +66,8 @@ For any `✗` failures:
|
|
|
65
66
|
For `⚠` warnings:
|
|
66
67
|
- Missing `hypo-config.md` → run `/hypo:init` — it creates the config marker.
|
|
67
68
|
- Missing baseline files (`index.md`, `hot.md`, etc.) → run `/hypo:init`.
|
|
68
|
-
- Partial hook files (some missing) →
|
|
69
|
+
- Partial hook files on a plugin install (some missing, and `Hook files installed` says the plugin loader supplies them) → leftovers from an earlier non-plugin install. **Read `settings.json hook registrations` before calling it harmless.** Stale files alone do nothing, because the loader is what runs the hooks; stale files plus surviving settings.json entries mean every hook named there fires twice. That check reports the duplicate case on its own, so go by what it says. This one stays a `⚠` on purpose: nothing the user can run copies hooks on that channel, so failing over it would be a red they cannot clear.
|
|
70
|
+
- Hypomnema hooks registered in settings.json while the plugin loader is active → a double registration, not a partial one. Each named hook runs twice per event. Report it and give the user the removal step the detail names; do NOT tell them to run `/hypo:init`, which skips settings.json entirely on this channel.
|
|
69
71
|
- Partial settings.json registrations → run `/hypo:init` to merge missing entries.
|
|
70
72
|
- Missing git remote → `git -C <hypo-dir> remote add origin <url>`.
|
|
71
73
|
- `Installed cache vs marketplace HEAD` says the install is behind → the plugin
|
package/commands/feedback.md
CHANGED
|
@@ -78,6 +78,16 @@ When `--targets` includes `claude-learned`, `--global-summary` and `--promote-to
|
|
|
78
78
|
|
|
79
79
|
On a real (non-dry-run) write, the script automatically runs `feedback-sync --write` to refresh MEMORY.md / CLAUDE.md. If that post-step reports drift it prints a one-line warning — the page is still saved; reconcile with `hypomnema feedback-sync --check`.
|
|
80
80
|
|
|
81
|
+
A projection block you edited by hand is a conflict, except when the edit already matches what the wiki would write: the next `--write` takes it over. To keep the wiki version of a conflicted block, run `hypomnema feedback-sync --import-target-change --from=<target>` first to save a copy of the edit as a draft, then `hypomnema feedback-sync --accept-wiki=<slug>`. After the accept, the text you edited exists only under `.cache/`: in the kept copy of the whole file (`.cache/feedback-kept/`) and in that import draft (`.cache/feedback-drafts/`). Both are outside git and your other machines and are gone when `.cache/` is cleared, so the accept prints their paths as a warning (and in the `warnings` list of its `--json` report). Move what you want to keep to `pages/feedback/` or elsewhere before you clear `.cache/`. The accept itself does not move anything.
|
|
82
|
+
|
|
83
|
+
`--bootstrap` and `--import-target-change` write their drafts to `.cache/feedback-drafts/` in the wiki, not under `pages/feedback/`. A draft is a copy of your own hand-written lines or edited block, so it goes where the wiki's git does not look: in a wiki that is a git work tree nothing is written unless git ignores the whole `.cache/` directory (the default `.gitignore` does; a rule that names only the drafts is not enough, because the temp file a draft is written to first would still be visible), and otherwise the command exits 1 and says why. The same goes when git itself cannot answer (a broken `.git/config`, an ownership refusal): that is not read as "not a git work tree", so nothing is written. A wiki that git reports is not a git work tree has nothing that could stage them, so the drafts are written as they are. A run with nothing to draft skips this check and exits 0. If the hand line record cannot be saved, `--bootstrap` exits 1 before it publishes any draft (a draft without its record would be skipped as already there by every later run), so fix the cause and run it again. A draft has no `sensitivity` yet (`TODO`): set `public` or `sanitized` when you move it into `pages/feedback/`. The cost is that drafts are not synced to your other machines and are gone when `.cache/` is cleared. Drafts that an earlier version left in `pages/feedback/_drafts/` still count: `--bootstrap` does not draft the same slug again, and `--write` keeps the hand line record while one is there. Nothing moves or deletes them. Such a file is a copy of your own text that the earlier version wrote with `sensitivity: public`, and git can see it unless your `.gitignore` covers it: `--bootstrap`, `--import-target-change` and `--write` warn when it does, so move it to `.cache/feedback-drafts/` (`--bootstrap` and `--write` read it there just the same) or delete it. A slug skipped because of one of these files is reported as `draft-exists-legacy` with its path. A draft is created only if its name is free, and it appears under that name only once complete, so a second run or a crash never replaces or cuts one in half.
|
|
84
|
+
|
|
85
|
+
Before `--accept-wiki` replaces a file, before `--write` replaces `MEMORY.md` or `CLAUDE.md`, and before `--write` removes a hand line that `--bootstrap` drafted from, the old bytes are copied to `.cache/feedback-kept/` in the wiki. The copy holds your own text, so in a wiki that is a git work tree it is only written when git ignores `.cache/` (the default `.gitignore` does; a wiki that is not a git work tree has nothing that could stage it yet, so there the command adds a `.cache/` line to its `.gitignore` first, creating the file or appending one line (an append, so a symlinked `.gitignore` stays a link and keeps its mode) and saying so in the run's warnings, then asks git itself, in a throwaway repository over the wiki, whether `.cache/` is really ignored, and stops with exit 1 and writes nothing if it is not (a later `!.cache/` line overrides the rule: remove it or add a `.cache/` line after it) or if it cannot add the line; that way a later `git init` and `git add -A` does not stage the copy. Without git on the machine the line is added and the check is skipped, which the warning says); otherwise the command stops with exit 1 and writes nothing, and the same goes for the bootstrap line record. The one exception is the routine before-write copy of `MEMORY.md` or `CLAUDE.md`: in such a vault `--write` skips that copy, warns, and goes ahead. The same copy is made before `--write` rewrites or removes a generated `feedback_<slug>.md` that has no recorded hash (an older install, or a lost record); if that copy cannot be kept, that one file is left as it is and `--write` warns. Only the newest 10 copies of each kind are kept, and the older ones are deleted only after a run that ended with exit 0 or 3. The warnings a `--write` raises (a skipped copy, a side file it left alone, a record it could not save) are also in the `warnings` list of its `--json` report. The notice that a `--write` kept a hand line is printed once, and a `--write` whose output nobody may read (an older PreCompact hook discards it) also saves it on this machine, in `state/feedback-pending-notices-<id>.json` under the Claude home (outside the wiki, one file per wiki, so it needs no `.gitignore` rule and a wiki that does not ignore `.cache/` does not lose it; a file an earlier version kept in the wiki's `.cache/` is read once and deleted); the next run that shows its warnings to you (a `--check` or `--write` you run by hand, this command's post-step, the current PreCompact hook) reports it, and removes it when that run ends with exit 0 (a run that fails keeps it, so it is shown again). `--ensure-container` does not replace `CLAUDE.md` at all: it adds the container to the end with one append, so a save from your editor at the same moment is never overwritten and no copy is needed. If a run was cut off in the middle of that append, the next `--ensure-container` finishes it; text after the container tags that is not our own addition is still refused as corrupt. `--accept-wiki` reads each target again right before it copies and replaces it and stops with exit 1, writing nothing to that target, when it changed since the plan was made. `--accept-wiki` exits 3 when it accepted some target but another one that holds the block still needs a hand repair. That includes two blocks that lost an END and a START marker and now read as one: accept refuses to replace them.
|
|
86
|
+
|
|
87
|
+
A managed-block marker counts only when it starts its own line outside a code fence. An example of the markers inside a fenced block, inside inline code, or after other text on the same line is left alone. A fence that is never closed does not count as a fence, so marker lines below a stray ``` are still read as a real block; an example placed inside such an unclosed fence is rewritten like one, and the file's earlier bytes are in `.cache/feedback-kept/` when that copy could be kept.
|
|
88
|
+
|
|
89
|
+
A `feedback_<slug>.md` copy that you edited after `--write` generated it is neither rewritten nor removed. The run exits 3 and names the file: move your edits out and delete it, then `--write` generates it again. The hash of each generated copy is kept in `.cache/feedback-side-files.json`; a copy with no recorded hash is refreshed once and recorded. Every writing mode takes a lock on the wiki, and `--write` reads each file again right before replacing it, so a file that changed after the check is not overwritten (exit 1, run it again).
|
|
90
|
+
|
|
81
91
|
---
|
|
82
92
|
|
|
83
93
|
## Step 4 — Confirm
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -119,7 +119,7 @@ Hooks run automatically at Claude Code lifecycle events. They are deployed to `~
|
|
|
119
119
|
| `UserPromptSubmit` | `hypo-first-prompt.mjs` → `hypo-lookup.mjs` → `hypo-compact-guard.mjs` |
|
|
120
120
|
| `PreCompact` | `hypo-personal-check.mjs` |
|
|
121
121
|
| `PostToolUse` (Write/Edit) | `hypo-auto-stage.mjs` |
|
|
122
|
-
| `Stop` | `hypo-hot-rebuild.mjs` → `hypo-session-record.mjs` → `hypo-auto-commit.mjs` |
|
|
122
|
+
| `Stop` | `hypo-stop.mjs` (runs `hypo-hot-rebuild.mjs` → `hypo-session-record.mjs` → `hypo-auto-commit.mjs` → `hypo-auto-minimal-crystallize.mjs` as ordered stages, not separate registrations) |
|
|
123
123
|
| `CwdChanged` | `hypo-cwd-change.mjs` |
|
|
124
124
|
| `FileChanged` | `hypo-file-watch.mjs` |
|
|
125
125
|
|
|
@@ -127,17 +127,17 @@ Hooks run automatically at Claude Code lifecycle events. They are deployed to `~
|
|
|
127
127
|
|
|
128
128
|
| Hook | Responsibility |
|
|
129
129
|
|---|---|
|
|
130
|
-
| `hypo-session-start` |
|
|
130
|
+
| `hypo-session-start` | Run `git pull --ff-only` (silent fail on missing remote), rebuild the root `hot.md` projection, inject `index.md`, root `hot.md`, project `hot.md`/`session-state.md` |
|
|
131
131
|
| `hypo-first-prompt` | Reads the marker left by `hypo-session-start` or `hypo-cwd-change` and forces a one-line resume on the first user prompt (10-min TTL). It does not re-read or re-inject `hot.md` |
|
|
132
132
|
| `hypo-lookup` | BM25 search over the wiki on every prompt. **HIT** → inject top-3 page snippets (≤2000 chars each; a page whose `verify_by_date` is overdue gets a `[STALE verify_by_date=…]` marker prepended). **MISS** → emit closest-slug signal that prompts Claude to research + `/hypo:ingest` |
|
|
133
133
|
| `hypo-compact-guard` | Detect `/compact` or `/clear` typed in chat and, if session close is incomplete, tell Claude so. It never blocks `/compact` |
|
|
134
134
|
| `hypo-personal-check` | PreCompact detection: lint blockers, uncommitted changes, missing session-log entries surface as a `systemMessage`; `/compact` is never blocked here |
|
|
135
135
|
| `hypo-auto-stage` | After Write/Edit on a wiki path, run `git add` (skips paths matching `.hypoignore`) |
|
|
136
|
-
| `hypo-hot-rebuild` | At session stop, regenerate root `hot.md`
|
|
136
|
+
| `hypo-hot-rebuild` | At session stop, regenerate the root `hot.md` projection (the same generator `hypo-session-start` also calls); emit growth metrics + cache for next SessionStart. A scan failure (not just an empty `projects/`) skips the write and leaves a health notice for the next `SessionStart` to surface |
|
|
137
137
|
| `hypo-session-record` | At session stop, append `{session_id, transcript_path, recorded_at, cwd, device}` to `.cache/sessions/index.jsonl` (primary source for the observability audit) |
|
|
138
138
|
| `hypo-auto-commit` | At session stop, filter changed paths through `.hypoignore`, commit non-ignored changes, `git pull --no-rebase` + `git push` (silent fail on missing remote) |
|
|
139
|
-
| `hypo-cwd-change` | When working directory changes, re-resolve the active project and build
|
|
140
|
-
| `hypo-file-watch` | Build `
|
|
139
|
+
| `hypo-cwd-change` | When working directory changes, re-resolve the active project and build a notice carrying its `hot.md`. The hook reference documents `watchPaths` and `systemMessage` as `CwdChanged`'s output fields and says only that "it shows the `systemMessage` as a brief terminal notification" and "the message doesn't reach the SDK message stream"; it does not say whether `systemMessage` reaches the model on this event either way. As of Claude Code 2.1.276 (checked 2026-09-18) and reconfirmed on 2.1.283, tracing the installed binary shows the only consumer of that `systemMessage` is the terminal notification queue, a low-priority toast that a same-keyed message replaces and that disappears after five seconds, with no branch that forwards it to the model; outside the interactive REPL the injecting function is null. No live-session observation of this path exists either way. Until the documentation says otherwise, this event is treated as not reaching the model |
|
|
140
|
+
| `hypo-file-watch` | Build a `systemMessage` notice for a changed wiki file, over the same field as `hypo-cwd-change` whose reach to the model the reference leaves undocumented; as of Claude Code 2.1.276 (checked 2026-09-18) and reconfirmed on 2.1.283 it is likewise treated as not reaching the model, for the same shared-consumer reason (see the `hypo-cwd-change` row). On a stock install the event still has no registered trigger: its matcher key is omitted (not blank), which the reference says matches every watched file while adding nothing to the watch list, and nothing in this package returns `watchPaths` to seed that list. The watch list is session-global, not scoped to this plugin, so another hook or plugin that does seed it would make this matcher-less group fire on every watched file; the hook still returns immediately for any path that does not start with `HYPO_DIR` (a string-prefix check, not a symlink-resolving one). That check only screens the path string: a symlink living inside the wiki still starts with `HYPO_DIR` and passes it, and the hook's `readFileSync` follows the link, so such a symlink can still surface content from wherever it points. The event also fires regardless of who changed the file, so it is not an external-edit signal |
|
|
141
141
|
|
|
142
142
|
### Deployment constraint
|
|
143
143
|
|
|
@@ -293,6 +293,7 @@ SessionStart
|
|
|
293
293
|
│
|
|
294
294
|
├─► hypo-session-start.mjs
|
|
295
295
|
│ ├─► git pull --ff-only (if remote)
|
|
296
|
+
│ ├─► rebuild root hot.md (projection of projects/*/hot.md)
|
|
296
297
|
│ ├─► inject index.md / hot.md / project session-state.md
|
|
297
298
|
│ └─► (Claude resumes work)
|
|
298
299
|
│
|
|
@@ -308,14 +309,36 @@ SessionStart
|
|
|
308
309
|
│ └─► hypo-personal-check.mjs (lint + session-close gate)
|
|
309
310
|
│
|
|
310
311
|
├─► CwdChanged
|
|
311
|
-
│ └─► hypo-cwd-change.mjs (builds hot.md
|
|
312
|
+
│ └─► hypo-cwd-change.mjs (builds a hot.md notice on systemMessage; the reference documents the terminal notification, tracing the installed binary as of Claude Code 2.1.276, checked 2026-09-18 and reconfirmed on 2.1.283, shows it does not reach the model, and no live-session observation exists either way; see the hook table above)
|
|
312
313
|
│
|
|
313
314
|
└─► Stop
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
315
|
+
└─► hypo-stop.mjs, running four stages in order:
|
|
316
|
+
├─► hypo-hot-rebuild.mjs (regenerate root hot.md projection + growth cache; SessionStart also calls this generator)
|
|
317
|
+
├─► hypo-session-record.mjs (append .cache/sessions/index.jsonl)
|
|
318
|
+
├─► hypo-auto-commit.mjs (.hypoignore-filtered stage + commit + pull + push)
|
|
319
|
+
└─► hypo-auto-minimal-crystallize.mjs (blocks Stop if session close never ran)
|
|
317
320
|
```
|
|
318
321
|
|
|
322
|
+
Root hot.md's frontmatter `updated:` is the max of every `projects/*/hot.md` row date, not the
|
|
323
|
+
date the projection itself was last rewritten. A vault whose newest project row is a week old
|
|
324
|
+
keeps that same week-old date across every SessionStart and Stop until a project's own `hot.md`
|
|
325
|
+
moves forward, even though the root file's bytes get recomputed and checked far more often.
|
|
326
|
+
|
|
327
|
+
Before overwriting content it does not recognize as its own, the projection backs it up next to
|
|
328
|
+
`hot.md` as `hot.md.pre-projection-backup.md` (or a numbered variant, `-2.md`, `-3.md`, ...). These
|
|
329
|
+
backups are never cleaned up automatically. Delete the ones you no longer need by hand.
|
|
330
|
+
|
|
331
|
+
The write itself goes through `writeRootHotProjection` in `hooks/hypo-shared.mjs`, which holds
|
|
332
|
+
the vault commit lock (`vaultCommitLockTarget`, the same lock `hypo-auto-commit.mjs` and
|
|
333
|
+
`crystallize.mjs`'s apply take around stage and commit) across the whole projection write: the
|
|
334
|
+
first read of `hot.md`, the ownership check, the backup, and the replacing rename. `hypo-session-start.mjs`'s
|
|
335
|
+
own `git pull` takes and releases the same lock before calling into the projection, so the two
|
|
336
|
+
never overlap; they still serialize against each other and against a concurrent `auto-commit` or
|
|
337
|
+
`crystallize` apply. The lock is not reentrant: nothing inside the projection's write path may
|
|
338
|
+
take it a second time, and a caller that already holds it has to call the unlocked form instead.
|
|
339
|
+
When the lock cannot be acquired within its timeout, the write is skipped rather than blocked on
|
|
340
|
+
indefinitely, and the caller is told the pointer table was left as-is.
|
|
341
|
+
|
|
319
342
|
---
|
|
320
343
|
|
|
321
344
|
## Feedback → Behavior pipeline
|
package/docs/CONTRIBUTING.md
CHANGED
|
@@ -128,8 +128,13 @@ Everything else belongs in `commands/`, which loads the same way.
|
|
|
128
128
|
npm test # tests/*.test.mjs, sharded across processes — unit + smoke + contract
|
|
129
129
|
npm run lint # scripts/lint.mjs — frontmatter + wikilink validation + W8 (design-history stale
|
|
130
130
|
# vs session-log) + W14 (design-history missing but session-log implies one)
|
|
131
|
+
# + W19 (session-log heading date not on the calendar, no real later date)
|
|
131
132
|
# + W15 (synthesis older than the newest page in its sources_consulted)
|
|
132
|
-
# + W16 (sources_consulted
|
|
133
|
+
# + W16 (sources_consulted content problems: a source name W15 could not
|
|
134
|
+
# resolve, a source or the synthesis's own updated that fails a strict
|
|
135
|
+
# YYYY-MM-DD parse, or sources_consulted written as a YAML block list
|
|
136
|
+
# instead of the required flow list; a single page can carry more than one
|
|
137
|
+
# W16 at once)
|
|
133
138
|
npm run fix:verify # Phase 1 of learned_behavior #6 — verifies fix #N status claims in
|
|
134
139
|
# a wiki spec against `// @fix #N: <test-name>` anchors, read as a
|
|
135
140
|
# union across every tests/*.mjs. Maintainer dogfood; needs a wiki at
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The one temp+rename writer. Six byte-identical copies of this function used to
|
|
2
|
+
// live in hooks/ and scripts/lib/, and a fix landed in two of them (#296) while
|
|
3
|
+
// the other four kept the bug. That is the whole reason this file exists: a
|
|
4
|
+
// defense that lives in six places is a defense you can only half-apply.
|
|
5
|
+
//
|
|
6
|
+
// Hooks are copied standalone into ~/.claude/hooks/, so this has to live in
|
|
7
|
+
// hooks/ and be listed in hooks/shared.json. scripts/ may import it; the reverse
|
|
8
|
+
// direction is what breaks for every installed copy.
|
|
9
|
+
//
|
|
10
|
+
// Node built-ins only, per the hooks rule.
|
|
11
|
+
import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { dirname } from 'node:path';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Commit `content` to `path` via a temp file and a rename, so a partial or
|
|
16
|
+
* failed write lands on a throwaway name and the target is never torn.
|
|
17
|
+
*
|
|
18
|
+
* The temp name carries this pid and a fresh random rather than a shared
|
|
19
|
+
* `<path>.tmp` slot, so concurrent writers never fight over it. That is also
|
|
20
|
+
* why a leaked temp is permanent: nothing ever picks the same name again, so
|
|
21
|
+
* the file sits in the vault forever, close after close. Both failure points
|
|
22
|
+
* leak one: the rename (the target stays untouched, which is the point) and
|
|
23
|
+
* the write itself (ENOSPC or EDQUOT partway through). Clean up on either, and
|
|
24
|
+
* never let the cleanup hide the error that got us here.
|
|
25
|
+
*
|
|
26
|
+
* Rename atomicity swaps the directory entry. It is NOT power-loss durable.
|
|
27
|
+
* there is no fsync, same as everything else in the vault.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} path absolute path to write
|
|
30
|
+
* @param {string|Buffer} content bytes to commit
|
|
31
|
+
*/
|
|
32
|
+
export function atomicWrite(path, content) {
|
|
33
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
34
|
+
const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
|
|
35
|
+
try {
|
|
36
|
+
writeFileSync(tmp, content);
|
|
37
|
+
renameSync(tmp, path);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
try {
|
|
40
|
+
rmSync(tmp, { force: true });
|
|
41
|
+
} catch {}
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
}
|