hypomnema 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +12 -5
  4. package/README.md +12 -5
  5. package/commands/audit.md +46 -0
  6. package/commands/crystallize.md +113 -23
  7. package/commands/feedback.md +40 -26
  8. package/commands/ingest.md +31 -9
  9. package/commands/upgrade.md +2 -2
  10. package/docs/ARCHITECTURE.md +83 -9
  11. package/docs/CONTRIBUTING.md +2 -2
  12. package/hooks/hooks.json +39 -1
  13. package/hooks/hypo-auto-commit.mjs +23 -4
  14. package/hooks/hypo-auto-minimal-crystallize.mjs +145 -0
  15. package/hooks/hypo-auto-stage.mjs +9 -5
  16. package/hooks/hypo-compact-guard.mjs +33 -24
  17. package/hooks/hypo-cwd-change.mjs +107 -24
  18. package/hooks/hypo-file-watch.mjs +23 -10
  19. package/hooks/hypo-first-prompt.mjs +37 -23
  20. package/hooks/hypo-hot-rebuild.mjs +31 -8
  21. package/hooks/hypo-lookup.mjs +171 -65
  22. package/hooks/hypo-personal-check.mjs +207 -112
  23. package/hooks/hypo-pre-commit.mjs +46 -0
  24. package/hooks/hypo-session-end.mjs +58 -0
  25. package/hooks/hypo-session-record.mjs +60 -0
  26. package/hooks/hypo-session-start.mjs +312 -44
  27. package/hooks/hypo-shared.mjs +880 -28
  28. package/hooks/hypo-web-fetch-ingest.mjs +121 -0
  29. package/hooks/version-check-fetch.mjs +74 -0
  30. package/hooks/version-check.mjs +184 -0
  31. package/package.json +17 -3
  32. package/scripts/crystallize.mjs +623 -18
  33. package/scripts/doctor.mjs +739 -46
  34. package/scripts/feedback-sync.mjs +974 -0
  35. package/scripts/feedback.mjs +253 -44
  36. package/scripts/graph.mjs +35 -22
  37. package/scripts/ingest.mjs +89 -16
  38. package/scripts/init.mjs +442 -114
  39. package/scripts/lib/design-history-stale.mjs +83 -0
  40. package/scripts/lib/extensions.mjs +749 -0
  41. package/scripts/lib/frontmatter.mjs +5 -1
  42. package/scripts/lib/hypo-ignore.mjs +12 -10
  43. package/scripts/lib/pkg-json.mjs +23 -5
  44. package/scripts/lib/project-create.mjs +225 -0
  45. package/scripts/lib/schema-vocab.mjs +96 -0
  46. package/scripts/lint.mjs +238 -31
  47. package/scripts/query.mjs +26 -10
  48. package/scripts/resume.mjs +11 -5
  49. package/scripts/session-audit.mjs +277 -0
  50. package/scripts/smoke-pack.mjs +224 -0
  51. package/scripts/stats.mjs +24 -10
  52. package/scripts/uninstall.mjs +369 -48
  53. package/scripts/upgrade.mjs +766 -195
  54. package/scripts/verify.mjs +24 -14
  55. package/scripts/weekly-report.mjs +211 -0
  56. package/skills/crystallize/SKILL.md +24 -7
  57. package/skills/graph/SKILL.md +4 -0
  58. package/skills/ingest/SKILL.md +29 -5
  59. package/skills/lint/SKILL.md +4 -0
  60. package/skills/query/SKILL.md +4 -0
  61. package/skills/verify/SKILL.md +4 -0
  62. package/templates/.hypoignore +19 -2
  63. package/templates/Home.md +2 -0
  64. package/templates/SCHEMA.md +61 -6
  65. package/templates/extensions/agents/.gitkeep +0 -0
  66. package/templates/extensions/commands/.gitkeep +0 -0
  67. package/templates/extensions/hooks/.gitkeep +0 -0
  68. package/templates/extensions/skills/.gitkeep +0 -0
  69. package/templates/gitignore +5 -0
  70. package/templates/hot.md +2 -0
  71. package/templates/hypo-config.md +1 -1
  72. package/templates/hypo-guide.md +63 -1
  73. package/templates/hypo-help.md +1 -1
  74. package/templates/pages/observability/_index.md +77 -0
  75. package/templates/projects/_template/index.md +2 -2
  76. package/templates/projects/_template/prd.md +1 -1
@@ -10,29 +10,612 @@
10
10
  * node scripts/crystallize.mjs [options]
11
11
  *
12
12
  * Options:
