hypomnema 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +57 -0
  4. package/README.ko.md +32 -13
  5. package/README.md +32 -13
  6. package/commands/crystallize.md +83 -62
  7. package/commands/doctor.md +1 -0
  8. package/commands/feedback.md +10 -0
  9. package/commands/init.md +2 -1
  10. package/commands/resume.md +7 -6
  11. package/commands/upgrade.md +30 -0
  12. package/docs/ARCHITECTURE.md +61 -25
  13. package/docs/CONTRIBUTING.md +14 -0
  14. package/hooks/atomic-write.mjs +54 -6
  15. package/hooks/base-store.mjs +6 -9
  16. package/hooks/close-gate-store.mjs +46 -22
  17. package/hooks/close-journal.mjs +26 -0
  18. package/hooks/close-receipt.mjs +37 -3
  19. package/hooks/hypo-auto-commit.mjs +98 -35
  20. package/hooks/hypo-auto-minimal-crystallize.mjs +17 -1
  21. package/hooks/hypo-auto-stage.mjs +68 -4
  22. package/hooks/hypo-close-guard.mjs +92 -208
  23. package/hooks/hypo-compact-guard.mjs +15 -3
  24. package/hooks/hypo-first-prompt.mjs +24 -14
  25. package/hooks/hypo-hot-rebuild.mjs +33 -159
  26. package/hooks/hypo-lookup.mjs +6 -1
  27. package/hooks/hypo-personal-check.mjs +85 -8
  28. package/hooks/hypo-session-start.mjs +414 -509
  29. package/hooks/hypo-shared.mjs +4129 -1198
  30. package/hooks/hypo-stop.mjs +10 -10
  31. package/hooks/session-entries.mjs +1313 -0
  32. package/hooks/session-views.mjs +1309 -0
  33. package/hooks/shared.json +3 -1
  34. package/package.json +3 -1
  35. package/scripts/crystallize.mjs +30 -24
  36. package/scripts/doctor.mjs +293 -6
  37. package/scripts/feedback-sync.mjs +1786 -162
  38. package/scripts/feedback.mjs +16 -2
  39. package/scripts/graph.mjs +6 -1
  40. package/scripts/init.mjs +41 -17
  41. package/scripts/lib/code-fence.mjs +197 -0
  42. package/scripts/lib/crystallize-args.mjs +3 -3
  43. package/scripts/lib/crystallize-close-apply.mjs +1976 -510
  44. package/scripts/lib/crystallize-close-check.mjs +90 -33
  45. package/scripts/lib/crystallize-close-gate.mjs +20 -7
  46. package/scripts/lib/design-history-stale.mjs +113 -47
  47. package/scripts/lib/git-hooks-dir.mjs +1 -1
  48. package/scripts/lib/hypo-ignore.mjs +13 -0
  49. package/scripts/lib/project-create.mjs +76 -65
  50. package/scripts/lib/template-schema-version.mjs +1 -0
  51. package/scripts/lint.mjs +189 -11
  52. package/scripts/query.mjs +6 -1
  53. package/scripts/rename.mjs +117 -2
  54. package/scripts/resume.mjs +84 -50
  55. package/scripts/session-entries.mjs +301 -0
  56. package/scripts/upgrade.mjs +223 -0
  57. package/scripts/verify.mjs +7 -1
  58. package/templates/SCHEMA.md +34 -22
  59. package/templates/gitattributes +6 -0
  60. package/templates/gitignore +14 -8
  61. package/templates/hypo-automation.md +6 -6
  62. package/templates/hypo-config.md +6 -3
  63. package/templates/hypo-guide.md +48 -25
  64. package/templates/hypo-help.md +2 -1
  65. package/templates/projects/_template/index.md +3 -2
  66. package/templates/projects/_template/hot.md +0 -28
  67. package/templates/projects/_template/session-state.md +0 -9
@@ -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).** 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 `/compact` can pass. Any real close still requires all 4 mandatory files.
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 its own session entry, the session-log entry and the log.md line.
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,52 +30,58 @@ 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 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.
33
+ The session-close path is **payload-driven**. You compose a single JSON payload that describes this session and hand it to the `--apply-session-close` apply path (the Step 3 command). Apply creates **one immutable session entry**, `projects/<slug>/sessions/<date>-<close id>.md`, appends to the project's session-log shard and to `log.md`, and gates the result with lint. It overwrites no project state file: a project's `session-state.md` and `hot.md` are views generated from its entries, so do not write them. The close id belongs to this close request and stays the same across retries, so re-running a payload is safe.
34
34
 
35
- Payload shape (`project` + 3 content fields required; `log` optional/derived; `openQuestions` conditional, per Spec §5.2.7 / §8.3):
35
+ Payload shape (a single-track example with the required fields only):
36
36
 
37
37
  ```json
38
38
  {
39
- "project": "<project-name>",
40
- "sessionId": "<current session id — the SAME value you pass to --session-id>",
41
- "date": "YYYY-MM-DD",
42
- "sessionState": { "content": "<full body of projects/<name>/session-state.md>" },
43
- "projectHot": { "content": "<full body of projects/<name>/hot.md>" },
44
- "sessionLog": { "entry": "<entry to append to projects/<name>/session-log/YYYY-MM-DD.md (daily shard)>" },
45
- "log": { "entry": "<entry to append to <hypo-root>/log.md>" },
46
- "openQuestions": { "content": "<full body of pages/open-questions.md>" }
39
+ "project": "<slug>",
40
+ "sessionId": "<current session id>",
41
+ "summary": "<what this session did and decided, markdown>",
42
+ "tracks": [
43
+ { "id": "<track-id>", "title": "<display title>", "next": "<next steps and state, markdown>" }
44
+ ],
45
+ "sessionLog": { "entry": "<entry to append to projects/<slug>/session-log/YYYY-MM-DD.md>" }
47
46
  }
48
47
  ```
