hypomnema 1.3.1 → 1.3.3
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 +11 -10
- package/README.md +11 -10
- package/commands/audit.md +1 -1
- package/commands/crystallize.md +11 -11
- package/commands/doctor.md +1 -1
- package/commands/feedback.md +2 -2
- package/commands/graph.md +1 -1
- package/commands/ingest.md +1 -1
- package/commands/init.md +1 -1
- package/commands/lint.md +1 -1
- package/commands/query.md +1 -1
- package/commands/rename.md +79 -0
- package/commands/resume.md +2 -2
- package/commands/stats.md +1 -1
- package/commands/uninstall.md +1 -1
- package/commands/upgrade.md +1 -1
- package/commands/verify.md +1 -1
- package/docs/ARCHITECTURE.md +3 -3
- package/docs/CONTRIBUTING.md +2 -2
- package/hooks/hypo-auto-commit.mjs +8 -24
- package/hooks/hypo-auto-minimal-crystallize.mjs +57 -11
- package/hooks/hypo-compact-guard.mjs +6 -3
- 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 +1082 -89
- 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 +322 -110
- 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 +836 -0
- package/scripts/resume.mjs +75 -19
- package/scripts/uninstall.mjs +1 -1
- package/scripts/upgrade.mjs +26 -25
- package/skills/crystallize/SKILL.md +12 -9
- package/skills/graph/SKILL.md +1 -1
- package/skills/ingest/SKILL.md +1 -1
- package/skills/lint/SKILL.md +1 -1
- package/skills/query/SKILL.md +1 -1
- package/skills/verify/SKILL.md +1 -1
- package/templates/SCHEMA.md +2 -2
- package/templates/hypo-config.md +2 -2
- package/templates/hypo-guide.md +22 -1
- package/templates/hypo-help.md +1 -0
- 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,18 @@ 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,
|
|
88
|
+
resolveTranscriptBySessionId,
|
|
89
|
+
hasUserCloseSignal,
|
|
90
|
+
commitWikiChanges,
|
|
85
91
|
} from '../hooks/hypo-shared.mjs';
|
|
86
92
|
|
|
87
93
|
const LINT_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'lint.mjs');
|
|
@@ -94,7 +100,7 @@ const LINT_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'lint.mjs');
|
|
|
94
100
|
// with valid JSON is a normal "errors present" signal, not a crash.
|
|
95
101
|
// maxBuffer raised to 64 MiB: warn-only output on a large wiki can otherwise
|
|
96
102
|
// trip Node's 1 MiB default, truncate stdout, and turn a clean wiki into a
|
|
97
|
-
// JSON.parse crash (codex P3
|
|
103
|
+
// JSON.parse crash (codex P3 follow-up).
|
|
98
104
|
function runLint(hypoDir) {
|
|
99
105
|
const r = spawnSync(process.execPath, [LINT_SCRIPT, `--hypo-dir=${hypoDir}`, '--json'], {
|
|
100
106
|
encoding: 'utf-8',
|
|
@@ -103,8 +109,17 @@ function runLint(hypoDir) {
|
|
|
103
109
|
try {
|
|
104
110
|
return JSON.parse(r.stdout);
|
|
105
111
|
} catch {
|
|
112
|
+
// Report diagnostic metadata (sizes, exit/signal, spawn error code, a stderr
|
|
113
|
+
// tail) instead of dumping the whole — possibly huge, possibly truncated —
|
|
114
|
+
// stdout. lint.mjs now sets exitCode and exits naturally so its stdout is no
|
|
115
|
+
// longer cut at the 64 KiB pipe boundary; if this still fires it signals a
|
|
116
|
+
// genuine crash, and these fields say which kind.
|
|
117
|
+
const stderrTail = (r.stderr || '').slice(-2000);
|
|
106
118
|
throw new Error(
|
|
107
|
-
`lint helper produced unparseable output
|
|
119
|
+
`lint helper produced unparseable output ` +
|
|
120
|
+
`(exit=${r.status}, signal=${r.signal || 'none'}, ` +
|
|
121
|
+
`stdoutBytes=${(r.stdout || '').length}, spawnError=${r.error?.code || 'none'})` +
|
|
122
|
+
(stderrTail ? `\nstderr tail:\n${stderrTail}` : ''),
|
|
108
123
|
);
|
|
109
124
|
}
|
|
110
125
|
}
|
|
@@ -119,6 +134,7 @@ function parseArgs(argv) {
|
|
|
119
134
|
checkSessionClose: false,
|
|
120
135
|
applySessionClose: false,
|
|
121
136
|
markSessionClosed: false,
|
|
137
|
+
logOnly: false,
|
|
122
138
|
sessionId: null,
|
|
123
139
|
payload: null,
|
|
124
140
|
force: false,
|
|
@@ -130,6 +146,7 @@ function parseArgs(argv) {
|
|
|
130
146
|
else if (arg === '--check-session-close') args.checkSessionClose = true;
|
|
131
147
|
else if (arg === '--apply-session-close') args.applySessionClose = true;
|
|
132
148
|
else if (arg === '--mark-session-closed') args.markSessionClosed = true;
|
|
149
|
+
else if (arg === '--log-only') args.logOnly = true;
|
|
133
150
|
else if (arg.startsWith('--session-id=')) args.sessionId = arg.slice(13);
|
|
134
151
|
else if (arg.startsWith('--payload=')) args.payload = arg.slice(10);
|
|
135
152
|
else if (arg.startsWith('--transcript-path=')) args.transcriptPath = expandHome(arg.slice(18));
|
|
@@ -140,22 +157,71 @@ function parseArgs(argv) {
|
|
|
140
157
|
return args;
|
|
141
158
|
}
|
|
142
159
|
|
|
160
|
+
// ── session-close hard gate (ADR 0055) ───────────────────────────────────────
|
|
161
|
+
// The marker attests "the USER closed this session". Its evidence transcript is
|
|
162
|
+
// resolved STRICTLY from the session id (a globally-unique UUID) by globbing the
|
|
163
|
+
// Claude project dirs — never from a CLI arg. A model owns the whole subprocess
|
|
164
|
+
// invocation, so trusting a `--transcript-path` it supplies would let it point at
|
|
165
|
+
// a forged `<session-id>.jsonl` it just wrote with a fake close phrase. Resolving
|
|
166
|
+
// from the id alone closes that: the only file the glob finds is the live
|
|
167
|
+
// transcript the harness itself maintains, which the model cannot author. (If the
|
|
168
|
+
// model drops a second `<id>.jsonl` elsewhere the glob returns >1 and fails
|
|
169
|
+
// closed.) `--transcript-path` survives ONLY for `--check-session-close`'s lint
|
|
170
|
+
// scope, which writes no marker and so cannot cause an over-close.
|
|
171
|
+
|
|
143
172
|
// ── session-close check (spec §5.2.7 / §8.3) ────────────────────────
|
|
144
173
|
// Mirrors the hard gate in hypo-personal-check.mjs so the /hypo:crystallize
|
|
145
174
|
// flow can self-verify before /compact triggers PreCompact.
|
|
146
175
|
|
|
147
176
|
function runSessionCloseCheck(args) {
|
|
148
|
-
|
|
177
|
+
// ADR 0046: the check mirrors the FULL PreCompact gate via the shared
|
|
178
|
+
// precompactGateStatus (close files + lint + design-history + feedback
|
|
179
|
+
// projection), not just the close files — so a green check means /compact
|
|
180
|
+
// won't block on a human-fixable issue. Pass --transcript-path to widen the
|
|
181
|
+
// lint scope to the session's edited files exactly as the interactive hook
|
|
182
|
+
// does (without it, the scope is the mandatory close files only).
|
|
183
|
+
// Pass --session-id so a log-only marker activates log-only gate
|
|
184
|
+
// semantics here too. Without it the check would read the marker as present
|
|
185
|
+
// (marker_present:true) while `ok` still reflected the stale active project —
|
|
186
|
+
// the completion-signal trio (PreCompact / --check / marker) would diverge
|
|
187
|
+
// (codex design Finding 2).
|
|
188
|
+
const status = precompactGateStatus(args.hypoDir, {
|
|
189
|
+
...(args.transcriptPath ? { transcriptPath: args.transcriptPath } : {}),
|
|
190
|
+
...(args.sessionId ? { sessionId: args.sessionId } : {}),
|
|
191
|
+
});
|
|
192
|
+
const close = status.close;
|
|
193
|
+
|
|
194
|
+
// ADR 0047: when a --session-id is supplied, report whether THIS session's
|
|
195
|
+
// per-session marker (the Stop-chain completion signal) exists. This is a
|
|
196
|
+
// separate field, NOT folded into `ok` — `ok` stays the ADR 0046 compact-
|
|
197
|
+
// readiness verdict. A green gate with marker_present=false is exactly the
|
|
198
|
+
// hand-edit close state: close is compact-ready but the Stop hook will
|
|
199
|
+
// still block until the marker is written.
|
|
200
|
+
//
|
|
201
|
+
// Use the SAME reader the Stop hook gates on (readSessionClosedMarker), not
|
|
202
|
+
// raw file existence: a stale/corrupt marker file exists on disk but the hook
|
|
203
|
+
// rejects (and unlinks) it, so raw existsSync would report marker_present=true
|
|
204
|
+
// while /compact's Stop still blocks — the exact incoherence this ADR closes
|
|
205
|
+
// (codex pre-commit CONCERN). readSessionClosedMarker unlinks an invalid
|
|
206
|
+
// marker as it reads, matching the hook's behavior on the next Stop.
|
|
207
|
+
const markerPresent = args.sessionId
|
|
208
|
+
? readSessionClosedMarker(args.hypoDir, args.sessionId) !== null
|
|
209
|
+
: null;
|
|
149
210
|
|
|
150
211
|
if (args.json) {
|
|
151
212
|
console.log(
|
|
152
213
|
JSON.stringify(
|
|
153
214
|
{
|
|
154
215
|
ok: status.ok,
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
216
|
+
// flat close fields preserved for back-compat with prior readers
|
|
217
|
+
project: close.project,
|
|
218
|
+
dates: close.dates,
|
|
219
|
+
stale: close.stale,
|
|
220
|
+
missing: close.missing,
|
|
221
|
+
blockers: status.blockers,
|
|
222
|
+
notices: status.notices,
|
|
223
|
+
skipped: status.skipped,
|
|
224
|
+
...(args.sessionId ? { session_id: args.sessionId, marker_present: markerPresent } : {}),
|
|
159
225
|
},
|
|
160
226
|
null,
|
|
161
227
|
2,
|
|
@@ -164,31 +230,51 @@ function runSessionCloseCheck(args) {
|
|
|
164
230
|
process.exit(status.ok ? 0 : 1);
|
|
165
231
|
}
|
|
166
232
|
|
|
167
|
-
const proj =
|
|
168
|
-
console.log(`
|
|
233
|
+
const proj = close.project || '(unresolved)';
|
|
234
|
+
console.log(`Compact-ready check (project: ${proj}, date: ${close.dates.join(' / ')}):\n`);
|
|
169
235
|
|
|
170
|
-
const required =
|
|
236
|
+
const required = close.project
|
|
171
237
|
? [
|
|
172
|
-
`projects/${
|
|
173
|
-
`projects/${
|
|
238
|
+
`projects/${close.project}/session-state.md`,
|
|
239
|
+
`projects/${close.project}/hot.md`,
|
|
174
240
|
'hot.md',
|
|
175
|
-
|
|
241
|
+
sessionLogShardPath(close.project, close.dates[0]),
|
|
176
242
|
'log.md',
|
|
177
243
|
]
|
|
178
244
|
: [];
|
|
179
245
|
for (const f of required) {
|
|
180
|
-
const bad =
|
|
246
|
+
const bad = close.missing.includes(f) ? 'missing' : close.stale.includes(f) ? 'stale' : '';
|
|
181
247
|
console.log(` ${bad ? '✗' : '✓'} ${f}${bad ? ` — ${bad}` : ''}`);
|
|
182
248
|
}
|
|
183
249
|
// Surface anything not covered by the canonical list (e.g. unresolved project).
|
|
184
|
-
for (const f of [...
|
|
250
|
+
for (const f of [...close.missing, ...close.stale]) {
|
|
185
251
|
if (!required.includes(f)) console.log(` ✗ ${f}`);
|
|
186
252
|
}
|
|
253
|
+
// Beyond the close files: the rest of the PreCompact gate (lint, design-history,
|
|
254
|
+
// feedback over-cap/conflict). These are what made a "close-complete" check
|
|
255
|
+
// disagree with the real /compact gate before ADR 0046.
|
|
256
|
+
for (const b of status.blockers) {
|
|
257
|
+
if (b.type !== 'close') console.log(` ✗ ${b.reason}`);
|
|
258
|
+
}
|
|
259
|
+
if (status.notices.length > 0) {
|
|
260
|
+
console.log('');
|
|
261
|
+
for (const n of status.notices) console.log(` · ${n.reason}`);
|
|
262
|
+
}
|
|
263
|
+
// ADR 0047: surface the per-session marker state (separate from compact-
|
|
264
|
+
// readiness) so a green-but-unmarked close is visible at verify time.
|
|
265
|
+
if (args.sessionId) {
|
|
266
|
+
console.log('');
|
|
267
|
+
console.log(
|
|
268
|
+
markerPresent
|
|
269
|
+
? ` ✓ session-closed marker present (session_id: ${args.sessionId}).`
|
|
270
|
+
: ` · 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}` : ''}\`.`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
187
273
|
console.log('');
|
|
188
274
|
console.log(
|
|
189
275
|
status.ok
|
|
190
|
-
? '✓
|
|
191
|
-
: '✗
|
|
276
|
+
? '✓ 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.)'
|
|
277
|
+
: '✗ Not compact-ready — resolve the ✗ items above, then retry. /compact would block on these.',
|
|
192
278
|
);
|
|
193
279
|
process.exit(status.ok ? 0 : 1);
|
|
194
280
|
}
|
|
@@ -252,7 +338,7 @@ function writeIfChanged(path, content) {
|
|
|
252
338
|
/**
|
|
253
339
|
* Append `entry` to `path` only if `alreadyPresent(content)` is false.
|
|
254
340
|
* 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
|
|
341
|
+
* mid-append cannot leave log.md or session-log/YYYY-MM-DD.md half-written, which
|
|
256
342
|
* matters for these append-only history files.
|
|
257
343
|
*/
|
|
258
344
|
function appendIfAbsent(path, entry, alreadyPresent) {
|
|
@@ -283,7 +369,7 @@ function todayLocal() {
|
|
|
283
369
|
// bug, not a no-op. Caller is the LLM session-close flow, which composes the
|
|
284
370
|
// payload deliberately; partial payloads must fail loudly so caller fixes them
|
|
285
371
|
// rather than silently relying on yesterday's freshness state. (Codex review
|
|
286
|
-
// of
|
|
372
|
+
// of the apply path — Worker 1 finding 1.)
|
|
287
373
|
const REQUIRED_PAYLOAD_FIELDS = [
|
|
288
374
|
['sessionState', 'content'],
|
|
289
375
|
['projectHot', 'content'],
|
|
@@ -329,8 +415,9 @@ function validatePayloadShape(payload) {
|
|
|
329
415
|
// crystallize` is the only Reader; writer authority is intentionally split
|
|
330
416
|
// between this CLI and the auto-write at the tail of applySessionClose.
|
|
331
417
|
//
|
|
332
|
-
// Contract: marker is only
|
|
333
|
-
// A failed
|
|
418
|
+
// Contract: the marker is written only when the FULL /compact gate
|
|
419
|
+
// (precompactGateStatus, ADR 0046) is green. A failed gate exits 1 with no
|
|
420
|
+
// marker — the next Stop hook re-blocks.
|
|
334
421
|
|
|
335
422
|
function runMarkSessionClosed(args) {
|
|
336
423
|
if (!args.sessionId) {
|
|
@@ -338,20 +425,39 @@ function runMarkSessionClosed(args) {
|
|
|
338
425
|
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
339
426
|
process.exit(1);
|
|
340
427
|
}
|
|
341
|
-
// ADR
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
428
|
+
// ADR 0047: the per-session marker is the THIRD session-close completion
|
|
429
|
+
// signal (after the PreCompact gate and `--check-session-close`). It must use
|
|
430
|
+
// the SAME gate that governs /compact — precompactGateStatus — so the marker
|
|
431
|
+
// can never attest "closed" while /compact would still block. This subsumes
|
|
432
|
+
// the prior (sessionCloseGlobalStatus + hypoIsClean + scoped-lint) gate and
|
|
433
|
+
// additionally enforces feedback projection (over-cap/conflict), W8 design-
|
|
434
|
+
// history staleness, and root hot.md structure — the checks that the narrower
|
|
435
|
+
// marker gate skipped (the divergence behind this fix). git-clean is now a
|
|
436
|
+
// `git` blocker inside the gate. Pass --transcript-path to widen the lint
|
|
437
|
+
// scope to this session's edited files exactly as the interactive hook does;
|
|
438
|
+
// without it the scope is the mandatory close files only.
|
|
439
|
+
// --log-only marks a non-project (tooling / wiki-only) session as
|
|
440
|
+
// closed without attributing it to any project. The gate runs in log-only mode
|
|
441
|
+
// (project-close invariant → a today log.md entry; lint/W8 scoped to shared +
|
|
442
|
+
// touched files, never the active/phantom project), but git / hot / feedback
|
|
443
|
+
// still apply — log-only is NOT a global-gate bypass.
|
|
444
|
+
// Resolve the close transcript once from the session id (glob, never a CLI
|
|
445
|
+
// arg): it both widens the lint scope inside the gate AND is the evidence
|
|
446
|
+
// source for the user-close hard gate below.
|
|
447
|
+
const closeTranscript = resolveTranscriptBySessionId(args.sessionId);
|
|
448
|
+
const gate = precompactGateStatus(args.hypoDir, {
|
|
449
|
+
...(closeTranscript ? { transcriptPath: closeTranscript } : {}),
|
|
450
|
+
...(args.logOnly ? { logOnly: true } : {}),
|
|
451
|
+
});
|
|
452
|
+
const status = gate.close;
|
|
453
|
+
if (!gate.ok) {
|
|
348
454
|
const result = {
|
|
349
455
|
ok: false,
|
|
350
456
|
session_id: args.sessionId,
|
|
351
457
|
project: status.project,
|
|
352
458
|
missing: status.missing,
|
|
353
459
|
stale: status.stale,
|
|
354
|
-
|
|
460
|
+
blockers: gate.blockers,
|
|
355
461
|
error: 'session-close gate not satisfied — marker not written',
|
|
356
462
|
};
|
|
357
463
|
if (args.json) {
|
|
@@ -360,56 +466,34 @@ function runMarkSessionClosed(args) {
|
|
|
360
466
|
console.log(
|
|
361
467
|
`✗ session-close gate not satisfied — marker not written (project: ${status.project || '(unresolved)'}):`,
|
|
362
468
|
);
|
|
363
|
-
for (const
|
|
364
|
-
for (const f of status.stale) console.log(` ✗ ${f} (stale)`);
|
|
365
|
-
if (!git.clean) console.log(` ✗ git: ${git.reason}`);
|
|
469
|
+
for (const b of gate.blockers) console.log(` ✗ ${b.reason}`);
|
|
366
470
|
}
|
|
367
471
|
process.exit(1);
|
|
368
472
|
}
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
}
|
|
473
|
+
// User-close hard gate (ADR 0055): the compact gate above only proves the wiki
|
|
474
|
+
// is compact-ready; it does NOT prove the USER asked to close. Refuse the marker
|
|
475
|
+
// unless the transcript carries a genuine user close signal (NL close phrase,
|
|
476
|
+
// /compact, or an AskUserQuestion close answer). This is the hard backstop for
|
|
477
|
+
// model over-close, where prose guidance lost to a conflicting global rule.
|
|
478
|
+
// Fail-closed when the transcript can't be resolved.
|
|
479
|
+
if (!closeTranscript || !hasUserCloseSignal(closeTranscript)) {
|
|
480
|
+
const reason = !closeTranscript
|
|
481
|
+
? `cannot resolve a transcript for session ${args.sessionId} — the session-closed marker requires a verifiable user close signal`
|
|
482
|
+
: "no user close signal in this session's transcript — marker refused (the user did not signal session close)";
|
|
483
|
+
const result = {
|
|
484
|
+
ok: false,
|
|
485
|
+
session_id: args.sessionId,
|
|
486
|
+
project: status.project,
|
|
487
|
+
skipReason: 'no-user-close-signal',
|
|
488
|
+
error: reason,
|
|
489
|
+
};
|
|
490
|
+
console.log(args.json ? JSON.stringify(result, null, 2) : `✗ ${reason}`);
|
|
491
|
+
process.exit(1);
|
|
411
492
|
}
|
|
412
|
-
writeSessionClosedMarker(args.hypoDir, args.sessionId, {
|
|
493
|
+
writeSessionClosedMarker(args.hypoDir, args.sessionId, {
|
|
494
|
+
project: status.project,
|
|
495
|
+
...(args.logOnly ? { scope: 'log-only' } : {}),
|
|
496
|
+
});
|
|
413
497
|
// Marker writer swallows IO errors (best-effort, see hypo-shared.mjs). Verify
|
|
414
498
|
// the file actually landed before claiming success — otherwise CLI exits 0
|
|
415
499
|
// while next Stop re-blocks, hiding a permission/disk problem.
|
|
@@ -427,22 +511,36 @@ function runMarkSessionClosed(args) {
|
|
|
427
511
|
ok: true,
|
|
428
512
|
session_id: args.sessionId,
|
|
429
513
|
project: status.project,
|
|
514
|
+
scope: args.logOnly ? 'log-only' : 'project',
|
|
430
515
|
date: status.dates[0],
|
|
516
|
+
notices: gate.notices,
|
|
517
|
+
// ADR 0047: pure feedback-projection drift is a non-blocker — the marker
|
|
518
|
+
// attests "compact-ready (no human-fixable blocker)", and the PreCompact
|
|
519
|
+
// hook self-heals the projection (feedback-sync --write) at /compact. Surface
|
|
520
|
+
// the deferral so the caller knows MEMORY/CLAUDE sync is pending, not lost.
|
|
521
|
+
drift_deferred: gate.driftTargets,
|
|
431
522
|
};
|
|
432
523
|
if (args.json) {
|
|
433
524
|
console.log(JSON.stringify(result, null, 2));
|
|
434
525
|
} else {
|
|
435
526
|
console.log(
|
|
436
|
-
|
|
527
|
+
args.logOnly
|
|
528
|
+
? `✓ session-closed marker written (session_id: ${args.sessionId}, scope: log-only — no project attribution).`
|
|
529
|
+
: `✓ session-closed marker written (session_id: ${args.sessionId}, project: ${status.project}).`,
|
|
437
530
|
);
|
|
531
|
+
if (gate.driftTargets.length > 0) {
|
|
532
|
+
console.log(
|
|
533
|
+
` · feedback projection drift (${gate.driftTargets.join(', ')}) — will self-heal at /compact.`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
438
536
|
}
|
|
439
537
|
process.exit(0);
|
|
440
538
|
}
|
|
441
539
|
|
|
442
540
|
function applySessionClose(args) {
|
|
443
|
-
//
|
|
541
|
+
// Option D: early-exit fires only when NO payload was supplied.
|
|
444
542
|
// Rationale: payload presence is explicit close intent and must always run
|
|
445
|
-
// the full apply path —
|
|
543
|
+
// the full apply path — the per-entry idempotency (writeIfChanged +
|
|
446
544
|
// exact-entry append dedup) keeps re-apply cheap without short-circuiting,
|
|
447
545
|
// and avoids silent-success when a same-day second close brings new bytes.
|
|
448
546
|
// Payload-less invocation is treated as a cheap "already complete?" probe.
|
|
@@ -450,7 +548,9 @@ function applySessionClose(args) {
|
|
|
450
548
|
// for any actual apply work (readPayload below surfaces "payload is
|
|
451
549
|
// required" the same way it always has).
|
|
452
550
|
if (!args.force && !args.payload) {
|
|
453
|
-
|
|
551
|
+
// ADR 0043: no-payload "already complete?" probe uses the
|
|
552
|
+
// global invariant, not a recency pick.
|
|
553
|
+
const probe = sessionCloseGlobalStatus(args.hypoDir);
|
|
454
554
|
if (probe.ok) {
|
|
455
555
|
const result = {
|
|
456
556
|
ok: true,
|
|
@@ -505,10 +605,22 @@ function applySessionClose(args) {
|
|
|
505
605
|
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
506
606
|
process.exit(1);
|
|
507
607
|
}
|
|
608
|
+
// The freshness verification below (and at the post-apply check) already honors
|
|
609
|
+
// payload.project — `project` wins over the inferred active project, and the
|
|
610
|
+
// post-apply sessionCloseFileStatus call passes it as projectOverride. But when the
|
|
611
|
+
// payload targets a DIFFERENT project than the one active-project resolution infers
|
|
612
|
+
// (probe.project), that divergence used to be silent, so an operator couldn't tell
|
|
613
|
+
// which project the close actually verified. Surface it on stderr (the stdout JSON
|
|
614
|
+
// contract is untouched) so the verified/closed project is always explicit.
|
|
615
|
+
if (payload.project && probe.project && probe.project !== payload.project) {
|
|
616
|
+
process.stderr.write(
|
|
617
|
+
`note: payload.project="${payload.project}" differs from the inferred active ` +
|
|
618
|
+
`project "${probe.project}"; verifying and closing "${payload.project}".\n`,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
508
621
|
const date = payload.date || todayLocal();
|
|
509
|
-
const ym = date.slice(0, 7);
|
|
510
622
|
|
|
511
|
-
//
|
|
623
|
+
// Preflight: lint the wiki BEFORE writing any payload bytes. If lint
|
|
512
624
|
// has blockers (errors) in files this apply WON'T overwrite, the wiki is in
|
|
513
625
|
// a degraded state and apply would mask the root cause — abort fail-fast.
|
|
514
626
|
//
|
|
@@ -531,11 +643,27 @@ function applySessionClose(args) {
|
|
|
531
643
|
// the files it writes (other projects, shared pages this close did not author).
|
|
532
644
|
// payloadScope = every file this apply writes or appends. Both lint passes are
|
|
533
645
|
// judged against it; errors elsewhere are surfaced as notices, never blocking.
|
|
646
|
+
//
|
|
647
|
+
// session-log needs TWO entries (ADR 0050): the daily WRITE target (what this
|
|
648
|
+
// apply creates/appends, judged by post-apply lint) AND the freshness EVIDENCE
|
|
649
|
+
// file. They coincide except in the hybrid cutover month, where a fallback-
|
|
650
|
+
// aware no-op (the identical entry already lives in the legacy monthly file)
|
|
651
|
+
// writes no daily shard, leaving the monthly as the proof of freshness. Scope
|
|
652
|
+
// must then include that monthly file, or a CORRUPT monthly evidence file would
|
|
653
|
+
// pass the gate with its lint error demoted to a non-blocking notice.
|
|
654
|
+
// sessionLogScopePath returns the monthly ONLY when it carries today's heading
|
|
655
|
+
// (otherwise the daily write target), so unrelated monthly debt stays a notice.
|
|
656
|
+
// join() (platform-native), not the POSIX helper output: payloadScope membership
|
|
657
|
+
// is tested against lint's raw `e.file` (path.relative) WITHOUT posix
|
|
658
|
+
// normalization, so it must use the OS-native separator the sibling entries use.
|
|
659
|
+
const sessionLogWriteTarget = join('projects', project, 'session-log', `${date}.md`);
|
|
660
|
+
const sessionLogEvidence = join(...sessionLogScopePath(args.hypoDir, project, date).split('/'));
|
|
534
661
|
const payloadScope = new Set([
|
|
535
662
|
join('projects', project, 'session-state.md'),
|
|
536
663
|
join('projects', project, 'hot.md'),
|
|
537
664
|
'hot.md',
|
|
538
|
-
|
|
665
|
+
sessionLogWriteTarget,
|
|
666
|
+
sessionLogEvidence, // == write target, except a hybrid-month monthly fallback
|
|
539
667
|
'log.md',
|
|
540
668
|
...(payload.openQuestions ? [join('pages', 'open-questions.md')] : []),
|
|
541
669
|
]);
|
|
@@ -590,18 +718,56 @@ function applySessionClose(args) {
|
|
|
590
718
|
// dated today". The freshness gate (sessionCloseFileStatus) is what answers
|
|
591
719
|
// "was this file touched today?"; that's a different concern and must not
|
|
592
720
|
// be reused for apply-time dedup, or a legitimate same-day second close gets
|
|
593
|
-
// silently dropped (Codex review of
|
|
721
|
+
// silently dropped (Codex review of the apply path — Worker 1 finding 2).
|
|
594
722
|
const entryAlreadyPresent = (entry) => (content) =>
|
|
595
723
|
content.includes(entry.endsWith('\n') ? entry.replace(/\n+$/, '') : entry);
|
|
596
724
|
|
|
597
725
|
{
|
|
598
|
-
const rel = join('projects', project, 'session-log', `${
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
)
|
|
604
|
-
|
|
726
|
+
const rel = join('projects', project, 'session-log', `${date}.md`);
|
|
727
|
+
const full = join(args.hypoDir, rel);
|
|
728
|
+
const isPresent = entryAlreadyPresent(payload.sessionLog.entry);
|
|
729
|
+
// Fallback-aware idempotency (ADR 0050 hybrid cutover): during the month the
|
|
730
|
+
// shard takes over, today's entry may already live in the legacy monthly
|
|
731
|
+
// file from an earlier (pre-cutover) close. Treat presence in EITHER the
|
|
732
|
+
// daily shard or the legacy monthly file as "already written" so a same-day
|
|
733
|
+
// second close does not duplicate an identical entry across both files —
|
|
734
|
+
// and so an idempotent re-apply stays a true no-op (no shard is created).
|
|
735
|
+
let alreadyThere = false;
|
|
736
|
+
for (const cand of sessionLogReadCandidates(project, date)) {
|
|
737
|
+
const cf = join(args.hypoDir, cand);
|
|
738
|
+
if (!existsSync(cf)) continue;
|
|
739
|
+
try {
|
|
740
|
+
if (isPresent(readFileSync(cf, 'utf-8'))) {
|
|
741
|
+
alreadyThere = true;
|
|
742
|
+
break;
|
|
743
|
+
}
|
|
744
|
+
} catch {
|
|
745
|
+
/* unreadable candidate — fall through to the write path */
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (alreadyThere) {
|
|
749
|
+
skipped.push(`sessionLog (${rel})`);
|
|
750
|
+
} else if (!existsSync(full)) {
|
|
751
|
+
// A daily shard is a new file most days. Seed minimal valid frontmatter
|
|
752
|
+
// (title + type, the two REQUIRED_FIELDS) so the shard is a first-class
|
|
753
|
+
// wiki page rather than a W1 "no frontmatter" warning, and write the header
|
|
754
|
+
// AND the first entry in ONE atomic write — never leave a header-only shard
|
|
755
|
+
// on disk, which freshness would skip (no dated heading) while derive could
|
|
756
|
+
// otherwise mistake it for the evidence file. The dated `## [date] ...`
|
|
757
|
+
// heading lives inside the entry, so freshness / derive / design-history
|
|
758
|
+
// are unchanged.
|
|
759
|
+
const header =
|
|
760
|
+
`---\ntitle: Session Log ${date} (${project})\n` +
|
|
761
|
+
`type: session-log\nupdated: ${date}\n---\n\n` +
|
|
762
|
+
`# Session Log ${date} (${project})\n`;
|
|
763
|
+
const entry = payload.sessionLog.entry;
|
|
764
|
+
const body = entry.endsWith('\n') ? entry : `${entry}\n`;
|
|
765
|
+
atomicWrite(full, `${header}\n${body}`);
|
|
766
|
+
applied.push(`sessionLog (${rel})`);
|
|
767
|
+
} else {
|
|
768
|
+
const wrote = appendIfAbsent(full, payload.sessionLog.entry, isPresent);
|
|
769
|
+
(wrote ? applied : skipped).push(`sessionLog (${rel})`);
|
|
770
|
+
}
|
|
605
771
|
}
|
|
606
772
|
|
|
607
773
|
{
|
|
@@ -613,14 +779,14 @@ function applySessionClose(args) {
|
|
|
613
779
|
(wrote ? applied : skipped).push('log (log.md)');
|
|
614
780
|
}
|
|
615
781
|
|
|
616
|
-
//
|
|
782
|
+
// Same-date-tie fix: verify against the SAME project this apply just wrote
|
|
617
783
|
// (`project` = payload.project || probe.project, resolved at the top). Without
|
|
618
784
|
// the override, sessionCloseFileStatus re-derives via resolveActiveProject and,
|
|
619
785
|
// on a same-date root-hot.md tie, can pick a different project — false-failing
|
|
620
786
|
// a completed close (the 2026-06-09 security-ops-kb incident).
|
|
621
787
|
const verification = sessionCloseFileStatus(args.hypoDir, { projectOverride: project });
|
|
622
788
|
|
|
623
|
-
//
|
|
789
|
+
// Post-apply lint: payload may have introduced a malformed body or
|
|
624
790
|
// bad frontmatter. Surface as a distinct `stage` so caller can tell "lint
|
|
625
791
|
// broke" apart from "frontmatter stale". This runs even if the freshness gate
|
|
626
792
|
// also failed — both failure modes are useful to the caller.
|
|
@@ -660,18 +826,61 @@ function applySessionClose(args) {
|
|
|
660
826
|
// ADR 0022 amendment 2026-05-19: auto-write the per-session
|
|
661
827
|
// closed marker on a verified close. Hook authority is read-only; this is
|
|
662
828
|
// one of the two writer paths (the other is --mark-session-closed standalone).
|
|
663
|
-
//
|
|
664
|
-
// ADR
|
|
665
|
-
//
|
|
829
|
+
//
|
|
830
|
+
// ADR 0047: the marker write is governed by the SAME gate as standalone
|
|
831
|
+
// --mark-session-closed and /compact (precompactGateStatus), NOT just apply's
|
|
832
|
+
// `ok` + git-clean. Apply's payload preflight/post-apply lint and `ok` still
|
|
833
|
+
// govern apply SUCCESS (exit code below), but the marker must additionally
|
|
834
|
+
// clear feedback projection / W8 design-history / hot.md structure, else this
|
|
835
|
+
// path could issue a marker the standalone path would refuse (the second
|
|
836
|
+
// divergence codex flagged).
|
|
837
|
+
//
|
|
838
|
+
// ADR 0056: apply just wrote the payload, so the tree is dirty by its OWN
|
|
839
|
+
// writes — the gate's `uncommitted` git blocker would always trip and the
|
|
840
|
+
// marker would be skipped, deferring the close to a manual --mark-session-closed
|
|
841
|
+
// (the ADR 0047 "done but still blocked" regression). Commit the payload HERE, via
|
|
842
|
+
// the SAME .hypoignore-aware helper the auto-commit Stop hook uses, so the gate sees
|
|
843
|
+
// a committed tree. Push stays deferred to the Stop hook; the resulting
|
|
844
|
+
// committed-but-unpushed state is a gate notice, not a blocker (ADR 0056), so
|
|
845
|
+
// this still marks. A commit failure (not a repo / pre-commit reject / git error)
|
|
846
|
+
// skips the marker WITH a surfaced reason — today's behavior was also "no marker",
|
|
847
|
+
// but silently.
|
|
848
|
+
let markerWritten = false;
|
|
849
|
+
let markerSkipReason = null;
|
|
666
850
|
if (ok && args.sessionId) {
|
|
667
|
-
const
|
|
668
|
-
if (
|
|
669
|
-
|
|
851
|
+
const commitOutcome = commitWikiChanges(args.hypoDir);
|
|
852
|
+
if (!commitOutcome.committed) {
|
|
853
|
+
markerSkipReason = `commit-failed: ${commitOutcome.reason}`;
|
|
854
|
+
} else {
|
|
855
|
+
const closeTranscript = resolveTranscriptBySessionId(args.sessionId);
|
|
856
|
+
const markerGate = precompactGateStatus(
|
|
857
|
+
args.hypoDir,
|
|
858
|
+
closeTranscript ? { transcriptPath: closeTranscript } : {},
|
|
859
|
+
);
|
|
860
|
+
if (!markerGate.ok) {
|
|
861
|
+
// compact gate not ok → skip. Caller's `result.ok` already reflects the
|
|
862
|
+
// file/lint state; next Stop re-blocks until the remaining blocker
|
|
863
|
+
// (feedback/W8/hot/lint — git is now committed) is resolved.
|
|
864
|
+
markerSkipReason = 'compact-gate-not-ok';
|
|
865
|
+
} else if (!closeTranscript || !hasUserCloseSignal(closeTranscript)) {
|
|
866
|
+
// User-close hard gate (ADR 0055): apply succeeded (payload files written)
|
|
867
|
+
// but the user never signalled session close, so the marker — which attests
|
|
868
|
+
// "user closed" — is withheld. The wiki record stands; the session is simply
|
|
869
|
+
// not marked closed. Surfaced (not silent) so the caller knows.
|
|
870
|
+
markerSkipReason = closeTranscript ? 'no-user-close-signal' : 'transcript-unresolved';
|
|
871
|
+
} else {
|
|
872
|
+
writeSessionClosedMarker(args.hypoDir, args.sessionId, { project });
|
|
873
|
+
// Codex CONCERN (ADR 0055/0056): the writer swallows IO errors (best-effort).
|
|
874
|
+
// Verify the file actually landed — mirroring the standalone path — instead of
|
|
875
|
+
// asserting markerWritten=true, so a .cache permission/disk problem surfaces
|
|
876
|
+
// rather than the caller reporting "closed" while the next Stop re-blocks.
|
|
877
|
+
if (existsSync(sessionClosedMarkerPath(args.hypoDir, args.sessionId))) {
|
|
878
|
+
markerWritten = true;
|
|
879
|
+
} else {
|
|
880
|
+
markerSkipReason = 'marker-did-not-land';
|
|
881
|
+
}
|
|
882
|
+
}
|
|
670
883
|
}
|
|
671
|
-
// git not clean → silent skip: caller's `result.ok` already reflects the
|
|
672
|
-
// file/lint state; surfacing a "marker skipped" warning here would
|
|
673
|
-
// confuse the close-applied success path. Next Stop re-blocks until
|
|
674
|
-
// git is clean (auto-commit retries on subsequent runs).
|
|
675
884
|
}
|
|
676
885
|
const stage = ok
|
|
677
886
|
? null
|
|
@@ -688,6 +897,9 @@ function applySessionClose(args) {
|
|
|
688
897
|
applied,
|
|
689
898
|
skipped,
|
|
690
899
|
verification,
|
|
900
|
+
// ADR 0055: surface the marker outcome instead of skipping silently, so the
|
|
901
|
+
// caller can tell "closed" from "applied but not marked".
|
|
902
|
+
...(args.sessionId ? { markerWritten, markerSkipReason } : {}),
|
|
691
903
|
lint: { preflight: preflightLint, postApply: postApplyLint },
|
|
692
904
|
// Pre-existing lint debt in files this close did not author (Bug B): surfaced
|
|
693
905
|
// for visibility, never gated. Empty on a clean vault.
|