13
- * --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
14
- * --min-group=<n> Min pages per tag group to report (default: 2)
15
- * --json Output as JSON
13
+ * --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
14
+ * --min-group=<n> Min pages per tag group to report (default: 2)
15
+ * --check-session-close Verify the strict session-close memory files — 5 mandatory + open-questions conditional (fix #17)
16
+ * --apply-session-close Apply a JSON payload that updates the 5 mandatory memory files
17
+ * (+ optional open-questions). Idempotent — re-running with the same
18
+ * payload is a no-op. Always finishes with the strict gate check.
19
+ *
20
+ * Without --payload, runs as a cheap "already complete?" probe:
21
+ * if the strict gate is ok, exits 0 with alreadyComplete:true;
22
+ * otherwise exits 1 with "payload is required". Fix #39 (option D):
23
+ * payload presence = explicit close intent → always full apply
24
+ * (fix #38's per-entry idempotency keeps re-apply cheap).
25
+ * --payload=<path|-> Path to JSON payload (file or `-` for stdin). Required for any
26
+ * apply work; omit only for the probe path above.
27
+ * --force Bypass the no-payload probe early-exit. Payload is still required
28
+ * for any apply work — --force only opts out of the alreadyComplete
29
+ * shortcut. Reserved for explicit diagnostics / scripted recovery.
30
+ * --json Output as JSON
31
+ *
32
+ * Payload schema (fix #38):
33
+ * {
34
+ * "project": "<slug>", // optional — defaults to resolveActiveProject()
35
+ * "date": "YYYY-MM-DD", // optional — defaults to today (local)
36
+ * "sessionState": { "content": "<full file>" }, // overwrite (idempotent: identical bytes → skip)
37
+ * "projectHot": { "content": "<full file>" }, // overwrite
38
+ * "rootHot": { "content": "<full file>" }, // overwrite
39
+ * "sessionLog": { "entry": "## [date] ..." }, // append, skip if heading already present
40
+ * "log": { "entry": "## [date] session | <project> ..." }, // append, skip if entry present
41
+ * "openQuestions":{ "content": "<full file>" } // optional overwrite
42
+ * }
43
+ *
44
+ * The helper does NOT auto-fix `updated:` frontmatter. If a payload field carries a
45
+ * stale date, the final sessionCloseFileStatus check fails with a clear error so the
46
+ * caller fixes the payload and retries. Silent rewrites would mask payload bugs.
47
+ *
48
+ * Lint gates (fix #40):
49
+ * • Preflight — runs `lint.mjs --json` BEFORE any payload byte is written.
50
+ * Errors in files this payload will OVERWRITE (sessionState/projectHot/
51
+ * rootHot/openQuestions) are filtered out — they're about to be replaced,
52
+ * and not filtering them dead-locks the documented "fix payload and retry"
53
+ * recovery after a post-apply-lint failure (codex P2). Errors in any other
54
+ * file → exit 1 with stage='preflight-lint', no apply occurs. PreCompact's
55
+ * hypo-personal-check is still the final enforcement.
56
+ * • Post-apply — runs after the writes. Surfaces as stage='post-apply-lint'
57
+ * (or 'post-apply-verification+lint' if freshness also fails). Catches
58
+ * payloads that introduce a broken wikilink / malformed body.
16
59
  */
17
60
 
18
- import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
19
- import { join, relative, extname } from 'path';
61
+ import {
62
+ existsSync,
63
+ readFileSync,
64
+ readdirSync,
65
+ statSync,
66
+ writeFileSync,
67
+ mkdirSync,
68
+ renameSync,
69
+ } from 'fs';
70
+ import { join, relative, extname, dirname } from 'path';
71
+ import { spawnSync } from 'child_process';
72
+ import { fileURLToPath } from 'url';
20
73
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
21
74
  import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
75
+ import {
76
+ sessionCloseFileStatus,
77
+ writeSessionClosedMarker,
78
+ sessionClosedMarkerPath,
79
+ hypoIsClean,
80
+ } from '../hooks/hypo-shared.mjs';
81
+
82
+ const LINT_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'lint.mjs');
83
+
84
+ // Spawn lint.mjs --json against `hypoDir` and return parsed result.
85
+ // We shell out instead of refactoring lint.mjs into a library because lint.mjs
86
+ // keeps issues in module scope (scripts/lint.mjs:139,250) — a programmatic
87
+ // extraction is its own chore. spawnSync is the minimum-invasive path for #40.
88
+ // Throws only on JSON parse failure (lint crashed mid-run); a lint that exits 1
89
+ // with valid JSON is a normal "errors present" signal, not a crash.
90
+ // maxBuffer raised to 64 MiB: warn-only output on a large wiki can otherwise
91
+ // trip Node's 1 MiB default, truncate stdout, and turn a clean wiki into a
92
+ // JSON.parse crash (codex P3 — fix #40 follow-up).
93
+ function runLint(hypoDir) {
94
+ const r = spawnSync(process.execPath, [LINT_SCRIPT, `--hypo-dir=${hypoDir}`, '--json'], {
95
+ encoding: 'utf-8',
96
+ maxBuffer: 64 * 1024 * 1024,
97
+ });
98
+ try {
99
+ return JSON.parse(r.stdout);
100
+ } catch {
101
+ throw new Error(
102
+ `lint helper produced unparseable output (exit=${r.status}):\n${r.stdout}\n${r.stderr}`,
103
+ );
104
+ }
105
+ }
22
106
 
23
107
  // ── arg parsing ──────────────────────────────────────────────────────────────
24
108
 
25
109
  function parseArgs(argv) {
26
- const args = { hypoDir: null, minGroup: 2, json: false };
110
+ const args = {
111
+ hypoDir: null,
112
+ minGroup: 2,
113
+ json: false,
114
+ checkSessionClose: false,
115
+ applySessionClose: false,
116
+ markSessionClosed: false,
117
+ sessionId: null,
118
+ payload: null,
119
+ force: false,
120
+ };
27
121
  for (const arg of argv.slice(2)) {
28
- if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
122
+ if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
29
123
  else if (arg.startsWith('--min-group=')) args.minGroup = parseInt(arg.slice(12), 10) || 2;
30
- else if (arg === '--json') args.json = true;
124
+ else if (arg === '--check-session-close') args.checkSessionClose = true;
125
+ else if (arg === '--apply-session-close') args.applySessionClose = true;
126
+ else if (arg === '--mark-session-closed') args.markSessionClosed = true;
127
+ else if (arg.startsWith('--session-id=')) args.sessionId = arg.slice(13);
128
+ else if (arg.startsWith('--payload=')) args.payload = arg.slice(10);
129
+ else if (arg === '--force') args.force = true;
130
+ else if (arg === '--json') args.json = true;
31
131
  }
32
132
  if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
33
133
  return args;
34
134
  }
35
135
 
136
+ // ── session-close check (spec §5.2.7 / §8.3) ────────────────────────
137
+ // Mirrors the hard gate in hypo-personal-check.mjs so the /hypo:crystallize
138
+ // flow can self-verify before /compact triggers PreCompact.
139
+
140
+ function runSessionCloseCheck(args) {
141
+ const status = sessionCloseFileStatus(args.hypoDir);
142
+
143
+ if (args.json) {
144
+ console.log(
145
+ JSON.stringify(
146
+ {
147
+ ok: status.ok,
148
+ project: status.project,
149
+ dates: status.dates,
150
+ stale: status.stale,
151
+ missing: status.missing,
152
+ },
153
+ null,
154
+ 2,
155
+ ),
156
+ );
157
+ process.exit(status.ok ? 0 : 1);
158
+ }
159
+
160
+ const proj = status.project || '(unresolved)';
161
+ console.log(`Session-close check (project: ${proj}, date: ${status.dates.join(' / ')}):\n`);
162
+
163
+ const required = status.project
164
+ ? [
165
+ `projects/${status.project}/session-state.md`,
166
+ `projects/${status.project}/hot.md`,
167
+ 'hot.md',
168
+ `projects/${status.project}/session-log/${status.dates[0].slice(0, 7)}.md`,
169
+ 'log.md',
170
+ ]
171
+ : [];
172
+ for (const f of required) {
173
+ const bad = status.missing.includes(f) ? 'missing' : status.stale.includes(f) ? 'stale' : '';
174
+ console.log(` ${bad ? '✗' : '✓'} ${f}${bad ? ` — ${bad}` : ''}`);
175
+ }
176
+ // Surface anything not covered by the canonical list (e.g. unresolved project).
177
+ for (const f of [...status.missing, ...status.stale]) {
178
+ if (!required.includes(f)) console.log(` ✗ ${f}`);
179
+ }
180
+ console.log('');
181
+ console.log(
182
+ status.ok
183
+ ? '✓ All required memory files updated this session. (open-questions.md: conditional, not checked)'
184
+ : '✗ Session close incomplete — update the files marked above, then retry.',
185
+ );
186
+ process.exit(status.ok ? 0 : 1);
187
+ }
188
+
189
+ // ── session-close apply ────────────────────────────────────────────
190
+ // Idempotent payload-driven application of the 5 mandatory session-close memory
191
+ // files (+ optional open-questions). Used by the LLM session-close flow as the
192
+ // canonical entrypoint instead of issuing 5+ Write tool calls by hand.
193
+ //
194
+ // Idempotency:
195
+ // • full-content fields (sessionState/projectHot/rootHot/openQuestions): write
196
+ // only when on-disk bytes differ — re-running with same payload is a no-op.
197
+ // • append fields (sessionLog/log): skip when the dated heading/entry is
198
+ // already present (regex shared with sessionCloseFileStatus via hypo-shared).
199
+ //
200
+ // Validation: never auto-fixes the payload. The final sessionCloseFileStatus
201
+ // check fails fast on stale `updated:` or missing entries so the caller fixes
202
+ // the payload and retries — silent rewrites would hide payload bugs (advisor #3).
203
+
204
+ function readPayload(source) {
205
+ if (!source)
206
+ throw new Error('--payload is required with --apply-session-close (path or `-` for stdin)');
207
+ let raw;
208
+ if (source === '-') {
209
+ // Synchronous stdin read; payloads are tiny (a few hundred KB at most).
210
+ raw = readFileSync(0, 'utf-8');
211
+ } else {
212
+ const path = expandHome(source);
213
+ if (!existsSync(path)) throw new Error(`payload file not found: ${path}`);
214
+ raw = readFileSync(path, 'utf-8');
215
+ }
216
+ try {
217
+ return JSON.parse(raw);
218
+ } catch (e) {
219
+ throw new Error(`payload is not valid JSON: ${e.message}`);
220
+ }
221
+ }
222
+
223
+ /** Atomic write via tmp+rename. `<path>.<pid>.<rand>.tmp` so concurrent helpers
224
+ * don't fight over the same shared `<path>.tmp` slot. */
225
+ function atomicWrite(path, content) {
226
+ mkdirSync(dirname(path), { recursive: true });
227
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
228
+ writeFileSync(tmp, content);
229
+ renameSync(tmp, path);
230
+ }
231
+
232
+ /** Atomic write that skips when on-disk bytes already match `content`. */
233
+ function writeIfChanged(path, content) {
234
+ if (existsSync(path)) {
235
+ try {
236
+ if (readFileSync(path, 'utf-8') === content) return false; // idempotent skip
237
+ } catch {
238
+ /* fall through to overwrite */
239
+ }
240
+ }
241
+ atomicWrite(path, content);
242
+ return true;
243
+ }
244
+
245
+ /**
246
+ * Append `entry` to `path` only if `alreadyPresent(content)` is false.
247
+ * Atomic: rebuilds the full file content and writes via atomicWrite — a crash
248
+ * mid-append cannot leave log.md or session-log/YYYY-MM.md half-written, which
249
+ * matters for these append-only history files (codex review of fix #38).
250
+ */
251
+ function appendIfAbsent(path, entry, alreadyPresent) {
252
+ let content = '';
253
+ if (existsSync(path)) {
254
+ try {
255
+ content = readFileSync(path, 'utf-8');
256
+ } catch {
257
+ content = '';
258
+ }
259
+ }
260
+ if (alreadyPresent(content)) return false;
261
+ // Ensure single blank line between existing tail and new entry, no trailing dup.
262
+ const sep =
263
+ content === '' ? '' : content.endsWith('\n\n') ? '' : content.endsWith('\n') ? '\n' : '\n\n';
264
+ const next = entry.endsWith('\n') ? entry : entry + '\n';
265
+ atomicWrite(path, content + sep + next);
266
+ return true;
267
+ }
268
+
269
+ function todayLocal() {
270
+ const d = new Date();
271
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
272
+ }
273
+
274
+ // Spec §5.2.7 / §8.3 + ADR 0029: 5 mandatory + 1 conditional. The payload
275
+ // shape MUST mirror that contract — missing a mandatory field is a payload
276
+ // bug, not a no-op. Caller is the LLM session-close flow, which composes the
277
+ // payload deliberately; partial payloads must fail loudly so caller fixes them
278
+ // rather than silently relying on yesterday's freshness state. (Codex review
279
+ // of fix #38 — Worker 1 finding 1.)
280
+ const REQUIRED_PAYLOAD_FIELDS = [
281
+ ['sessionState', 'content'],
282
+ ['projectHot', 'content'],
283
+ ['rootHot', 'content'],
284
+ ['sessionLog', 'entry'],
285
+ ['log', 'entry'],
286
+ ];
287
+
288
+ function validatePayloadShape(payload) {
289
+ const errs = [];
290
+ if (!payload || typeof payload !== 'object') {
291
+ errs.push('payload must be a JSON object');
292
+ return errs;
293
+ }
294
+ for (const [field, key] of REQUIRED_PAYLOAD_FIELDS) {
295
+ const slot = payload[field];
296
+ if (!slot || typeof slot !== 'object') {
297
+ errs.push(`payload.${field} is required (object with .${key})`);
298
+ continue;
299
+ }
300
+ if (typeof slot[key] !== 'string') {
301
+ errs.push(`payload.${field}.${key} must be a string`);
302
+ }
303
+ }
304
+ if (payload.openQuestions !== undefined) {
305
+ if (
306
+ !payload.openQuestions ||
307
+ typeof payload.openQuestions !== 'object' ||
308
+ typeof payload.openQuestions.content !== 'string'
309
+ ) {
310
+ errs.push('payload.openQuestions, when present, must be { content: string }');
311
+ }
312
+ }
313
+ if (payload.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(payload.date))) {
314
+ errs.push('payload.date, when present, must be YYYY-MM-DD');
315
+ }
316
+ return errs;
317
+ }
318
+
319
+ // ── session-close marker (ADR 0022 amendment 2026-05-19) ──────
320
+ // Standalone marker writer. Used when the LLM closes the session via direct
321
+ // Write tool calls (not --apply-session-close). Hook `hypo-auto-minimal-
322
+ // crystallize` is the only Reader; writer authority is intentionally split
323
+ // between this CLI and the auto-write at the tail of applySessionClose.
324
+ //
325
+ // Contract: marker is only written when sessionCloseFileStatus(hypoDir).ok.
326
+ // A failed check exits 1 with no marker — the next Stop hook will re-block.
327
+
328
+ function runMarkSessionClosed(args) {
329
+ if (!args.sessionId) {
330
+ const msg = '--session-id=<id> is required with --mark-session-closed';
331
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
332
+ process.exit(1);
333
+ }
334
+ // ADR 0022 amendment 2026-05-19 Q2: marker write authority requires BOTH
335
+ // sessionCloseFileStatus.ok AND hypoIsClean.clean — git dirty would let a
336
+ // Stop hook pass while wiki changes are still uncommitted (auto-commit may
337
+ // have failed in this run). Codex Worker-1 BLOCKER (pre-commit review).
338
+ const status = sessionCloseFileStatus(args.hypoDir);
339
+ const git = hypoIsClean(args.hypoDir);
340
+ if (!status.ok || !git.clean) {
341
+ const result = {
342
+ ok: false,
343
+ session_id: args.sessionId,
344
+ project: status.project,
345
+ missing: status.missing,
346
+ stale: status.stale,
347
+ git_reason: git.clean ? null : git.reason,
348
+ error: 'session-close gate not satisfied — marker not written',
349
+ };
350
+ if (args.json) {
351
+ console.log(JSON.stringify(result, null, 2));
352
+ } else {
353
+ console.log(`✗ session-close gate not satisfied — marker not written (project: ${status.project || '(unresolved)'}):`);
354
+ for (const f of status.missing) console.log(` ✗ ${f} (missing)`);
355
+ for (const f of status.stale) console.log(` ✗ ${f} (stale)`);
356
+ if (!git.clean) console.log(` ✗ git: ${git.reason}`);
357
+ }
358
+ process.exit(1);
359
+ }
360
+ writeSessionClosedMarker(args.hypoDir, args.sessionId, { project: status.project });
361
+ // Marker writer swallows IO errors (best-effort, see hypo-shared.mjs). Verify
362
+ // the file actually landed before claiming success — otherwise CLI exits 0
363
+ // while next Stop re-blocks, hiding a permission/disk problem.
364
+ // Codex Worker-2 CONCERN (pre-commit review).
365
+ if (!existsSync(sessionClosedMarkerPath(args.hypoDir, args.sessionId))) {
366
+ const err = 'marker file did not land after write (likely .cache permission/disk issue)';
367
+ console.log(args.json ? JSON.stringify({ ok: false, session_id: args.sessionId, error: err }, null, 2) : `✗ ${err}`);
368
+ process.exit(1);
369
+ }
370
+ const result = {
371
+ ok: true,
372
+ session_id: args.sessionId,
373
+ project: status.project,
374
+ date: status.dates[0],
375
+ };
376
+ if (args.json) {
377
+ console.log(JSON.stringify(result, null, 2));
378
+ } else {
379
+ console.log(`✓ session-closed marker written (session_id: ${args.sessionId}, project: ${status.project}).`);
380
+ }
381
+ process.exit(0);
382
+ }
383
+
384
+ function applySessionClose(args) {
385
+ // Fix #39 (option D): early-exit fires only when NO payload was supplied.
386
+ // Rationale: payload presence is explicit close intent and must always run
387
+ // the full apply path — fix #38's per-entry idempotency (writeIfChanged +
388
+ // exact-entry append dedup) keeps re-apply cheap without short-circuiting,
389
+ // and avoids silent-success when a same-day second close brings new bytes.
390
+ // Payload-less invocation is treated as a cheap "already complete?" probe.
391
+ // --force opts out of that probe shortcut only — payload remains required
392
+ // for any actual apply work (readPayload below surfaces "payload is
393
+ // required" the same way it always has).
394
+ if (!args.force && !args.payload) {
395
+ const probe = sessionCloseFileStatus(args.hypoDir);
396
+ if (probe.ok) {
397
+ const result = {
398
+ ok: true,
399
+ alreadyComplete: true,
400
+ project: probe.project,
401
+ date: probe.dates[0],
402
+ message: '오늘 이미 close 완료로 보임 (probe 모드 — payload 미지정).',
403
+ };
404
+ if (args.json) {
405
+ console.log(JSON.stringify(result, null, 2));
406
+ } else {
407
+ console.log(`✓ ${result.message}`);
408
+ console.log(` project: ${result.project} / date: ${result.date}`);
409
+ }
410
+ process.exit(0);
411
+ }
412
+ // gate not ok → fall through to readPayload, which surfaces
413
+ // "payload is required" with the same error shape as before.
414
+ }
415
+
416
+ let payload;
417
+ try {
418
+ payload = readPayload(args.payload);
419
+ } catch (e) {
420
+ const out = { ok: false, error: e.message };
421
+ console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${e.message}`);
422
+ process.exit(1);
423
+ }
424
+
425
+ const schemaErrs = validatePayloadShape(payload);
426
+ if (schemaErrs.length > 0) {
427
+ const out = { ok: false, error: 'payload schema invalid', details: schemaErrs };
428
+ console.log(
429
+ args.json
430
+ ? JSON.stringify(out, null, 2)
431
+ : `✗ payload schema invalid:\n ${schemaErrs.join('\n ')}`,
432
+ );
433
+ process.exit(1);
434
+ }
435
+
436
+ // Resolve project: explicit payload.project wins; else fall back to active project.
437
+ // Done via sessionCloseFileStatus to keep one source of truth (and so a
438
+ // missing pointer table surfaces the same error shape as --check-session-close).
439
+ // Resolved BEFORE preflight because preflight needs overwrite-target paths
440
+ // (which require the project slug) to filter out errors in files this apply
441
+ // is about to replace — see the filter rationale below.
442
+ const probe = sessionCloseFileStatus(args.hypoDir);
443
+ const project = payload.project || probe.project;
444
+ if (!project) {
445
+ const msg =
446
+ 'no project resolved (payload.project missing and root hot.md has no active-project row)';
447
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
448
+ process.exit(1);
449
+ }
450
+ const date = payload.date || todayLocal();
451
+ const ym = date.slice(0, 7);
452
+
453
+ // Fix #40 preflight: lint the wiki BEFORE writing any payload bytes. If lint
454
+ // has blockers (errors) in files this apply WON'T overwrite, the wiki is in
455
+ // a degraded state and apply would mask the root cause — abort fail-fast.
456
+ //
457
+ // Overwrite-target filter (codex P2 follow-up): errors in files we're about
458
+ // to fully replace are IGNORED at preflight. Otherwise a bad payload
459
+ // (post-apply-lint fail) would leave the broken file on disk and the very
460
+ // next retry — even with a corrected payload — gets dead-locked here. The
461
+ // post-apply lint is the authoritative check on payload content.
462
+ //
463
+ // Append targets (session-log, log.md) are NOT filtered: appending can't
464
+ // repair existing corruption, so a corrupt session-log must still block.
465
+ // Warns are informational (not gated) in either pass.
466
+ const overwriteTargets = new Set();
467
+ if (payload.sessionState) overwriteTargets.add(join('projects', project, 'session-state.md'));
468
+ if (payload.projectHot) overwriteTargets.add(join('projects', project, 'hot.md'));
469
+ if (payload.rootHot) overwriteTargets.add('hot.md');
470
+ if (payload.openQuestions) overwriteTargets.add(join('pages', 'open-questions.md'));
471
+
472
+ let preflightLint;
473
+ try {
474
+ preflightLint = runLint(args.hypoDir);
475
+ } catch (e) {
476
+ const out = { ok: false, stage: 'preflight-lint', error: e.message };
477
+ console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${e.message}`);
478
+ process.exit(1);
479
+ }
480
+ const blockingErrors = preflightLint.errors.filter((e) => !overwriteTargets.has(e.file));
481
+ if (blockingErrors.length > 0) {
482
+ const out = {
483
+ ok: false,
484
+ stage: 'preflight-lint',
485
+ error: 'lint preflight failed — apply aborted (no payload bytes written)',
486
+ lint: { ...preflightLint, blockingErrors },
487
+ };
488
+ if (args.json) {
489
+ console.log(JSON.stringify(out, null, 2));
490
+ } else {
491
+ console.log('✗ lint preflight failed — apply aborted (no payload bytes written):');
492
+ for (const e of blockingErrors) console.log(` ✗ ${e.file}: ${e.message}`);
493
+ console.log(' Fix the wiki (run `node scripts/lint.mjs`) and retry.');
494
+ }
495
+ process.exit(1);
496
+ }
497
+
498
+ const applied = [];
499
+ const skipped = [];
500
+
501
+ const overwrite = (key, relPath, field) => {
502
+ if (!field || typeof field.content !== 'string') return; // optional / absent
503
+ const wrote = writeIfChanged(join(args.hypoDir, relPath), field.content);
504
+ (wrote ? applied : skipped).push(`${key} (${relPath})`);
505
+ };
506
+
507
+ overwrite('sessionState', join('projects', project, 'session-state.md'), payload.sessionState);
508
+ overwrite('projectHot', join('projects', project, 'hot.md'), payload.projectHot);
509
+ overwrite('rootHot', 'hot.md', payload.rootHot);
510
+ overwrite('openQuestions', join('pages', 'open-questions.md'), payload.openQuestions);
511
+
512
+ // Append idempotency: dedup by exact-entry presence, not by "any heading
513
+ // dated today". The freshness gate (sessionCloseFileStatus) is what answers
514
+ // "was this file touched today?"; that's a different concern and must not
515
+ // be reused for apply-time dedup, or a legitimate same-day second close gets
516
+ // silently dropped (Codex review of fix #38 — Worker 1 finding 2).
517
+ const entryAlreadyPresent = (entry) => (content) =>
518
+ content.includes(entry.endsWith('\n') ? entry.replace(/\n+$/, '') : entry);
519
+
520
+ {
521
+ const rel = join('projects', project, 'session-log', `${ym}.md`);
522
+ const wrote = appendIfAbsent(
523
+ join(args.hypoDir, rel),
524
+ payload.sessionLog.entry,
525
+ entryAlreadyPresent(payload.sessionLog.entry),
526
+ );
527
+ (wrote ? applied : skipped).push(`sessionLog (${rel})`);
528
+ }
529
+
530
+ {
531
+ const wrote = appendIfAbsent(
532
+ join(args.hypoDir, 'log.md'),
533
+ payload.log.entry,
534
+ entryAlreadyPresent(payload.log.entry),
535
+ );
536
+ (wrote ? applied : skipped).push('log (log.md)');
537
+ }
538
+
539
+ const verification = sessionCloseFileStatus(args.hypoDir);
540
+
541
+ // Fix #40 post-apply lint: payload may have introduced a broken wikilink or
542
+ // a malformed session-state body. Surface as a distinct `stage` so caller can
543
+ // tell "lint broke" apart from "frontmatter stale". This runs even if the
544
+ // freshness gate also failed — both failure modes are useful to the caller.
545
+ let postApplyLint;
546
+ try {
547
+ postApplyLint = runLint(args.hypoDir);
548
+ } catch (e) {
549
+ postApplyLint = {
550
+ ok: false,
551
+ errors: [{ file: '(lint crash)', message: e.message }],
552
+ warns: [],
553
+ };
554
+ }
555
+
556
+ const ok = verification.ok && postApplyLint.ok;
557
+
558
+ // fix #27 PR-C (ADR 0022 amendment 2026-05-19): auto-write the per-session
559
+ // closed marker on a verified close. Hook authority is read-only; this is
560
+ // one of the two writer paths (the other is --mark-session-closed standalone).
561
+ // Marker requires BOTH file/lint gate (already in `ok`) AND clean git tree —
562
+ // ADR Q2 explicit. Auto-commit may have failed silently in the Stop chain;
563
+ // a dirty git would otherwise let the marker pass for an unrecorded close.
564
+ if (ok && args.sessionId) {
565
+ const git = hypoIsClean(args.hypoDir);
566
+ if (git.clean) {
567
+ writeSessionClosedMarker(args.hypoDir, args.sessionId, { project });
568
+ }
569
+ // git not clean → silent skip: caller's `result.ok` already reflects the
570
+ // file/lint state; surfacing a "marker skipped" warning here would
571
+ // confuse the close-applied success path. Next Stop re-blocks until
572
+ // git is clean (auto-commit retries on subsequent runs).
573
+ }
574
+ const stage = ok
575
+ ? null
576
+ : !verification.ok && !postApplyLint.ok
577
+ ? 'post-apply-verification+lint'
578
+ : !verification.ok
579
+ ? 'post-apply-verification'
580
+ : 'post-apply-lint';
581
+ const result = {
582
+ ok,
583
+ stage,
584
+ project,
585
+ date,
586
+ applied,
587
+ skipped,
588
+ verification,
589
+ lint: { preflight: preflightLint, postApply: postApplyLint },
590
+ };
591
+
592
+ if (args.json) {
593
+ console.log(JSON.stringify(result, null, 2));
594
+ } else {
595
+ console.log(`Session-close apply (project: ${project}, date: ${date}):`);
596
+ for (const a of applied) console.log(` ✓ wrote ${a}`);
597
+ for (const s of skipped) console.log(` · skipped ${s} (already current)`);
598
+ if (ok) {
599
+ console.log('\n✓ session-close verified — all 5 mandatory files fresh, lint clean.');
600
+ } else {
601
+ if (!verification.ok) {
602
+ const bad = [
603
+ ...verification.missing.map((f) => `${f} (missing)`),
604
+ ...verification.stale.map((f) => `${f} (stale)`),
605
+ ].join(', ');
606
+ console.log(`\n✗ session-close still incomplete after apply: ${bad}`);
607
+ console.log(' Fix the payload (likely an `updated:` field) and retry.');
608
+ }
609
+ if (!postApplyLint.ok) {
610
+ console.log('\n✗ post-apply lint failed:');
611
+ for (const e of postApplyLint.errors) console.log(` ✗ ${e.file}: ${e.message}`);
612
+ console.log(' Payload introduced a lint blocker — fix the payload content and retry.');
613
+ }
614
+ }
615
+ }
616
+ process.exit(ok ? 0 : 1);
617
+ }
618
+
36
619
  // ── helpers ──────────────────────────────────────────────────────────────────
37
620
 
38
621
  function collectPages(dir, root, acc = [], ignorePatterns = []) {
@@ -57,7 +640,10 @@ function parseFrontmatter(content) {
57
640
  for (const line of m[1].split('\n')) {
58
641
  const idx = line.indexOf(':');
59
642
  if (idx < 0) continue;
60
- fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim().replace(/^["']|["']$/g, '');
643
+ fm[line.slice(0, idx).trim()] = line
644
+ .slice(idx + 1)
645
+ .trim()
646
+ .replace(/^["']|["']$/g, '');
61
647
  }
62
648
  return fm;
63
649
  }
@@ -65,34 +651,53 @@ function parseFrontmatter(content) {
65
651
  function parseTags(fm) {
66
652
  if (!fm.tags) return [];
67
653
  const raw = fm.tags.trim().replace(/^\[|\]$/g, '');
68
- return raw.split(',').map(t => t.trim()).filter(Boolean);
654
+ return raw
655
+ .split(',')
656
+ .map((t) => t.trim())
657
+ .filter(Boolean);
69
658
  }
70
659
 
71
660
  function extractWikilinks(content) {
72
- return [...content.matchAll(/\[\[([^\]|#]+?)(?:[|#][^\]]*?)?\]\]/g)].map(m => m[1].trim());
661
+ return [...content.matchAll(/\[\[([^\]|#]+?)(?:[|#][^\]]*?)?\]\]/g)].map((m) => m[1].trim());
73
662
  }
74
663
 
75
664
  // ── main ─────────────────────────────────────────────────────────────────────
76
665
 
77
666
  const args = parseArgs(process.argv);
78
667
 
668
+ if (args.markSessionClosed) {
669
+ runMarkSessionClosed(args); // exits
670
+ }
671
+
672
+ if (args.applySessionClose) {
673
+ applySessionClose(args); // exits
674
+ }
675
+
676
+ if (args.checkSessionClose) {
677
+ runSessionCloseCheck(args); // exits
678
+ }
679
+
79
680
  const ignorePatterns = loadHypoIgnore(args.hypoDir);
80
681
  const pagesDir = join(args.hypoDir, 'pages');
81
682
  const pages = collectPages(pagesDir, args.hypoDir, [], ignorePatterns);
82
683
 
83
- const tagGroups = {}; // tag → [{ slug, title }]
84
- const unlinked = []; // pages with no outbound wikilinks
85
- const drafts = []; // pages tagged draft
684
+ const tagGroups = {}; // tag → [{ slug, title }]
685
+ const unlinked = []; // pages with no outbound wikilinks
686
+ const drafts = []; // pages tagged draft
86
687
 
87
688
  for (const { path, rel } of pages) {
88
689
  let content;
89
- try { content = readFileSync(path, 'utf-8'); } catch { continue; }
690
+ try {
691
+ content = readFileSync(path, 'utf-8');
692
+ } catch {
693
+ continue;
694
+ }
90
695
  const fm = parseFrontmatter(content);
91
696
  if (!fm) continue;
92
697
 
93
- const slug = rel.replace(/\.md$/, '');
698
+ const slug = rel.replace(/\.md$/, '');
94
699
  const title = fm.title || slug;
95
- const tags = parseTags(fm);
700
+ const tags = parseTags(fm);
96
701
 
97
702
  // tag groups
98
703
  for (const tag of tags) {
@@ -106,7 +711,7 @@ for (const { path, rel } of pages) {
106
711
  }
107
712
 
108
713
  // unlinked (no outbound wikilinks in body)
109
- const body = content.replace(/^---[\s\S]*?---/, '');
714
+ const body = content.replace(/^---[\s\S]*?---/, '');
110
715
  const links = extractWikilinks(body);
111
716
  if (links.length === 0) unlinked.push({ slug, title });
112
717
  }