49
48
 
50
- > **Important:** the JSON above is a literal template — do not add `//` or `#` comments when materializing it. `readPayload()` runs `JSON.parse`, which rejects comments and would fail the apply before any write.
49
+ > **Important:** the JSON above is a literal template: replace every `<...>` placeholder with a real value, and do not add `//` or `#` comments when materializing it. `readPayload()` runs `JSON.parse`, which rejects comments and would fail the apply before any write.
51
50
 
52
51
  Field rules:
53
52
 
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.
53
+ - `project`: **required**. Slug of the project being closed (matches a `projects/<slug>/` directory). Must be a single path segment, made of letters, marks and numbers of any script plus `.`, `_` and `-`, with at least one letter or number 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.
55
54
  - `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.
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.
57
- - `openQuestions` — optional. Include only when `pages/open-questions.md` exists and changed this session.
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'`.
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.
55
+ - `summary`: **required**, a string. A snapshot of this session under 500 words: what changed and what was decided, as markdown. Do **not** put next-step tasks here; those belong in `tracks[].next`.
56
+ - `tracks`: an array with one element per track this session worked on. A track is one thread of work in the project (a project running two or three in parallel is normal). The array may be empty when the session moved no track. Each element has:
57
+ - `id`: **required**. Lowercase letters, digits and hyphens, starting with a letter or digit, at most 48 characters. It must name a track the project already has, or be a new one (see `new` below).
58
+ - `next`: the next tasks and state of that track, as markdown. This becomes the track's current head, the first thing the next session reads for it. An element that only ends the track (`done`) may leave it out.
59
+ - `title`: an optional display title.
60
+ - `sessionLog.entry`: **required**. The entry to append to `projects/<slug>/session-log/YYYY-MM-DD.md` (the daily shard). It must carry a dated `## [YYYY-MM-DD] <title>` heading.
61
+
62
+ Optional fields, which stay out of the JSON template above:
63
+
64
+ - `date`: 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.
65
+ - `new: true` on a track element starts a track the project has not registered yet. An unregistered id without it is refused (`track-unknown`, and the error lists the registered ids). `new: true` on a registered id is refused as well (`track-exists`).
66
+ - `done: true` on a track element ends the track. It may be sent without a body: `{ "id": "<track-id>", "done": true }`. It ends only the heads this session knew about (the ones it was shown in full or as a pointer line). A head another session wrote to the same track at the same time stays active. To end that one too, read that entry first, then send `done` again in a later close.
67
+ - `supersedes` on a track element names the earlier heads of that track this update replaces. Leave it out and the update replaces every head of the track this session was told about. When you name them, use a full close id as shown in the injection or the view, or a prefix that is unique within that track. A close id that does not exist, or an ambiguous prefix, is refused (`supersedes-unknown`).
68
+ - Never put the same track id in `tracks` twice (`track-duplicate`). A normal update plus a separate `done` for one track is the usual way to hit it: send one element for that track instead.
69
+ - `log.entry`: a custom line for `<hypo-root>/log.md`. Omit it by default: apply derives the canonical `## [date] session | <project>` line from your `sessionLog` heading. Supply it only for a custom line, which must still be a canonical `session | <project>` heading, or the apply fails at `stage='pre-apply-verification'`.
70
+ - `openQuestions.content`: the full body of `pages/open-questions.md`. Include it only when this session actually changed that file. It is the one overwrite left in a close, so it goes through the base protection and the approval path described below and in Step 4 (`proposal-pending`).
61
71
 
62
72
  Notes:
63
73
 
64
- - `sessionState` / `projectHot` / `openQuestions` are **overwrite** (full-file content); `sessionLog` / `log` are **append** (entry-level idempotency, exact-entry dedup, safe to re-run).
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. **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
- - 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.
74
+ - The session entry is create-only. `sessionLog` and `log` are **append** (entry-level idempotency, exact-entry dedup, safe to re-run). Only `openQuestions` is **overwrite** (full-file content).
75
+ - A summary or track body must not contain a line that is exactly one of this close's own entry markers (an HTML comment of the form `<!-- hypomnema:summary <close id> -->` or `<!-- hypomnema:track <track id> <close id> -->` that carries this close's id). Such a line is refused (`payload-reserved-marker`) before anything is written. Any other text, including other HTML comments, passes.
76
+ - If this close's entry was already committed by an earlier run of the same close, a re-run keeps that entry as it is and reports one line in `notices[]`: `이미 기록된 원본이 있어 이번 payload의 요약은 반영되지 않았습니다`. It means the summary and track text in this payload were not written. Tell the user, and carry whatever still matters into the summary of the next close.
77
+ - If the injected context at session start showed a track head only as a pointer line, this session's close still replaces that head by default. Before you continue that track, read the file the pointer line names: the entry under `projects/<slug>/sessions/` before the vault is migrated, `projects/<slug>/session-state.md` after it is (a baseline head always points there).
78
+ - **Overwrite means overwrite: whatever you did not carry into `openQuestions.content` is gone.** Before composing that 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.
79
+ - Apply enforces the same rule mechanically: if the `openQuestions` payload drops 2 or more of the file's existing `##` sections, apply withholds it (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).
68
80
  - 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.
