hypomnema 1.3.1 → 1.3.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 +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/README.ko.md +10 -10
- package/README.md +10 -10
- package/commands/crystallize.md +8 -8
- package/commands/feedback.md +1 -1
- package/commands/resume.md +1 -1
- package/docs/ARCHITECTURE.md +3 -3
- package/docs/CONTRIBUTING.md +2 -2
- package/hooks/hypo-auto-minimal-crystallize.mjs +57 -11
- package/hooks/hypo-compact-guard.mjs +1 -1
- package/hooks/hypo-cwd-change.mjs +1 -1
- package/hooks/hypo-first-prompt.mjs +2 -2
- package/hooks/hypo-hot-rebuild.mjs +14 -1
- package/hooks/hypo-personal-check.mjs +91 -179
- package/hooks/hypo-pre-commit.mjs +1 -1
- package/hooks/hypo-session-end.mjs +1 -1
- package/hooks/hypo-session-start.mjs +7 -7
- package/hooks/hypo-shared.mjs +788 -72
- package/hooks/hypo-web-fetch-ingest.mjs +2 -2
- package/hooks/version-check-fetch.mjs +2 -8
- package/hooks/version-check.mjs +18 -0
- package/package.json +3 -2
- package/scripts/check-tracker-ids.mjs +329 -0
- package/scripts/crystallize.mjs +244 -109
- package/scripts/doctor.mjs +6 -9
- package/scripts/feedback-sync.mjs +1 -1
- package/scripts/init.mjs +1 -1
- package/scripts/install-git-hooks.mjs +75 -40
- package/scripts/lib/check-tracker-ids.mjs +140 -0
- package/scripts/lib/design-history-stale.mjs +59 -14
- package/scripts/lib/extensions.mjs +4 -4
- package/scripts/lib/fix-manifest.mjs +2 -2
- package/scripts/lib/fix-status-verify.mjs +5 -4
- package/scripts/lib/plugin-detect.mjs +15 -6
- package/scripts/lib/project-create.mjs +1 -1
- package/scripts/lint.mjs +63 -8
- package/scripts/rename.mjs +373 -0
- package/scripts/resume.mjs +75 -19
- package/scripts/uninstall.mjs +1 -1
- package/scripts/upgrade.mjs +26 -25
- package/skills/crystallize/SKILL.md +10 -7
- package/templates/SCHEMA.md +2 -2
- package/templates/hypo-config.md +2 -2
- package/templates/hypo-guide.md +20 -1
- package/templates/projects/_template/index.md +1 -1
package/scripts/crystallize.mjs
CHANGED
|
@@ -12,16 +12,16 @@
|
|
|
12
12
|
* Options:
|
|
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
|
-
* --check-session-close Verify the strict session-close memory files — 5 mandatory + open-questions conditional
|
|
15
|
+
* --check-session-close Verify the strict session-close memory files — 5 mandatory + open-questions conditional
|
|
16
16
|
* --apply-session-close Apply a JSON payload that updates the 5 mandatory memory files
|
|
17
17
|
* (+ optional open-questions). Idempotent — re-running with the same
|
|
18
18
|
* payload is a no-op. Always finishes with the strict gate check.
|
|
19
19
|
*
|
|
20
20
|
* Without --payload, runs as a cheap "already complete?" probe:
|
|
21
21
|
* if the strict gate is ok, exits 0 with alreadyComplete:true;
|
|
22
|
-
* otherwise exits 1 with "payload is required".
|
|
22
|
+
* otherwise exits 1 with "payload is required". Option D:
|
|
23
23
|
* payload presence = explicit close intent → always full apply
|
|
24
|
-
* (
|
|
24
|
+
* (the per-entry idempotency keeps re-apply cheap).
|
|
25
25
|
* --payload=<path|-> Path to JSON payload (file or `-` for stdin). Required for any
|
|
26
26
|
* apply work; omit only for the probe path above.
|
|
27
27
|
* --force Bypass the no-payload probe early-exit. Payload is still required
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* shortcut. Reserved for explicit diagnostics / scripted recovery.
|
|
30
30
|
* --json Output as JSON
|
|
31
31
|
*
|
|
32
|
-
* Payload schema
|
|
32
|
+
* Payload schema:
|
|
33
33
|
* {
|
|
34
34
|
* "project": "<slug>", // optional — defaults to resolveActiveProject()
|
|
35
35
|
* "date": "YYYY-MM-DD", // optional — defaults to today (local)
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
* stale date, the final sessionCloseFileStatus check fails with a clear error so the
|
|
46
46
|
* caller fixes the payload and retries. Silent rewrites would mask payload bugs.
|
|
47
47
|
*
|
|
48
|
-
* Lint gates
|
|
48
|
+
* Lint gates:
|
|
49
49
|
* • Preflight — runs `lint.mjs --json` BEFORE any payload byte is written.
|
|
50
50
|
* Errors in files this payload will OVERWRITE (sessionState/projectHot/
|
|
51
51
|
* rootHot/openQuestions) are filtered out — they're about to be replaced,
|
|
@@ -76,12 +76,15 @@ import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
|
76
76
|
import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
|
|
77
77
|
import {
|
|
78
78
|
sessionCloseFileStatus,
|
|
79
|
+
sessionCloseGlobalStatus,
|
|
80
|
+
precompactGateStatus,
|
|
79
81
|
writeSessionClosedMarker,
|
|
80
82
|
sessionClosedMarkerPath,
|
|
81
|
-
|
|
82
|
-
extractTouchedWikiFiles,
|
|
83
|
-
closeFileTargets,
|
|
83
|
+
readSessionClosedMarker,
|
|
84
84
|
partitionLintScope,
|
|
85
|
+
sessionLogShardPath,
|
|
86
|
+
sessionLogReadCandidates,
|
|
87
|
+
sessionLogScopePath,
|
|
85
88
|
} from '../hooks/hypo-shared.mjs';
|
|
86
89
|
|
|
87
90
|
const LINT_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'lint.mjs');
|
|
@@ -94,7 +97,7 @@ const LINT_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'lint.mjs');
|
|
|
94
97
|
// with valid JSON is a normal "errors present" signal, not a crash.
|
|
95
98
|
// maxBuffer raised to 64 MiB: warn-only output on a large wiki can otherwise
|
|
96
99
|
// trip Node's 1 MiB default, truncate stdout, and turn a clean wiki into a
|
|
97
|
-
// JSON.parse crash (codex P3
|
|
100
|
+
// JSON.parse crash (codex P3 follow-up).
|
|
98
101
|
function runLint(hypoDir) {
|
|
99
102
|
const r = spawnSync(process.execPath, [LINT_SCRIPT, `--hypo-dir=${hypoDir}`, '--json'], {
|
|
100
103
|
encoding: 'utf-8',
|
|
@@ -103,8 +106,17 @@ function runLint(hypoDir) {
|
|
|
103
106
|
try {
|
|
104
107
|
return JSON.parse(r.stdout);
|
|
105
108
|
} catch {
|
|
109
|
+
// Report diagnostic metadata (sizes, exit/signal, spawn error code, a stderr
|
|
110
|
+
// tail) instead of dumping the whole — possibly huge, possibly truncated —
|
|
111
|
+
// stdout. lint.mjs now sets exitCode and exits naturally so its stdout is no
|
|
112
|
+
// longer cut at the 64 KiB pipe boundary; if this still fires it signals a
|
|
113
|
+
// genuine crash, and these fields say which kind.
|
|
114
|
+
const stderrTail = (r.stderr || '').slice(-2000);
|
|
106
115
|
throw new Error(
|
|
107
|
-
`lint helper produced unparseable output
|
|
116
|
+
`lint helper produced unparseable output ` +
|
|
117
|
+
`(exit=${r.status}, signal=${r.signal || 'none'}, ` +
|
|
118
|
+
`stdoutBytes=${(r.stdout || '').length}, spawnError=${r.error?.code || 'none'})` +
|
|
119
|
+
(stderrTail ? `\nstderr tail:\n${stderrTail}` : ''),
|
|
108
120
|
);
|
|
109
121
|
}
|
|
110
122
|
}
|
|
@@ -119,6 +131,7 @@ function parseArgs(argv) {
|
|
|
119
131
|
checkSessionClose: false,
|
|
120
132
|
applySessionClose: false,
|
|
121
133
|
markSessionClosed: false,
|
|
134
|
+
logOnly: false,
|
|
122
135
|
sessionId: null,
|
|
123
136
|
payload: null,
|
|
124
137
|
force: false,
|
|
@@ -130,6 +143,7 @@ function parseArgs(argv) {
|
|
|
130
143
|
else if (arg === '--check-session-close') args.checkSessionClose = true;
|
|
131
144
|
else if (arg === '--apply-session-close') args.applySessionClose = true;
|
|
132
145
|
else if (arg === '--mark-session-closed') args.markSessionClosed = true;
|
|
146
|
+
else if (arg === '--log-only') args.logOnly = true;
|
|
133
147
|
else if (arg.startsWith('--session-id=')) args.sessionId = arg.slice(13);
|
|
134
148
|
else if (arg.startsWith('--payload=')) args.payload = arg.slice(10);
|
|
135
149
|
else if (arg.startsWith('--transcript-path=')) args.transcriptPath = expandHome(arg.slice(18));
|
|
@@ -145,17 +159,54 @@ function parseArgs(argv) {
|
|
|
145
159
|
// flow can self-verify before /compact triggers PreCompact.
|
|
146
160
|
|
|
147
161
|
function runSessionCloseCheck(args) {
|
|
148
|
-
|
|
162
|
+
// ADR 0046: the check mirrors the FULL PreCompact gate via the shared
|
|
163
|
+
// precompactGateStatus (close files + lint + design-history + feedback
|
|
164
|
+
// projection), not just the close files — so a green check means /compact
|
|
165
|
+
// won't block on a human-fixable issue. Pass --transcript-path to widen the
|
|
166
|
+
// lint scope to the session's edited files exactly as the interactive hook
|
|
167
|
+
// does (without it, the scope is the mandatory close files only).
|
|
168
|
+
// Pass --session-id so a log-only marker activates log-only gate
|
|
169
|
+
// semantics here too. Without it the check would read the marker as present
|
|
170
|
+
// (marker_present:true) while `ok` still reflected the stale active project —
|
|
171
|
+
// the completion-signal trio (PreCompact / --check / marker) would diverge
|
|
172
|
+
// (codex design Finding 2).
|
|
173
|
+
const status = precompactGateStatus(args.hypoDir, {
|
|
174
|
+
...(args.transcriptPath ? { transcriptPath: args.transcriptPath } : {}),
|
|
175
|
+
...(args.sessionId ? { sessionId: args.sessionId } : {}),
|
|
176
|
+
});
|
|
177
|
+
const close = status.close;
|
|
178
|
+
|
|
179
|
+
// ADR 0047: when a --session-id is supplied, report whether THIS session's
|
|
180
|
+
// per-session marker (the Stop-chain completion signal) exists. This is a
|
|
181
|
+
// separate field, NOT folded into `ok` — `ok` stays the ADR 0046 compact-
|
|
182
|
+
// readiness verdict. A green gate with marker_present=false is exactly the
|
|
183
|
+
// hand-edit close state: close is compact-ready but the Stop hook will
|
|
184
|
+
// still block until the marker is written.
|
|
185
|
+
//
|
|
186
|
+
// Use the SAME reader the Stop hook gates on (readSessionClosedMarker), not
|
|
187
|
+
// raw file existence: a stale/corrupt marker file exists on disk but the hook
|
|
188
|
+
// rejects (and unlinks) it, so raw existsSync would report marker_present=true
|
|
189
|
+
// while /compact's Stop still blocks — the exact incoherence this ADR closes
|
|
190
|
+
// (codex pre-commit CONCERN). readSessionClosedMarker unlinks an invalid
|
|
191
|
+
// marker as it reads, matching the hook's behavior on the next Stop.
|
|
192
|
+
const markerPresent = args.sessionId
|
|
193
|
+
? readSessionClosedMarker(args.hypoDir, args.sessionId) !== null
|
|
194
|
+
: null;
|
|
149
195
|
|
|
150
196
|
if (args.json) {
|
|
151
197
|
console.log(
|
|
152
198
|
JSON.stringify(
|
|
153
199
|
{
|
|
154
200
|
ok: status.ok,
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
201
|
+
// flat close fields preserved for back-compat with prior readers
|
|
202
|
+
project: close.project,
|
|
203
|
+
dates: close.dates,
|
|
204
|
+
stale: close.stale,
|
|
205
|
+
missing: close.missing,
|
|
206
|
+
blockers: status.blockers,
|
|
207
|
+
notices: status.notices,
|
|
208
|
+
skipped: status.skipped,
|
|
209
|
+
...(args.sessionId ? { session_id: args.sessionId, marker_present: markerPresent } : {}),
|
|
159
210
|
},
|
|
160
211
|
null,
|
|
161
212
|
2,
|
|
@@ -164,31 +215,51 @@ function runSessionCloseCheck(args) {
|
|
|
164
215
|
process.exit(status.ok ? 0 : 1);
|
|
165
216
|
}
|
|
166
217
|
|
|
167
|
-
const proj =
|
|
168
|
-
console.log(`
|
|
218
|
+
const proj = close.project || '(unresolved)';
|
|
219
|
+
console.log(`Compact-ready check (project: ${proj}, date: ${close.dates.join(' / ')}):\n`);
|
|
169
220
|
|
|
170
|
-
const required =
|
|
221
|
+
const required = close.project
|
|
171
222
|
? [
|
|
172
|
-
`projects/${
|
|
173
|
-
`projects/${
|
|
223
|
+
`projects/${close.project}/session-state.md`,
|
|
224
|
+
`projects/${close.project}/hot.md`,
|
|
174
225
|
'hot.md',
|
|
175
|
-
|
|
226
|
+
sessionLogShardPath(close.project, close.dates[0]),
|
|
176
227
|
'log.md',
|
|
177
228
|
]
|
|
178
229
|
: [];
|
|
179
230
|
for (const f of required) {
|
|
180
|
-
const bad =
|
|
231
|
+
const bad = close.missing.includes(f) ? 'missing' : close.stale.includes(f) ? 'stale' : '';
|
|
181
232
|
console.log(` ${bad ? '✗' : '✓'} ${f}${bad ? ` — ${bad}` : ''}`);
|
|
182
233
|
}
|
|
183
234
|
// Surface anything not covered by the canonical list (e.g. unresolved project).
|
|
184
|
-
for (const f of [...
|
|
235
|
+
for (const f of [...close.missing, ...close.stale]) {
|
|
185
236
|
if (!required.includes(f)) console.log(` ✗ ${f}`);
|
|
186
237
|
}
|
|
238
|
+
// Beyond the close files: the rest of the PreCompact gate (lint, design-history,
|
|
239
|
+
// feedback over-cap/conflict). These are what made a "close-complete" check
|
|
240
|
+
// disagree with the real /compact gate before ADR 0046.
|
|
241
|
+
for (const b of status.blockers) {
|
|
242
|
+
if (b.type !== 'close') console.log(` ✗ ${b.reason}`);
|
|
243
|
+
}
|
|
244
|
+
if (status.notices.length > 0) {
|
|
245
|
+
console.log('');
|
|
246
|
+
for (const n of status.notices) console.log(` · ${n.reason}`);
|
|
247
|
+
}
|
|
248
|
+
// ADR 0047: surface the per-session marker state (separate from compact-
|
|
249
|
+
// readiness) so a green-but-unmarked close is visible at verify time.
|
|
250
|
+
if (args.sessionId) {
|
|
251
|
+
console.log('');
|
|
252
|
+
console.log(
|
|
253
|
+
markerPresent
|
|
254
|
+
? ` ✓ session-closed marker present (session_id: ${args.sessionId}).`
|
|
255
|
+
: ` · session-closed marker absent (session_id: ${args.sessionId}) — the Stop hook will block until it is written. Run \`crystallize --mark-session-closed --session-id=${args.sessionId}${args.transcriptPath ? ` --transcript-path=${args.transcriptPath}` : ''}\`.`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
187
258
|
console.log('');
|
|
188
259
|
console.log(
|
|
189
260
|
status.ok
|
|
190
|
-
? '✓
|
|
191
|
-
: '✗
|
|
261
|
+
? '✓ 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.)'
|
|
262
|
+
: '✗ Not compact-ready — resolve the ✗ items above, then retry. /compact would block on these.',
|
|
192
263
|
);
|
|
193
264
|
process.exit(status.ok ? 0 : 1);
|
|
194
265
|
}
|
|
@@ -252,7 +323,7 @@ function writeIfChanged(path, content) {
|
|
|
252
323
|
/**
|
|
253
324
|
* Append `entry` to `path` only if `alreadyPresent(content)` is false.
|
|
254
325
|
* Atomic: rebuilds the full file content and writes via atomicWrite — a crash
|
|
255
|
-
* mid-append cannot leave log.md or session-log/YYYY-MM.md half-written, which
|
|
326
|
+
* mid-append cannot leave log.md or session-log/YYYY-MM-DD.md half-written, which
|
|
256
327
|
* matters for these append-only history files.
|
|
257
328
|
*/
|
|
258
329
|
function appendIfAbsent(path, entry, alreadyPresent) {
|
|
@@ -283,7 +354,7 @@ function todayLocal() {
|
|
|
283
354
|
// bug, not a no-op. Caller is the LLM session-close flow, which composes the
|
|
284
355
|
// payload deliberately; partial payloads must fail loudly so caller fixes them
|
|
285
356
|
// rather than silently relying on yesterday's freshness state. (Codex review
|
|
286
|
-
// of
|
|
357
|
+
// of the apply path — Worker 1 finding 1.)
|
|
287
358
|
const REQUIRED_PAYLOAD_FIELDS = [
|
|
288
359
|
['sessionState', 'content'],
|
|
289
360
|
['projectHot', 'content'],
|
|
@@ -329,8 +400,9 @@ function validatePayloadShape(payload) {
|
|
|
329
400
|
// crystallize` is the only Reader; writer authority is intentionally split
|
|
330
401
|
// between this CLI and the auto-write at the tail of applySessionClose.
|
|
331
402
|
//
|
|
332
|
-
// Contract: marker is only
|
|
333
|
-
// A failed
|
|
403
|
+
// Contract: the marker is written only when the FULL /compact gate
|
|
404
|
+
// (precompactGateStatus, ADR 0046) is green. A failed gate exits 1 with no
|
|
405
|
+
// marker — the next Stop hook re-blocks.
|
|
334
406
|
|
|
335
407
|
function runMarkSessionClosed(args) {
|
|
336
408
|
if (!args.sessionId) {
|
|
@@ -338,20 +410,35 @@ function runMarkSessionClosed(args) {
|
|
|
338
410
|
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
339
411
|
process.exit(1);
|
|
340
412
|
}
|
|
341
|
-
// ADR
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
413
|
+
// ADR 0047: the per-session marker is the THIRD session-close completion
|
|
414
|
+
// signal (after the PreCompact gate and `--check-session-close`). It must use
|
|
415
|
+
// the SAME gate that governs /compact — precompactGateStatus — so the marker
|
|
416
|
+
// can never attest "closed" while /compact would still block. This subsumes
|
|
417
|
+
// the prior (sessionCloseGlobalStatus + hypoIsClean + scoped-lint) gate and
|
|
418
|
+
// additionally enforces feedback projection (over-cap/conflict), W8 design-
|
|
419
|
+
// history staleness, and root hot.md structure — the checks that the narrower
|
|
420
|
+
// marker gate skipped (the divergence behind this fix). git-clean is now a
|
|
421
|
+
// `git` blocker inside the gate. Pass --transcript-path to widen the lint
|
|
422
|
+
// scope to this session's edited files exactly as the interactive hook does;
|
|
423
|
+
// without it the scope is the mandatory close files only.
|
|
424
|
+
// --log-only marks a non-project (tooling / wiki-only) session as
|
|
425
|
+
// closed without attributing it to any project. The gate runs in log-only mode
|
|
426
|
+
// (project-close invariant → a today log.md entry; lint/W8 scoped to shared +
|
|
427
|
+
// touched files, never the active/phantom project), but git / hot / feedback
|
|
428
|
+
// still apply — log-only is NOT a global-gate bypass.
|
|
429
|
+
const gate = precompactGateStatus(args.hypoDir, {
|
|
430
|
+
...(args.transcriptPath ? { transcriptPath: args.transcriptPath } : {}),
|
|
431
|
+
...(args.logOnly ? { logOnly: true } : {}),
|
|
432
|
+
});
|
|
433
|
+
const status = gate.close;
|
|
434
|
+
if (!gate.ok) {
|
|
348
435
|
const result = {
|
|
349
436
|
ok: false,
|
|
350
437
|
session_id: args.sessionId,
|
|
351
438
|
project: status.project,
|
|
352
439
|
missing: status.missing,
|
|
353
440
|
stale: status.stale,
|
|
354
|
-
|
|
441
|
+
blockers: gate.blockers,
|
|
355
442
|
error: 'session-close gate not satisfied — marker not written',
|
|
356
443
|
};
|
|
357
444
|
if (args.json) {
|
|
@@ -360,56 +447,14 @@ function runMarkSessionClosed(args) {
|
|
|
360
447
|
console.log(
|
|
361
448
|
`✗ session-close gate not satisfied — marker not written (project: ${status.project || '(unresolved)'}):`,
|
|
362
449
|
);
|
|
363
|
-
for (const
|
|
364
|
-
for (const f of status.stale) console.log(` ✗ ${f} (stale)`);
|
|
365
|
-
if (!git.clean) console.log(` ✗ git: ${git.reason}`);
|
|
450
|
+
for (const b of gate.blockers) console.log(` ✗ ${b.reason}`);
|
|
366
451
|
}
|
|
367
452
|
process.exit(1);
|
|
368
453
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
// legacy freshness+git recovery path (lint left to PreCompact).
|
|
374
|
-
if (args.transcriptPath) {
|
|
375
|
-
let scopedLint = null;
|
|
376
|
-
try {
|
|
377
|
-
scopedLint = runLint(args.hypoDir);
|
|
378
|
-
} catch (err) {
|
|
379
|
-
// Lint crash → fail-open (never strand the marker writer on tooling), same
|
|
380
|
-
// posture as the PreCompact hook.
|
|
381
|
-
process.stderr.write(
|
|
382
|
-
`[crystallize] mark-session-closed lint skipped: ${err?.message ?? err}\n`,
|
|
383
|
-
);
|
|
384
|
-
}
|
|
385
|
-
if (scopedLint) {
|
|
386
|
-
const scope = new Set([
|
|
387
|
-
...extractTouchedWikiFiles(args.transcriptPath, args.hypoDir),
|
|
388
|
-
...closeFileTargets(args.hypoDir),
|
|
389
|
-
]);
|
|
390
|
-
const { blocking } = partitionLintScope(scopedLint.errors || [], scope);
|
|
391
|
-
if (blocking.length > 0) {
|
|
392
|
-
const files = [...new Set(blocking.map((b) => b.file))];
|
|
393
|
-
const result = {
|
|
394
|
-
ok: false,
|
|
395
|
-
session_id: args.sessionId,
|
|
396
|
-
project: status.project,
|
|
397
|
-
lint_blockers: files,
|
|
398
|
-
error: "session-close gate not satisfied — lint errors in this session's files",
|
|
399
|
-
};
|
|
400
|
-
if (args.json) {
|
|
401
|
-
console.log(JSON.stringify(result, null, 2));
|
|
402
|
-
} else {
|
|
403
|
-
console.log(
|
|
404
|
-
`✗ lint errors in files this session touched — marker not written (fix then re-run):`,
|
|
405
|
-
);
|
|
406
|
-
for (const b of blocking) console.log(` ✗ ${b.file}: ${b.message}`);
|
|
407
|
-
}
|
|
408
|
-
process.exit(1);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
writeSessionClosedMarker(args.hypoDir, args.sessionId, { project: status.project });
|
|
454
|
+
writeSessionClosedMarker(args.hypoDir, args.sessionId, {
|
|
455
|
+
project: status.project,
|
|
456
|
+
...(args.logOnly ? { scope: 'log-only' } : {}),
|
|
457
|
+
});
|
|
413
458
|
// Marker writer swallows IO errors (best-effort, see hypo-shared.mjs). Verify
|
|
414
459
|
// the file actually landed before claiming success — otherwise CLI exits 0
|
|
415
460
|
// while next Stop re-blocks, hiding a permission/disk problem.
|
|
@@ -427,22 +472,36 @@ function runMarkSessionClosed(args) {
|
|
|
427
472
|
ok: true,
|
|
428
473
|
session_id: args.sessionId,
|
|
429
474
|
project: status.project,
|
|
475
|
+
scope: args.logOnly ? 'log-only' : 'project',
|
|
430
476
|
date: status.dates[0],
|
|
477
|
+
notices: gate.notices,
|
|
478
|
+
// ADR 0047: pure feedback-projection drift is a non-blocker — the marker
|
|
479
|
+
// attests "compact-ready (no human-fixable blocker)", and the PreCompact
|
|
480
|
+
// hook self-heals the projection (feedback-sync --write) at /compact. Surface
|
|
481
|
+
// the deferral so the caller knows MEMORY/CLAUDE sync is pending, not lost.
|
|
482
|
+
drift_deferred: gate.driftTargets,
|
|
431
483
|
};
|
|
432
484
|
if (args.json) {
|
|
433
485
|
console.log(JSON.stringify(result, null, 2));
|
|
434
486
|
} else {
|
|
435
487
|
console.log(
|
|
436
|
-
|
|
488
|
+
args.logOnly
|
|
489
|
+
? `✓ session-closed marker written (session_id: ${args.sessionId}, scope: log-only — no project attribution).`
|
|
490
|
+
: `✓ session-closed marker written (session_id: ${args.sessionId}, project: ${status.project}).`,
|
|
437
491
|
);
|
|
492
|
+
if (gate.driftTargets.length > 0) {
|
|
493
|
+
console.log(
|
|
494
|
+
` · feedback projection drift (${gate.driftTargets.join(', ')}) — will self-heal at /compact.`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
438
497
|
}
|
|
439
498
|
process.exit(0);
|
|
440
499
|
}
|
|
441
500
|
|
|
442
501
|
function applySessionClose(args) {
|
|
443
|
-
//
|
|
502
|
+
// Option D: early-exit fires only when NO payload was supplied.
|
|
444
503
|
// Rationale: payload presence is explicit close intent and must always run
|
|
445
|
-
// the full apply path —
|
|
504
|
+
// the full apply path — the per-entry idempotency (writeIfChanged +
|
|
446
505
|
// exact-entry append dedup) keeps re-apply cheap without short-circuiting,
|
|
447
506
|
// and avoids silent-success when a same-day second close brings new bytes.
|
|
448
507
|
// Payload-less invocation is treated as a cheap "already complete?" probe.
|
|
@@ -450,7 +509,9 @@ function applySessionClose(args) {
|
|
|
450
509
|
// for any actual apply work (readPayload below surfaces "payload is
|
|
451
510
|
// required" the same way it always has).
|
|
452
511
|
if (!args.force && !args.payload) {
|
|
453
|
-
|
|
512
|
+
// ADR 0043: no-payload "already complete?" probe uses the
|
|
513
|
+
// global invariant, not a recency pick.
|
|
514
|
+
const probe = sessionCloseGlobalStatus(args.hypoDir);
|
|
454
515
|
if (probe.ok) {
|
|
455
516
|
const result = {
|
|
456
517
|
ok: true,
|
|
@@ -505,10 +566,22 @@ function applySessionClose(args) {
|
|
|
505
566
|
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
506
567
|
process.exit(1);
|
|
507
568
|
}
|
|
569
|
+
// The freshness verification below (and at the post-apply check) already honors
|
|
570
|
+
// payload.project — `project` wins over the inferred active project, and the
|
|
571
|
+
// post-apply sessionCloseFileStatus call passes it as projectOverride. But when the
|
|
572
|
+
// payload targets a DIFFERENT project than the one active-project resolution infers
|
|
573
|
+
// (probe.project), that divergence used to be silent, so an operator couldn't tell
|
|
574
|
+
// which project the close actually verified. Surface it on stderr (the stdout JSON
|
|
575
|
+
// contract is untouched) so the verified/closed project is always explicit.
|
|
576
|
+
if (payload.project && probe.project && probe.project !== payload.project) {
|
|
577
|
+
process.stderr.write(
|
|
578
|
+
`note: payload.project="${payload.project}" differs from the inferred active ` +
|
|
579
|
+
`project "${probe.project}"; verifying and closing "${payload.project}".\n`,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
508
582
|
const date = payload.date || todayLocal();
|
|
509
|
-
const ym = date.slice(0, 7);
|
|
510
583
|
|
|
511
|
-
//
|
|
584
|
+
// Preflight: lint the wiki BEFORE writing any payload bytes. If lint
|
|
512
585
|
// has blockers (errors) in files this apply WON'T overwrite, the wiki is in
|
|
513
586
|
// a degraded state and apply would mask the root cause — abort fail-fast.
|
|
514
587
|
//
|
|
@@ -531,11 +604,27 @@ function applySessionClose(args) {
|
|
|
531
604
|
// the files it writes (other projects, shared pages this close did not author).
|
|
532
605
|
// payloadScope = every file this apply writes or appends. Both lint passes are
|
|
533
606
|
// judged against it; errors elsewhere are surfaced as notices, never blocking.
|
|
607
|
+
//
|
|
608
|
+
// session-log needs TWO entries (ADR 0050): the daily WRITE target (what this
|
|
609
|
+
// apply creates/appends, judged by post-apply lint) AND the freshness EVIDENCE
|
|
610
|
+
// file. They coincide except in the hybrid cutover month, where a fallback-
|
|
611
|
+
// aware no-op (the identical entry already lives in the legacy monthly file)
|
|
612
|
+
// writes no daily shard, leaving the monthly as the proof of freshness. Scope
|
|
613
|
+
// must then include that monthly file, or a CORRUPT monthly evidence file would
|
|
614
|
+
// pass the gate with its lint error demoted to a non-blocking notice.
|
|
615
|
+
// sessionLogScopePath returns the monthly ONLY when it carries today's heading
|
|
616
|
+
// (otherwise the daily write target), so unrelated monthly debt stays a notice.
|
|
617
|
+
// join() (platform-native), not the POSIX helper output: payloadScope membership
|
|
618
|
+
// is tested against lint's raw `e.file` (path.relative) WITHOUT posix
|
|
619
|
+
// normalization, so it must use the OS-native separator the sibling entries use.
|
|
620
|
+
const sessionLogWriteTarget = join('projects', project, 'session-log', `${date}.md`);
|
|
621
|
+
const sessionLogEvidence = join(...sessionLogScopePath(args.hypoDir, project, date).split('/'));
|
|
534
622
|
const payloadScope = new Set([
|
|
535
623
|
join('projects', project, 'session-state.md'),
|
|
536
624
|
join('projects', project, 'hot.md'),
|
|
537
625
|
'hot.md',
|
|
538
|
-
|
|
626
|
+
sessionLogWriteTarget,
|
|
627
|
+
sessionLogEvidence, // == write target, except a hybrid-month monthly fallback
|
|
539
628
|
'log.md',
|
|
540
629
|
...(payload.openQuestions ? [join('pages', 'open-questions.md')] : []),
|
|
541
630
|
]);
|
|
@@ -590,18 +679,56 @@ function applySessionClose(args) {
|
|
|
590
679
|
// dated today". The freshness gate (sessionCloseFileStatus) is what answers
|
|
591
680
|
// "was this file touched today?"; that's a different concern and must not
|
|
592
681
|
// be reused for apply-time dedup, or a legitimate same-day second close gets
|
|
593
|
-
// silently dropped (Codex review of
|
|
682
|
+
// silently dropped (Codex review of the apply path — Worker 1 finding 2).
|
|
594
683
|
const entryAlreadyPresent = (entry) => (content) =>
|
|
595
684
|
content.includes(entry.endsWith('\n') ? entry.replace(/\n+$/, '') : entry);
|
|
596
685
|
|
|
597
686
|
{
|
|
598
|
-
const rel = join('projects', project, 'session-log', `${
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
)
|
|
604
|
-
|
|
687
|
+
const rel = join('projects', project, 'session-log', `${date}.md`);
|
|
688
|
+
const full = join(args.hypoDir, rel);
|
|
689
|
+
const isPresent = entryAlreadyPresent(payload.sessionLog.entry);
|
|
690
|
+
// Fallback-aware idempotency (ADR 0050 hybrid cutover): during the month the
|
|
691
|
+
// shard takes over, today's entry may already live in the legacy monthly
|
|
692
|
+
// file from an earlier (pre-cutover) close. Treat presence in EITHER the
|
|
693
|
+
// daily shard or the legacy monthly file as "already written" so a same-day
|
|
694
|
+
// second close does not duplicate an identical entry across both files —
|
|
695
|
+
// and so an idempotent re-apply stays a true no-op (no shard is created).
|
|
696
|
+
let alreadyThere = false;
|
|
697
|
+
for (const cand of sessionLogReadCandidates(project, date)) {
|
|
698
|
+
const cf = join(args.hypoDir, cand);
|
|
699
|
+
if (!existsSync(cf)) continue;
|
|
700
|
+
try {
|
|
701
|
+
if (isPresent(readFileSync(cf, 'utf-8'))) {
|
|
702
|
+
alreadyThere = true;
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
} catch {
|
|
706
|
+
/* unreadable candidate — fall through to the write path */
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
if (alreadyThere) {
|
|
710
|
+
skipped.push(`sessionLog (${rel})`);
|
|
711
|
+
} else if (!existsSync(full)) {
|
|
712
|
+
// A daily shard is a new file most days. Seed minimal valid frontmatter
|
|
713
|
+
// (title + type, the two REQUIRED_FIELDS) so the shard is a first-class
|
|
714
|
+
// wiki page rather than a W1 "no frontmatter" warning, and write the header
|
|
715
|
+
// AND the first entry in ONE atomic write — never leave a header-only shard
|
|
716
|
+
// on disk, which freshness would skip (no dated heading) while derive could
|
|
717
|
+
// otherwise mistake it for the evidence file. The dated `## [date] ...`
|
|
718
|
+
// heading lives inside the entry, so freshness / derive / design-history
|
|
719
|
+
// are unchanged.
|
|
720
|
+
const header =
|
|
721
|
+
`---\ntitle: Session Log ${date} (${project})\n` +
|
|
722
|
+
`type: session-log\nupdated: ${date}\n---\n\n` +
|
|
723
|
+
`# Session Log ${date} (${project})\n`;
|
|
724
|
+
const entry = payload.sessionLog.entry;
|
|
725
|
+
const body = entry.endsWith('\n') ? entry : `${entry}\n`;
|
|
726
|
+
atomicWrite(full, `${header}\n${body}`);
|
|
727
|
+
applied.push(`sessionLog (${rel})`);
|
|
728
|
+
} else {
|
|
729
|
+
const wrote = appendIfAbsent(full, payload.sessionLog.entry, isPresent);
|
|
730
|
+
(wrote ? applied : skipped).push(`sessionLog (${rel})`);
|
|
731
|
+
}
|
|
605
732
|
}
|
|
606
733
|
|
|
607
734
|
{
|
|
@@ -613,14 +740,14 @@ function applySessionClose(args) {
|
|
|
613
740
|
(wrote ? applied : skipped).push('log (log.md)');
|
|
614
741
|
}
|
|
615
742
|
|
|
616
|
-
//
|
|
743
|
+
// Same-date-tie fix: verify against the SAME project this apply just wrote
|
|
617
744
|
// (`project` = payload.project || probe.project, resolved at the top). Without
|
|
618
745
|
// the override, sessionCloseFileStatus re-derives via resolveActiveProject and,
|
|
619
746
|
// on a same-date root-hot.md tie, can pick a different project — false-failing
|
|
620
747
|
// a completed close (the 2026-06-09 security-ops-kb incident).
|
|
621
748
|
const verification = sessionCloseFileStatus(args.hypoDir, { projectOverride: project });
|
|
622
749
|
|
|
623
|
-
//
|
|
750
|
+
// Post-apply lint: payload may have introduced a malformed body or
|
|
624
751
|
// bad frontmatter. Surface as a distinct `stage` so caller can tell "lint
|
|
625
752
|
// broke" apart from "frontmatter stale". This runs even if the freshness gate
|
|
626
753
|
// also failed — both failure modes are useful to the caller.
|
|
@@ -660,18 +787,26 @@ function applySessionClose(args) {
|
|
|
660
787
|
// ADR 0022 amendment 2026-05-19: auto-write the per-session
|
|
661
788
|
// closed marker on a verified close. Hook authority is read-only; this is
|
|
662
789
|
// one of the two writer paths (the other is --mark-session-closed standalone).
|
|
663
|
-
//
|
|
664
|
-
// ADR
|
|
665
|
-
//
|
|
790
|
+
//
|
|
791
|
+
// ADR 0047: the marker write is governed by the SAME gate as standalone
|
|
792
|
+
// --mark-session-closed and /compact (precompactGateStatus), NOT just apply's
|
|
793
|
+
// `ok` + git-clean. Apply's payload preflight/post-apply lint and `ok` still
|
|
794
|
+
// govern apply SUCCESS (exit code below), but the marker must additionally
|
|
795
|
+
// clear feedback projection / W8 design-history / hot.md structure, else this
|
|
796
|
+
// path could issue a marker the standalone path would refuse (the second
|
|
797
|
+
// divergence codex flagged). git-clean is subsumed by the gate's git blocker.
|
|
666
798
|
if (ok && args.sessionId) {
|
|
667
|
-
const
|
|
668
|
-
|
|
799
|
+
const markerGate = precompactGateStatus(
|
|
800
|
+
args.hypoDir,
|
|
801
|
+
args.transcriptPath ? { transcriptPath: args.transcriptPath } : {},
|
|
802
|
+
);
|
|
803
|
+
if (markerGate.ok) {
|
|
669
804
|
writeSessionClosedMarker(args.hypoDir, args.sessionId, { project });
|
|
670
805
|
}
|
|
671
|
-
//
|
|
806
|
+
// gate not ok → silent skip: caller's `result.ok` already reflects the
|
|
672
807
|
// file/lint state; surfacing a "marker skipped" warning here would
|
|
673
|
-
// confuse the close-applied success path. Next Stop re-blocks until
|
|
674
|
-
//
|
|
808
|
+
// confuse the close-applied success path. Next Stop re-blocks until the
|
|
809
|
+
// remaining blocker (git/feedback/W8/hot/lint) is resolved.
|
|
675
810
|
}
|
|
676
811
|
const stage = ok
|
|
677
812
|
? null
|
package/scripts/doctor.mjs
CHANGED
|
@@ -222,7 +222,7 @@ function checkDirectories(hypoDir) {
|
|
|
222
222
|
'projects',
|
|
223
223
|
'sources',
|
|
224
224
|
// Extensions baseline (ADR 0024). Existence only — SHA / settings /
|
|
225
|
-
// manifest integrity is E5
|
|
225
|
+
// manifest integrity is E5.
|
|
226
226
|
'extensions/hooks',
|
|
227
227
|
'extensions/commands',
|
|
228
228
|
'extensions/skills',
|
|
@@ -310,7 +310,7 @@ function checkSettingsJson() {
|
|
|
310
310
|
// stale hypo-* entries (uninstall remnants).
|
|
311
311
|
// hypo-ext-* commands are user-extension entries (ADR 0024) — not core hooks,
|
|
312
312
|
// so they are intentionally absent from HOOK_MAP. Excluded here; their
|
|
313
|
-
// integrity (SHA + manifest + entry match) is checked separately in E5
|
|
313
|
+
// integrity (SHA + manifest + entry match) is checked separately in E5.
|
|
314
314
|
const isExtCommand = (cmd) => /(?:^|[/\s])hypo-ext-[^/\s]+\.mjs(?=$|["'\s])/.test(cmd);
|
|
315
315
|
const expectedCmds = new Set(
|
|
316
316
|
Object.entries(HOOK_MAP).flatMap(([, files]) =>
|
|
@@ -352,7 +352,7 @@ function checkSettingsJson() {
|
|
|
352
352
|
if (!g || typeof g !== 'object') continue;
|
|
353
353
|
for (const h of g.hooks || []) {
|
|
354
354
|
if (typeof h.command !== 'string' || !/hypo-[^/]+\.mjs/.test(h.command)) continue;
|
|
355
|
-
if (isExtCommand(h.command)) continue; // ext duplicates are E5's concern
|
|
355
|
+
if (isExtCommand(h.command)) continue; // ext duplicates are E5's concern
|
|
356
356
|
if (seen.has(h.command)) dupes.push(`${event}:${h.command}`);
|
|
357
357
|
else seen.add(h.command);
|
|
358
358
|
}
|
|
@@ -399,10 +399,7 @@ function checkGit(hypoDir) {
|
|
|
399
399
|
);
|
|
400
400
|
}
|
|
401
401
|
} else {
|
|
402
|
-
warn(
|
|
403
|
-
'.git/hooks/pre-commit',
|
|
404
|
-
'Not installed — run /hypo:init to install .hypoignore guard (fix #24)',
|
|
405
|
-
);
|
|
402
|
+
warn('.git/hooks/pre-commit', 'Not installed — run /hypo:init to install .hypoignore guard');
|
|
406
403
|
}
|
|
407
404
|
}
|
|
408
405
|
|
|
@@ -679,7 +676,7 @@ function checkCodexPaths() {
|
|
|
679
676
|
//
|
|
680
677
|
// E5 is doctor SURFACE for extensions integrity. The mixed-group surgical
|
|
681
678
|
// *write* (preserve sibling-plugin hooks, swap only ours) used to be deferred
|
|
682
|
-
// here;
|
|
679
|
+
// here; ADR 0024 amendment 2026-05-23 lifted that deferral —
|
|
683
680
|
// registerSettings (extensions.mjs:478 docstring) now does occurrence-first +
|
|
684
681
|
// 8-rank canonical write, and the (b) loop below mirrors that read-path via
|
|
685
682
|
// collectOurOccurrences so a valid mixed-group occurrence is no longer warned
|
|
@@ -773,7 +770,7 @@ function checkExtensions(hypoDir, claudeHome, target = 'claude') {
|
|
|
773
770
|
const expected = buildExpectedSettingsEntries(discovered.hooks, hooksDir);
|
|
774
771
|
|
|
775
772
|
// (b) for each registrable hook: locate every occurrence of our command
|
|
776
|
-
// (single-hook OR mixed group
|
|
773
|
+
// (single-hook OR mixed group) and pick the canonical via the
|
|
777
774
|
// SAME 8-rank logic registerSettings uses.
|
|
778
775
|
// Without this mirror, doctor picked the first traversal-order occurrence
|
|
779
776
|
// under the target event and warned "differs" even when a later
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* feedback-sync.mjs — project wiki feedback as SoT, external memory as projection (ADR 0031
|
|
3
|
+
* feedback-sync.mjs — project wiki feedback as SoT, external memory as projection (ADR 0031)
|
|
4
4
|
*
|
|
5
5
|
* Wiki `pages/feedback/<slug>.md` is the single source of truth for
|
|
6
6
|
* learning/correction knowledge. Two Claude Code memory surfaces are derived
|
package/scripts/init.mjs
CHANGED
|
@@ -928,7 +928,7 @@ if (args.hooks) {
|
|
|
928
928
|
}
|
|
929
929
|
for (const r of extResult.registered) log('merged', `extension ${r}`);
|
|
930
930
|
for (const w of extResult.warnings) log('skipped', `extension: ${w}`);
|
|
931
|
-
// E3
|
|
931
|
+
// E3: a hard conflict (unowned/symlinked target) blocks install — surface
|
|
932
932
|
// the recovery and force a non-zero exit. Drift is advisory (resolvable, no block).
|
|
933
933
|
if (extResult.conflicts.length > 0) {
|
|
934
934
|
log('errors', '[WIKI: existing file conflicts. Backup and retry, or use --force-extensions]');
|