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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/commands/crystallize.md +4 -2
- package/docs/CONTRIBUTING.md +12 -5
- package/hooks/hypo-auto-minimal-crystallize.mjs +23 -11
- package/hooks/hypo-shared.mjs +96 -14
- package/package.json +1 -1
- package/scripts/crystallize.mjs +245 -37
- package/scripts/lib/project-create.mjs +23 -5
- package/scripts/lib/schema-vocab.mjs +105 -1
- package/scripts/lint.mjs +9 -1
- package/skills/crystallize/SKILL.md +9 -1
- package/templates/SCHEMA.md +11 -3
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +4 -1
package/README.ko.md
CHANGED
|
@@ -25,7 +25,7 @@ Andrej Karpathy의 "LLM 네이티브 위키" 스케치에서 출발했습니다.
|
|
|
25
25
|
|
|
26
26
|
### 지금 어디까지 자동인가
|
|
27
27
|
|
|
28
|
-
현재 릴리스는 v1.4.
|
|
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.
|
|
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.
|
package/commands/crystallize.md
CHANGED
|
@@ -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
|
|
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`
|
|
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.
|
package/docs/CONTRIBUTING.md
CHANGED
|
@@ -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`.
|
|
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
|
-
- **
|
|
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
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
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 =
|
|
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
|
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -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))
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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 ||
|
|
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
package/scripts/crystallize.mjs
CHANGED
|
@@ -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>", //
|
|
39
|
+
* "project": "<slug>", // REQUIRED — single 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> ..." }, //
|
|
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
|
-
|
|
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.
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
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(
|
|
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 \`
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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:
|
|
362
|
-
// shape MUST mirror that contract — missing a mandatory field is a
|
|
363
|
-
// bug, not a no-op. Caller is the LLM session-close flow, which composes
|
|
364
|
-
// payload deliberately; partial payloads must fail loudly so caller fixes
|
|
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:
|
|
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:
|
|
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: ${
|
|
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:
|
|
589
|
-
//
|
|
590
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/templates/SCHEMA.md
CHANGED
|
@@ -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.
|
|
158
|
-
|
|
159
|
-
|
|
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 |
|
package/templates/hypo-config.md
CHANGED
package/templates/hypo-guide.md
CHANGED
|
@@ -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
|