81
+ - `rootHot` is **gone from this payload.** The root `hot.md` pointer table is regenerated by the SessionStart and Stop hooks, 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.
82
+ - An older copy of this command also sent `sessionState` and `projectHot` instead of `summary` and `tracks`. That form is still accepted so a not-yet-upgraded install keeps closing: `projectHot.content` becomes the summary and `sessionState.content` the body of a track named `legacy`, and `notices[]` carries one line asking for the new form from the next close. Do not write new payloads this way. A payload that carries both forms is refused.
69
83
 
70
- Content guidance for each slot:
71
-
72
- 1. **sessionState** — next tasks list for the upcoming session (what to tackle first next time).
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. **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.
84
+ There is no slot for the project `hot.md`, `session-state.md` or the root `hot.md`. They are generated from the session entries (and the root table from the projects' entries) on every session start and session end, so anything written into them by hand is thrown away.
79
85
 
80
86
  ---
81
87
 
@@ -114,14 +120,14 @@ the apply first files a commit-backed close checkpoint receipt at
114
120
  `HYPO_DIR/.cache/sessions/<id>/close-receipt.json`, then writes the per-session compat marker
115
121
  `HYPO_DIR/.cache/session-closed-<id>.marker` as a projection of it. If the marker does not land,
116
122
  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 `/compact` does. An uncommitted
123
+ receipt behind. The checkpoint gate reads git more narrowly than the PreCompact gate and `--check-session-close` do. An uncommitted
118
124
  file inside the project folder being closed (`projects/<project>/`) blocks whether or not this
119
125
  session has a record of writing it. An uncommitted file at the vault root or in another project's
120
126
  folder blocks only when this session wrote it through Write or Edit since its last auto-commit;
121
127
  otherwise it is reported as a notice. If the record of what this session wrote cannot be read, the
122
128
  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 `/compact` still waits on
124
- someone else's dirty file. The checkpoint receipt is the thing the
129
+ not determine who owns those files. So the checkpoint can land while `--check-session-close` stays red on
130
+ someone else's dirty file. Neither one stops `/compact`: the PreCompact hook only shows a notice. The checkpoint receipt is the thing the
125
131
  Stop-chain Layer 3 hook (`hypo-auto-minimal-crystallize`) actually checks: it proves only that
126
132
  the file versions it names are in a specific commit, never that every change this session made
127
133
  is saved. (It has nothing to do with the `close-receipt-failed` result of `proposal resolve`
@@ -148,7 +154,7 @@ no payload and needs no session id.
148
154
 
149
155
  | Invocation | Behavior |
150
156
  |---|---|
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. |
157
+ | `--apply-session-close` (no `--payload`) | **Probe mode**, writes nothing to the vault. It may delete a close-intent record under `.cache/close-intent/` that is more than 30 minutes old (bookkeeping of a close that never finished, so it stops counting as a torn close). With `--session-id`, exits 0 (`scope: 'session'`) only when THIS session's own close is complete: its committed session entry, found by the close id its close pin names for that project. The entry alone is the proof: the session-log heading and `log.md` line carry no close id, so another close's lines would pass for this one, and a gap there only comes back as a `close-logs-missing` entry in the result's `notices[]`. That notice is per project and date, so another close's lines of the same project that day make it disappear: a quiet result does not show that this close wrote its lines. A close of the same project by another session does not count, and neither does an earlier close of this session while a new close request is open. Without `--session-id` it can only see that the project has today's session entry, session-log entry and `log.md` line, which may be another session's close: it exits 0 with `scope: 'global'` and says so. Otherwise exits 1 with "payload is required". |
152
158
  | `--apply-session-close --payload=<path>` | **Refused**, exit 1, `reason: 'session-id-required'`. Nothing is written and nothing is committed. |
153
159
  | `--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. |
154
160
  | `--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. |
@@ -167,30 +173,33 @@ read the same way (see the `markerSkipReason` branches below).
167
173
 
168
174
  **Two lint gates run automatically, scoped to the files this close writes:**
169
175
 
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).
176
+ Both gates judge only the **payload files**: this close's session entry, the session-log shard (or the monthly file that already holds today's heading), `log.md`, `open-questions.md` when the payload carries it, and a project `index.md` this close creates. The project's generated `session-state.md` and `hot.md` and the root `hot.md` are not payload files, so debt in them never blocks a close. 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).
171
177
 
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.
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).
178
+ 1. **Preflight**: an internal `lint --json` preflight runs **before** any payload bytes are written. Errors in the files this close is about to write (its own session entry, `openQuestions`) are filtered (a retry may replace them). 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.
179
+ 2. **Post-apply**: lint re-runs after the writes and **before** the commit. 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'`. A lint failure stops the close before anything is committed, so the post-apply verification (which reads the commit) never runs next to it.
174
180
 
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).
181
+ > **A close is always the apply above.** Writing the close files with direct Write tool calls is not supported: the session entry, its close id and the receipt only come from apply. `--mark-session-closed --session-id=<id>` is the recovery path for an apply that committed its writes but had its marker step withheld (a `compact-gate-not-ok` or receipt reason that you have since fixed): it certifies that same committed close and clears the Stop-chain block. 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).
182
+ >
183
+ > `--mark-session-closed` files the checkpoint receipt only when this session's own close entry under `projects/<project>/sessions/` of every project it attributes is already **committed**, so commit it first. The receipt names that entry and nothing else. The session-log heading and `log.md` line are no longer part of the proof or the receipt (they carry no close id, so another close of the same day would satisfy them for this one), but the close gate still requires today's session-log heading and `log.md` line for the project, and it accepts anyone's. A project with no session-log heading or `log.md` line at all does not get that far: the close gate above refuses first (`ok: false`, a blocker naming the missing log, no notice in the result), so write the log lines and mark again. A `close-logs-missing` entry in the result's `notices[]` appears only when that gate passes and the lines under the entry's date are still missing; report it. The notice is per project and date, so another close's lines of the same day mask it. The generated `session-state.md` and `hot.md` prove nothing here. "This session's own close entry" is the entry of the close request that is in force: while the user's newest close request has no entry yet, an entry from an earlier close of the same session does not count. In that state the command refuses with `reason: 'incomplete'` and `awaitingApply: true`, and `error` carries `이 세션에 새 close 요청이 열려 있고 아직 이 요청의 원본이 없습니다. close payload로 apply를 먼저 실행하세요`: run `--apply-session-close` with the close payload first, there is nothing to commit by hand. On any failure it prints `ok: false` and exits 1 without a marker. A project mark that lands also records that this close request is resolved, the same record a successful apply writes, so the user's next close request gets a new close id; if that record cannot be written, the receipt and marker are withdrawn and it fails with `close-resolution-failed`. When this session's recorded resolution cannot be verified against the transcript as it reads now (rewritten or shortened), the mark still writes the receipt and marker but leaves that record untouched, and the result carries `resolution: 'left-unverifiable'` with a `resolution_note`: the gate refuses a new close in that session, so close from a new session. A `--log-only` mark proves no project close, so it records no resolution and leaves this session's pending close and its id as they are.
176
184
  >
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.
185
+ > A session that closes several projects in turn proves each project by its own last close. That proof follows the close signal, not the work: if you close P, keep working in P, then close Q, P is still proven by its earlier close and the later work in P belongs to no close. Close P again to cover that work.
178
186
  >
