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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +12 -5
- package/README.md +12 -5
- package/commands/audit.md +46 -0
- package/commands/crystallize.md +113 -23
- package/commands/feedback.md +40 -26
- package/commands/ingest.md +31 -9
- package/commands/upgrade.md +2 -2
- package/docs/ARCHITECTURE.md +83 -9
- package/docs/CONTRIBUTING.md +2 -2
- package/hooks/hooks.json +39 -1
- package/hooks/hypo-auto-commit.mjs +23 -4
- package/hooks/hypo-auto-minimal-crystallize.mjs +145 -0
- package/hooks/hypo-auto-stage.mjs +9 -5
- package/hooks/hypo-compact-guard.mjs +33 -24
- package/hooks/hypo-cwd-change.mjs +107 -24
- package/hooks/hypo-file-watch.mjs +23 -10
- package/hooks/hypo-first-prompt.mjs +37 -23
- package/hooks/hypo-hot-rebuild.mjs +31 -8
- package/hooks/hypo-lookup.mjs +171 -65
- package/hooks/hypo-personal-check.mjs +207 -112
- package/hooks/hypo-pre-commit.mjs +46 -0
- package/hooks/hypo-session-end.mjs +58 -0
- package/hooks/hypo-session-record.mjs +60 -0
- package/hooks/hypo-session-start.mjs +312 -44
- package/hooks/hypo-shared.mjs +880 -28
- package/hooks/hypo-web-fetch-ingest.mjs +121 -0
- package/hooks/version-check-fetch.mjs +74 -0
- package/hooks/version-check.mjs +184 -0
- package/package.json +17 -3
- package/scripts/crystallize.mjs +623 -18
- package/scripts/doctor.mjs +739 -46
- package/scripts/feedback-sync.mjs +974 -0
- package/scripts/feedback.mjs +253 -44
- package/scripts/graph.mjs +35 -22
- package/scripts/ingest.mjs +89 -16
- package/scripts/init.mjs +442 -114
- package/scripts/lib/design-history-stale.mjs +83 -0
- package/scripts/lib/extensions.mjs +749 -0
- package/scripts/lib/frontmatter.mjs +5 -1
- package/scripts/lib/hypo-ignore.mjs +12 -10
- package/scripts/lib/pkg-json.mjs +23 -5
- package/scripts/lib/project-create.mjs +225 -0
- package/scripts/lib/schema-vocab.mjs +96 -0
- package/scripts/lint.mjs +238 -31
- package/scripts/query.mjs +26 -10
- package/scripts/resume.mjs +11 -5
- package/scripts/session-audit.mjs +277 -0
- package/scripts/smoke-pack.mjs +224 -0
- package/scripts/stats.mjs +24 -10
- package/scripts/uninstall.mjs +369 -48
- package/scripts/upgrade.mjs +766 -195
- package/scripts/verify.mjs +24 -14
- package/scripts/weekly-report.mjs +211 -0
- package/skills/crystallize/SKILL.md +24 -7
- package/skills/graph/SKILL.md +4 -0
- package/skills/ingest/SKILL.md +29 -5
- package/skills/lint/SKILL.md +4 -0
- package/skills/query/SKILL.md +4 -0
- package/skills/verify/SKILL.md +4 -0
- package/templates/.hypoignore +19 -2
- package/templates/Home.md +2 -0
- package/templates/SCHEMA.md +61 -6
- package/templates/extensions/agents/.gitkeep +0 -0
- package/templates/extensions/commands/.gitkeep +0 -0
- package/templates/extensions/hooks/.gitkeep +0 -0
- package/templates/extensions/skills/.gitkeep +0 -0
- package/templates/gitignore +5 -0
- package/templates/hot.md +2 -0
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +63 -1
- package/templates/hypo-help.md +1 -1
- package/templates/pages/observability/_index.md +77 -0
- package/templates/projects/_template/index.md +2 -2
- package/templates/projects/_template/prd.md +1 -1
package/scripts/crystallize.mjs
CHANGED
|
@@ -10,29 +10,612 @@
|
|
|
10
10
|
* node scripts/crystallize.mjs [options]
|
|
11
11
|
*
|
|
12
12
|
* Options:
|
|
13
|
-
* --hypo-dir=<path>
|
|
14
|
-
* --min-group=<n>
|
|
15
|
-
* --
|
|
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 {
|
|
19
|
-
|
|
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 = {
|
|
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='))
|
|
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 === '--
|
|
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
|
|
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
|
|
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
|
|
84
|
-
const unlinked
|
|
85
|
-
const drafts
|
|
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 {
|
|
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
|
|
698
|
+
const slug = rel.replace(/\.md$/, '');
|
|
94
699
|
const title = fm.title || slug;
|
|
95
|
-
const tags
|
|
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
|
|
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
|
}
|