hypomnema 1.4.1 → 1.4.2

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.
@@ -11,7 +11,7 @@
11
11
  "name": "hypo",
12
12
  "source": "./",
13
13
  "description": "LLM-native personal wiki — session-aware knowledge base for Claude Code",
14
- "version": "1.4.1",
14
+ "version": "1.4.2",
15
15
  "homepage": "https://github.com/sk-lim19f/Hypomnema"
16
16
  }
17
17
  ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypo",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
4
  "description": "LLM-native personal wiki system — session-aware knowledge base for Claude Code",
5
5
  "author": {
6
6
  "name": "sk-lim19f",
package/README.ko.md CHANGED
@@ -25,7 +25,7 @@ Andrej Karpathy의 "LLM 네이티브 위키" 스케치에서 출발했습니다.
25
25
 
26
26
  ### 지금 어디까지 자동인가
27
27
 
28
- 현재 릴리스는 v1.4.1입니다. 위키 작업(자료 정리·검색·세션 마무리)은 아직 `/hypo:*` 명령어를 직접 입력해 시작합니다. v2의 목표는 Claude가 시키지 않아도 위키를 읽고 쓰고 합성하는 완전 자율 동작이고, 지금은 그쪽으로 가는 중입니다. v1.1.0이 위키가 한 세션에서 얼마나 쓰였는지 재는 관측성 점수를 넣었습니다. v1.2.0은 자동으로 동작하는 영역 네 가지를 추가했습니다.
28
+ 현재 릴리스는 v1.4.2이고, 세션 마무리 경로를 견고하게 다듬었습니다: 마무리가 올바른 프로젝트에 기록되고, 미등록 태그나 수기 `log.md` 엔트리가 끝난 마무리를 더는 잘못 막지 않으며, 안내하는 복구 명령이 플러그인 설치에서도 그대로 실행됩니다. 위키 작업(자료 정리·검색·세션 마무리)은 아직 `/hypo:*` 명령어를 직접 입력해 시작합니다. v2의 목표는 Claude가 시키지 않아도 위키를 읽고 쓰고 합성하는 완전 자율 동작이고, 지금은 그쪽으로 가는 중입니다. v1.1.0이 위키가 한 세션에서 얼마나 쓰였는지 재는 관측성 점수를 넣었습니다. v1.2.0은 자동으로 동작하는 영역 네 가지를 추가했습니다.
29
29
 
30
30
  - 피드백 한 곳만 고치면 나머지는 따라옵니다. `pages/feedback/`에 한 번 적으면 위키가 `MEMORY.md`와 `~/.claude/CLAUDE.md`의 `<learned_behaviors>` 블록을 자동으로 갱신합니다(단방향 projection).
31
31
  - 확장 파일도 함께 동기화. 위키 안 `~/hypomnema/extensions/{agents,commands,hooks,skills}/`에 둔 파일을 `~/.claude/`에 자동 반영합니다. `--codex`를 붙이면 `hooks`·`commands`는 `~/.codex/`에도 반영하지만, `agents`와 `skills`는 Claude 전용이라 건너뜁니다.
package/README.md CHANGED
@@ -25,7 +25,7 @@ New to the terms below? Keep the [Term decoder](#term-decoder) open in another t
25
25
 
26
26
  ### Where automation stands today
27
27
 
28
- The current release is v1.4.1. Wiki work (ingest, query, session-close) still runs on explicit `/hypo:*` commands. The v2 goal is full autonomy: Claude reading, writing, and synthesizing the wiki without being asked, which is the direction this is heading. v1.1.0 shipped the observability score that measures how often the wiki is actually used per session. v1.2.0 added four autonomous lanes on top:
28
+ The current release is v1.4.2, which hardened the session-close path: a close is attributed to the right project, an unregistered tag or a hand-written `log.md` entry no longer false-blocks it, and the recovery command it prints is runnable in a plugin install. Wiki work (ingest, query, session-close) still runs on explicit `/hypo:*` commands. The v2 goal is full autonomy: Claude reading, writing, and synthesizing the wiki without being asked, which is the direction this is heading. v1.1.0 shipped the observability score that measures how often the wiki is actually used per session. v1.2.0 added four autonomous lanes on top:
29
29
 
30
30
  - Edit feedback in one place and the rest follows. `pages/feedback/` is the single source of truth for behavior corrections, and Hypomnema derives `MEMORY.md` and the `<learned_behaviors>` block inside `~/.claude/CLAUDE.md` from it automatically.
31
31
  - Extensions sync alongside. Anything under `~/hypomnema/extensions/{agents,commands,hooks,skills}/` is mirrored into `~/.claude/` automatically. The `--codex` flag also mirrors `hooks` and `commands` into `~/.codex/`; `agents` and `skills` are Claude-only and skipped on purpose.
@@ -32,7 +32,7 @@ These are judgment calls; when uncertain, surface the question rather than skip
32
32
 
33
33
  The session-close path is **payload-driven**. Instead of writing the 5 mandatory files one-by-one, you compose a single JSON payload that describes the full session-close state, then hand it to `crystallize.mjs --apply-session-close`, which performs idempotent atomic writes and gates the result with lint.
34
34
 
35
- Payload shape (5 required + 1 conditional, per Spec §5.2.7 / §8.3 + ADR 0029):
35
+ Payload shape (`project` + 5 content fields required, 1 conditional, per Spec §5.2.7 / §8.3 + ADR 0029):
36
36
 
37
37
  ```json
38
38
  {
@@ -51,7 +51,7 @@ Payload shape (5 required + 1 conditional, per Spec §5.2.7 / §8.3 + ADR 0029):
51
51
 
52
52
  Field rules:
53
53
 
54
- - `project` optional. Falls back to the active project from root `hot.md` pointer table.
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.
55
55
  - `date` — optional. Defaults to today (local). Must be `YYYY-MM-DD` if supplied.
56
56
  - `openQuestions` — optional. Include only when `pages/open-questions.md` exists and changed this session.
57
57
  - All other top-level fields are required.
@@ -234,3 +234,5 @@ node ${CLAUDE_PLUGIN_ROOT}/scripts/crystallize.mjs --check-session-close [--hypo
234
234
  ```
235
235
 
236
236
  It reports any file as `missing` or `stale`. For an actual close, prefer `--apply-session-close --payload=<path>` (Step 3) — it bundles freshness + lint into one gate and is the documented dogfood path. (`parseArgs` only accepts the `--payload=<path>` spelling; a space-separated `--payload <path>` is silently ignored and triggers "payload is required".)
237
+
238
+ 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. The marker writer (`--mark-session-closed --project=<slug>`) keeps a **global** gate; there `--project` only sets the marker's attribution slug.
@@ -217,13 +217,20 @@ Before opening:
217
217
  4. If you changed a template or `init` flow, a fresh `init` produces the expected vault structure.
218
218
  5. README / ARCHITECTURE / docs are updated to match.
219
219
 
220
- Open the PR against `main`. Include:
220
+ Open the PR against `main`. The body is **bilingual**: write the full description twice, once under a `# English` heading and once under a `# 한국어` heading, each carrying the same sections. The `## Changelog` and `## Checklist` blocks are language-neutral, so they appear once, after both language blocks. Do **not** add any tool-attribution footer (no "Generated with ..." line) to the body.
221
+
222
+ Each language block includes:
223
+
224
+ - **What changed** / **변경 내용**: short summary
225
+ - **Why** / **이유**: the user-visible motivation
226
+ - **How** / **방법**: approach notes that aren't obvious from the diff
227
+ - **Manual verification** / **수동 검증**: anything the test suite cannot cover
228
+ - **Migration notes** / **마이그레이션 노트**: if upgrading existing installs needs special handling (else "None")
229
+
230
+ After both blocks, language-neutral:
221
231
 
222
- - **What changed** — short summary
223
- - **Why** — the user-visible motivation
224
- - **Manual verification steps** — anything the test suite cannot cover
225
232
  - **Changelog**: one English line plus one Korean line if the change is user-visible (see CHANGELOG conventions below)
226
- - **Migration notes** if upgrading existing installs needs special handling
233
+ - **Checklist**: the boxes from the PR template
227
234
 
228
235
  ---
229
236
 
@@ -59,21 +59,33 @@ function emitContinue() {
59
59
  }
60
60
 
61
61
  function emitBlock(sessionId, transcriptPath, gate = null) {
62
- // One-line, skill-first. /hypo:crystallize is the documented session-close
63
- // alias; passing --session-id there writes the per-session marker that clears
64
- // this block. CLI fallback + bypass live in commands/crystallize.md, not here
65
- // keep the Stop reason terse so the actionable instruction stands out.
66
- // Surface the transcript path so the close can pass --transcript-path=<path>,
67
- // which scopes the marker's lint gate to this session's own files (Bug A
68
- // coherence: a marker written without lint would only let Stop pass for
69
- // /compact to immediately re-block on the same errors).
70
- const transcriptHint = transcriptPath ? ` --transcript-path=${transcriptPath}` : '';
71
- const markCmd = `crystallize --mark-session-closed --session-id=${sessionId}${transcriptHint}`;
62
+ // One-line recovery action. The gate-precise branches below name an EXACT
63
+ // command so "only the marker is missing" stays a one-shot fix — but that
64
+ // command must be a runnable `node <pkg>/scripts/crystallize.mjs` invocation,
65
+ // never a bare `crystallize` (a package.json `bin` that is NOT on PATH in a
66
+ // plugin install `command not found`). When PKG_ROOT is
67
+ // unresolved we cannot build that path, so fall back to the /hypo:crystallize
68
+ // skill, which resolves its own package root (the same alias the generic
69
+ // branch uses). Passing --session-id writes the per-session marker that clears
70
+ // this block. Surface the transcript path so the close can pass
71
+ // --transcript-path=<path>, which scopes the marker's lint gate to this
72
+ // session's own files (Bug A coherence: a marker written without lint would
73
+ // only let Stop pass for /compact to immediately re-block on the same errors).
74
+ // Quote the paths so the printed command stays copy-paste runnable even when
75
+ // an install/transcript path contains spaces (display text only — never exec'd
76
+ // here).
77
+ const transcriptHint = transcriptPath ? ` --transcript-path="${transcriptPath}"` : '';
78
+ const cliBase = PKG_ROOT ? `node "${join(PKG_ROOT, 'scripts', 'crystallize.mjs')}"` : null;
79
+ const markCmd = cliBase
80
+ ? `${cliBase} --mark-session-closed --session-id=${sessionId}${transcriptHint}`
81
+ : `/hypo:crystallize (session_id=${sessionId}${transcriptHint})`;
72
82
  // The log-only escape hatch for a non-project (wiki/tooling-only)
73
83
  // session. Offered ONLY as an explicit alternative when a close blocker is
74
84
  // present — never as the default recovery, so a real project session is not
75
85
  // taught to bypass the ADR 0043 close invariant (codex design Finding 3).
76
- const logOnlyCmd = `crystallize --mark-session-closed --log-only --session-id=${sessionId}${transcriptHint}`;
86
+ const logOnlyCmd = cliBase
87
+ ? `${cliBase} --mark-session-closed --log-only --session-id=${sessionId}${transcriptHint}`
88
+ : `/hypo:crystallize --log-only (session_id=${sessionId}${transcriptHint})`;
77
89
  // ADR 0047: refine the message with the read-only /compact gate result.
78
90
  // - gate green → the close is compact-ready and ONLY the marker is missing
79
91
  // (the hand-edit close case: files Written + committed directly, bypassing
@@ -10,6 +10,7 @@ import {
10
10
  readFileSync,
11
11
  writeFileSync,
12
12
  existsSync,
13
+ statSync,
13
14
  appendFileSync,
14
15
  mkdirSync,
15
16
  rmSync,
@@ -607,7 +608,15 @@ function closeCandidateSlugs(hypoDir, dates) {
607
608
  }
608
609
  for (const d of dates) {
609
610
  const re = new RegExp('^## \\[' + escapeRegExp(d) + '\\] session \\| (\\S+)', 'gm');
610
- for (const m of content.matchAll(re)) slugs.add(m[1]);
611
+ for (const m of content.matchAll(re)) {
612
+ // B-1: only real projects are close candidates. A malformed log heading
613
+ // (`## [d] session | hypomnema:`) or a stale slug yields a ghost token
614
+ // that no longer maps to a `projects/<slug>/` directory — gating on disk
615
+ // keeps it out of the dangling-close set. Directory (not bare-exists)
616
+ // mirrors the apply-path project check (crystallize.mjs:193).
617
+ const dir = join(projectsDir, m[1]);
618
+ if (existsSync(dir) && statSync(dir).isDirectory()) slugs.add(m[1]);
619
+ }
611
620
  }
612
621
  }
613
622
  return slugs;
@@ -675,7 +684,29 @@ function hasTodayCloseActivity(hypoDir, project, dates) {
675
684
  * dates: string[], fallback: boolean, primary: string|null,
676
685
  * project: string|null, stale: string[], missing: string[]}}
677
686
  */
678
- export function sessionCloseGlobalStatus(hypoDir) {
687
+ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
688
+ // projectOverride (check-only): the caller (`crystallize --check-session-close
689
+ // --project=<slug>`) wants THIS project's close status, not the recency pick —
690
+ // bypass discovery and report the single project, preserving the global return
691
+ // shape. NEVER threaded from a marker-writing path (--mark / apply auto-marker /
692
+ // PreCompact): those stay global so the marker == compact-ready invariant holds
693
+ // (ADR 0047, codex design review). A green status here is a project-scoped
694
+ // diagnostic, not the global compact-readiness verdict.
695
+ if (opts.projectOverride) {
696
+ const s = sessionCloseFileStatus(hypoDir, { projectOverride: opts.projectOverride });
697
+ return {
698
+ ok: s.ok,
699
+ projects: s.project
700
+ ? [{ project: s.project, ok: s.ok, stale: s.stale, missing: s.missing }]
701
+ : [],
702
+ dates: freshDates(),
703
+ fallback: false,
704
+ primary: s.project,
705
+ project: s.project,
706
+ stale: s.stale,
707
+ missing: s.missing,
708
+ };
709
+ }
679
710
  const dates = freshDates();
680
711
  const recency = resolveActiveProject(hypoDir); // no cwd — close never picks by cwd
681
712
  const todayActive = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
@@ -748,6 +779,21 @@ function deriveLogTitle(tail) {
748
779
  return t.replace(/^\s*[—:-]\s*/, '').trim(); // drop a leading separator
749
780
  }
750
781
 
782
+ /**
783
+ * Build the canonical root log.md entry for ONE session-log heading. Single
784
+ * source of truth for the derived-entry format, shared by the global Stop-hook
785
+ * derive (deriveRootLogEntries) and apply's direct per-close write (B-1), so the
786
+ * two paths never drift on the `→ [[projects/<slug>/hot]]` pointer or spacing.
787
+ * `headingTail` is the text AFTER `## [date]` in the authored session-log heading.
788
+ * @returns {{ heading: string, block: string }} heading = the `## [date] session
789
+ * | <slug>` line used for exact-line dedup; block = the full two-line entry.
790
+ */
791
+ export function rootLogEntry(slug, date, headingTail) {
792
+ const title = deriveLogTitle(headingTail);
793
+ const heading = `## [${date}] session | ${slug}` + (title ? ` — ${title}` : '');
794
+ return { heading, block: `${heading}\n→ [[projects/${slug}/hot]]` };
795
+ }
796
+
751
797
  /**
752
798
  * Append any missing root log.md `## [date] session | <slug>` entries derived from
753
799
  * each today-active project's session-log heading(s). Idempotent: dedups on the
@@ -811,11 +857,10 @@ export function deriveRootLogEntries(hypoDir) {
811
857
  const headingRe = new RegExp('^#{1,6} \\[' + escapeRegExp(date) + '\\]\\s*(.*)$', 'gm');
812
858
  let m;
813
859
  while ((m = headingRe.exec(slog)) !== null) {
814
- const title = deriveLogTitle(m[1]);
815
- const heading = `## [${date}] session | ${slug}` + (title ? ` — ${title}` : '');
860
+ const { heading, block } = rootLogEntry(slug, date, m[1]);
816
861
  if (seenHeadings.has(heading)) continue; // exact-line dedup (log.md + queued)
817
862
  seenHeadings.add(heading);
818
- additions.push(`${heading}\n→ [[projects/${slug}/hot]]`);
863
+ additions.push(block);
819
864
  }
820
865
  }
821
866
  }
@@ -1560,23 +1605,40 @@ export function closeFileTargets(hypoDir) {
1560
1605
  * evidence file for EVERY freshDate (not just dates[0]) so the lint scope matches
1561
1606
  * what sessionCloseFileStatus actually checks across a local/UTC date boundary.
1562
1607
  */
1608
+ // The lint-scope target set for ONE project's close: the shared root files
1609
+ // (hot.md / log.md) plus that project's mandatory close files — session-state,
1610
+ // project hot, and each fresh date's session-log evidence file. Used both by
1611
+ // `--check-session-close --project=<slug>` (a project-scoped diagnostic — see
1612
+ // precompactGateStatus opts.projectOverride) and as the per-project building
1613
+ // block of closeFileTargetsGlobal, so the two scopes stay identical per project.
1614
+ export function closeFileTargetsForProject(hypoDir, slug) {
1615
+ const dates = freshDates();
1616
+ const out = new Set(['hot.md', 'log.md']);
1617
+ out.add(`projects/${slug}/session-state.md`);
1618
+ out.add(`projects/${slug}/hot.md`);
1619
+ // Scope to the file each date's freshness is PROVEN by (daily shard or, via
1620
+ // fallback, the legacy monthly), so a corrupt evidence file can't pass the
1621
+ // gate while its lint error is demoted to an out-of-scope notice.
1622
+ for (const d of dates) out.add(sessionLogScopePath(hypoDir, slug, d));
1623
+ return out;
1624
+ }
1625
+
1563
1626
  export function closeFileTargetsGlobal(hypoDir) {
1564
1627
  const dates = freshDates();
1565
1628
  const out = new Set(['hot.md', 'log.md']);
1566
1629
  let active = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
1567
1630
  hasTodayCloseActivity(hypoDir, p, dates),
1568
1631
  );
1632
+ // No project closed today → fall back to the recency project (mirrors
1633
+ // sessionCloseGlobalStatus's own fallback at the top of this file), so the lint
1634
+ // scope never narrows below the close-status scope. Dropping this (root-only
1635
+ // when active=[]) would re-open the very gap closeFileTargetsForProject closes.
1569
1636
  if (active.length === 0) {
1570
1637
  const recency = resolveActiveProject(hypoDir);
1571
1638
  if (recency) active = [recency];
1572
1639
  }
1573
1640
  for (const p of active) {
1574
- out.add(`projects/${p}/session-state.md`);
1575
- out.add(`projects/${p}/hot.md`);
1576
- // Scope to the file each date's freshness is PROVEN by (daily shard or, via
1577
- // fallback, the legacy monthly), so a corrupt evidence file can't pass the
1578
- // gate while its lint error is demoted to an out-of-scope notice.
1579
- for (const d of dates) out.add(sessionLogScopePath(hypoDir, p, d));
1641
+ for (const f of closeFileTargetsForProject(hypoDir, p)) out.add(f);
1580
1642
  }
1581
1643
  return out;
1582
1644
  }
@@ -1629,8 +1691,18 @@ export function partitionLintScope(findings, scope) {
1629
1691
  * opts.transcriptPath to widen it to the session's edited files exactly as the
1630
1692
  * hook does.
1631
1693
  *
1694
+ * opts.projectOverride (CHECK-ONLY) narrows BOTH the close status and the lint
1695
+ * scope to a single project, for `--check-session-close --project=<slug>`. A
1696
+ * green result is then a project-scoped diagnostic, NOT the global compact-ready
1697
+ * verdict (ADR 0046) — the caller must surface the scope. It is NEVER passed from
1698
+ * a marker-writing path (--mark / apply auto-marker / PreCompact); those stay
1699
+ * global so a marker can't attest compact-ready while PreCompact re-checks red
1700
+ * (the marker == compact-ready invariant, ADR 0047 / codex design review). When a
1701
+ * log-only marker governs the session, log-only mode wins and projectOverride is
1702
+ * ignored.
1703
+ *
1632
1704
  * @param {string} hypoDir
1633
- * @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string}} [opts]
1705
+ * @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string, projectOverride?: string|null}} [opts]
1634
1706
  * @returns {{ok: boolean, close: object, blockers: {type:string,reason:string}[], notices: {type:string,reason:string}[], driftTargets: string[], skipped: {lint:boolean, feedback:boolean}}}