179
187
  > 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
188
  >
181
189
  > | `reason` | What broke | How to recover |
182
190
  > |---|---|---|
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. |
191
+ > | `incomplete` | `incompleteProjects[]` names each project whose session entry for this close is missing or not committed. When `awaitingApply: true` is also present, a new close request is open and no entry was written for it yet, so an earlier close of the session cannot vouch for it. | Commit the close entry of the projects named, or finish writing it, then re-run the same command. With `awaitingApply: true`, run `--apply-session-close` with the close payload (Step 2) first. |
184
192
  > | `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
193
  > | `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
194
  > | `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
195
  > | `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. |
196
+ > | `close-resolution-failed` | The receipt and marker landed, but recording that this close request is resolved (under `.cache/close-gate/`) failed. Both are withdrawn again, because a close left unresolved would hand its close id to the user's next close request; `retractFailed` is present only when withdrawing failed too. | Fix what blocks `.cache/close-gate/` (permissions, disk space), then re-run. The close signal is still open, so no fresh close phrase is needed. |
188
197
  > | `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
198
  > | `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
199
  >
191
200
  > 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
201
  >
193
- > A close is complete once the receipt is issued: `ok: true` from this command, and `close_state: closed` from `--check-session-close --session-id=<id>`. The "Compact-ready" line of `--check-session-close` covers the whole vault, so another session's uncommitted file can keep it red after your close has landed.
202
+ > 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.
194
203
 
195
204
  ---
196
205
 
@@ -201,29 +210,41 @@ The result JSON of `--apply-session-close` includes a `stage` field when `ok: fa
201
210
  | `stage` | What broke | How to recover |
202
211
  |---|---|---|
203
212
  | `session-id-mismatch` | The payload's `sessionId` is a non-empty string that does not equal `--session-id`, so this file was authored for a different session (a reused or overwritten temp path). Caught **before** any write. Absent or `null` `sessionId` fails open (no check); an empty string counts as a mismatch. | Rebuild the payload for THIS session and write it to a session-scoped path (`/tmp/hypo-session-close-<session-id>.json`), then re-run. No payload bytes were written. |
204
- | `pre-apply-verification` | A payload heading does not match the freshness contract the close gate enforces: `sessionLog.entry` has no dated `## [YYYY-MM-DD] …` heading, or an explicit `log.entry` is not the canonical `## [date] session | <project>` line. Caught **before** any write. | Fix the heading in the payload (the session-log entry needs a bracketed dated heading; the log line needs `session \| <project>` after the date, colon or space delimiter), then re-run. No payload bytes were written. |
213
+ | `pre-apply-verification` | A payload heading does not match the freshness contract the close gate enforces: `sessionLog.entry` has no dated `## [YYYY-MM-DD] …` heading, or an explicit `log.entry` is not the canonical `## [date] session | <project>` line. A retry of a close whose session entry was already published on an earlier day (the retry crossed midnight) is held to that day: its logs go under the entry's date whatever `date` says, and the `error` names the entry and the date. Caught **before** any write. | Fix the heading in the payload (for the retry case, date it with the day the `error` names) (the session-log entry needs a bracketed dated heading; the log line needs `session \| <project>` after the date, colon or space delimiter), then re-run. No payload bytes were written. |
214
+ | `track-duplicate` | The same track id appears in `tracks` more than once (typically a normal update plus a separate `done` for one track). Caught **before** any write. | Merge the elements into one per track id, then re-run. No payload bytes were written. |
215
+ | `track-unknown` | A `tracks` element names an id the project has no track for and does not carry `new: true`. The `error` lists the registered ids. Caught **before** any write. | If it is a typo, use the registered id. If it is a new track, add `"new": true`. Re-run. Nothing was written. |
216
+ | `track-exists` | A `tracks` element carries `new: true` for an id the project already has. Caught **before** any write. | Drop `"new": true` to update the track (or pick another id for a new one), then re-run. Nothing was written. |
217
+ | `supersedes-unknown` | A `supersedes` value is not a close id of an update of that track, or is a prefix that matches more than one. The `error` lists the track's updates. Caught **before** any write. | Use a full close id or a prefix unique within that track, or drop `supersedes` to take the default (every head this session was told about), then re-run. Nothing was written. |
218
+ | `payload-reserved-marker` | The summary or a track body holds a line that is exactly an entry marker of this close (the whole line, carrying this close's id). Caught **before** any write. | Change or remove that line, then re-run. Other HTML comments and similar text do not trip it. Nothing was written. |
219
+ | `invalid-entry` | The entry could not be formatted for a reason other than a marker or a duplicate track: a value that does not fit the entry format (for example a malformed session id or project name). The `error` names it. Caught **before** any write. | Fix the value the `error` names, then re-run. Nothing was written. |
220
+ | `entry-project-mismatch` | This close request already published its session entry in another project (or recorded the intent to), and this payload names a different `project`, typically a retry after a failed commit with the project changed. One close request writes one entry in one project. The `error` names the existing entry and its project. Caught **before** any write; `committed: null`. | Re-run with `project` set to the project the `error` names, which finishes that close. Close the other project afterwards, with a new close request. |
221
+ | `entry-conflict` | This close's entry path already holds bytes that this close did not write, or that were edited after the commit. Apply leaves the file alone. The `error` names the path. Caught **before** any write; `committed: null`. | Look at the file and the vault history. Once you have moved or removed the foreign bytes yourself, re-run the same close: the retry needs no fresh close phrase. |
222
+ | `close-pin` | Apply could not record this close's id, or its intent to publish the entry, in `.cache/close-pin/` (usually a permission or disk problem under `.cache/`). Refused before the entry is written, so nothing changed on disk and `committed` is `null`. (For a `.hypoignore` project, recording the entry's local proof comes after the write; the retry recognizes the file as this close's own.) | Fix the problem named in `error`, then re-run the same payload. |
205
223
  | `preflight-lint` | A payload file (append target: session-log / log.md) has a pre-existing blocking lint error. | Fix the lint error in that file, then re-run. No payload bytes were written. (Debt outside the payload files is a non-blocking notice, not this stage.) |
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. |
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.) |
208
- | `post-apply-verification+lint` | Both above. | Fix both; re-run. |
224
+ | `post-apply-verification` | After the commit landed, this close's own state did not read back right: its session entry (found by its close id) is not in HEAD. The `error` lists what is `missing` or `stale`. A session-log heading or `log.md` line that is absent does not fail this stage: it is a `close-logs-missing` entry in the result's `verification.notices[]`, which you report. | Check the entry, the session-log shard and `log.md` in the vault (`git status`, `git log`), then re-run the same close. Writes are idempotent, so re-applying is safe. A lint failure never shows up here: lint runs before the commit, and its failure is reported as `post-apply-lint`. |
225
+ | `vault-operation-in-progress` | The vault repo is in the middle of a merge, rebase, `git am`, cherry-pick or revert, or has unmerged paths (the result's `blocker.state` is `merge`, `rebase`, `am`, `cherry-pick`, `revert` or `unmerged`). A commit cannot land in that state. Caught **before** any write; `committed: null`, exit 1. | Do what `error` says: finish or abort the operation in the vault (`git merge --abort`, `git rebase --abort`, `git am --abort`, and so on), or resolve and stage the conflicted paths. Then re-run the same close. Nothing was written. |
226
+ | `vault-locked` | Another session holds the vault commit lock (its Stop sync is merging or pushing), so the repo state could not be read safely. Caught **before** any write; `committed: null`, exit 1. | Wait a moment and re-run the same close. Do not abort anything in the vault: the operation in flight is healthy. Nothing was written. |
227
+ | `commit-failed` | The commit ran after the writes and failed (`committed: false`; the reason is in `commitFailure` and `markerSkipReason`). Nothing of this close is published, no marker or receipt was written, and exit is 1. The session-log and `log.md` appends were taken back; the session entry stays on disk and is named in `appliedUncommitted[]`. | Fix the git problem the reason names (a rejecting hook, a lock, a repo state), then re-run the same close. The retry finds the entry by its close id, and needs no fresh close phrase. |
228
+ | `post-apply-lint` | The payload introduced an error-level lint blocker in a payload file (malformed body / bad frontmatter), or lint crashed. Nothing was committed. | Fix the offending content in the payload, then re-run. (Broken wikilinks are W4 warnings and are not gated.) |
209
229
  | `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. |
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. |
230
+ | `proposal-pending` (append lock timeout) | An append field (`sessionLog` or `log`) could not take its file lock within the timeout (default 20s), usually a concurrent close holding the same target. The session-log and `log.md` appends are withheld together, whichever of the two locks timed out. 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. If it repeats, wait about 30s and retry: the lock of a close that died is taken over only after 30s. |
211
231
  | `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
232
  | `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
233
  | `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. |
234
+ | `close-resolution-failed` | The commit, the checkpoint receipt and the marker all landed, but recording that this close request is resolved (the file under `.cache/close-gate/`) failed. Left that way, the user's next close request would reuse this close's id and its summary would be lost, so the receipt and marker are withdrawn again and the result is `ok: false`, exit 1, `committed: true`. If withdrawing them failed too, `mismatches[]` carries `{path: '(receipt)', reason: 'retract-failed: …'}`. | Fix what is blocking `.cache/close-gate/` (permissions, a file sitting where the directory should be, disk space), then re-run the same close. **No fresh close phrase is needed**: the signal was not spent, and the retry keeps the entry this close already committed. |
214
235
  | `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. |
215
236
  | `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. |
216
237
 
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.
238
+ 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 entries instead (this close's session entry, the session-log, `log.md`, plus `openQuestions` when the payload carries it). 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.
218
239
 
219
- - ✓ session-state.md applied (or already current, per `skipped`)
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).
240
+ - ✓ session entry `projects/<slug>/sessions/<date>-<close id>.md` created (or already current, per `skipped`; both arrays name it `sessionEntry (<path>)`). A close writes this one new file and replaces nothing: the project's `session-state.md` and `hot.md` are generated views, not part of this apply. The root `hot.md` pointer table is a separate projection a hook rebuilds from the projects' entries. 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).
221
241
  - ✓ session-log entry appended (or already present)