1635
1707
  */
1636
1708
  export function precompactGateStatus(hypoDir, opts = {}) {
@@ -1693,7 +1765,9 @@ export function precompactGateStatus(hypoDir, opts = {}) {
1693
1765
  });
1694
1766
  }
1695
1767
  } else {
1696
- close = sessionCloseGlobalStatus(hypoDir);
1768
+ // projectOverride narrows the close status to one project (check-only); a
1769
+ // marker-writing caller never sets it, so the marker path stays global.
1770
+ close = sessionCloseGlobalStatus(hypoDir, { projectOverride: opts.projectOverride });
1697
1771
  if (!close.ok) {
1698
1772
  blockers.push({
1699
1773
  type: 'close',
@@ -1744,8 +1818,16 @@ export function precompactGateStatus(hypoDir, opts = {}) {
1744
1818
  // mandatory files in and re-introduce the cross-project attribution. The
1745
1819
  // session's own transcript-touched files are still added below (a log-only
1746
1820
  // session is accountable for the wiki files it actually edited).
1821
+ // Lint scope: explicit opts.lintScope wins; else log-only uses the shared
1822
+ // root files only; else projectOverride narrows to that one project's close
1823
+ // files (matching the narrowed close status above); else the global set.
1747
1824
  const scope = new Set(
1748
- opts.lintScope || (logOnly ? ['hot.md', 'log.md'] : closeFileTargetsGlobal(hypoDir)),
1825
+ opts.lintScope ||
1826
+ (logOnly
1827
+ ? ['hot.md', 'log.md']
1828
+ : opts.projectOverride
1829
+ ? closeFileTargetsForProject(hypoDir, opts.projectOverride)
1830
+ : closeFileTargetsGlobal(hypoDir)),
1749
1831
  );
1750
1832
  if (opts.transcriptPath && existsSync(opts.transcriptPath)) {
1751
1833
  for (const f of extractTouchedWikiFiles(opts.transcriptPath, hypoDir)) scope.add(f);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -13,6 +13,11 @@
13
13
  * --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
14
14
  * --min-group=<n> Min pages per tag group to report (default: 2)
15
15
  * --check-session-close Verify the strict session-close memory files — 5 mandatory + open-questions conditional
16
+ * --project=<slug> Override the recency-inferred project on --check / --mark (single segment
17
+ * [A-Za-z0-9._-]+, projects/<slug>/ must exist). On --check it NARROWS the
18
+ * gate to that one project — a project-scoped diagnostic, NOT a global
19
+ * compact-ready verdict. On --mark it is ATTRIBUTION only; the gate stays
20
+ * global (the marker == compact-ready invariant). Ignored on --apply.
16
21
  * --apply-session-close Apply a JSON payload that updates the 5 mandatory memory files
17
22
  * (+ optional open-questions). Idempotent — re-running with the same
18
23
  * payload is a no-op. Always finishes with the strict gate check.
@@ -31,13 +36,13 @@
31
36
  *
32
37
  * Payload schema:
33
38
  * {
34
- * "project": "<slug>", // optionaldefaults to resolveActiveProject()
39
+ * "project": "<slug>", // REQUIREDsingle segment [A-Za-z0-9._-]+ (≥1 alnum, not dot-only), projects/<slug>/ dir must exist (B-3: no recency fallback for apply)
35
40
  * "date": "YYYY-MM-DD", // optional — defaults to today (local)
36
41
  * "sessionState": { "content": "<full file>" }, // overwrite (idempotent: identical bytes → skip)
37
42
  * "projectHot": { "content": "<full file>" }, // overwrite
38
43
  * "rootHot": { "content": "<full file>" }, // overwrite
39
44
  * "sessionLog": { "entry": "## [date] ..." }, // append, skip if heading already present
40
- * "log": { "entry": "## [date] session | <project> ..." }, // append, skip if entry present
45
+ * "log": { "entry": "## [date] session | <project> ..." }, // OPTIONAL (B-1): omit it and apply derives the root log.md entry from this close's sessionLog heading; supply it only for a deliberately custom log line
41
46
  * "openQuestions":{ "content": "<full file>" } // optional overwrite
42
47
  * }
43
48
  *
@@ -60,7 +65,7 @@
60
65
  * hard-fails regardless of scope.
61
66
  */
62
67
 
63
- import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs';
68
+ import { existsSync, statSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs';
64
69
  import { join, dirname } from 'path';
65
70
  import { hostname } from 'os';
66
71
  import { spawnSync } from 'child_process';
@@ -68,6 +73,8 @@ import { fileURLToPath } from 'url';
68
73
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
69
74
  import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
70
75
  import { collectPagesCrystallize, extractWikilinks } from './lib/wikilink.mjs';
76
+ import { isValidProjectName } from './lib/project-create.mjs';
77
+ import { appendPendingTags, checkForbidden } from './lib/schema-vocab.mjs';
71
78
  import {
72
79
  sessionCloseFileStatus,
73
80
  sessionCloseGlobalStatus,
@@ -79,12 +86,17 @@ import {
79
86
  sessionLogShardPath,
80
87
  sessionLogReadCandidates,
81
88
  sessionLogScopePath,
89
+ rootLogEntry,
82
90
  resolveTranscriptBySessionId,
83
91
  hasUserCloseSignal,
84
92
  commitWikiChanges,
85
93
  } from '../hooks/hypo-shared.mjs';
86
94
 
87
- const LINT_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'lint.mjs');
95
+ // This script's own absolute path. Used to print copy-pasteable recovery
96
+ // commands as `node <SELF_SCRIPT> ...` rather than a bare `crystallize` bin,
97
+ // which is not on PATH in a Claude Code plugin install (only in an npm global).
98
+ const SELF_SCRIPT = fileURLToPath(import.meta.url);
99
+ const LINT_SCRIPT = join(dirname(SELF_SCRIPT), 'lint.mjs');
88
100
 
89
101
  // Spawn lint.mjs --json against `hypoDir` and return parsed result.
90
102
  // We shell out instead of refactoring lint.mjs into a library because lint.mjs
@@ -133,6 +145,7 @@ function parseArgs(argv) {
133
145
  payload: null,
134
146
  force: false,
135
147
  transcriptPath: null,
148
+ project: null,
136
149
  };
137
150
  for (const arg of argv.slice(2)) {
138
151
  if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
@@ -144,10 +157,23 @@ function parseArgs(argv) {
144
157
  else if (arg.startsWith('--session-id=')) args.sessionId = arg.slice(13);
145
158
  else if (arg.startsWith('--payload=')) args.payload = arg.slice(10);
146
159
  else if (arg.startsWith('--transcript-path=')) args.transcriptPath = expandHome(arg.slice(18));
160
+ else if (arg.startsWith('--project=')) args.project = arg.slice(10);
147
161
  else if (arg === '--force') args.force = true;
148
162
  else if (arg === '--json') args.json = true;
149
163
  }
150
164
  if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
165
+ // --project=<slug> override (check/mark only). Validate the SYNTAX here so a
166
+ // traversal/charset attack (`--project=../x`) is rejected before any path is
167
+ // built from it — sessionCloseFileStatus(projectOverride) joins it directly.
168
+ // isValidProjectName is the SHARED validator (project-create.mjs), so the
169
+ // override accepts exactly the namespace createProject can scaffold. Existence
170
+ // (a real projects/<slug>/ directory) is checked in the run functions, where
171
+ // hypoDir is resolved and only the check/mark paths consume --project.
172
+ if (args.project != null && !isValidProjectName(args.project)) {
173
+ const msg = `--project "${args.project}" is not a valid project name (need a single segment with ≥1 alnum, charset A-Za-z0-9._-, not "."/"..")`;
174
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
175
+ process.exit(1);
176
+ }
151
177
  return args;
152
178
  }
153
179
 
@@ -163,6 +189,20 @@ function parseArgs(argv) {
163
189
  // closed.) `--transcript-path` survives ONLY for `--check-session-close`'s lint
164
190
  // scope, which writes no marker and so cannot cause an over-close.
165
191
 
192
+ // Validate that an explicit --project=<slug> override names a real project
193
+ // DIRECTORY. Syntax was already checked in parseArgs; this is the existence half,
194
+ // mirroring apply's payload.project check — a regular file or an absent dir at
195
+ // projects/<slug> is a hard error so the override never silently resolves to an
196
+ // all-missing status (which a reader would misread as "exists but incomplete").
197
+ function requireProjectDir(args, slug) {
198
+ const projectDir = join(args.hypoDir, 'projects', slug);
199
+ if (!existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
200
+ const msg = `--project "${slug}" does not exist as a directory (no projects/${slug}/ directory)`;
201
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
202
+ process.exit(1);
203
+ }
204
+ }
205
+
166
206
  // ── session-close check (spec §5.2.7 / §8.3) ────────────────────────
167
207
  // Mirrors the hard gate in hypo-personal-check.mjs so the /hypo:crystallize
168
208
  // flow can self-verify before /compact triggers PreCompact.
@@ -179,8 +219,21 @@ function runSessionCloseCheck(args) {
179
219
  // (marker_present:true) while `ok` still reflected the stale active project —
180
220
  // the completion-signal trio (PreCompact / --check / marker) would diverge
181
221
  // (codex design Finding 2).
222
+ //
223
+ // --project=<slug> narrows BOTH the close status and the lint scope to that one
224
+ // project: a project-scoped DIAGNOSTIC, NOT the global compact-ready verdict
225
+ // (ADR 0046 caveat below). It is check-only — the marker writers stay global so
226
+ // the marker == compact-ready invariant holds (ADR 0047). When narrowed, the
227
+ // transcript widening is suppressed: a transcript touch in some OTHER project
228
+ // would re-add that project's files to the lint scope and re-block the scoped
229
+ // check, defeating the point. The global (no --project) check keeps widening.
230
+ if (args.project) requireProjectDir(args, args.project);
182
231
  const status = precompactGateStatus(args.hypoDir, {
183
- ...(args.transcriptPath ? { transcriptPath: args.transcriptPath } : {}),
232
+ ...(args.project
233
+ ? { projectOverride: args.project }
234
+ : args.transcriptPath
235
+ ? { transcriptPath: args.transcriptPath }
236
+ : {}),
184
237
  ...(args.sessionId ? { sessionId: args.sessionId } : {}),
185
238
  });
186
239
  const close = status.close;
@@ -198,9 +251,18 @@ function runSessionCloseCheck(args) {
198
251
  // while /compact's Stop still blocks — the exact incoherence this ADR closes
199
252
  // (codex pre-commit CONCERN). readSessionClosedMarker unlinks an invalid
200
253
  // marker as it reads, matching the hook's behavior on the next Stop.
201
- const markerPresent = args.sessionId
202
- ? readSessionClosedMarker(args.hypoDir, args.sessionId) !== null
203
- : null;
254
+ const markerObj = args.sessionId ? readSessionClosedMarker(args.hypoDir, args.sessionId) : null;
255
+ const markerPresent = args.sessionId ? markerObj !== null : null;
256
+
257
+ // Scope of this check (codex design review finding 2 — the scope must be
258
+ // explicit in JSON + prose, not implied). `global` = the full PreCompact mirror
259
+ // (green ⇒ compact-ready). `project` = narrowed to --project=<slug> (green ⇒
260
+ // only THAT project is close-complete, NOT global compact-readiness). When a
261
+ // log-only marker governs the session, the gate runs in log-only mode and the
262
+ // --project override is IGNORED — surface that rather than implying X was
263
+ // checked (it was not).
264
+ const logOnlyWon = args.project != null && markerObj?.scope === 'log-only';
265
+ const scope = args.project ? (logOnlyWon ? 'log-only' : 'project') : 'global';
204
266
 
205
267
  if (args.json) {
206
268
  console.log(
@@ -215,6 +277,14 @@ function runSessionCloseCheck(args) {
215
277
  blockers: status.blockers,
216
278
  notices: status.notices,
217
279
  skipped: status.skipped,
280
+ // scope is additive; `global` keeps prior semantics for existing readers
281
+ scope,
282
+ ...(args.project
283
+ ? {
284
+ scoped_project: args.project,
285
+ ...(logOnlyWon ? { project_override_ignored: true } : {}),
286
+ }
287
+ : {}),
218
288
  ...(args.sessionId ? { session_id: args.sessionId, marker_present: markerPresent } : {}),
219
289
  },
220
290
  null,
@@ -224,8 +294,20 @@ function runSessionCloseCheck(args) {
224
294
  process.exit(status.ok ? 0 : 1);
225
295
  }
226
296
 
297
+ if (logOnlyWon) {
298
+ console.log(
299
+ `Note: a log-only session-closed marker governs session ${args.sessionId}, so the gate ran in log-only mode and --project=${args.project} was IGNORED (no project was checked).\n`,
300
+ );
301
+ } else if (scope === 'project') {
302
+ console.log(
303
+ `Note: --project=${args.project} — this is a PROJECT-SCOPED diagnostic, not the global /compact gate. A green result means only ${args.project} is close-complete; another project can still block /compact.\n`,
304
+ );
305
+ }
306
+
227
307
  const proj = close.project || '(unresolved)';
228
- console.log(`Compact-ready check (project: ${proj}, date: ${close.dates.join(' / ')}):\n`);
308
+ console.log(
309
+ `Compact-ready check (${scope === 'global' ? `project: ${proj}` : `scope: ${scope}, project: ${proj}`}, date: ${close.dates.join(' / ')}):\n`,
310
+ );
229
311
 
230
312
  const required = close.project
231
313
  ? [
@@ -261,15 +343,25 @@ function runSessionCloseCheck(args) {
261
343
  console.log(
262
344
  markerPresent
263
345
  ? ` ✓ session-closed marker present (session_id: ${args.sessionId}).`
264
- : ` · session-closed marker absent (session_id: ${args.sessionId}) — the Stop hook will block until it is written. Run \`crystallize --mark-session-closed --session-id=${args.sessionId}${args.transcriptPath ? ` --transcript-path=${args.transcriptPath}` : ''}\`.`,
346
+ : ` · session-closed marker absent (session_id: ${args.sessionId}) — the Stop hook will block until it is written. Run \`node "${SELF_SCRIPT}" --mark-session-closed --session-id=${args.sessionId}${args.transcriptPath ? ` --transcript-path="${args.transcriptPath}"` : ''}\`.`,
265
347
  );
266
348
  }
267
349
  console.log('');
268
- console.log(
269
- status.ok
270
- ? '✓ Compact-ready no PreCompact gate blocker needs a human fix. (open-questions.md: conditional, not checked. The live /compact can still differ on a context-≥70% prompt, HYPO_SKIP_GATE, or a transcript-scoped lint error this check did not see — pass --transcript-path to include the latter.)'
271
- : '✗ Not compact-ready — resolve the ✗ items above, then retry. /compact would block on these.',
272
- );
350
+ if (scope === 'project') {
351
+ // Project-scoped diagnostic: green means ONLY this project is close-complete.
352
+ // Do NOT claim global compact-readiness (the whole point of the narrow).
353
+ console.log(
354
+ status.ok
355
+ ? `✓ ${args.project} is close-complete (project-scoped). This is NOT a global /compact guarantee — run \`--check-session-close\` without --project for that.`
356
+ : `✗ ${args.project} is not close-complete — resolve the ✗ items above.`,
357
+ );
358
+ } else {
359
+ console.log(
360
+ status.ok
361
+ ? '✓ Compact-ready — no PreCompact gate blocker needs a human fix. (open-questions.md: conditional, not checked. The live /compact can still differ on a context-≥70% prompt, HYPO_SKIP_GATE, or a transcript-scoped lint error this check did not see — pass --transcript-path to include the latter.)'
362
+ : '✗ Not compact-ready — resolve the ✗ items above, then retry. /compact would block on these.',
363
+ );
364
+ }
273
365
  process.exit(status.ok ? 0 : 1);
274
366
  }
275
367
 
@@ -358,18 +450,19 @@ function todayLocal() {
358
450
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
359
451
  }
360
452
 
361
- // Spec §5.2.7 / §8.3 + ADR 0029: 5 mandatory + 1 conditional. The payload
362
- // shape MUST mirror that contract — missing a mandatory field is a payload
363
- // bug, not a no-op. Caller is the LLM session-close flow, which composes the
364
- // payload deliberately; partial payloads must fail loudly so caller fixes them
365
- // rather than silently relying on yesterday's freshness state. (Codex review
366
- // of the apply path — Worker 1 finding 1.)
453
+ // Spec §5.2.7 / §8.3 + ADR 0029: 4 mandatory + 2 optional (`log`, `openQuestions`).
454
+ // The payload shape MUST mirror that contract — missing a mandatory field is a
455
+ // payload bug, not a no-op. Caller is the LLM session-close flow, which composes
456
+ // the payload deliberately; partial payloads must fail loudly so caller fixes
457
+ // them rather than silently relying on yesterday's freshness state. (Codex review
458
+ // of the apply path — Worker 1 finding 1.) `log` left the mandatory set in B-1:
459
+ // the root log.md entry is a DERIVABLE artifact (rootLogEntry over this close's
460
+ // sessionLog heading), so apply auto-fills it when the field is absent.
367
461
  const REQUIRED_PAYLOAD_FIELDS = [
368
462
  ['sessionState', 'content'],
369
463
  ['projectHot', 'content'],
370
464
  ['rootHot', 'content'],
371
465
  ['sessionLog', 'entry'],
372
- ['log', 'entry'],
373
466
  ];
374
467
 
375
468
  function validatePayloadShape(payload) {
@@ -397,6 +490,11 @@ function validatePayloadShape(payload) {
397
490
  errs.push('payload.openQuestions, when present, must be { content: string }');
398
491
  }
399
492
  }
493
+ if (payload.log !== undefined) {
494
+ if (!payload.log || typeof payload.log !== 'object' || typeof payload.log.entry !== 'string') {
495
+ errs.push('payload.log, when present, must be { entry: string }');
496
+ }
497
+ }
400
498
  if (payload.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(payload.date))) {
401
499
  errs.push('payload.date, when present, must be YYYY-MM-DD');
402
500
  }
@@ -419,6 +517,14 @@ function runMarkSessionClosed(args) {
419
517
  console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
420
518
  process.exit(1);
421
519
  }
520
+ // --project=<slug> on --mark is ATTRIBUTION ONLY (the marker's `project` field).
521
+ // The gate stays GLOBAL — never narrowed — because a marker that narrowed its
522
+ // gate could attest compact-ready while PreCompact re-checks all of today's
523
+ // projects and stays red (the marker == compact-ready invariant, ADR 0047 /
524
+ // codex design finding 1/2). Validate the attribution slug exists as a
525
+ // directory, exactly as --check does, but only when it is actually used (a
526
+ // --log-only mark attributes to no project, so --project is moot there).
527
+ if (args.project && !args.logOnly) requireProjectDir(args, args.project);
422
528
  // ADR 0047: the per-session marker is the THIRD session-close completion
423
529
  // signal (after the PreCompact gate and `--check-session-close`). It must use
424
530
  // the SAME gate that governs /compact — precompactGateStatus — so the marker
@@ -484,8 +590,12 @@ function runMarkSessionClosed(args) {
484
590
  console.log(args.json ? JSON.stringify(result, null, 2) : `✗ ${reason}`);
485
591
  process.exit(1);
486
592
  }
593
+ // --project attributes the marker to that slug (gate stayed global); falls back
594
+ // to the gate's resolved primary. log-only marks attribute to no project. Used
595
+ // for the marker, the JSON result, and the success message so all three agree.
596
+ const markerProject = !args.logOnly && args.project ? args.project : status.project;
487
597
  writeSessionClosedMarker(args.hypoDir, args.sessionId, {
488
- project: status.project,
598
+ project: markerProject,
489
599
  ...(args.logOnly ? { scope: 'log-only' } : {}),
490
600
  });
491
601
  // Marker writer swallows IO errors (best-effort, see hypo-shared.mjs). Verify
@@ -504,7 +614,7 @@ function runMarkSessionClosed(args) {
504
614
  const result = {
505
615
  ok: true,
506
616
  session_id: args.sessionId,
507
- project: status.project,
617
+ project: markerProject,
508
618
  scope: args.logOnly ? 'log-only' : 'project',
509
619
  date: status.dates[0],
510
620
  notices: gate.notices,
@@ -520,7 +630,7 @@ function runMarkSessionClosed(args) {
520
630
  console.log(
521
631
  args.logOnly
522
632
  ? `✓ session-closed marker written (session_id: ${args.sessionId}, scope: log-only — no project attribution).`
523
- : `✓ session-closed marker written (session_id: ${args.sessionId}, project: ${status.project}).`,
633
+ : `✓ session-closed marker written (session_id: ${args.sessionId}, project: ${markerProject}).`,
524
634
  );
525
635
  if (gate.driftTargets.length > 0) {
526
636
  console.log(
@@ -585,20 +695,48 @@ function applySessionClose(args) {
585
695
  process.exit(1);
586
696
  }
587
697
 
588
- // Resolve project: explicit payload.project wins; else fall back to active project.
589
- // Done via sessionCloseFileStatus to keep one source of truth (and so a
590
- // missing pointer table surfaces the same error shape as --check-session-close).
698
+ // Resolve project: payload.project is REQUIRED (B-3, close-gate-hardening). The
699
+ // old recency fallback (payload.project || probe.project) could, on a same-date
700
+ // root-hot.md tie, resolve a DIFFERENT project than the one the payload's files
701
+ // belong to — apply would then write the close into the wrong project (silent
702
+ // data loss). Validate fail-fast, BEFORE the probe is consulted:
703
+ // - missing → no target to write; abort rather than infer.
704
+ // - invalid name → reject (non-string, wrong charset, or dot-only) BEFORE the
705
+ // existsSync(join(...)) path build, so a `../`-style value
706
+ // never reaches a path builder (traversal guard — order is
707
+ // the guard). isValidProjectName is SHARED with createProject
708
+ // so apply accepts exactly the namespace the repo can
709
+ // scaffold (A-Za-z0-9._-, single segment) — no narrower.
710
+ // - non-existent → projects/<slug>/ absent; abort rather than create.
711
+ // A payload.project that merely DIFFERS from the inferred active project is NOT an
712
+ // error — it is surfaced as a stderr note below and the close proceeds.
713
+ if (payload.project === undefined || payload.project === null) {
714
+ const msg = 'payload.project is required (apply must not infer the close target project)';
715
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
716
+ process.exit(1);
717
+ }
718
+ if (!isValidProjectName(payload.project)) {
719
+ const msg = `payload.project ${JSON.stringify(payload.project)} is not a valid project name (single segment, charset A-Za-z0-9._-, ≥1 alnum, not "."/"..")`;
720
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
721
+ process.exit(1);
722
+ }
723
+ // existsSync alone is not enough: a regular FILE at projects/<slug> would pass,
724
+ // then apply would build child paths under it and fail with an unstructured
725
+ // filesystem error (codex re-review). Require it to be a directory.
726
+ const projectDir = join(args.hypoDir, 'projects', payload.project);
727
+ if (!existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
728
+ const msg = `payload.project "${payload.project}" does not exist as a directory (no projects/${payload.project}/ directory)`;
729
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
730
+ process.exit(1);
731
+ }
732
+ const project = payload.project;
733
+ // probe (the recency-inferred active project) is now consulted ONLY to surface a
734
+ // divergence note — never to resolve the target. Computed AFTER validation so a
735
+ // malformed/missing payload.project fails fast without a pointer-table read.
591
736
  // Resolved BEFORE preflight because preflight needs overwrite-target paths
592
737
  // (which require the project slug) to filter out errors in files this apply
593
738
  // is about to replace — see the filter rationale below.
594
739
  const probe = sessionCloseFileStatus(args.hypoDir);
595
- const project = payload.project || probe.project;
596
- if (!project) {
597
- const msg =
598
- 'no project resolved (payload.project missing and root hot.md has no active-project row)';
599
- console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
600
- process.exit(1);
601
- }
602
740
  // The freshness verification below (and at the post-apply check) already honors
603
741
  // payload.project — `project` wins over the inferred active project, and the
604
742
  // post-apply sessionCloseFileStatus call passes it as projectOverride. But when the
@@ -606,7 +744,7 @@ function applySessionClose(args) {
606
744
  // (probe.project), that divergence used to be silent, so an operator couldn't tell
607
745
  // which project the close actually verified. Surface it on stderr (the stdout JSON
608
746
  // contract is untouched) so the verified/closed project is always explicit.
609
- if (payload.project && probe.project && probe.project !== payload.project) {
747
+ if (probe.project && probe.project !== payload.project) {
610
748
  process.stderr.write(
611
749
  `note: payload.project="${payload.project}" differs from the inferred active ` +
612
750
  `project "${probe.project}"; verifying and closing "${payload.project}".\n`,
@@ -614,6 +752,23 @@ function applySessionClose(args) {
614
752
  }
615
753
  const date = payload.date || todayLocal();
616
754
 
755
+ // B-1 derive precondition: when `log` is omitted, apply reconstructs the root
756
+ // log.md entry from THIS close's sessionLog heading. If sessionLog.entry has no
757
+ // `## [<date>] …` heading there is nothing to derive — and on a same-day SECOND
758
+ // close the date-level freshness verifier would still pass on the earlier
759
+ // close's entry, so the no-write would slip through as ok:true. Fail loud here,
760
+ // before any writes (codex pre-commit review).
761
+ if (
762
+ !payload.log &&
763
+ !new RegExp(`^#{1,6} \\[${date}\\]`, 'm').test(payload.sessionLog.entry || '')
764
+ ) {
765
+ const msg =
766
+ `payload.sessionLog.entry has no "## [${date}] …" heading to derive the log.md ` +
767
+ `entry from. Give it a dated heading, or supply payload.log explicitly.`;
768
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
769
+ process.exit(1);
770
+ }
771
+
617
772
  // Preflight: lint the wiki BEFORE writing any payload bytes. If lint
618
773
  // has blockers (errors) in files this apply WON'T overwrite, the wiki is in
619
774
  // a degraded state and apply would mask the root cause — abort fail-fast.
@@ -776,13 +931,43 @@ function applySessionClose(args) {
776
931
  }
777
932
  }
778
933
 
779
- {
934
+ // log.md: `payload.log` is OPTIONAL (B-1). When the caller supplies it, keep
935
+ // the explicit appendIfAbsent path (backward-compat: a custom log line, with
936
+ // the same idempotent dedup). When it is ABSENT, the root log.md entry is a
937
+ // DERIVABLE artifact: reconstruct the canonical `## [date] session | <project>`
938
+ // line directly from THIS close's session-log heading (`payload.sessionLog`),
939
+ // not by re-reading the session-log files. Deriving from the payload is what
940
+ // makes the per-close entry exact: a same-day second close lands its distinct
941
+ // heading, and a hybrid daily/monthly session-log split can't hide it (apply
942
+ // never reads those files for this). The global scan-based deriveRootLogEntries
943
+ // (the Stop hook) still backfills OTHER projects; calling it here would either
944
+ // miss the current entry (single-candidate read) or, with a loosened guard,
945
+ // append onto a deliberately custom payload.log (codex pre-commit review). The
946
+ // two payload paths are mutually exclusive: deriving on top of a present-but-
947
+ // malformed payload.log would mask it and weaken the verifier's fail-loud.
948
+ if (payload.log) {
780
949
  const wrote = appendIfAbsent(
781
950
  join(args.hypoDir, 'log.md'),
782
951
  payload.log.entry,
783
952
  entryAlreadyPresent(payload.log.entry),
784
953
  );
785
954
  (wrote ? applied : skipped).push('log (log.md)');
955
+ } else {
956
+ // matchAll (not exec) mirrors deriveRootLogEntries: a payload that carried
957
+ // more than one dated heading derives one canonical line each, symmetric with
958
+ // the global path. Exact-line dedup on the heading keeps a second apply (or a
959
+ // titleless vs titled same-day pair) from duplicating.
960
+ const logFull = join(args.hypoDir, 'log.md');
961
+ const headingRe = new RegExp(`^#{1,6} \\[${date}\\]\\s*(.*)$`, 'gm');
962
+ let wroteAny = false;
963
+ for (const m of (payload.sessionLog.entry || '').matchAll(headingRe)) {
964
+ const { heading, block } = rootLogEntry(project, date, m[1]);
965
+ const wrote = appendIfAbsent(logFull, block, (c) =>
966
+ (c || '').split(/\r?\n/).includes(heading),
967
+ );
968
+ wroteAny = wroteAny || wrote;
969
+ }
970
+ (wroteAny ? applied : skipped).push('log (log.md, derived)');
786
971
  }
787
972
 
788
973
  // Same-date-tie fix: verify against the SAME project this apply just wrote
@@ -792,6 +977,29 @@ function applySessionClose(args) {
792
977
  // a completed close (the 2026-06-09 security-ops-kb incident).
793
978
  const verification = sessionCloseFileStatus(args.hypoDir, { projectOverride: project });
794
979
 
980
+ // B-4 auto-register: lift unknown (non-forbidden) tags surfaced by the PREFLIGHT
981
+ // lint into SCHEMA.md's `### Pending` section so the post-apply lint sees them as
982
+ // known and the close never stalls on a vocabulary gap. The W10 id is hidden in
983
+ // non-strict --json output (lint.mjs toOut), so the unknown-tag warns are matched
984
+ // and the tag extracted from the message string itself — kept in lockstep with
985
+ // lint.mjs's W10 emit (a copy-edit there breaks this; the close-path round-trip
986
+ // test guards it). Forbidden patterns stay hard errors and are filtered out.
987
+ // SCOPE (eventual consistency, intended): this registers PRE-EXISTING wiki debt
988
+ // visible at preflight, NOT a novel tag this very close's payload introduces —
989
+ // that one would surface only at post-apply and lands on the NEXT close. The
990
+ // contract is "must not stall", which warns (not errors) already satisfy; the
991
+ // registration just keeps the vocabulary catching up.
992
+ // The capture is anchored on the FULL message suffix (not `[^"]+`) so a tag that
993
+ // itself contains a `"` — non-forbidden, so reachable — is captured whole rather
994
+ // than truncated at its first quote (codex stage-2 CONCERN).
995
+ const unknownTagRe = /^Unknown tag: "(.+)" \(not in SCHEMA\.md Tag Vocabulary\)/;
996
+ const pendingTags = [];
997
+ for (const w of preflightLint.warns || []) {
998
+ const m = unknownTagRe.exec(w.message || '');
999
+ if (m && !checkForbidden(m[1])) pendingTags.push(m[1]);
1000
+ }
1001
+ if (pendingTags.length > 0) appendPendingTags(args.hypoDir, pendingTags);
1002
+
795
1003
  // Post-apply lint: payload may have introduced a malformed body or
796
1004
  // bad frontmatter. Surface as a distinct `stage` so caller can tell "lint
797
1005
  // broke" apart from "frontmatter stale". This runs even if the freshness gate
@@ -90,13 +90,31 @@ export function insertHotRow(content, name, today) {
90
90
  * @param {{hypoDir: string, name: string, workingDir: string, started?: string, today?: string}} opts
91
91
  * @returns {{created: string[], skipped: string[], warnings: string[], projectDir: string}}
92
92
  */
93
+ /**
94
+ * A project name must be a SINGLE path segment with at least one alnum. The
95
+ * charset alone is not enough: `.`, `..`, `...` all pass `[A-Za-z0-9._-]+` yet
96
+ * would resolve `projects/<name>` to the wiki root or `projects/` itself (codex
97
+ * review 2026-05-22, both workers) — so dot-only names are rejected and ≥1 alnum
98
+ * required. The leading `typeof` guard lets non-CLI callers (e.g. a JSON payload
99
+ * where the value could be a number) reuse this without a JS regex coercing a
100
+ * non-string into a "valid" name. Shared so the apply path (crystallize.mjs B-3)
101
+ * accepts exactly the namespace createProject can scaffold — no wider, no narrower.
102
+ *
103
+ * @param {unknown} name
104
+ * @returns {boolean}
105
+ */
106
+ export function isValidProjectName(name) {
107
+ return (
108
+ typeof name === 'string' &&
109
+ /^[A-Za-z0-9._-]+$/.test(name) &&
110
+ !/^\.+$/.test(name) &&
111
+ /[A-Za-z0-9]/.test(name)
112
+ );
113
+ }
114
+
93
115
  export function createProject(opts) {
94
116
  const { name, workingDir } = opts;
95
- // Name must be a single path segment with at least one alnum. The charset
96
- // alone is not enough: `.`, `..`, `...` all pass `[A-Za-z0-9._-]+` yet would
97
- // resolve `projects/<name>` to the wiki root or `projects/` itself (codex
98
- // review 2026-05-22, both workers). Reject dot-only names and require alnum.
99
- if (!name || !/^[A-Za-z0-9._-]+$/.test(name) || /^\.+$/.test(name) || !/[A-Za-z0-9]/.test(name)) {
117
+ if (!isValidProjectName(name)) {
100
118
  throw new Error(
101
119
  `invalid project name: ${JSON.stringify(name)} (need a single segment with ≥1 alnum, charset A-Za-z0-9._-, not "."/"..")`,
102
120
  );
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'fs';
1
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
 
4
4
  const VOCAB_HEADER_RE = /^##\s+(?:\d+\.\s+)?Tag\s+(?:Vocabulary|Taxonomy)\s*$/m;
@@ -66,6 +66,110 @@ export function parseSchemaVocab(hypoDir) {
66
66
  return vocab;
67
67
  }
68
68
 
69
+ // Prose seeded into a freshly-created `### Pending` block. It carries NO
70
+ // backtick tokens, so parseSchemaVocab's vocabLineTokens never mistakes it for a
71
+ // vocabulary line — only the `**Pending**:` data line below it contributes tags.
72
+ const PENDING_HEADING = '### Pending (auto-registered)';
73
+ const PENDING_PROSE =
74
+ 'Tags auto-registered by a crystallize close because they were not yet in the ' +
75
+ 'vocabulary above. Review periodically: promote each into a category and delete ' +
76
+ 'it here, or drop the tag from the page.';
77
+ const PENDING_HEADING_RE = /^###[ \t]+Pending\b.*$/im; // prefix/word match
78
+ const PENDING_DATA_RE = /^\*\*Pending[^*]*\*\*:.*$/im;
79
+ const NEXT_H3_RE = /^###[ \t]+/m;
80
+ const FORBIDDEN_HEADING_RE = /^###[ \t]+Forbidden\b/im;
81
+
82
+ function backtickList(tags) {
83
+ return tags.map((t) => `\`${t}\``).join(', ');
84
+ }
85
+
86
+ // Auto-register unknown (non-forbidden) tags into the vault SCHEMA.md `### Pending`
87
+ // section so a close never stalls on a vocabulary gap and the next lint sees them
88
+ // as known (B-4). Pending lives INSIDE the "## Tag Vocabulary" H2 section because
89
+ // parseSchemaVocab is H2-bounded — placing it elsewhere would not widen the vocab.
90
+ // No-ops (returns []) when SCHEMA.md or the Tag Vocabulary header is absent, when
91
+ // every tag is already in the vocabulary, or when a tag is forbidden — registering
92
+ // a forbidden pattern is pointless (lint errors on it before the vocab check) and
93
+ // would only pollute Pending. Idempotent: re-running with the same tags writes
94
+ // nothing (they are already in the parsed vocabulary on the second call).
95
+ export function appendPendingTags(hypoDir, tags) {
96
+ const path = join(hypoDir, 'SCHEMA.md');
97
+ if (!existsSync(path)) return [];
98
+ const content = readFileSync(path, 'utf-8');
99
+
100
+ const headerMatch = VOCAB_HEADER_RE.exec(content);
101
+ if (!headerMatch) return [];
102
+
103
+ const current = parseSchemaVocab(hypoDir);
104
+ const seen = new Set();
105
+ const newTags = [];
106
+ for (const raw of tags || []) {
107
+ const t = typeof raw === 'string' ? raw.trim() : '';
108
+ // A literal backtick cannot be serialized inside the backtick-delimited
109
+ // `**Pending**:` line — it would close the token early and make
110
+ // parseSchemaVocab mis-tokenize (or blank) the whole line, taking valid
111
+ // sibling tags down with it (codex stage-2 CONCERN). Skip it: the tag stays a
112
+ // non-blocking unknown-tag warn the author can rename. (A `"` is safe — it
113
+ // round-trips between backticks — so only the backtick is rejected.)
114
+ if (!t || t.includes('`') || seen.has(t) || current.has(t) || checkForbidden(t)) continue;
115
+ seen.add(t);
116
+ newTags.push(t);
117
+ }
118
+ if (newTags.length === 0) return [];
119
+
120
+ const sectionStart = headerMatch.index + headerMatch[0].length;
121
+ const rest = content.slice(sectionStart);
122
+ const nextH2 = NEXT_H2_RE.exec(rest);
123
+ const sectionEnd = nextH2 ? sectionStart + nextH2.index : content.length;
124
+ const section = content.slice(sectionStart, sectionEnd);
125
+
126
+ let newSection;
127
+ const heading = PENDING_HEADING_RE.exec(section);
128
+ if (heading) {
129
+ // Pending subsection exists — bound it (heading → next H3 or section end) and
130
+ // either extend its `**Pending**:` data line or seed one if the block is empty
131
+ // (the template ships heading + prose with no tags yet).
132
+ const subStart = heading.index;
133
+ const after = section.slice(heading.index + heading[0].length);
134
+ const nextH3 = NEXT_H3_RE.exec(after);
135
+ const subEnd = nextH3 ? heading.index + heading[0].length + nextH3.index : section.length;
136
+ const sub = section.slice(subStart, subEnd);
137
+
138
+ const dataMatch = PENDING_DATA_RE.exec(sub);
139
+ let newSub;
140
+ if (dataMatch) {
141
+ const existing = [];
142
+ for (const m of dataMatch[0].matchAll(BACKTICK_TOKEN_RE)) {
143
+ const t = m[1].trim();
144
+ if (t && !t.includes(' ')) existing.push(t);
145
+ }
146
+ const merged = [...existing, ...newTags];
147
+ const dataLine = `**Pending**: ${backtickList(merged)}`;
148
+ newSub =
149
+ sub.slice(0, dataMatch.index) + dataLine + sub.slice(dataMatch.index + dataMatch[0].length);
150
+ } else {
151
+ const dataLine = `**Pending**: ${backtickList(newTags)}`;
152
+ newSub = `${sub.replace(/\s*$/, '')}\n\n${dataLine}\n`;
153
+ }
154
+ newSection = section.slice(0, subStart) + newSub + section.slice(subEnd);
155
+ } else {
156
+ // No Pending block yet — build one and place it before "### Forbidden patterns"
157
+ // if present (Pending is vocabulary; Forbidden is a separate concern), else at
158
+ // the end of the Tag Vocabulary section.
159
+ const block = `${PENDING_HEADING}\n\n${PENDING_PROSE}\n\n**Pending**: ${backtickList(newTags)}\n`;
160
+ const forbidden = FORBIDDEN_HEADING_RE.exec(section);
161
+ if (forbidden) {
162
+ newSection =
163
+ section.slice(0, forbidden.index) + `${block}\n` + section.slice(forbidden.index);
164
+ } else {
165
+ newSection = `${section.replace(/\s*$/, '')}\n\n${block}\n`;
166
+ }
167
+ }
168
+
169
+ writeFileSync(path, content.slice(0, sectionStart) + newSection + content.slice(sectionEnd));
170
+ return newTags;
171
+ }
172
+
69
173
  // Derive the set of valid immediate subdirectories under pages/ from the
70
174
  // SCHEMA "Page Type Taxonomy" table (e.g. learnings, feedback, people). Single
71
175
  // source of truth — adding a type+dir row to SCHEMA automatically widens the
package/scripts/lint.mjs CHANGED
@@ -426,7 +426,15 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs, validTypes) {
426
426
  continue;
427
427
  }
428
428
  if (!tagVocab.has(tag)) {
429
- issue('error', rel, `Unknown tag: "${tag}" (not in SCHEMA.md Tag Vocabulary)`);
429
+ // W10 (B-4): an unknown but non-forbidden tag is a WARNING, not a hard
430
+ // error — a session close must never stall on a vocabulary gap. The
431
+ // crystallize apply path parses these warns (by this exact message
432
+ // string — load-bearing, see scripts/crystallize.mjs auto-register block)
433
+ // and registers them into SCHEMA.md's `### Pending` section, so the next
434
+ // lint sees them as known. W10 is intentionally NOT in STRICT_PROMOTE_IDS:
435
+ // an unregistered tag is a vocabulary lag, not a content defect, so even
436
+ // `--strict` keeps it a warning (it only surfaces the id in --json output).
437
+ issue('warn', rel, `Unknown tag: "${tag}" (not in SCHEMA.md Tag Vocabulary)`, null, 'W10');
430
438
  }
431
439
  }
432
440
  }
@@ -61,7 +61,8 @@ When uncertain, surface the question rather than skip it. None of the four block
61
61
  3. **hot.md (root)** — update `<wiki-root>/hot.md` active-projects pointer table: set the `Last Session` date for this project to today.
62
62
  4. **session-log** — append a session entry to `projects/<name>/session-log/YYYY-MM-DD.md` (daily shard; the apply path creates today's file with seeded frontmatter if it does not exist yet).
63
63
  5. **open-questions** — only if `pages/open-questions.md` exists and questions were raised or resolved this session: move resolved ones out; add newly raised ones. Skip if unchanged.
64
- 6. **log.md** — append a `session` entry to `<wiki-root>/log.md`.
64
+
65
+ > Do **not** hand-write the root `log.md` session entry, and do **not** put a `log` field in the apply payload. The `## [date] session | <project>` entry is a derivable artifact: `--apply-session-close` calls `deriveRootLogEntries` after writing the session-log, reconstructing the canonical line from the session-log heading. (Supply `log` only for a deliberately custom line; it is now an optional payload field.)
65
66
 
66
67
  After completing the checklist, verify it before reporting:
67
68
 
@@ -76,6 +77,13 @@ reports and re-run until it prints **"Compact-ready"** — that is the signal th
76
77
  session is closed. A close-files-only pass is not enough; the real `/compact`
77
78
  also blocks on those other checks.
78
79
 
80
+ Optional `--project=<slug>` narrows the check to ONE project (close status plus
81
+ lint scope). It is a project-scoped **diagnostic**, not the compact-ready
82
+ signal. A green `--project` result (JSON `scope: "project"`) means only that
83
+ project is close-complete; another today-active project can still block
84
+ `/compact`. Use the plain `--check-session-close` (no `--project`) for the
85
+ go/no-go close signal.
86
+
79
87
  **When using `--apply-session-close --session-id=<id>`** (the payload-driven
80
88
  path), the `--session-id` must be the main conversation's session id. Do NOT
81
89
  extract it from a background task or Agent output path (e.g., a UUID from
@@ -154,9 +154,11 @@ Edit the feedback page only — never hand-edit the generated
154
154
 
155
155
  ## 4. Tag Vocabulary
156
156
 
157
- Use lowercase, hyphenated tags. Vocabulary is locked — `lint` blocks unknown tags
158
- and forbidden patterns (PascalCase, plurals, whitespace, generic words).
159
- Extend this list (and `~/hypomnema/SCHEMA.md`) before introducing a new tag.
157
+ Use lowercase, hyphenated tags. `lint` hard-blocks forbidden patterns (PascalCase,
158
+ plurals, whitespace, generic words); an unknown but well-formed tag is a warning,
159
+ and a session close auto-registers it into the `### Pending` section below so the
160
+ next lint accepts it. Promote a pending tag into a category here (and in
161
+ `~/hypomnema/SCHEMA.md`) once it has settled.
160
162
 
161
163
  **Meta**: `wiki`, `index`, `pages`, `home`, `overview`, `guide`, `operations`, `schema`, `reference`, `hypo`, `commands`, `hot-cache`, `migration`
162
164
  **Workflow**: `automation`, `hooks`, `observability`, `autonomy`, `wiki-health`, `weekly`
@@ -165,6 +167,12 @@ Extend this list (and `~/hypomnema/SCHEMA.md`) before introducing a new tag.
165
167
  **Status**: `active`, `completed`, `archived`, `draft`, `stable`, `deprecated`, `needs-review`, `proposed`, `superseded`
166
168
  **Content classification**: `learning`, `tip`, `feedback`, `gotcha`, `concept`, `pattern`
167
169
 
170
+ ### Pending (auto-registered)
171
+
172
+ Tags auto-registered by a crystallize close because they were not yet in the
173
+ vocabulary above. Review periodically: promote each into a category and delete it
174
+ here, or drop the tag from the page.
175
+
168
176
  ### Forbidden patterns
169
177
 
170
178
  | Pattern | Reason | Use instead |
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Hypomnema Config
3
3
  type: config
4
- version: "1.4.1"
4
+ version: "1.4.2"
5
5
  created: YYYY-MM-DD
6
6
  ---
7
7
 
@@ -95,7 +95,10 @@ Ask: *"이 작업이 마무리되었나요? 세션을 정리(crystallize)할까
95
95
  guarantee: the live gate can still differ on a context-≥70% prompt,
96
96
  `HYPO_SKIP_GATE`, or a transcript-scoped lint error — pass `--transcript-path`
97
97
  to include the last.) Pass `--session-id=<id>` to also see `marker_present`
98
- (step 7).
98
+ (step 7). `--project=<slug>` narrows the check to one project (a scoped
99
+ diagnostic, JSON `scope: "project"`): green there means only that slug is
100
+ close-complete, **not** that `/compact` is globally unblocked. Use the plain
101
+ check for the go/no-go signal.
99
102
  7. Record the session-closed marker (ADR 0047). The Stop hook blocks until this
100
103
  session's per-session marker exists, and a hand-edit close (writing the files
101
104
  directly + committing) never writes it; the marker is written only by the