222
242
  - ✓ open-questions applied (or skipped if unchanged)
223
243
  - ✓ log.md entry appended (or already present)
224
- - ✓ post-apply lint clean
225
- - **`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.
226
- - **`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.
244
+ - ✓ 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")
245
+ - **`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.
246
+ - **`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. Two more notices come from the entry itself. One says the payload was in the old `sessionState`/`projectHot` form and was recorded as an entry. The other, `이미 기록된 원본이 있어 이번 payload의 요약은 반영되지 않았습니다`, means this close's entry had already been committed and was kept as it was, so this payload's summary and track text were not written: tell the user, and carry what still matters into the next close's summary.
247
+ - **`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, when it certifies a close whose marker step was withheld.
227
248
  - **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.
228
249
 
229
250
  **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.
@@ -232,7 +253,7 @@ Once `ok: true`, report from the result JSON's `applied` and `skipped` arrays to
232
253
  - **`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."
233
254
  - **`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."
234
255
 
235
- If `markerWritten: true`: ask: "Session closed. Would you like to also run knowledge synthesis now, or stop here?"
256
+ If `markerWritten: true`: ask: "The close checkpoint is recorded. Would you like to also run knowledge synthesis now, or stop here?"
236
257
 
237
258
  **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.
238
259
 
@@ -245,10 +266,10 @@ If `markerWritten: true`: ask: "Session closed. Would you like to also run knowl
245
266
 
246
267
  In all three: do NOT touch the close-signal matcher itself, and do not hand-write the files to work around the refusal.
247
268
 
248
- 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.
269
+ If the apply succeeded but `markerWritten: false`, do NOT say "session closed." Branch on `markerSkipReason`, which carries one of three values here. Four more (`marker-did-not-land`, `receipt-proof-mismatch`, `receipt-write-failed`, `close-resolution-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.
249
270
 
250
- - `compact-gate-not-ok`, `commit-failed: …`: surface the reason verbatim and address it (resolve the compact 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.
251
- **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.
271
+ - `compact-gate-not-ok`: surface the reason verbatim and address it (resolve the gate blocker) 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. **Never commit or revert another session's `projects/<p>/sessions/` entry, nor a session-log shard another session is appending to.** The entry is the only copy of that session's close summary, the appended heading is that session's log line, and that session's own close commits both. Neither blocks this close: a new entry waiting for its commit shows up as a `foreign-entry` notice, and a committed session-log shard whose only change is what other sessions' closes appended after its committed bytes (each close records its append) shows up as a `foreign-append` notice. If one stays uncommitted (that session's commit failed), it stays unsynced until that session runs its close again. A shard whose committed part was changed, one that gained any line no other close recorded (a hand edit, even a dated heading), one with a staged edit or deletion, or one this session itself appended to and has not committed still blocks. **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.
272
+ **A commit failure is `ok: false` with `stage: 'commit-failed'` (see the stage table), not a withheld marker. Re-run the apply, not `--mark-session-closed`.** Nothing of this close is committed yet, so `--mark-session-closed` has nothing to certify. The failed attempt took its session-log and `log.md` appends back, and `appliedUncommitted` lists only what is still on disk (the entry and its index side effects). The retry finds the entry by its close id and appends again, then commits both. An append file that another write changed since then (another session appended to the same shard, say) is left alone and keeps 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.
252
273
  - `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.
253
274
  - `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.
254
275
 
@@ -269,8 +290,6 @@ otherwise omit it.
269
290
 
270
291
  An unrecognized flag exits 2 instead of being ignored.
271
292
 
272
- Its verdict covers the whole vault, 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), not the vault-wide verdict.
273
-
274
293
  Show the output to the user. If no candidates are found, tell them Hypomnema looks well-connected and no crystallization is needed.
275
294
 
276
295
  ---
@@ -341,7 +360,7 @@ Show what was created or modified, and offer to run `/hypo:lint` to verify all n
341
360
  `--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:
342
361
 
343
362
  ```bash
344
- node ${CLAUDE_PLUGIN_ROOT}/scripts/crystallize.mjs --check-session-close
363
+ node ${CLAUDE_PLUGIN_ROOT}/scripts/crystallize.mjs --check-session-close --session-id=<current-session-id>
345
364
  ```
346
365
 
347
366
  Add `--hypo-dir="<path>"` only when the user specified a Hypomnema directory explicitly;
@@ -349,8 +368,10 @@ otherwise omit it.
349
368
 
350
369
  An unrecognized flag exits 2 instead of being ignored.
351
370
 
352
- 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.
371
+ 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.
372
+
373
+ Its required lines are this close's session entry (`projects/<project>/sessions/<date>-<close id>.md`), the session-log shard and `log.md`; the generated `session-state.md` and `hot.md` are not among them. 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.
353
374
 
354
- 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 `/compact` is unblocked globally.
375
+ 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.
355
376
 
356
377
  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.
@@ -66,6 +66,7 @@ For any `✗` failures:
66
66
  For `⚠` warnings:
67
67
  - Missing `hypo-config.md` → run `/hypo:init` — it creates the config marker.
68
68
  - Missing baseline files (`index.md`, `hot.md`, etc.) → run `/hypo:init`.
69
+ - Session entry notes (the vault has not moved to session entries yet, or was rolled back; the generated `hot.md` / `session-state.md` files are tracked again; backups of overwritten generated files are waiting; a project whose entries no machine can see) → report what the detail says. When the vault has a git remote and has not moved, the fix is to upgrade every machine and then run `/hypo:upgrade --migrate-sessions` once; never run it on the user's behalf without that confirmation. Recent traces of a machine on an older version (a close recorded in the session log with no entry for that day) mean that machine needs the upgrade. Entries under `projects/<name>/sessions/` are never deleted to clear a warning.
69
70
  - 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
71
  - 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.
71
72
  - Partial settings.json registrations → run `/hypo:init` to merge missing entries.
@@ -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/commands/init.md CHANGED
@@ -11,7 +11,8 @@ You are running `/hypo:init`. Set up a new personal wiki powered by Hypomnema.
11
11
  ## What this does
12
12
 
13
13
  - Creates the Hypomnema directory structure (`pages/`, `projects/`, `sources/`, etc.)
14
- - Copies baseline template files (`index.md`, `hot.md`, `log.md`, `hypo-config.md`, `SCHEMA.md`, `hypo-guide.md`)
14
+ - Copies baseline template files (`index.md`, `log.md`, `hypo-config.md`, `SCHEMA.md`, `hypo-guide.md`) and generates the root `hot.md` pointer table
15
+ - Writes the vault `.gitignore` so the generated session views (root `hot.md`, and each project's `hot.md` and `session-state.md`) are not committed. A new vault starts on session entries: a session close writes one entry under `projects/<name>/sessions/`, and those three files are built from the entries. `projects/_template` holds only `index.md` and `prd.md`
15
16
  - Installs Claude Code hooks for automatic context injection
16
17
  - Merges hook entries into `~/.claude/settings.json` (idempotent)
17
18
  - Optionally sets up git with a remote
@@ -6,8 +6,7 @@ You are running `/hypo:resume`. Load the session state for an active project and
6
6
 
7
7
  ## What this does
8
8
 
9
- - Reads `projects/<name>/session-state.md` (next tasks)
10
- - Reads `projects/<name>/hot.md` (what was done last session)
9
+ - Renders the next tasks and what was done last session from the session entries in `projects/<name>/sessions/`. A project that has not moved to session entries yet shows its old `session-state.md` and `hot.md` in their place.
11
10
  - Offers to continue from the last stopping point
12
11
 
13
12
  ---
@@ -17,9 +16,11 @@ You are running `/hypo:resume`. Load the session state for an active project and
17
16
  If the user named a project in the command invocation, use that. Otherwise, run:
18
17
 
19
18
  ```bash
20
- node ${CLAUDE_PLUGIN_ROOT}/scripts/resume.mjs [--hypo-dir="<path>"] [--project=<name>]
19
+ node ${CLAUDE_PLUGIN_ROOT}/scripts/resume.mjs [--hypo-dir="<path>"] [--project=<name>] [--session-id=<current-session-id>]
21
20
  ```
22
21
 
22
+ Pass `--session-id` with the main conversation's session id (`$CLAUDE_CODE_SESSION_ID`) when you have it. The script records which task heads it showed, so the next close can replace them. Without an id it still works and records nothing.
23
+
23
24
  Bundled scripts here run via `${CLAUDE_PLUGIN_ROOT}/scripts/`. To resolve that package root: if `${CLAUDE_PLUGIN_ROOT}` is already an absolute path, use it; otherwise read `pkgRoot` from `~/.claude/hypo-pkg.json` (only when non-empty and the target script exists under it); otherwise use the `hypo@hypomnema` (or legacy `hypomnema@hypomnema`) installPath in `~/.claude/plugins/installed_plugins.json`; if none resolve, stop and tell the user to run `hypomnema upgrade --apply` (or `/hypo:upgrade` on a plugin install) or reinstall instead of guessing the cache layout.
24
25
 
25
26
  When `--project` is omitted, the script prefers the project whose `working_dir` contains the current directory (cwd-first); if nothing under the current directory matches, it falls back to the most recently active project from `hot.md`.
@@ -31,8 +32,8 @@ When `--project` is omitted, the script prefers the project whose `working_dir`
31
32
  Show the output from the script:
32
33
  - **`[WIKI: ...]` notice**, if present, first and verbatim. It names uncommitted changes elsewhere in the vault (another project, or unattributable to any project) that are not this session's scope. Do not fold this line into the project summary or fetch/edit the paths it names without the user's explicit instruction.
33
34
  - **Project** name
34
- - **Next tasks** from `session-state.md`
35
- - **Background** from `hot.md` (what was done last session, condensed)
35
+ - **Next up**: the list of active tasks, then the rendered session state with each task's text
36
+ - **Background**: the recent session summaries
36
37
 
37
38
  ---
38
39
 
@@ -48,4 +49,4 @@ If the session-state lists numbered tasks, offer them as options. Start immediat
48
49
 
49
50
  ## Step 4 — On completion of a task
50
51
 
51
- When a task is marked done, offer to update `session-state.md` to reflect the new state before closing.
52
+ When a task is marked done, mention it in the close payload's `tracks` when closing. `session-state.md` and `hot.md` are generated from the session entries, so do not edit them by hand: the close guard refuses the write.
@@ -4,12 +4,16 @@ description: Check for Hypomnema updates and optionally apply them. Use when the
4
4
 
5
5
  You are running `/hypo:upgrade`. Check if the installed Hypomnema wiki is out of date and offer to apply updates.
6
6
 
7
+ **If the arguments contain `--migrate-sessions`**, resolve the package root (Step 1), then skip Steps 2 to 5 and go straight to the `--migrate-sessions` section at the end of this file.
8
+
7
9
  ## What this checks
8
10
 
9
11
  - **SCHEMA version**: compares `~/hypomnema/SCHEMA.md` version against the package's current version
10
12
  - **Hook files**: checks if any hooks in `~/.claude/hooks/` are stale or missing
11
13
  - **settings.json**: checks if all hook registrations are present in `~/.claude/settings.json`
12
14
 
15
+ A vault with no git remote moves its session state into per-session entries (`projects/<p>/sessions/`) by itself at the next SessionStart. A vault with a remote is never moved from a hook; SessionStart only tells you it has not moved.
16
+
13
17
  ---
14
18
 
15
19
  ## Step 1 — Run script
@@ -63,3 +67,29 @@ If there is anything to update, ask the user:
63
67
  ## Step 5 — Post-apply
64
68
 
65
69
  After applying, recommend running `/hypo:doctor` to verify the installation is healthy.
70
+
71
+ ---
72
+
73
+ ## `--migrate-sessions` (a vault with a git remote)
74
+
75
+ ```bash
76
+ node ${CLAUDE_PLUGIN_ROOT}/scripts/upgrade.mjs [--hypo-dir="<path>"] --migrate-sessions
77
+ ```
78
+
79
+ This moves the vault from the tracked `hot.md` and `session-state.md` files to per-session entries under `projects/<p>/sessions/`. A vault with a remote does not move by itself: it waits for this command, which commits the move and pushes it right away.
80
+
81
+ **Upgrade all machines first.** Every machine that uses the vault must run this Hypomnema version before you run the command. A machine that skipped the upgrade, once it pulls the move commit:
82
+
83
+ 1. loses the session view in what it injects at session start,
84
+ 2. stalls its whole sync if it holds an unpushed commit that edits `hot.md` or `session-state.md`, and
85
+ 3. if it uses the plugin channel, is not refreshed on its own (run `/plugin marketplace update hypomnema`, then `/reload-plugins`).
86
+
87
+ **When to ask.** If the user typed `/hypo:upgrade --migrate-sessions` directly, run it without asking again: typing the flag is their statement that all machines are upgraded. Only when you are the one proposing this flag, or about to run it during another flow, first ask the user whether every machine that uses the same vault has been upgraded, because the command pushes the migration commit at once.
88
+
89
+ What it does, in order: fetch; if another machine already moved the vault, take in that commit without migrating (this machine never migrates a remote that is already moved), then pull and push as the last step does; otherwise merge the remote history first so the move is built on the newest commit; migrate (also clearing a rollback marker); pull and push. It brings an older edition of the `.gitignore` / `.gitattributes` block up to the current one. If taking in a moved remote leaves generated views tracked here (an old machine's unpushed work came along), it stops before pushing, names the paths and the `git rm --cached` repair, and exits 1.
90
+
91
+ During this one-time merge, a page that one machine renamed and edited while another machine edited it too ends as a modify/delete conflict, and the sync aborts so you can merge that page by hand.
92
+
93
+ Show the output verbatim. Exit code 1 means a step stopped and printed why (a deferred pull, a lock held by another session, a rejected push). Fix the cause and run the same command again: a repeat after a failed push adds no new commit and only pushes.
94
+
95
+ While machines are catching up, a plain `git pull` or Obsidian Git can hit conflicts that Hypomnema's own sync resolves. Tell the user this once after the command succeeds.