hypomnema 1.7.3 → 1.8.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/CHANGELOG.md +1202 -0
- package/README.ko.md +7 -7
- package/README.md +7 -7
- package/commands/audit.md +1 -1
- package/commands/capture.md +2 -2
- package/commands/crystallize.md +4 -4
- package/commands/doctor.md +15 -1
- package/commands/feedback.md +1 -1
- package/commands/graph.md +1 -1
- package/commands/ingest.md +1 -1
- package/commands/init.md +2 -2
- package/commands/lint.md +1 -1
- package/commands/query.md +1 -1
- package/commands/rename.md +1 -1
- package/commands/resume.md +1 -1
- package/commands/stats.md +1 -1
- package/commands/uninstall.md +17 -5
- package/commands/upgrade.md +2 -2
- package/commands/verify.md +1 -1
- package/docs/ARCHITECTURE.md +9 -4
- package/docs/CONTRIBUTING.md +15 -5
- package/hooks/base-store.mjs +198 -0
- package/hooks/close-gate-store.mjs +436 -0
- package/hooks/hooks.json +2 -1
- package/hooks/hypo-auto-minimal-crystallize.mjs +10 -0
- package/hooks/hypo-close-guard.mjs +24 -4
- package/hooks/hypo-compact-guard.mjs +5 -3
- package/hooks/hypo-cwd-change.mjs +8 -0
- package/hooks/hypo-personal-check.mjs +128 -68
- package/hooks/hypo-session-end.mjs +21 -2
- package/hooks/hypo-session-start.mjs +91 -10
- package/hooks/hypo-shared.mjs +498 -180
- package/hooks/version-check.mjs +44 -6
- package/package.json +10 -3
- package/scripts/capture.mjs +64 -21
- package/scripts/crystallize.mjs +20 -2044
- package/scripts/doctor.mjs +334 -14
- package/scripts/init.mjs +132 -92
- package/scripts/lib/crystallize-args.mjs +50 -0
- package/scripts/lib/crystallize-close-apply.mjs +1830 -0
- package/scripts/lib/crystallize-close-check.mjs +238 -0
- package/scripts/lib/crystallize-close-gate.mjs +58 -0
- package/scripts/lib/crystallize-helpers.mjs +55 -0
- package/scripts/lib/design-history-stale.mjs +26 -7
- package/scripts/lib/extensions.mjs +235 -65
- package/scripts/lib/git-hooks-dir.mjs +214 -2
- package/scripts/lib/plugin-detect.mjs +176 -21
- package/scripts/lib/slug-resolver.mjs +181 -0
- package/scripts/lint.mjs +33 -23
- package/scripts/proposal.mjs +3 -3
- package/scripts/rename.mjs +38 -141
- package/scripts/uninstall.mjs +155 -58
- package/scripts/upgrade.mjs +243 -20
- package/skills/crystallize/SKILL.md +16 -14
- package/skills/graph/SKILL.md +3 -3
- package/skills/ingest/SKILL.md +2 -2
- package/skills/lint/SKILL.md +3 -3
- package/skills/query/SKILL.md +3 -3
- package/skills/verify/SKILL.md +3 -3
- package/templates/hypo-automation.md +59 -17
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +12 -9
- package/templates/hypo-help.md +1 -1
package/scripts/crystallize.mjs
CHANGED
|
@@ -65,2053 +65,29 @@
|
|
|
65
65
|
* hard-fails regardless of scope.
|
|
66
66
|
*/
|
|
67
67
|
|
|
68
|
-
import {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
readFileSync,
|
|
72
|
-
writeFileSync,
|
|
73
|
-
mkdirSync,
|
|
74
|
-
renameSync,
|
|
75
|
-
realpathSync,
|
|
76
|
-
openSync,
|
|
77
|
-
writeSync,
|
|
78
|
-
closeSync,
|
|
79
|
-
} from 'fs';
|
|
80
|
-
import { join, dirname } from 'path';
|
|
81
|
-
import { spawnSync } from 'child_process';
|
|
82
|
-
import { fileURLToPath, pathToFileURL } from 'url';
|
|
83
|
-
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
68
|
+
import { readFileSync, realpathSync } from 'fs';
|
|
69
|
+
import { join } from 'path';
|
|
70
|
+
import { pathToFileURL } from 'url';
|
|
84
71
|
import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
|
|
85
72
|
import { collectPagesCrystallize, extractWikilinks } from './lib/wikilink.mjs';
|
|
86
73
|
import { aggregateColdCandidates } from './lib/page-usage.mjs';
|
|
87
|
-
import {
|
|
88
|
-
import {
|
|
74
|
+
import { parseArgs } from './lib/crystallize-args.mjs';
|
|
75
|
+
import { runSessionCloseCheck } from './lib/crystallize-close-check.mjs';
|
|
89
76
|
import {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
resolveTranscriptBySessionId,
|
|
105
|
-
hasUserCloseSignal,
|
|
106
|
-
commitWikiChanges,
|
|
107
|
-
vaultCommitLockTarget,
|
|
108
|
-
currentDevice,
|
|
109
|
-
scopeVisible,
|
|
110
|
-
readVisibilityScope,
|
|
111
|
-
withFileLock,
|
|
112
|
-
extractTouchedWikiFilesWithTrust,
|
|
113
|
-
} from '../hooks/hypo-shared.mjs';
|
|
114
|
-
import { hashContent, readBaseEntry, advanceBase } from '../hooks/base-store.mjs';
|
|
115
|
-
import { writeProposal } from '../hooks/proposal-store.mjs';
|
|
116
|
-
|
|
117
|
-
// This script's own absolute path. Used to print copy-pasteable recovery
|
|
118
|
-
// commands as `node <SELF_SCRIPT> ...` rather than a bare `crystallize` bin,
|
|
119
|
-
// which is not on PATH in a Claude Code plugin install (only in an npm global).
|
|
120
|
-
const SELF_SCRIPT = fileURLToPath(import.meta.url);
|
|
121
|
-
const LINT_SCRIPT = join(dirname(SELF_SCRIPT), 'lint.mjs');
|
|
122
|
-
|
|
123
|
-
// Spawn lint.mjs --json against `hypoDir` and return parsed result.
|
|
124
|
-
// We shell out instead of refactoring lint.mjs into a library because lint.mjs
|
|
125
|
-
// keeps issues in module scope (scripts/lint.mjs:139,250) — a programmatic
|
|
126
|
-
// extraction is its own chore. spawnSync is the minimum-invasive path for #40.
|
|
127
|
-
// Throws only on JSON parse failure (lint crashed mid-run); a lint that exits 1
|
|
128
|
-
// with valid JSON is a normal "errors present" signal, not a crash.
|
|
129
|
-
// maxBuffer raised to 64 MiB: warn-only output on a large wiki can otherwise
|
|
130
|
-
// trip Node's 1 MiB default, truncate stdout, and turn a clean wiki into a
|
|
131
|
-
// JSON.parse crash (codex P3 follow-up).
|
|
132
|
-
function runLint(hypoDir) {
|
|
133
|
-
const r = spawnSync(process.execPath, [LINT_SCRIPT, `--hypo-dir=${hypoDir}`, '--json'], {
|
|
134
|
-
encoding: 'utf-8',
|
|
135
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
136
|
-
});
|
|
137
|
-
try {
|
|
138
|
-
return JSON.parse(r.stdout);
|
|
139
|
-
} catch {
|
|
140
|
-
// Report diagnostic metadata (sizes, exit/signal, spawn error code, a stderr
|
|
141
|
-
// tail) instead of dumping the whole — possibly huge, possibly truncated —
|
|
142
|
-
// stdout. lint.mjs now sets exitCode and exits naturally so its stdout is no
|
|
143
|
-
// longer cut at the 64 KiB pipe boundary; if this still fires it signals a
|
|
144
|
-
// genuine crash, and these fields say which kind.
|
|
145
|
-
const stderrTail = (r.stderr || '').slice(-2000);
|
|
146
|
-
throw new Error(
|
|
147
|
-
`lint helper produced unparseable output ` +
|
|
148
|
-
`(exit=${r.status}, signal=${r.signal || 'none'}, ` +
|
|
149
|
-
`stdoutBytes=${(r.stdout || '').length}, spawnError=${r.error?.code || 'none'})` +
|
|
150
|
-
(stderrTail ? `\nstderr tail:\n${stderrTail}` : ''),
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
156
|
-
|
|
157
|
-
function parseArgs(argv) {
|
|
158
|
-
const args = {
|
|
159
|
-
hypoDir: null,
|
|
160
|
-
minGroup: 2,
|
|
161
|
-
json: false,
|
|
162
|
-
checkSessionClose: false,
|
|
163
|
-
applySessionClose: false,
|
|
164
|
-
markSessionClosed: false,
|
|
165
|
-
logOnly: false,
|
|
166
|
-
sessionId: null,
|
|
167
|
-
payload: null,
|
|
168
|
-
force: false,
|
|
169
|
-
transcriptPath: null,
|
|
170
|
-
project: null,
|
|
171
|
-
};
|
|
172
|
-
for (const arg of argv.slice(2)) {
|
|
173
|
-
if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
174
|
-
else if (arg.startsWith('--min-group=')) args.minGroup = parseInt(arg.slice(12), 10) || 2;
|
|
175
|
-
else if (arg === '--check-session-close') args.checkSessionClose = true;
|
|
176
|
-
else if (arg === '--apply-session-close') args.applySessionClose = true;
|
|
177
|
-
else if (arg === '--mark-session-closed') args.markSessionClosed = true;
|
|
178
|
-
else if (arg === '--log-only') args.logOnly = true;
|
|
179
|
-
else if (arg.startsWith('--session-id=')) args.sessionId = arg.slice(13);
|
|
180
|
-
else if (arg.startsWith('--payload=')) args.payload = arg.slice(10);
|
|
181
|
-
else if (arg.startsWith('--transcript-path=')) args.transcriptPath = expandHome(arg.slice(18));
|
|
182
|
-
else if (arg.startsWith('--session-cwd=')) args.sessionCwd = expandHome(arg.slice(14));
|
|
183
|
-
else if (arg.startsWith('--project=')) args.project = arg.slice(10);
|
|
184
|
-
else if (arg === '--force') args.force = true;
|
|
185
|
-
else if (arg === '--json') args.json = true;
|
|
186
|
-
}
|
|
187
|
-
if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
|
|
188
|
-
// --project=<slug> override (check/mark only). Validate the SYNTAX here so a
|
|
189
|
-
// traversal/charset attack (`--project=../x`) is rejected before any path is
|
|
190
|
-
// built from it — sessionCloseFileStatus(projectOverride) joins it directly.
|
|
191
|
-
// isValidProjectName is the SHARED validator (project-create.mjs), so the
|
|
192
|
-
// override accepts exactly the namespace createProject can scaffold. Existence
|
|
193
|
-
// (a real projects/<slug>/ directory) is checked in the run functions, where
|
|
194
|
-
// hypoDir is resolved and only the check/mark paths consume --project.
|
|
195
|
-
if (args.project != null && !isValidProjectName(args.project)) {
|
|
196
|
-
const msg = `--project "${args.project}" is not a valid project name (need a single segment with ≥1 alnum, charset A-Za-z0-9._-, not "."/"..")`;
|
|
197
|
-
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
198
|
-
process.exit(1);
|
|
199
|
-
}
|
|
200
|
-
return args;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// ── session-close hard gate ───────────────────────────────────────────────────
|
|
204
|
-
// The marker attests "the USER closed this session". Its evidence transcript is
|
|
205
|
-
// resolved STRICTLY from the session id (a globally-unique UUID) by globbing the
|
|
206
|
-
// Claude project dirs — never from a CLI arg. A model owns the whole subprocess
|
|
207
|
-
// invocation, so trusting a `--transcript-path` it supplies would let it point at
|
|
208
|
-
// a forged `<session-id>.jsonl` it just wrote with a fake close phrase. Resolving
|
|
209
|
-
// from the id alone closes that: the only file the glob finds is the live
|
|
210
|
-
// transcript the harness itself maintains, which the model cannot author. (If the
|
|
211
|
-
// model drops a second `<id>.jsonl` elsewhere the glob returns >1 and fails
|
|
212
|
-
// closed.) `--transcript-path` survives ONLY for `--check-session-close`'s lint
|
|
213
|
-
// scope, which writes no marker and so cannot cause an over-close.
|
|
214
|
-
|
|
215
|
-
// Validate that an explicit --project=<slug> override names a real project
|
|
216
|
-
// DIRECTORY. Syntax was already checked in parseArgs; this is the existence half,
|
|
217
|
-
// mirroring apply's payload.project check — a regular file or an absent dir at
|
|
218
|
-
// projects/<slug> is a hard error so the override never silently resolves to an
|
|
219
|
-
// all-missing status (which a reader would misread as "exists but incomplete").
|
|
220
|
-
function requireProjectDir(args, slug) {
|
|
221
|
-
const projectDir = join(args.hypoDir, 'projects', slug);
|
|
222
|
-
if (!existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
|
|
223
|
-
const msg = `--project "${slug}" does not exist as a directory (no projects/${slug}/ directory)`;
|
|
224
|
-
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
225
|
-
process.exit(1);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// When the global gate's own discovery (hot.md pointer table + today
|
|
230
|
-
// close-activity scan, both in hypo-shared.mjs) comes back with NO project at
|
|
231
|
-
// all, a real apply never hits that dead end: it is handed `payload.project`
|
|
232
|
-
// directly and never infers. --check-session-close has no payload, so its one
|
|
233
|
-
// remaining authoritative signal is the same session's own transcript — which
|
|
234
|
-
// project's files did THIS session actually touch. Reusing the exact
|
|
235
|
-
// evidence-resolution helper the widened-lint-scope path already trusts here
|
|
236
|
-
// keeps this a single inference vocabulary (touched wiki files), not a second
|
|
237
|
-
// one: the difference is only which project-shaped question gets asked of it.
|
|
238
|
-
// Never guessed: a transcript touching zero or more than one project's files
|
|
239
|
-
// leaves the check exactly as unresolved as it was before this fallback.
|
|
240
|
-
function deriveTouchedProject(hypoDir, transcriptPath) {
|
|
241
|
-
if (!transcriptPath) return null;
|
|
242
|
-
const { files, trusted } = extractTouchedWikiFilesWithTrust(transcriptPath, hypoDir);
|
|
243
|
-
// `trusted:false` means the walk itself may be incomplete (a missing/unreadable
|
|
244
|
-
// transcript, or a line that failed to parse). A truncated line could have named
|
|
245
|
-
// a SECOND project the walk never saw, so treating this Set as "the whole
|
|
246
|
-
// truth" would resolve a single-project reading off a scope that is only
|
|
247
|
-
// single-project because part of it is missing, exactly the ambiguity this
|
|
248
|
-
// fallback exists to refuse rather than guess through.
|
|
249
|
-
if (!trusted) return null;
|
|
250
|
-
const slugs = new Set();
|
|
251
|
-
for (const f of files) {
|
|
252
|
-
const m = /^projects\/([^/]+)\//.exec(f);
|
|
253
|
-
if (m && existsSync(join(hypoDir, 'projects', m[1]))) slugs.add(m[1]);
|
|
254
|
-
}
|
|
255
|
-
return slugs.size === 1 ? [...slugs][0] : null;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// ── session-close check (spec §5.2.7 / §8.3) ────────────────────────
|
|
259
|
-
// Mirrors the hard gate in hypo-personal-check.mjs so the /hypo:crystallize
|
|
260
|
-
// flow can self-verify before /compact triggers PreCompact.
|
|
261
|
-
|
|
262
|
-
function runSessionCloseCheck(args) {
|
|
263
|
-
// The check mirrors the FULL PreCompact gate via the shared
|
|
264
|
-
// precompactGateStatus (close files + lint + design-history + feedback
|
|
265
|
-
// projection), not just the close files — so a green check means /compact
|
|
266
|
-
// won't block on a human-fixable issue. Pass --transcript-path to widen the
|
|
267
|
-
// lint scope to the session's edited files exactly as the interactive hook
|
|
268
|
-
// does (without it, the scope is the mandatory close files only).
|
|
269
|
-
// Pass --session-id so a log-only marker activates log-only gate
|
|
270
|
-
// semantics here too. Without it the check would read the marker as present
|
|
271
|
-
// (marker_present:true) while `ok` still reflected the stale active project —
|
|
272
|
-
// the completion-signal trio (PreCompact / --check / marker) would diverge
|
|
273
|
-
// (codex design Finding 2).
|
|
274
|
-
//
|
|
275
|
-
// --project=<slug> narrows BOTH the close status and the lint scope to that one
|
|
276
|
-
// project: a project-scoped DIAGNOSTIC, NOT the global compact-ready verdict.
|
|
277
|
-
// It is check-only: the marker writers stay global so
|
|
278
|
-
// the marker == compact-ready invariant holds. When narrowed, the
|
|
279
|
-
// transcript widening is suppressed: a transcript touch in some OTHER project
|
|
280
|
-
// would re-add that project's files to the lint scope and re-block the scoped
|
|
281
|
-
// check, defeating the point. The global (no --project) check keeps widening.
|
|
282
|
-
if (args.project) requireProjectDir(args, args.project);
|
|
283
|
-
// Resolve the transcript from --session-id when --transcript-path was not given,
|
|
284
|
-
// exactly as --mark and the apply auto-marker already do. The transcript is what
|
|
285
|
-
// attributes the close as well as widening the lint scope, and PreCompact
|
|
286
|
-
// always has one from its hook payload. A check without it would compute an EMPTY
|
|
287
|
-
// close scope, fall back to the global block, and report RED for debt that /compact
|
|
288
|
-
// demotes. Checklist step 14 tells the model to trust this command, so an over-red
|
|
289
|
-
// check is as harmful as an over-green one.
|
|
290
|
-
const checkTranscript =
|
|
291
|
-
args.transcriptPath ||
|
|
292
|
-
(args.sessionId ? resolveTranscriptBySessionId(args.sessionId) : null) ||
|
|
293
|
-
null;
|
|
294
|
-
let status = precompactGateStatus(args.hypoDir, {
|
|
295
|
-
...(args.project
|
|
296
|
-
? { projectOverride: args.project }
|
|
297
|
-
: checkTranscript
|
|
298
|
-
? { transcriptPath: checkTranscript }
|
|
299
|
-
: {}),
|
|
300
|
-
...(args.sessionId ? { sessionId: args.sessionId } : {}),
|
|
301
|
-
// The P2 cwd close check (session-close attribution) applies to the GLOBAL gate only. Under
|
|
302
|
-
// --project the check is a project-scoped diagnostic, so a cwd blocker for a
|
|
303
|
-
// DIFFERENT project would muddy that scoped answer — pass sessionCwd only for
|
|
304
|
-
// the global form. process.cwd() is deliberately NOT a fallback: a check run
|
|
305
|
-
// after `cd ~/hypomnema` would map to the vault, not the session (authoritative
|
|
306
|
-
// enforcement lives in the PreCompact/Stop hooks, which carry payload.cwd).
|
|
307
|
-
...(args.sessionCwd && !args.project ? { sessionCwd: args.sessionCwd } : {}),
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
// check/apply divergence (2026-08-25 QA): a real apply never hits discovery
|
|
311
|
-
// dead-ends because payload.project is required input, not an inference. This
|
|
312
|
-
// check has no payload, so when discovery finds NO project at all (not even
|
|
313
|
-
// the recency fallback), it retries scoped to whatever single project this
|
|
314
|
-
// session's own transcript shows it touching. This is a diagnostic estimate,
|
|
315
|
-
// not a preview of what a real apply will do: a payload's `project` field is
|
|
316
|
-
// whatever the caller puts there and can legitimately name a project the
|
|
317
|
-
// transcript never mentions. Only fires on a fully unresolved global result,
|
|
318
|
-
// and only on a TRUSTED single-project reading (see deriveTouchedProject): an
|
|
319
|
-
// already-successful discovery, an ambiguous/empty transcript, or one the walk
|
|
320
|
-
// could not fully read is left untouched rather than guessed at.
|
|
321
|
-
let inferredProject = null;
|
|
322
|
-
if (!args.project && !status.close.project) {
|
|
323
|
-
inferredProject = deriveTouchedProject(args.hypoDir, checkTranscript);
|
|
324
|
-
if (inferredProject) {
|
|
325
|
-
status = precompactGateStatus(args.hypoDir, {
|
|
326
|
-
projectOverride: inferredProject,
|
|
327
|
-
...(args.sessionId ? { sessionId: args.sessionId } : {}),
|
|
328
|
-
});
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
const close = status.close;
|
|
332
|
-
const scopedProject = args.project || inferredProject;
|
|
333
|
-
|
|
334
|
-
// When a --session-id is supplied, report whether THIS session's
|
|
335
|
-
// per-session marker (the Stop-chain completion signal) exists. This is a
|
|
336
|
-
// separate field, NOT folded into `ok`: `ok` stays the compact-
|
|
337
|
-
// readiness verdict. A green gate with marker_present=false is exactly the
|
|
338
|
-
// hand-edit close state: close is compact-ready but the Stop hook will
|
|
339
|
-
// still block until the marker is written.
|
|
340
|
-
//
|
|
341
|
-
// Use the SAME reader the Stop hook gates on (readSessionClosedMarker), not
|
|
342
|
-
// raw file existence: a stale/corrupt marker file exists on disk but the hook
|
|
343
|
-
// rejects (and unlinks) it, so raw existsSync would report marker_present=true
|
|
344
|
-
// while /compact's Stop still blocks — the exact incoherence this ADR closes
|
|
345
|
-
// (codex pre-commit CONCERN). readSessionClosedMarker unlinks an invalid
|
|
346
|
-
// marker as it reads, matching the hook's behavior on the next Stop.
|
|
347
|
-
const markerObj = args.sessionId ? readSessionClosedMarker(args.hypoDir, args.sessionId) : null;
|
|
348
|
-
const markerPresent = args.sessionId ? markerObj !== null : null;
|
|
349
|
-
|
|
350
|
-
// Scope of this check (codex design review finding 2 — the scope must be
|
|
351
|
-
// explicit in JSON + prose, not implied). `global` = the full PreCompact mirror
|
|
352
|
-
// (green ⇒ compact-ready). `project` = narrowed to --project=<slug> (green ⇒
|
|
353
|
-
// only THAT project is close-complete, NOT global compact-readiness). When a
|
|
354
|
-
// log-only marker governs the session, the gate runs in log-only mode and the
|
|
355
|
-
// --project override is IGNORED — surface that rather than implying X was
|
|
356
|
-
// checked (it was not).
|
|
357
|
-
const logOnlyWon = scopedProject != null && markerObj?.scope === 'log-only';
|
|
358
|
-
const scope = scopedProject ? (logOnlyWon ? 'log-only' : 'project') : 'global';
|
|
359
|
-
|
|
360
|
-
if (args.json) {
|
|
361
|
-
console.log(
|
|
362
|
-
JSON.stringify(
|
|
363
|
-
{
|
|
364
|
-
ok: status.ok,
|
|
365
|
-
// flat close fields preserved for back-compat with prior readers. They now
|
|
366
|
-
// describe what BLOCKS: a foreign project's incomplete close is demoted out
|
|
367
|
-
// of stale/missing into close_debt, so a reader that treats a
|
|
368
|
-
// non-empty `missing` as failure still agrees with `ok` instead of
|
|
369
|
-
// contradicting it.
|
|
370
|
-
project: close.project,
|
|
371
|
-
dates: close.dates,
|
|
372
|
-
stale: close.stale,
|
|
373
|
-
missing: close.missing,
|
|
374
|
-
...(close.debt?.length ? { close_debt: close.debt } : {}),
|
|
375
|
-
...(close.scope ? { close_scope: close.scope } : {}),
|
|
376
|
-
blockers: status.blockers,
|
|
377
|
-
notices: status.notices,
|
|
378
|
-
skipped: status.skipped,
|
|
379
|
-
// scope is additive; `global` keeps prior semantics for existing readers
|
|
380
|
-
scope,
|
|
381
|
-
...(scopedProject
|
|
382
|
-
? {
|
|
383
|
-
scoped_project: scopedProject,
|
|
384
|
-
// Distinguishes a user-typed --project from this check picking one
|
|
385
|
-
// for itself off the transcript — a reader should not mistake the
|
|
386
|
-
// latter for an explicit ask (see deriveTouchedProject above).
|
|
387
|
-
...(inferredProject ? { project_inferred_from_transcript: true } : {}),
|
|
388
|
-
...(logOnlyWon ? { project_override_ignored: true } : {}),
|
|
389
|
-
}
|
|
390
|
-
: {}),
|
|
391
|
-
...(args.sessionId ? { session_id: args.sessionId, marker_present: markerPresent } : {}),
|
|
392
|
-
},
|
|
393
|
-
null,
|
|
394
|
-
2,
|
|
395
|
-
),
|
|
396
|
-
);
|
|
397
|
-
process.exit(status.ok ? 0 : 1);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// Label the scoped project by how it was chosen — an explicit --project reads
|
|
401
|
-
// as a flag the caller typed; an inferred one reads as this check's own guess
|
|
402
|
-
// off the transcript, so a reader does not credit the caller with an ask
|
|
403
|
-
// nobody made.
|
|
404
|
-
const scopedProjectLabel = args.project
|
|
405
|
-
? `--project=${args.project}`
|
|
406
|
-
: `project=${scopedProject} (inferred from the session transcript, no --project given)`;
|
|
407
|
-
if (logOnlyWon) {
|
|
408
|
-
console.log(
|
|
409
|
-
`Note: a log-only session-closed marker governs session ${args.sessionId}, so the gate ran in log-only mode and ${scopedProjectLabel} was IGNORED (no project was checked).\n`,
|
|
410
|
-
);
|
|
411
|
-
} else if (scope === 'project') {
|
|
412
|
-
console.log(
|
|
413
|
-
`Note: ${scopedProjectLabel} — this is a PROJECT-SCOPED diagnostic, not the global /compact gate. A green result means only ${scopedProject} is close-complete; another project can still block /compact.\n`,
|
|
414
|
-
);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
const proj = close.project || '(unresolved)';
|
|
418
|
-
console.log(
|
|
419
|
-
`Compact-ready check (${scope === 'global' ? `project: ${proj}` : `scope: ${scope}, project: ${proj}`}, date: ${close.dates.join(' / ')}):\n`,
|
|
420
|
-
);
|
|
421
|
-
|
|
422
|
-
const required = close.project
|
|
423
|
-
? [
|
|
424
|
-
`projects/${close.project}/session-state.md`,
|
|
425
|
-
`projects/${close.project}/hot.md`,
|
|
426
|
-
'hot.md',
|
|
427
|
-
sessionLogShardPath(close.project, close.dates[0]),
|
|
428
|
-
'log.md',
|
|
429
|
-
]
|
|
430
|
-
: [];
|
|
431
|
-
for (const f of required) {
|
|
432
|
-
const bad = close.missing.includes(f) ? 'missing' : close.stale.includes(f) ? 'stale' : '';
|
|
433
|
-
console.log(` ${bad ? '✗' : '✓'} ${f}${bad ? ` — ${bad}` : ''}`);
|
|
434
|
-
}
|
|
435
|
-
// Surface anything not covered by the canonical list (e.g. unresolved project).
|
|
436
|
-
for (const f of [...close.missing, ...close.stale]) {
|
|
437
|
-
if (!required.includes(f)) console.log(` ✗ ${f}`);
|
|
438
|
-
}
|
|
439
|
-
// Beyond the close files: the rest of the PreCompact gate (lint, design-history,
|
|
440
|
-
// feedback over-cap/conflict). These are what made a "close-complete" check
|
|
441
|
-
// disagree with the real /compact gate before this check was added.
|
|
442
|
-
for (const b of status.blockers) {
|
|
443
|
-
if (b.type !== 'close') console.log(` ✗ ${b.reason}`);
|
|
444
|
-
}
|
|
445
|
-
if (status.notices.length > 0) {
|
|
446
|
-
console.log('');
|
|
447
|
-
for (const n of status.notices) console.log(` · ${n.reason}`);
|
|
448
|
-
}
|
|
449
|
-
// Surface the per-session marker state (separate from compact-
|
|
450
|
-
// readiness) so a green-but-unmarked close is visible at verify time.
|
|
451
|
-
if (args.sessionId) {
|
|
452
|
-
console.log('');
|
|
453
|
-
console.log(
|
|
454
|
-
markerPresent
|
|
455
|
-
? ` ✓ session-closed marker present (session_id: ${args.sessionId}).`
|
|
456
|
-
: ` · session-closed marker absent (session_id: ${args.sessionId}) — the Stop hook will block until it is written. Run \`node "${SELF_SCRIPT}" --mark-session-closed --session-id=${args.sessionId}${args.transcriptPath ? ` --transcript-path="${args.transcriptPath}"` : ''}\`.`,
|
|
457
|
-
);
|
|
458
|
-
}
|
|
459
|
-
console.log('');
|
|
460
|
-
if (scope === 'project') {
|
|
461
|
-
// Project-scoped diagnostic: green means ONLY this project is close-complete.
|
|
462
|
-
// Do NOT claim global compact-readiness (the whole point of the narrow).
|
|
463
|
-
console.log(
|
|
464
|
-
status.ok
|
|
465
|
-
? `✓ ${scopedProject} is close-complete (project-scoped). This is NOT a global /compact guarantee — run \`--check-session-close\` without --project for that.`
|
|
466
|
-
: `✗ ${scopedProject} is not close-complete — resolve the ✗ items above.`,
|
|
467
|
-
);
|
|
468
|
-
} else {
|
|
469
|
-
console.log(
|
|
470
|
-
status.ok
|
|
471
|
-
? '✓ 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.)'
|
|
472
|
-
: '✗ Not compact-ready — resolve the ✗ items above, then retry. /compact would block on these.',
|
|
473
|
-
);
|
|
474
|
-
}
|
|
475
|
-
process.exit(status.ok ? 0 : 1);
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
// ── session-close apply ────────────────────────────────────────────
|
|
479
|
-
// Idempotent payload-driven application of the 5 mandatory session-close memory
|
|
480
|
-
// files (+ optional open-questions). Used by the LLM session-close flow as the
|
|
481
|
-
// canonical entrypoint instead of issuing 5+ Write tool calls by hand.
|
|
482
|
-
//
|
|
483
|
-
// Idempotency:
|
|
484
|
-
// • full-content fields (sessionState/projectHot/rootHot/openQuestions): write
|
|
485
|
-
// only when on-disk bytes differ — re-running with same payload is a no-op.
|
|
486
|
-
// • append fields (sessionLog/log): skip when the dated heading/entry is
|
|
487
|
-
// already present (regex shared with sessionCloseFileStatus via hypo-shared).
|
|
488
|
-
//
|
|
489
|
-
// Validation: never auto-fixes the payload. The final sessionCloseFileStatus
|
|
490
|
-
// check fails fast on stale `updated:` or missing entries so the caller fixes
|
|
491
|
-
// the payload and retries — silent rewrites would hide payload bugs (advisor #3).
|
|
492
|
-
|
|
493
|
-
function readPayload(source) {
|
|
494
|
-
if (!source)
|
|
495
|
-
throw new Error('--payload is required with --apply-session-close (path or `-` for stdin)');
|
|
496
|
-
let raw;
|
|
497
|
-
if (source === '-') {
|
|
498
|
-
// Synchronous stdin read; payloads are tiny (a few hundred KB at most).
|
|
499
|
-
raw = readFileSync(0, 'utf-8');
|
|
500
|
-
} else {
|
|
501
|
-
const path = expandHome(source);
|
|
502
|
-
if (!existsSync(path)) throw new Error(`payload file not found: ${path}`);
|
|
503
|
-
raw = readFileSync(path, 'utf-8');
|
|
504
|
-
}
|
|
505
|
-
try {
|
|
506
|
-
return JSON.parse(raw);
|
|
507
|
-
} catch (e) {
|
|
508
|
-
throw new Error(`payload is not valid JSON: ${e.message}`);
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// How long an append waits for its per-target lock before withholding to the
|
|
513
|
-
// proposal-pending gate (see withFileLock). A withheld append blocks the close but
|
|
514
|
-
// is NOT parked as a proposal artifact — the next close re-appends. Default 5s is
|
|
515
|
-
// generous for a real close;
|
|
516
|
-
// the env override exists ONLY so tests can force a fast timeout instead of
|
|
517
|
-
// spinning the full 5s. Not a documented production knob.
|
|
518
|
-
const APPEND_LOCK_TIMEOUT_MS = Number(process.env.HYPO_APPEND_LOCK_TIMEOUT_MS) || 5000;
|
|
519
|
-
|
|
520
|
-
/** Atomic write via tmp+rename. `<path>.<pid>.<rand>.tmp` so concurrent helpers
|
|
521
|
-
* don't fight over the same shared `<path>.tmp` slot. */
|
|
522
|
-
function atomicWrite(path, content) {
|
|
523
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
524
|
-
const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
|
|
525
|
-
writeFileSync(tmp, content);
|
|
526
|
-
renameSync(tmp, path);
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
/**
|
|
530
|
-
* Read a target's current bytes, distinguishing "absent" from "unreadable" the
|
|
531
|
-
* same way base-store's hashFile does. The overwrite guard needs all three
|
|
532
|
-
* answers: content to compare, `null` to know creating is safe, `undefined` to
|
|
533
|
-
* refuse to guess.
|
|
534
|
-
* @returns {string|null|undefined} bytes, `null` if absent, `undefined` if unreadable
|
|
535
|
-
*/
|
|
536
|
-
function readTarget(path) {
|
|
537
|
-
if (!existsSync(path)) return null;
|
|
538
|
-
try {
|
|
539
|
-
return readFileSync(path, 'utf-8');
|
|
540
|
-
} catch {
|
|
541
|
-
return undefined;
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
/**
|
|
546
|
-
* Has the target drifted away from what this session observed at start?
|
|
547
|
-
*
|
|
548
|
-
* Branches on base-store's `state` discriminator, never on the truthiness of
|
|
549
|
-
* `hash`: 'absent' and 'unknown' both carry `hash: null`, and collapsing them
|
|
550
|
-
* would read never-observed as safe-to-write, defeating the guard entirely.
|
|
551
|
-
*
|
|
552
|
-
* @param {{state: 'hash'|'absent'|'unknown', hash: string|null}} entry
|
|
553
|
-
* @param {string|null|undefined} disk current bytes / absent / unreadable
|
|
554
|
-
* @returns {string|null} a conflict reason, or null when this session may write
|
|
555
|
-
*/
|
|
556
|
-
function overwriteConflictReason(entry, disk) {
|
|
557
|
-
// Cannot read what we are about to replace: fail safe, never assume unchanged.
|
|
558
|
-
if (disk === undefined) return 'target-unreadable';
|
|
559
|
-
switch (entry.state) {
|
|
560
|
-
case 'unknown':
|
|
561
|
-
// No snapshot for this (session, target). Someone else's edits could be
|
|
562
|
-
// sitting on disk and we would have no way to tell.
|
|
563
|
-
return 'base-unknown';
|
|
564
|
-
case 'absent':
|
|
565
|
-
// We observed no file. Creating it is safe; finding one now means another
|
|
566
|
-
// writer got there first.
|
|
567
|
-
return disk === null ? null : 'base-absent-target-exists';
|
|
568
|
-
case 'hash':
|
|
569
|
-
if (disk === null) return 'base-hash-target-missing';
|
|
570
|
-
return hashContent(disk) === entry.hash ? null : 'base-mismatch';
|
|
571
|
-
default:
|
|
572
|
-
return 'base-unknown';
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/**
|
|
577
|
-
* Append `entry` to `path` only if `alreadyPresent(content)` is false.
|
|
578
|
-
* Atomic: rebuilds the full file content and writes via atomicWrite — a crash
|
|
579
|
-
* mid-append cannot leave log.md or session-log/YYYY-MM-DD.md half-written, which
|
|
580
|
-
* matters for these append-only history files.
|
|
581
|
-
*/
|
|
582
|
-
function appendIfAbsent(path, entry, alreadyPresent) {
|
|
583
|
-
let content = '';
|
|
584
|
-
if (existsSync(path)) {
|
|
585
|
-
try {
|
|
586
|
-
content = readFileSync(path, 'utf-8');
|
|
587
|
-
} catch (err) {
|
|
588
|
-
// ENOENT here is a narrow existsSync-then-readFileSync race (the file
|
|
589
|
-
// vanished between the two calls) — content='' is safe, we would create
|
|
590
|
-
// it fresh anyway. Anything else (EACCES/EISDIR/...) is a PERSISTENT
|
|
591
|
-
// read failure: retrying (the caller's lock-timeout conflict path) would
|
|
592
|
-
// never fix it, and swallowing it here would fall through to the
|
|
593
|
-
// atomicWrite below and silently replace the existing file's bytes with
|
|
594
|
-
// just this one entry — a data-loss overwrite. So rethrow and let the
|
|
595
|
-
// withFileLock/call-site catch hard-fail the close instead.
|
|
596
|
-
if (err?.code !== 'ENOENT') throw err;
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
if (alreadyPresent(content)) return false;
|
|
600
|
-
// Ensure single blank line between existing tail and new entry, no trailing dup.
|
|
601
|
-
const sep =
|
|
602
|
-
content === '' ? '' : content.endsWith('\n\n') ? '' : content.endsWith('\n') ? '\n' : '\n\n';
|
|
603
|
-
const next = entry.endsWith('\n') ? entry : entry + '\n';
|
|
604
|
-
atomicWrite(path, content + sep + next);
|
|
605
|
-
return true;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
function todayLocal() {
|
|
609
|
-
const d = new Date();
|
|
610
|
-
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
// Spec §5.2.7 / §8.3: 4 mandatory + 2 optional (`log`, `openQuestions`).
|
|
614
|
-
// The payload shape MUST mirror that contract — missing a mandatory field is a
|
|
615
|
-
// payload bug, not a no-op. Caller is the LLM session-close flow, which composes
|
|
616
|
-
// the payload deliberately; partial payloads must fail loudly so caller fixes
|
|
617
|
-
// them rather than silently relying on yesterday's freshness state. (Codex review
|
|
618
|
-
// of the apply path — Worker 1 finding 1.) `log` left the mandatory set in B-1:
|
|
619
|
-
// the root log.md entry is a DERIVABLE artifact (rootLogEntry over this close's
|
|
620
|
-
// sessionLog heading), so apply auto-fills it when the field is absent.
|
|
621
|
-
const REQUIRED_PAYLOAD_FIELDS = [
|
|
622
|
-
['sessionState', 'content'],
|
|
623
|
-
['projectHot', 'content'],
|
|
624
|
-
['rootHot', 'content'],
|
|
625
|
-
['sessionLog', 'entry'],
|
|
626
|
-
];
|
|
627
|
-
|
|
628
|
-
function validatePayloadShape(payload) {
|
|
629
|
-
const errs = [];
|
|
630
|
-
if (!payload || typeof payload !== 'object') {
|
|
631
|
-
errs.push('payload must be a JSON object');
|
|
632
|
-
return errs;
|
|
633
|
-
}
|
|
634
|
-
for (const [field, key] of REQUIRED_PAYLOAD_FIELDS) {
|
|
635
|
-
const slot = payload[field];
|
|
636
|
-
if (!slot || typeof slot !== 'object') {
|
|
637
|
-
errs.push(`payload.${field} is required (object with .${key})`);
|
|
638
|
-
continue;
|
|
639
|
-
}
|
|
640
|
-
if (typeof slot[key] !== 'string') {
|
|
641
|
-
errs.push(`payload.${field}.${key} must be a string`);
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
if (payload.openQuestions !== undefined) {
|
|
645
|
-
if (
|
|
646
|
-
!payload.openQuestions ||
|
|
647
|
-
typeof payload.openQuestions !== 'object' ||
|
|
648
|
-
typeof payload.openQuestions.content !== 'string'
|
|
649
|
-
) {
|
|
650
|
-
errs.push('payload.openQuestions, when present, must be { content: string }');
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
if (payload.log !== undefined) {
|
|
654
|
-
if (!payload.log || typeof payload.log !== 'object' || typeof payload.log.entry !== 'string') {
|
|
655
|
-
errs.push('payload.log, when present, must be { entry: string }');
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
if (payload.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(payload.date))) {
|
|
659
|
-
errs.push('payload.date, when present, must be YYYY-MM-DD');
|
|
660
|
-
}
|
|
661
|
-
if (
|
|
662
|
-
payload.sessionId !== undefined &&
|
|
663
|
-
payload.sessionId !== null &&
|
|
664
|
-
typeof payload.sessionId !== 'string'
|
|
665
|
-
) {
|
|
666
|
-
errs.push('payload.sessionId, when present, must be a string');
|
|
667
|
-
}
|
|
668
|
-
return errs;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
// ── session-close marker (amendment 2026-05-19) ───────────────
|
|
672
|
-
// Standalone marker writer. Used when the LLM closes the session via direct
|
|
673
|
-
// Write tool calls (not --apply-session-close). Hook `hypo-auto-minimal-
|
|
674
|
-
// crystallize` is the only Reader; writer authority is intentionally split
|
|
675
|
-
// between this CLI and the auto-write at the tail of applySessionClose.
|
|
676
|
-
//
|
|
677
|
-
// Contract: the marker is written only when the FULL /compact gate
|
|
678
|
-
// (precompactGateStatus) is green. A failed gate exits 1 with no
|
|
679
|
-
// marker — the next Stop hook re-blocks.
|
|
680
|
-
|
|
681
|
-
function runMarkSessionClosed(args) {
|
|
682
|
-
if (!args.sessionId) {
|
|
683
|
-
const msg = '--session-id=<id> is required with --mark-session-closed';
|
|
684
|
-
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
685
|
-
process.exit(1);
|
|
686
|
-
}
|
|
687
|
-
// --project=<slug> on --mark names the project THIS session closed. It sets the
|
|
688
|
-
// marker's `project` field AND enters the close scope, so an incomplete close in a
|
|
689
|
-
// project this session did not touch is demoted to a notice instead of refusing the
|
|
690
|
-
// marker. It is NOT a gate narrow in the old sense: the marker records the same slug
|
|
691
|
-
// it scoped by, and PreCompact re-derives its own scope FROM that marker — so the
|
|
692
|
-
// two can never disagree, which is what the earlier "attribution only, gate stays
|
|
693
|
-
// global" rule was protecting (it assumed PreCompact stayed global; it no longer
|
|
694
|
-
// does). Everything else the gate checks stays global. Validate the slug exists as a
|
|
695
|
-
// directory, exactly as --check does, but only when it is actually used (a
|
|
696
|
-
// --log-only mark attributes to no project, so --project is moot there).
|
|
697
|
-
if (args.project && !args.logOnly) requireProjectDir(args, args.project);
|
|
698
|
-
// The per-session marker is the THIRD session-close completion
|
|
699
|
-
// signal (after the PreCompact gate and `--check-session-close`). It must use
|
|
700
|
-
// the SAME gate that governs /compact — precompactGateStatus — so the marker
|
|
701
|
-
// can never attest "closed" while /compact would still block. This subsumes
|
|
702
|
-
// the prior (sessionCloseGlobalStatus + hypoIsClean + scoped-lint) gate and
|
|
703
|
-
// additionally enforces feedback projection (over-cap/conflict), W8 design-
|
|
704
|
-
// history staleness, and root hot.md structure — the checks that the narrower
|
|
705
|
-
// marker gate skipped (the divergence behind this fix). git-clean is now a
|
|
706
|
-
// `git` blocker inside the gate. Pass --transcript-path to widen the lint
|
|
707
|
-
// scope to this session's edited files exactly as the interactive hook does;
|
|
708
|
-
// without it the scope is the mandatory close files only.
|
|
709
|
-
// --log-only marks a non-project (tooling / wiki-only) session as
|
|
710
|
-
// closed without attributing it to any project. The gate runs in log-only mode
|
|
711
|
-
// (project-close invariant → a today log.md entry; lint/W8 scoped to shared +
|
|
712
|
-
// touched files, never the active/phantom project), but git / hot / feedback
|
|
713
|
-
// still apply — log-only is NOT a global-gate bypass.
|
|
714
|
-
// Resolve the close transcript once from the session id (glob, never a CLI
|
|
715
|
-
// arg): it both widens the lint scope inside the gate AND is the evidence
|
|
716
|
-
// source for the user-close hard gate below.
|
|
717
|
-
const closeTranscript = resolveTranscriptBySessionId(args.sessionId);
|
|
718
|
-
const gate = precompactGateStatus(args.hypoDir, {
|
|
719
|
-
...(args.project && !args.logOnly ? { closeScope: [args.project] } : {}),
|
|
720
|
-
...(closeTranscript ? { transcriptPath: closeTranscript } : {}),
|
|
721
|
-
...(args.logOnly ? { logOnly: true } : {}),
|
|
722
|
-
// P2 (session-close attribution): the marker gate must refuse to attest compact-ready while the
|
|
723
|
-
// session's cwd project has an unstarted close. logOnly exempts it in-gate.
|
|
724
|
-
...(args.sessionCwd ? { sessionCwd: args.sessionCwd } : {}),
|
|
725
|
-
});
|
|
726
|
-
const status = gate.close;
|
|
727
|
-
if (!gate.ok) {
|
|
728
|
-
const result = {
|
|
729
|
-
ok: false,
|
|
730
|
-
session_id: args.sessionId,
|
|
731
|
-
project: status.project,
|
|
732
|
-
missing: status.missing,
|
|
733
|
-
stale: status.stale,
|
|
734
|
-
blockers: gate.blockers,
|
|
735
|
-
error: 'session-close gate not satisfied — marker not written',
|
|
736
|
-
};
|
|
737
|
-
if (args.json) {
|
|
738
|
-
console.log(JSON.stringify(result, null, 2));
|
|
739
|
-
} else {
|
|
740
|
-
console.log(
|
|
741
|
-
`✗ session-close gate not satisfied — marker not written (project: ${status.project || '(unresolved)'}):`,
|
|
742
|
-
);
|
|
743
|
-
for (const b of gate.blockers) console.log(` ✗ ${b.reason}`);
|
|
744
|
-
}
|
|
745
|
-
process.exit(1);
|
|
746
|
-
}
|
|
747
|
-
// User-close hard gate: the compact gate above only proves the wiki
|
|
748
|
-
// is compact-ready; it does NOT prove the USER asked to close. Refuse the marker
|
|
749
|
-
// unless the transcript carries a genuine user close signal (NL close phrase,
|
|
750
|
-
// /compact, or an AskUserQuestion close answer). This is the hard backstop for
|
|
751
|
-
// model over-close, where prose guidance lost to a conflicting global rule.
|
|
752
|
-
// Fail-closed when the transcript can't be resolved.
|
|
753
|
-
if (!closeTranscript || !hasUserCloseSignal(closeTranscript)) {
|
|
754
|
-
const reason = !closeTranscript
|
|
755
|
-
? `cannot resolve a transcript for session ${args.sessionId} — the session-closed marker requires a verifiable user close signal`
|
|
756
|
-
: "no user close signal in this session's transcript — marker refused (the user did not signal session close)";
|
|
757
|
-
const result = {
|
|
758
|
-
ok: false,
|
|
759
|
-
session_id: args.sessionId,
|
|
760
|
-
project: status.project,
|
|
761
|
-
skipReason: 'no-user-close-signal',
|
|
762
|
-
error: reason,
|
|
763
|
-
};
|
|
764
|
-
console.log(args.json ? JSON.stringify(result, null, 2) : `✗ ${reason}`);
|
|
765
|
-
process.exit(1);
|
|
766
|
-
}
|
|
767
|
-
// Marker attribution comes from EVIDENCE, never from the gate's global
|
|
768
|
-
// `primary` (which is recency-derived, hypo-shared.mjs). The marker is what
|
|
769
|
-
// PreCompact later re-derives its own scope from, so attributing it to a project
|
|
770
|
-
// this session did not close hands PreCompact a scope the marker never cleared.
|
|
771
|
-
// The evidence set is the close scope — explicit --project ∪ the transcript's
|
|
772
|
-
// touched close files ∪ any prior marker for this session — all of which
|
|
773
|
-
// resolveCloseScope already unioned into status.scope. The recency primary is
|
|
774
|
-
// deliberately excluded: an empty scope means this session has no proof it closed
|
|
775
|
-
// any project (evidence-based close attribution), so we FAIL CLOSED rather than misattribute to recency.
|
|
776
|
-
const closeScope = status.scope || [];
|
|
777
|
-
const markerProjects = [
|
|
778
|
-
...new Set([...(!args.logOnly && args.project ? [args.project] : []), ...closeScope]),
|
|
779
|
-
];
|
|
780
|
-
if (!args.logOnly && markerProjects.length === 0) {
|
|
781
|
-
const err =
|
|
782
|
-
'cannot attribute this close to a project — no evidence (no --project, no transcript close-file edits, no prior marker). ' +
|
|
783
|
-
'Pass --project=<slug> for the project this session closed, or --log-only for a non-project (tooling/wiki-only) session.';
|
|
784
|
-
console.log(
|
|
785
|
-
args.json
|
|
786
|
-
? JSON.stringify(
|
|
787
|
-
{
|
|
788
|
-
ok: false,
|
|
789
|
-
session_id: args.sessionId,
|
|
790
|
-
skipReason: 'no-attribution-evidence',
|
|
791
|
-
error: err,
|
|
792
|
-
},
|
|
793
|
-
null,
|
|
794
|
-
2,
|
|
795
|
-
)
|
|
796
|
-
: `✗ ${err}`,
|
|
797
|
-
);
|
|
798
|
-
process.exit(1);
|
|
799
|
-
}
|
|
800
|
-
const markerProject = !args.logOnly && args.project ? args.project : markerProjects[0];
|
|
801
|
-
writeSessionClosedMarker(args.hypoDir, args.sessionId, {
|
|
802
|
-
project: markerProject,
|
|
803
|
-
projects: args.logOnly ? [] : markerProjects,
|
|
804
|
-
...(args.logOnly ? { scope: 'log-only' } : {}),
|
|
805
|
-
});
|
|
806
|
-
// Marker writer swallows IO errors (best-effort, see hypo-shared.mjs). Verify
|
|
807
|
-
// the file actually landed before claiming success — otherwise CLI exits 0
|
|
808
|
-
// while next Stop re-blocks, hiding a permission/disk problem.
|
|
809
|
-
// Codex Worker-2 CONCERN (pre-commit review).
|
|
810
|
-
if (!existsSync(sessionClosedMarkerPath(args.hypoDir, args.sessionId))) {
|
|
811
|
-
const err = 'marker file did not land after write (likely .cache permission/disk issue)';
|
|
812
|
-
console.log(
|
|
813
|
-
args.json
|
|
814
|
-
? JSON.stringify({ ok: false, session_id: args.sessionId, error: err }, null, 2)
|
|
815
|
-
: `✗ ${err}`,
|
|
816
|
-
);
|
|
817
|
-
process.exit(1);
|
|
818
|
-
}
|
|
819
|
-
const result = {
|
|
820
|
-
ok: true,
|
|
821
|
-
session_id: args.sessionId,
|
|
822
|
-
project: markerProject,
|
|
823
|
-
scope: args.logOnly ? 'log-only' : 'project',
|
|
824
|
-
date: status.dates[0],
|
|
825
|
-
notices: gate.notices,
|
|
826
|
-
// pure feedback-projection drift is a non-blocker: the marker
|
|
827
|
-
// attests "compact-ready (no human-fixable blocker)", and the PreCompact
|
|
828
|
-
// hook self-heals the projection (feedback-sync --write) at /compact. Surface
|
|
829
|
-
// the deferral so the caller knows MEMORY/CLAUDE sync is pending, not lost.
|
|
830
|
-
drift_deferred: gate.driftTargets,
|
|
831
|
-
};
|
|
832
|
-
if (args.json) {
|
|
833
|
-
console.log(JSON.stringify(result, null, 2));
|
|
834
|
-
} else {
|
|
835
|
-
console.log(
|
|
836
|
-
args.logOnly
|
|
837
|
-
? `✓ session-closed marker written (session_id: ${args.sessionId}, scope: log-only — no project attribution).`
|
|
838
|
-
: `✓ session-closed marker written (session_id: ${args.sessionId}, project: ${markerProject}).`,
|
|
839
|
-
);
|
|
840
|
-
if (gate.driftTargets.length > 0) {
|
|
841
|
-
console.log(
|
|
842
|
-
` · feedback projection drift (${gate.driftTargets.join(', ')}) — will self-heal at /compact.`,
|
|
843
|
-
);
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
process.exit(0);
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
// The close pipeline's marker decision as a PURE function of pre-resolved
|
|
850
|
-
// signals. The caller (applySessionClose) keeps the IO lazy (commit first, then
|
|
851
|
-
// transcript resolve, then gate, then user-signal scan only once the gate
|
|
852
|
-
// passes) and feeds the resulting booleans here; this function owns only the
|
|
853
|
-
// branch PRIORITY and the reason strings, so a deterministic table test can
|
|
854
|
-
// exercise the state machine without spawning the
|
|
855
|
-
// CLI. Returns { write, skipReason }: `write` true means the caller should
|
|
856
|
-
// attempt writeSessionClosedMarker; `skipReason` (non-null on every non-write
|
|
857
|
-
// branch) is the surfaced reason the marker was withheld.
|
|
858
|
-
// - !ok / no session id → not a marker-write path (skipReason null)
|
|
859
|
-
// - commit failed → commit-failed: <reason>
|
|
860
|
-
// - compact gate not ok → compact-gate-not-ok
|
|
861
|
-
// - no transcript → transcript-unresolved
|
|
862
|
-
// - transcript, no signal → no-user-close-signal
|
|
863
|
-
// - all clear → write:true
|
|
864
|
-
export function planMarkerDecision({
|
|
865
|
-
ok,
|
|
866
|
-
hasSessionId,
|
|
867
|
-
committed,
|
|
868
|
-
commitReason,
|
|
869
|
-
gateOk,
|
|
870
|
-
transcriptResolved,
|
|
871
|
-
hasUserSignal,
|
|
872
|
-
}) {
|
|
873
|
-
if (!ok || !hasSessionId) return { write: false, skipReason: null };
|
|
874
|
-
if (!committed) return { write: false, skipReason: `commit-failed: ${commitReason}` };
|
|
875
|
-
if (!gateOk) return { write: false, skipReason: 'compact-gate-not-ok' };
|
|
876
|
-
if (!transcriptResolved || !hasUserSignal) {
|
|
877
|
-
return {
|
|
878
|
-
write: false,
|
|
879
|
-
skipReason: transcriptResolved ? 'no-user-close-signal' : 'transcript-unresolved',
|
|
880
|
-
};
|
|
881
|
-
}
|
|
882
|
-
return { write: true, skipReason: null };
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
// The runtime close-result invariant self-check. Given
|
|
886
|
-
// the settled marker fields, return a non-null contradiction tag when the result
|
|
887
|
-
// is internally inconsistent, else null. This is a REGRESSION GUARD: every
|
|
888
|
-
// non-write branch of planMarkerDecision already records a reason today, so the
|
|
889
|
-
// contradictions below are unreachable — the seam exists so a future branch that
|
|
890
|
-
// forgets to record a reason (or double-sets a written marker with a skip
|
|
891
|
-
// reason) fails LOUDLY (ok flipped false, exit 1) instead of silently emitting a
|
|
892
|
-
// misleading ok:true that a skill-following model reads as "session closed".
|
|
893
|
-
// A "real reason" is a non-blank STRING — every legitimate skip reason is one.
|
|
894
|
-
// Anything else (null, a blank string, or a non-string like false/0/{}) is not a
|
|
895
|
-
// surfaced reason and must not suppress the check, so a future bad assignment
|
|
896
|
-
// cannot hide a withheld marker behind a bogus value.
|
|
897
|
-
// A: ok:true but the marker was withheld with no reason recorded.
|
|
898
|
-
// B: a marker was written AND a skip reason was also set (mutually exclusive).
|
|
899
|
-
export function closeResultContradiction({ ok, markerWritten, markerSkipReason }) {
|
|
900
|
-
const hasRealReason = typeof markerSkipReason === 'string' && markerSkipReason.trim() !== '';
|
|
901
|
-
if (ok === true && markerWritten === false && !hasRealReason) {
|
|
902
|
-
return 'internal-contradiction:marker-withheld-without-reason';
|
|
903
|
-
}
|
|
904
|
-
if (markerWritten === true && hasRealReason) {
|
|
905
|
-
return 'internal-contradiction:marker-written-with-skip-reason';
|
|
906
|
-
}
|
|
907
|
-
return null;
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
// What the model should do when the close is refused. Deliberately does NOT name
|
|
911
|
-
// a flag: the way out of this gate cannot be an argument the model can add, or
|
|
912
|
-
// the gate is decorative. The way out is the user, which is the one input the
|
|
913
|
-
// model does not author.
|
|
914
|
-
const CLOSE_REFUSAL_HELP = [
|
|
915
|
-
'Nothing was written and nothing was committed.',
|
|
916
|
-
'',
|
|
917
|
-
'Do NOT add a bypass flag, and do NOT write the close files directly with an editor',
|
|
918
|
-
'or a shell — that is the same close without the check, and it is the thing this gate',
|
|
919
|
-
'exists to stop.',
|
|
920
|
-
'',
|
|
921
|
-
'If the user has not asked to close: do not close. Session-close is not a reward for',
|
|
922
|
-
'finishing a task, and a long session is not a close signal. Keep working, or ask ONCE',
|
|
923
|
-
'whether to wrap up, and take no for an answer.',
|
|
924
|
-
'',
|
|
925
|
-
'If the user HAS asked: pass the current main-conversation --session-id (not a',
|
|
926
|
-
'background-task or agent uuid from a /tmp path) and re-run this exact payload. The',
|
|
927
|
-
'writes are idempotent.',
|
|
928
|
-
].join('\n');
|
|
929
|
-
|
|
930
|
-
/**
|
|
931
|
-
* May this session apply a close at all?
|
|
932
|
-
*
|
|
933
|
-
* Authority comes from the transcript, because the transcript is the one input the
|
|
934
|
-
* model cannot author: the user's own words are in it, and nothing the model says
|
|
935
|
-
* counts (extractUserMessages drops injected, tool, and hook-feedback text).
|
|
936
|
-
*
|
|
937
|
-
* { ok: true }
|
|
938
|
-
* { ok: false, reason, error } reason: session-id-required | transcript-unresolved
|
|
939
|
-
* | no-user-close-signal
|
|
940
|
-
*/
|
|
941
|
-
function verifyCloseAuthority(sessionId) {
|
|
942
|
-
if (!sessionId) {
|
|
943
|
-
return {
|
|
944
|
-
ok: false,
|
|
945
|
-
reason: 'session-id-required',
|
|
946
|
-
error:
|
|
947
|
-
'session-close apply refused before any wiki write or commit: --session-id is required, ' +
|
|
948
|
-
"because the close signal is verified against that session's transcript. Omitting it does " +
|
|
949
|
-
'not skip the check, it fails it.',
|
|
950
|
-
};
|
|
951
|
-
}
|
|
952
|
-
const transcript = resolveTranscriptBySessionId(sessionId);
|
|
953
|
-
if (!transcript) {
|
|
954
|
-
return {
|
|
955
|
-
ok: false,
|
|
956
|
-
reason: 'transcript-unresolved',
|
|
957
|
-
error:
|
|
958
|
-
`session-close apply refused before any wiki write or commit: no transcript resolves for ` +
|
|
959
|
-
`session ${sessionId}. Pass the MAIN conversation's session id — a uuid taken from a ` +
|
|
960
|
-
`background-task output path or an agent thread is not it, and --transcript-path is not ` +
|
|
961
|
-
`authority here.`,
|
|
962
|
-
};
|
|
963
|
-
}
|
|
964
|
-
if (!hasUserCloseSignal(transcript)) {
|
|
965
|
-
return {
|
|
966
|
-
ok: false,
|
|
967
|
-
reason: 'no-user-close-signal',
|
|
968
|
-
error:
|
|
969
|
-
"session-close apply refused before any wiki write or commit: this session's transcript " +
|
|
970
|
-
'carries no user close signal. The user did not ask to close.',
|
|
971
|
-
};
|
|
972
|
-
}
|
|
973
|
-
return { ok: true };
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
// A-1 (project index lifecycle): seed projects/<project>/index.md from the
|
|
977
|
-
// template the first time this project closes without one. SCHEMA.md declares
|
|
978
|
-
// project-index at projects/*/index.md and templates/projects/_template/ ships
|
|
979
|
-
// one, but createProject (the auto-project-offer path) is the only writer of
|
|
980
|
-
// it today — a project directory created any other way (a manual mkdir, an
|
|
981
|
-
// older vault, direct Write tool calls) never gets one. Idempotent: an
|
|
982
|
-
// existing index.md is left untouched; this only fills the gap, and only the
|
|
983
|
-
// three tokens the template defines are substituted — the rest (one-line
|
|
984
|
-
// description, Progress checklist) stays human-authored prose exactly as
|
|
985
|
-
// createProject already leaves it.
|
|
986
|
-
//
|
|
987
|
-
// `working_dir` has no authoritative source in this flow: apply never
|
|
988
|
-
// receives the session's cwd (see hooks/hypo-shared.mjs's precompactGateStatus
|
|
989
|
-
// doc — process.cwd() is explicitly non-authoritative here, since it reflects
|
|
990
|
-
// this script's own launch directory, not the session's). It is left EMPTY,
|
|
991
|
-
// not filled with a placeholder string: hooks/hypo-shared.mjs's collector
|
|
992
|
-
// (collectProjectWorkingDirs) and findBackfillCandidate both treat any truthy
|
|
993
|
-
// `working_dir` as "already anchored" and stop offering to backfill the real
|
|
994
|
-
// cwd — a fake placeholder is truthy, so it would silently and permanently
|
|
995
|
-
// swallow the exact anchor-recovery path a human would otherwise get. Empty
|
|
996
|
-
// stays falsy there, so the project surfaces as a genuine backfill candidate
|
|
997
|
-
// until a person (or the auto-project-offer flow) fills in a real path. The
|
|
998
|
-
// key itself stays present in the frontmatter (substituted to an empty value,
|
|
999
|
-
// not omitted) so the shape matches every other index.md and a human sees
|
|
1000
|
-
// exactly where to type the answer.
|
|
1001
|
-
// "Never overwrite an existing index" is this feature's explicit contract, so
|
|
1002
|
-
// creation uses an EXCLUSIVE create (`wx`), not the existsSync-then-atomicWrite
|
|
1003
|
-
// shape every other target in this file uses. atomicWrite's tmp+rename
|
|
1004
|
-
// replaces whatever sits at `dest` the instant rename fires — existing or not —
|
|
1005
|
-
// so a plain `if (existsSync(dest)) return null` beforehand only narrows the
|
|
1006
|
-
// race, it does not close it: another writer (a human, or a concurrent close)
|
|
1007
|
-
// can land real bytes at `dest` between that check and this function's rename.
|
|
1008
|
-
// `wx` makes the OS do the check-and-create atomically, mirroring
|
|
1009
|
-
// hooks/base-store.mjs's snapshotBase (`openSync(path, 'wx')` + EEXIST ==
|
|
1010
|
-
// "someone already got there, leave it"). No tmp+rename is needed here: unlike
|
|
1011
|
-
// atomicWrite's use case (replacing bytes a reader might already be mid-read
|
|
1012
|
-
// of), a `wx` create can never observably tear — the file either doesn't
|
|
1013
|
-
// exist yet (nothing to tear) or the open fails outright.
|
|
1014
|
-
export function ensureProjectIndex(hypoDir, project, relPath, today) {
|
|
1015
|
-
const dest = join(hypoDir, relPath);
|
|
1016
|
-
const src = join(TEMPLATE_DIR, 'index.md');
|
|
1017
|
-
if (!existsSync(src)) return null; // template missing — nothing to scaffold from
|
|
1018
|
-
const content = substituteTokens(readFileSync(src, 'utf-8'), {
|
|
1019
|
-
name: project,
|
|
1020
|
-
started: today,
|
|
1021
|
-
workingDir: '',
|
|
1022
|
-
today,
|
|
1023
|
-
});
|
|
1024
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
1025
|
-
let fd;
|
|
1026
|
-
try {
|
|
1027
|
-
fd = openSync(dest, 'wx');
|
|
1028
|
-
} catch (e) {
|
|
1029
|
-
if (e && e.code === 'EEXIST') return null; // another writer already created it
|
|
1030
|
-
throw e;
|
|
1031
|
-
}
|
|
1032
|
-
try {
|
|
1033
|
-
writeSync(fd, content);
|
|
1034
|
-
} finally {
|
|
1035
|
-
closeSync(fd);
|
|
1036
|
-
}
|
|
1037
|
-
return relPath;
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
function applySessionClose(args) {
|
|
1041
|
-
// Option D: early-exit fires only when NO payload was supplied.
|
|
1042
|
-
// Rationale: payload presence is explicit close intent and must always run
|
|
1043
|
-
// the full apply path — the per-entry idempotency (overwrite's step-1 skip +
|
|
1044
|
-
// exact-entry append dedup) keeps re-apply cheap without short-circuiting,
|
|
1045
|
-
// and avoids silent-success when a same-day second close brings new bytes.
|
|
1046
|
-
// Payload-less invocation is treated as a cheap "already complete?" probe.
|
|
1047
|
-
// --force opts out of that probe shortcut only — payload remains required
|
|
1048
|
-
// for any actual apply work (readPayload below surfaces "payload is
|
|
1049
|
-
// required" the same way it always has).
|
|
1050
|
-
if (!args.force && !args.payload) {
|
|
1051
|
-
// No-payload "already complete?" probe uses the
|
|
1052
|
-
// global invariant, not a recency pick.
|
|
1053
|
-
const probe = sessionCloseGlobalStatus(args.hypoDir);
|
|
1054
|
-
if (probe.ok) {
|
|
1055
|
-
const result = {
|
|
1056
|
-
ok: true,
|
|
1057
|
-
alreadyComplete: true,
|
|
1058
|
-
project: probe.project,
|
|
1059
|
-
date: probe.dates[0],
|
|
1060
|
-
message: '오늘 이미 close 완료로 보임 (probe 모드 — payload 미지정).',
|
|
1061
|
-
};
|
|
1062
|
-
if (args.json) {
|
|
1063
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1064
|
-
} else {
|
|
1065
|
-
console.log(`✓ ${result.message}`);
|
|
1066
|
-
console.log(` project: ${result.project} / date: ${result.date}`);
|
|
1067
|
-
}
|
|
1068
|
-
process.exit(0);
|
|
1069
|
-
}
|
|
1070
|
-
// gate not ok → fall through to readPayload, which surfaces
|
|
1071
|
-
// "payload is required" with the same error shape as before.
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
// ── close authority: BEFORE any write ──────────────────────────────────────
|
|
1075
|
-
// A payload is a claim that the user asked to close. Verify it here, ahead of
|
|
1076
|
-
// readPayload and every byte that follows, because everything after this point
|
|
1077
|
-
// is authoritative: session-state.md and the hot files are overwritten, the
|
|
1078
|
-
// session-log and log.md are appended, and hypo-auto-commit lands the lot in git
|
|
1079
|
-
// on the next Stop whether or not this command commits.
|
|
1080
|
-
//
|
|
1081
|
-
// The gate used to stand at the END, next to the marker (planMarkerDecision).
|
|
1082
|
-
// It refused the MARKER on a close the user never asked for, and let the writes
|
|
1083
|
-
// and the commit through. Worse, it was opt-in BY THE CALLER: with no
|
|
1084
|
-
// --session-id there was no transcript to check, so the check simply did not
|
|
1085
|
-
// run, and the apply reported ok:true with no skip reason at all. The model
|
|
1086
|
-
// chose whether the model got checked. A model that has already decided to close
|
|
1087
|
-
// does not reach for the flag that would stop it.
|
|
1088
|
-
//
|
|
1089
|
-
// So: no verifiable close signal, no close. Zero bytes, no commit, exit 1.
|
|
1090
|
-
//
|
|
1091
|
-
// What this does and does not promise. It promises that THIS entrypoint refuses
|
|
1092
|
-
// a close with no recognized transcript evidence behind it. It does not promise
|
|
1093
|
-
// the model cannot write these files at all: Write and Bash remain outside this
|
|
1094
|
-
// gate, and the evidence predicate itself is coarse (any close signal anywhere in
|
|
1095
|
-
// the transcript counts, so a stale one from earlier in a long session still
|
|
1096
|
-
// passes, and it is the model that authors the AskUserQuestion option labels the
|
|
1097
|
-
// user picks from). Narrowing that (current-turn binding, revocation, consent
|
|
1098
|
-
// that the model did not word) is the follow-up this ADR names; it is not a
|
|
1099
|
-
// reason to leave the default open in the meantime.
|
|
1100
|
-
// Only a payload-bearing call can write. A payload-less one falls through to the
|
|
1101
|
-
// "payload is required" error below without touching a byte, so gating it here
|
|
1102
|
-
// would just replace one refusal with a less accurate one.
|
|
1103
|
-
const closeAuth = args.payload ? verifyCloseAuthority(args.sessionId) : { ok: true };
|
|
1104
|
-
if (!closeAuth.ok) {
|
|
1105
|
-
const out = {
|
|
1106
|
-
ok: false,
|
|
1107
|
-
stage: 'no-user-close-signal',
|
|
1108
|
-
reason: closeAuth.reason,
|
|
1109
|
-
applied: [],
|
|
1110
|
-
// `null`, not `false`: this refusal fires before the commit step is ever
|
|
1111
|
-
// reached (see the general result's own `committed` contract below).
|
|
1112
|
-
// `false` is reserved for a commit that actually ran and failed.
|
|
1113
|
-
committed: null,
|
|
1114
|
-
error: closeAuth.error,
|
|
1115
|
-
};
|
|
1116
|
-
console.log(
|
|
1117
|
-
args.json ? JSON.stringify(out, null, 2) : `✗ ${closeAuth.error}\n\n${CLOSE_REFUSAL_HELP}`,
|
|
1118
|
-
);
|
|
1119
|
-
process.exit(1);
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
let payload;
|
|
1123
|
-
try {
|
|
1124
|
-
payload = readPayload(args.payload);
|
|
1125
|
-
} catch (e) {
|
|
1126
|
-
const out = { ok: false, error: e.message };
|
|
1127
|
-
console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${e.message}`);
|
|
1128
|
-
process.exit(1);
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
const schemaErrs = validatePayloadShape(payload);
|
|
1132
|
-
if (schemaErrs.length > 0) {
|
|
1133
|
-
const out = { ok: false, error: 'payload schema invalid', details: schemaErrs };
|
|
1134
|
-
console.log(
|
|
1135
|
-
args.json
|
|
1136
|
-
? JSON.stringify(out, null, 2)
|
|
1137
|
-
: `✗ payload schema invalid:\n ${schemaErrs.join('\n ')}`,
|
|
1138
|
-
);
|
|
1139
|
-
process.exit(1);
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
// Payload↔session binding (cross-session payload collision). The payload temp
|
|
1143
|
-
// file is now written to a session-scoped path (see commands/crystallize.md), so
|
|
1144
|
-
// two same-day sessions no longer share a file. This is the belt to that
|
|
1145
|
-
// suspenders: if the payload names
|
|
1146
|
-
// the session it was authored for, it must be THIS one — the --session-id whose
|
|
1147
|
-
// transcript already cleared close authority above. A mismatch means the file on
|
|
1148
|
-
// disk is not this session's close (a stray or hand-reused path handed us another
|
|
1149
|
-
// session's payload), and applying it would stamp that content with this session's
|
|
1150
|
-
// marker while the original session's record vanishes — the exact loss this
|
|
1151
|
-
// guard exists to prevent. Refuse before a byte is written.
|
|
1152
|
-
//
|
|
1153
|
-
// Absent field → fail open: older payloads predate this field, and Part 1's unique
|
|
1154
|
-
// path already prevents the collision. So the check only ever tightens; it never
|
|
1155
|
-
// rejects a close it would otherwise have allowed on a matching (or absent) id. It
|
|
1156
|
-
// is deliberately identity-based, not cwd-based: closing project B from a session
|
|
1157
|
-
// whose cwd is project A stays supported (payload.project is authoritative), so a
|
|
1158
|
-
// legitimately cross-project close is untouched.
|
|
1159
|
-
if (
|
|
1160
|
-
payload.sessionId !== undefined &&
|
|
1161
|
-
payload.sessionId !== null &&
|
|
1162
|
-
payload.sessionId !== args.sessionId
|
|
1163
|
-
) {
|
|
1164
|
-
const msg =
|
|
1165
|
-
`payload.sessionId ${JSON.stringify(payload.sessionId)} does not match --session-id ` +
|
|
1166
|
-
`${JSON.stringify(args.sessionId)}: this payload was authored for a different session, so ` +
|
|
1167
|
-
`it is not this session's close. Refusing before any write (cross-session guard).`;
|
|
1168
|
-
const out = {
|
|
1169
|
-
ok: false,
|
|
1170
|
-
stage: 'session-id-mismatch',
|
|
1171
|
-
error: msg,
|
|
1172
|
-
applied: [],
|
|
1173
|
-
// `null`, not `false` — refused before the commit step, same contract as
|
|
1174
|
-
// the `no-user-close-signal` refusal above.
|
|
1175
|
-
committed: null,
|
|
1176
|
-
};
|
|
1177
|
-
console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${msg}`);
|
|
1178
|
-
process.exit(1);
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
// Resolve project: payload.project is REQUIRED (B-3, close-gate-hardening). The
|
|
1182
|
-
// old recency fallback (payload.project || probe.project) could, on a same-date
|
|
1183
|
-
// root-hot.md tie, resolve a DIFFERENT project than the one the payload's files
|
|
1184
|
-
// belong to — apply would then write the close into the wrong project (silent
|
|
1185
|
-
// data loss). Validate fail-fast, BEFORE the probe is consulted:
|
|
1186
|
-
// - missing → no target to write; abort rather than infer.
|
|
1187
|
-
// - invalid name → reject (non-string, wrong charset, or dot-only) BEFORE the
|
|
1188
|
-
// existsSync(join(...)) path build, so a `../`-style value
|
|
1189
|
-
// never reaches a path builder (traversal guard — order is
|
|
1190
|
-
// the guard). isValidProjectName is SHARED with createProject
|
|
1191
|
-
// so apply accepts exactly the namespace the repo can
|
|
1192
|
-
// scaffold (A-Za-z0-9._-, single segment) — no narrower.
|
|
1193
|
-
// - non-existent → projects/<slug>/ absent; abort rather than create.
|
|
1194
|
-
// A payload.project that merely DIFFERS from the inferred active project is NOT an
|
|
1195
|
-
// error — it is surfaced as a stderr note below and the close proceeds.
|
|
1196
|
-
if (payload.project === undefined || payload.project === null) {
|
|
1197
|
-
const msg = 'payload.project is required (apply must not infer the close target project)';
|
|
1198
|
-
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
1199
|
-
process.exit(1);
|
|
1200
|
-
}
|
|
1201
|
-
if (!isValidProjectName(payload.project)) {
|
|
1202
|
-
const msg = `payload.project ${JSON.stringify(payload.project)} is not a valid project name (single segment, charset A-Za-z0-9._-, ≥1 alnum, not "."/"..")`;
|
|
1203
|
-
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
1204
|
-
process.exit(1);
|
|
1205
|
-
}
|
|
1206
|
-
// existsSync alone is not enough: a regular FILE at projects/<slug> would pass,
|
|
1207
|
-
// then apply would build child paths under it and fail with an unstructured
|
|
1208
|
-
// filesystem error (codex re-review). Require it to be a directory.
|
|
1209
|
-
const projectDir = join(args.hypoDir, 'projects', payload.project);
|
|
1210
|
-
if (!existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
|
|
1211
|
-
const msg = `payload.project "${payload.project}" does not exist as a directory (no projects/${payload.project}/ directory)`;
|
|
1212
|
-
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
1213
|
-
process.exit(1);
|
|
1214
|
-
}
|
|
1215
|
-
const project = payload.project;
|
|
1216
|
-
// probe (the recency-inferred active project) is now consulted ONLY to surface a
|
|
1217
|
-
// divergence note — never to resolve the target. Computed AFTER validation so a
|
|
1218
|
-
// malformed/missing payload.project fails fast without a pointer-table read.
|
|
1219
|
-
// Resolved BEFORE preflight because preflight needs overwrite-target paths
|
|
1220
|
-
// (which require the project slug) to filter out errors in files this apply
|
|
1221
|
-
// is about to replace — see the filter rationale below.
|
|
1222
|
-
const probe = sessionCloseFileStatus(args.hypoDir);
|
|
1223
|
-
// The freshness verification below (and at the post-apply check) already honors
|
|
1224
|
-
// payload.project — `project` wins over the inferred active project, and the
|
|
1225
|
-
// post-apply sessionCloseFileStatus call passes it as projectOverride. But when the
|
|
1226
|
-
// payload targets a DIFFERENT project than the one active-project resolution infers
|
|
1227
|
-
// (probe.project), that divergence used to be silent, so an operator couldn't tell
|
|
1228
|
-
// which project the close actually verified. Surface it on stderr (the stdout JSON
|
|
1229
|
-
// contract is untouched) so the verified/closed project is always explicit.
|
|
1230
|
-
if (probe.project && probe.project !== payload.project) {
|
|
1231
|
-
process.stderr.write(
|
|
1232
|
-
`note: payload.project="${payload.project}" differs from the inferred active ` +
|
|
1233
|
-
`project "${probe.project}"; verifying and closing "${payload.project}".\n`,
|
|
1234
|
-
);
|
|
1235
|
-
}
|
|
1236
|
-
const date = payload.date || todayLocal();
|
|
1237
|
-
|
|
1238
|
-
// Pre-apply freshness-contract gate: the post-apply verification holds
|
|
1239
|
-
// sessionCloseFileStatus's hasSessionLogHeading / hasLogEntry as the
|
|
1240
|
-
// definition of "closed today". Enforce that SAME contract on the payload
|
|
1241
|
-
// BEFORE writing a byte, so a heading the gate won't recognize is rejected
|
|
1242
|
-
// here as a format mismatch — not written and then misdiagnosed downstream as
|
|
1243
|
-
// "stale" (the "not updated" vs "format mismatch" conflation). All checks
|
|
1244
|
-
// exit 1 with stage='pre-apply-verification' and leave the tree untouched.
|
|
1245
|
-
const failPreApply = (msg) => {
|
|
1246
|
-
console.log(
|
|
1247
|
-
args.json
|
|
1248
|
-
? JSON.stringify({ ok: false, stage: 'pre-apply-verification', error: msg }, null, 2)
|
|
1249
|
-
: `✗ ${msg}`,
|
|
1250
|
-
);
|
|
1251
|
-
process.exit(1);
|
|
1252
|
-
};
|
|
1253
|
-
// (a) The session-log entry must carry a dated `## [<date>] …` ATX heading. The
|
|
1254
|
-
// post-apply gate checks the session-log file for exactly this heading, so a
|
|
1255
|
-
// headingless entry would write then false-fail as "stale". This also doubles
|
|
1256
|
-
// as the B-1 derive precondition: when `log` is omitted the root log.md entry
|
|
1257
|
-
// is reconstructed from THIS heading, and on a same-day SECOND close the
|
|
1258
|
-
// date-level verifier would still pass on the earlier entry, so a no-derive
|
|
1259
|
-
// would slip through as ok:true. The `!payload.log` branch keeps the original
|
|
1260
|
-
// derive-specific wording (a test asserts it).
|
|
1261
|
-
if (!hasSessionLogHeading(payload.sessionLog.entry || '', date)) {
|
|
1262
|
-
failPreApply(
|
|
1263
|
-
!payload.log
|
|
1264
|
-
? `payload.sessionLog.entry has no "## [${date}] …" heading to derive the log.md ` +
|
|
1265
|
-
`entry from. Give it a dated heading, or supply payload.log explicitly.`
|
|
1266
|
-
: `payload.sessionLog.entry has no "## [${date}] …" heading. The close gate ` +
|
|
1267
|
-
`identifies a session-log by its dated ATX heading; give the entry a ` +
|
|
1268
|
-
`"## [${date}] <title>" heading (the brackets are required).`,
|
|
1269
|
-
);
|
|
1270
|
-
}
|
|
1271
|
-
// (b) An explicit payload.log entry must match the canonical
|
|
1272
|
-
// `## [<date>] session | <project>` line the gate looks for (colon or space
|
|
1273
|
-
// delimiter after the slug). Otherwise the write lands but post-apply
|
|
1274
|
-
// verification reports log.md as stale. When `log` is omitted the line is
|
|
1275
|
-
// derived canonically (rootLogEntry) so this cannot mismatch.
|
|
1276
|
-
if (payload.log && !hasLogEntry(payload.log.entry || '', date, project)) {
|
|
1277
|
-
failPreApply(
|
|
1278
|
-
`payload.log.entry has no "## [${date}] session | ${project}" heading that the ` +
|
|
1279
|
-
`close gate recognizes. Fix the entry heading, or omit payload.log to derive it.`,
|
|
1280
|
-
);
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
// Preflight: lint the wiki BEFORE writing any payload bytes. If lint
|
|
1284
|
-
// has blockers (errors) in files this apply WON'T overwrite, the wiki is in
|
|
1285
|
-
// a degraded state and apply would mask the root cause — abort fail-fast.
|
|
1286
|
-
//
|
|
1287
|
-
// Overwrite-target filter (codex P2 follow-up): errors in files we're about
|
|
1288
|
-
// to fully replace are IGNORED at preflight. Otherwise a bad payload
|
|
1289
|
-
// (post-apply-lint fail) would leave the broken file on disk and the very
|
|
1290
|
-
// next retry — even with a corrected payload — gets dead-locked here. The
|
|
1291
|
-
// post-apply lint is the authoritative check on payload content.
|
|
1292
|
-
//
|
|
1293
|
-
// Append targets (session-log, log.md) are NOT filtered: appending can't
|
|
1294
|
-
// repair existing corruption, so a corrupt session-log must still block.
|
|
1295
|
-
// Warns are informational (not gated) in either pass.
|
|
1296
|
-
//
|
|
1297
|
-
// The filter says "about to be replaced", and the observed-base guard can later
|
|
1298
|
-
// decline to replace one of these. Preflight runs before the guard, so it cannot
|
|
1299
|
-
// know. Harmless: post-apply lint re-scopes the same file and blocks on it there.
|
|
1300
|
-
const overwriteTargets = new Set();
|
|
1301
|
-
if (payload.sessionState) overwriteTargets.add(join('projects', project, 'session-state.md'));
|
|
1302
|
-
if (payload.projectHot) overwriteTargets.add(join('projects', project, 'hot.md'));
|
|
1303
|
-
if (payload.rootHot) overwriteTargets.add('hot.md');
|
|
1304
|
-
if (payload.openQuestions) overwriteTargets.add(join('pages', 'open-questions.md'));
|
|
1305
|
-
|
|
1306
|
-
// Bug B: the documented close path must not be blocked by lint debt OUTSIDE
|
|
1307
|
-
// the files it writes (other projects, shared pages this close did not author).
|
|
1308
|
-
// payloadScope = every file this apply writes or appends. Both lint passes are
|
|
1309
|
-
// judged against it; errors elsewhere are surfaced as notices, never blocking.
|
|
1310
|
-
//
|
|
1311
|
-
// session-log needs TWO entries: the daily WRITE target (what this
|
|
1312
|
-
// apply creates/appends, judged by post-apply lint) AND the freshness EVIDENCE
|
|
1313
|
-
// file. They coincide except in the hybrid cutover month, where a fallback-
|
|
1314
|
-
// aware no-op (the identical entry already lives in the legacy monthly file)
|
|
1315
|
-
// writes no daily shard, leaving the monthly as the proof of freshness. Scope
|
|
1316
|
-
// must then include that monthly file, or a CORRUPT monthly evidence file would
|
|
1317
|
-
// pass the gate with its lint error demoted to a non-blocking notice.
|
|
1318
|
-
// sessionLogScopePath returns the monthly ONLY when it carries today's heading
|
|
1319
|
-
// (otherwise the daily write target), so unrelated monthly debt stays a notice.
|
|
1320
|
-
// join() (platform-native), not the POSIX helper output: payloadScope membership
|
|
1321
|
-
// is tested against lint's raw `e.file` (path.relative) WITHOUT posix
|
|
1322
|
-
// normalization, so it must use the OS-native separator the sibling entries use.
|
|
1323
|
-
const sessionLogWriteTarget = join('projects', project, 'session-log', `${date}.md`);
|
|
1324
|
-
const sessionLogEvidence = join(...sessionLogScopePath(args.hypoDir, project, date).split('/'));
|
|
1325
|
-
// A-1: known here (read-only check, no write yet) so a freshly-scaffolded
|
|
1326
|
-
// index.md is scoped to THIS close's own payloadScope below, rather than
|
|
1327
|
-
// showing up as an unrelated pre-existing-content notice.
|
|
1328
|
-
const indexRelPath = join('projects', project, 'index.md');
|
|
1329
|
-
const indexMissing = !existsSync(join(args.hypoDir, indexRelPath));
|
|
1330
|
-
const payloadScope = new Set([
|
|
1331
|
-
join('projects', project, 'session-state.md'),
|
|
1332
|
-
join('projects', project, 'hot.md'),
|
|
1333
|
-
'hot.md',
|
|
1334
|
-
sessionLogWriteTarget,
|
|
1335
|
-
sessionLogEvidence, // == write target, except a hybrid-month monthly fallback
|
|
1336
|
-
'log.md',
|
|
1337
|
-
...(payload.openQuestions ? [join('pages', 'open-questions.md')] : []),
|
|
1338
|
-
...(indexMissing ? [indexRelPath] : []),
|
|
1339
|
-
]);
|
|
1340
|
-
|
|
1341
|
-
let preflightLint;
|
|
1342
|
-
try {
|
|
1343
|
-
preflightLint = runLint(args.hypoDir);
|
|
1344
|
-
} catch (e) {
|
|
1345
|
-
const out = { ok: false, stage: 'preflight-lint', error: e.message };
|
|
1346
|
-
console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${e.message}`);
|
|
1347
|
-
process.exit(1);
|
|
1348
|
-
}
|
|
1349
|
-
// Block only on errors in payload files we are NOT about to overwrite (append
|
|
1350
|
-
// targets — session-log, log.md — can't be repaired by appending, so existing
|
|
1351
|
-
// corruption there must block). Overwrite targets are about to be replaced;
|
|
1352
|
-
// out-of-scope debt is not this close's concern (Bug B).
|
|
1353
|
-
const blockingErrors = preflightLint.errors.filter(
|
|
1354
|
-
(e) => payloadScope.has(e.file) && !overwriteTargets.has(e.file),
|
|
1355
|
-
);
|
|
1356
|
-
if (blockingErrors.length > 0) {
|
|
1357
|
-
const out = {
|
|
1358
|
-
ok: false,
|
|
1359
|
-
stage: 'preflight-lint',
|
|
1360
|
-
error: 'lint preflight failed — apply aborted (no payload bytes written)',
|
|
1361
|
-
lint: { ...summarizeLintForOutput(preflightLint), blockingErrors },
|
|
1362
|
-
};
|
|
1363
|
-
if (args.json) {
|
|
1364
|
-
console.log(JSON.stringify(out, null, 2));
|
|
1365
|
-
} else {
|
|
1366
|
-
console.log('✗ lint preflight failed — apply aborted (no payload bytes written):');
|
|
1367
|
-
for (const e of blockingErrors) console.log(` ✗ ${e.file}: ${e.message}`);
|
|
1368
|
-
console.log(' Fix the wiki (run `node scripts/lint.mjs`) and retry.');
|
|
1369
|
-
}
|
|
1370
|
-
process.exit(1);
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
const applied = [];
|
|
1374
|
-
const skipped = [];
|
|
1375
|
-
// The ACTUAL vault-relative paths this apply wrote, kept separate
|
|
1376
|
-
// from `applied` (whose entries are display strings like `key (relPath)`,
|
|
1377
|
-
// not bare paths). This is the scope handed to commitWikiChanges below;
|
|
1378
|
-
// never the broader `payloadScope` above, which also includes lint/evidence
|
|
1379
|
-
// candidates this apply may not have written a byte to.
|
|
1380
|
-
const appliedPaths = [];
|
|
1381
|
-
// Overwrite targets this apply refused to write because the page moved under
|
|
1382
|
-
// it. T6 turns these into `.cache/proposals/` artifacts; here they are already
|
|
1383
|
-
// enough to withhold the bytes and fail the close.
|
|
1384
|
-
const conflicts = [];
|
|
1385
|
-
|
|
1386
|
-
/**
|
|
1387
|
-
* Replace a whole page, guarded by the base this session observed at start.
|
|
1388
|
-
*
|
|
1389
|
-
* The step order is load-bearing, not stylistic:
|
|
1390
|
-
*
|
|
1391
|
-
* 1. idempotent skip (disk already equals the payload)
|
|
1392
|
-
* 2. conflict (base unknown, or disk drifted away from base)
|
|
1393
|
-
* 3. direct write, then advance the base
|
|
1394
|
-
*
|
|
1395
|
-
* Step 1 must come first for two reasons. It keeps every existing
|
|
1396
|
-
* `--apply-session-close --session-id` test green (they read the payload
|
|
1397
|
-
* straight off disk, so they land here before any base lookup). And it breaks
|
|
1398
|
-
* the apply-then-reclose loop: once a human applies proposal P, disk == proposed
|
|
1399
|
-
* == payload.content, so the next close skips before it can re-raise a conflict.
|
|
1400
|
-
*
|
|
1401
|
-
* There is no caller here without a `--session-id`. verifyCloseAuthority refuses
|
|
1402
|
-
* that at the door, before a byte is written, so a session id is always present
|
|
1403
|
-
* by the time this runs and the base lookup always has something to look up.
|
|
1404
|
-
*/
|
|
1405
|
-
const overwrite = (key, relPath, field) => {
|
|
1406
|
-
if (!field || typeof field.content !== 'string') return; // optional / absent
|
|
1407
|
-
const full = join(args.hypoDir, relPath);
|
|
1408
|
-
const disk = readTarget(full);
|
|
1409
|
-
|
|
1410
|
-
// (1) idempotent skip — preserves writeIfChanged's contract
|
|
1411
|
-
if (disk === field.content) {
|
|
1412
|
-
skipped.push(`${key} (${relPath})`);
|
|
1413
|
-
return;
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
// (2) conflict, only where a session context makes a base observable
|
|
1417
|
-
if (args.sessionId) {
|
|
1418
|
-
const entry = readBaseEntry(args.hypoDir, args.sessionId, relPath);
|
|
1419
|
-
const reason = overwriteConflictReason(entry, disk);
|
|
1420
|
-
if (reason) {
|
|
1421
|
-
conflicts.push({
|
|
1422
|
-
key,
|
|
1423
|
-
target: relPath,
|
|
1424
|
-
reason,
|
|
1425
|
-
baseHash: entry.hash,
|
|
1426
|
-
currentHash: typeof disk === 'string' ? hashContent(disk) : null,
|
|
1427
|
-
proposedContent: field.content,
|
|
1428
|
-
});
|
|
1429
|
-
return; // target bytes untouched
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
|
-
// (3) write, then the content we just wrote IS this session's new base
|
|
1434
|
-
atomicWrite(full, field.content);
|
|
1435
|
-
if (args.sessionId)
|
|
1436
|
-
advanceBase(args.hypoDir, args.sessionId, relPath, hashContent(field.content));
|
|
1437
|
-
applied.push(`${key} (${relPath})`);
|
|
1438
|
-
appliedPaths.push(relPath);
|
|
1439
|
-
};
|
|
1440
|
-
|
|
1441
|
-
overwrite('sessionState', join('projects', project, 'session-state.md'), payload.sessionState);
|
|
1442
|
-
overwrite('projectHot', join('projects', project, 'hot.md'), payload.projectHot);
|
|
1443
|
-
overwrite('rootHot', 'hot.md', payload.rootHot);
|
|
1444
|
-
overwrite('openQuestions', join('pages', 'open-questions.md'), payload.openQuestions);
|
|
1445
|
-
|
|
1446
|
-
// A-1: fill a missing project index as part of this close's writes (after
|
|
1447
|
-
// preflight passed, so an aborted close never leaves a half-applied side
|
|
1448
|
-
// effect on disk).
|
|
1449
|
-
if (indexMissing) {
|
|
1450
|
-
const createdIndex = ensureProjectIndex(args.hypoDir, project, indexRelPath, date);
|
|
1451
|
-
if (createdIndex) {
|
|
1452
|
-
applied.push(`projectIndex (${createdIndex})`);
|
|
1453
|
-
appliedPaths.push(createdIndex);
|
|
1454
|
-
}
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
// Append idempotency: dedup by exact-entry presence, not by "any heading
|
|
1458
|
-
// dated today". The freshness gate (sessionCloseFileStatus) is what answers
|
|
1459
|
-
// "was this file touched today?"; that's a different concern and must not
|
|
1460
|
-
// be reused for apply-time dedup, or a legitimate same-day second close gets
|
|
1461
|
-
// silently dropped (Codex review of the apply path — Worker 1 finding 2).
|
|
1462
|
-
const entryAlreadyPresent = (entry) => (content) =>
|
|
1463
|
-
content.includes(entry.endsWith('\n') ? entry.replace(/\n+$/, '') : entry);
|
|
1464
|
-
|
|
1465
|
-
{
|
|
1466
|
-
const rel = join('projects', project, 'session-log', `${date}.md`);
|
|
1467
|
-
const full = join(args.hypoDir, rel);
|
|
1468
|
-
const isPresent = entryAlreadyPresent(payload.sessionLog.entry);
|
|
1469
|
-
// Serialize dedup + create/append on the daily shard so two concurrent
|
|
1470
|
-
// closes never lose an entry: the second closer takes the lock only after
|
|
1471
|
-
// the first committed, re-reads the shard under the lock, and appends onto
|
|
1472
|
-
// the committed bytes (temp+rename write-isolation is preserved — a partial
|
|
1473
|
-
// write never tears the target). Create and append share ONE lock, so the
|
|
1474
|
-
// "seed a new shard" and "append to an existing shard" branches can't race
|
|
1475
|
-
// each other — only one closer is ever in the create path (closes the
|
|
1476
|
-
// wx-window a bare exclusive-create would leave open).
|
|
1477
|
-
try {
|
|
1478
|
-
const outcome = withFileLock(
|
|
1479
|
-
full,
|
|
1480
|
-
() => {
|
|
1481
|
-
// Fallback-aware idempotency (hybrid cutover): during the month the
|
|
1482
|
-
// shard takes over, today's entry may already live in the legacy monthly
|
|
1483
|
-
// file from an earlier (pre-cutover) close. Treat presence in EITHER the
|
|
1484
|
-
// daily shard or the legacy monthly file as "already written" so a same-day
|
|
1485
|
-
// second close does not duplicate an identical entry across both files —
|
|
1486
|
-
// and so an idempotent re-apply stays a true no-op (no shard is created).
|
|
1487
|
-
for (const cand of sessionLogReadCandidates(project, date)) {
|
|
1488
|
-
const cf = join(args.hypoDir, cand);
|
|
1489
|
-
if (!existsSync(cf)) continue;
|
|
1490
|
-
try {
|
|
1491
|
-
if (isPresent(readFileSync(cf, 'utf-8'))) return 'skipped';
|
|
1492
|
-
} catch {
|
|
1493
|
-
/* unreadable candidate — fall through to the write path */
|
|
1494
|
-
}
|
|
1495
|
-
}
|
|
1496
|
-
if (!existsSync(full)) {
|
|
1497
|
-
// A daily shard is a new file most days. Seed minimal valid frontmatter
|
|
1498
|
-
// (title + type, the two REQUIRED_FIELDS) so the shard is a first-class
|
|
1499
|
-
// wiki page rather than a W1 "no frontmatter" warning, and write the header
|
|
1500
|
-
// AND the first entry in ONE atomic write — never leave a header-only shard
|
|
1501
|
-
// on disk, which freshness would skip (no dated heading) while derive could
|
|
1502
|
-
// otherwise mistake it for the evidence file. The dated `## [date] ...`
|
|
1503
|
-
// heading lives inside the entry, so freshness / derive / design-history
|
|
1504
|
-
// are unchanged.
|
|
1505
|
-
// Audit fields (device, session_id). The shard frontmatter is git-tracked and synced, so
|
|
1506
|
-
// `device` is an INTENTIONAL synced multi-machine identifier (privacy note:
|
|
1507
|
-
// docs/ARCHITECTURE.md). It is a CREATOR-only stamp — only the session/
|
|
1508
|
-
// machine that first seeds the daily shard is recorded; later same-day
|
|
1509
|
-
// appends do not touch it. The per-session-accurate store is the LOCAL
|
|
1510
|
-
// (.cache/, gitignored) index.jsonl written by hypo-session-record.mjs.
|
|
1511
|
-
// `session_id` is honest naming: the value is the Claude session UUID, and
|
|
1512
|
-
// it is present only on the Stop-chain close path that passes --session-id.
|
|
1513
|
-
const device = currentDevice();
|
|
1514
|
-
const auditFm =
|
|
1515
|
-
(args.sessionId
|
|
1516
|
-
? `session_id: ${String(args.sessionId).replace(/[\r\n]/g, '')}\n`
|
|
1517
|
-
: '') + `device: ${device}\n`;
|
|
1518
|
-
const header =
|
|
1519
|
-
`---\ntitle: Session Log ${date} (${project})\n` +
|
|
1520
|
-
`type: session-log\nupdated: ${date}\n${auditFm}---\n\n` +
|
|
1521
|
-
`# Session Log ${date} (${project})\n`;
|
|
1522
|
-
const entry = payload.sessionLog.entry;
|
|
1523
|
-
const body = entry.endsWith('\n') ? entry : `${entry}\n`;
|
|
1524
|
-
atomicWrite(full, `${header}\n${body}`);
|
|
1525
|
-
return 'created';
|
|
1526
|
-
}
|
|
1527
|
-
return appendIfAbsent(full, payload.sessionLog.entry, isPresent) ? 'appended' : 'skipped';
|
|
1528
|
-
},
|
|
1529
|
-
{ timeoutMs: APPEND_LOCK_TIMEOUT_MS },
|
|
1530
|
-
);
|
|
1531
|
-
(outcome === 'skipped' ? skipped : applied).push(`sessionLog (${rel})`);
|
|
1532
|
-
if (outcome !== 'skipped') appliedPaths.push(rel);
|
|
1533
|
-
} catch (err) {
|
|
1534
|
-
// Only a lock-TIMEOUT is withheld as a conflict. A real fn() write error
|
|
1535
|
-
// (disk-full, EACCES, mkdir failure) must NOT be masked as a proposal-
|
|
1536
|
-
// pending timeout — rethrow so it hard-fails like the overwrite path does.
|
|
1537
|
-
if (err?.code !== 'ELOCKTIMEOUT') throw err;
|
|
1538
|
-
// Lock-timeout: withhold rather than lose the entry. Recorded as a conflict
|
|
1539
|
-
// so the close goes proposal-pending (ok:false, no marker) and the next
|
|
1540
|
-
// close re-applies. `kind: 'append'` is what T6 branches on to SKIP parking
|
|
1541
|
-
// this: an append conflict never becomes a `.cache/proposals/` artifact —
|
|
1542
|
-
// the lock-timeout is transient and the next close self-heals by
|
|
1543
|
-
// re-appending, whereas a whole-file re-apply would drop this shard's other
|
|
1544
|
-
// entries. It still blocks the close; it just gets no artifact.
|
|
1545
|
-
conflicts.push({
|
|
1546
|
-
key: 'sessionLog',
|
|
1547
|
-
target: rel,
|
|
1548
|
-
reason: 'append-lock-timeout',
|
|
1549
|
-
kind: 'append',
|
|
1550
|
-
baseHash: null,
|
|
1551
|
-
currentHash: null,
|
|
1552
|
-
proposedContent: payload.sessionLog.entry,
|
|
1553
|
-
});
|
|
1554
|
-
}
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
// log.md: `payload.log` is OPTIONAL (B-1). When the caller supplies it, keep
|
|
1558
|
-
// the explicit appendIfAbsent path (backward-compat: a custom log line, with
|
|
1559
|
-
// the same idempotent dedup). When it is ABSENT, the root log.md entry is a
|
|
1560
|
-
// DERIVABLE artifact: reconstruct the canonical `## [date] session | <project>`
|
|
1561
|
-
// line directly from THIS close's session-log heading (`payload.sessionLog`),
|
|
1562
|
-
// not by re-reading the session-log files. Deriving from the payload is what
|
|
1563
|
-
// makes the per-close entry exact: a same-day second close lands its distinct
|
|
1564
|
-
// heading, and a hybrid daily/monthly session-log split can't hide it (apply
|
|
1565
|
-
// never reads those files for this). The global scan-based deriveRootLogEntries
|
|
1566
|
-
// (the Stop hook) still backfills OTHER projects; calling it here would either
|
|
1567
|
-
// miss the current entry (single-candidate read) or, with a loosened guard,
|
|
1568
|
-
// append onto a deliberately custom payload.log (codex pre-commit review). The
|
|
1569
|
-
// two payload paths are mutually exclusive: deriving on top of a present-but-
|
|
1570
|
-
// malformed payload.log would mask it and weaken the verifier's fail-loud.
|
|
1571
|
-
// log.md is shared across projects and also written by deriveRootLogEntries
|
|
1572
|
-
// (the Stop-hook backfill in hypo-shared.mjs). Both take the SAME lock on
|
|
1573
|
-
// log.md, so a concurrent close's append and this close's append serialize
|
|
1574
|
-
// instead of overwriting each other.
|
|
1575
|
-
const logFull = join(args.hypoDir, 'log.md');
|
|
1576
|
-
if (payload.log) {
|
|
1577
|
-
try {
|
|
1578
|
-
const wrote = withFileLock(
|
|
1579
|
-
logFull,
|
|
1580
|
-
() => appendIfAbsent(logFull, payload.log.entry, entryAlreadyPresent(payload.log.entry)),
|
|
1581
|
-
{ timeoutMs: APPEND_LOCK_TIMEOUT_MS },
|
|
1582
|
-
);
|
|
1583
|
-
(wrote ? applied : skipped).push('log (log.md)');
|
|
1584
|
-
if (wrote) appliedPaths.push('log.md');
|
|
1585
|
-
} catch (err) {
|
|
1586
|
-
if (err?.code !== 'ELOCKTIMEOUT') throw err;
|
|
1587
|
-
// proposedContent is append-ready root-log bytes (the custom log line).
|
|
1588
|
-
conflicts.push({
|
|
1589
|
-
key: 'log',
|
|
1590
|
-
target: 'log.md',
|
|
1591
|
-
reason: 'append-lock-timeout',
|
|
1592
|
-
kind: 'append',
|
|
1593
|
-
baseHash: null,
|
|
1594
|
-
currentHash: null,
|
|
1595
|
-
proposedContent: payload.log.entry,
|
|
1596
|
-
});
|
|
1597
|
-
}
|
|
1598
|
-
} else {
|
|
1599
|
-
// matchAll (not exec) mirrors deriveRootLogEntries: a payload that carried
|
|
1600
|
-
// more than one dated heading derives one canonical line each, symmetric with
|
|
1601
|
-
// the global path. Exact-line dedup on the heading keeps a second apply (or a
|
|
1602
|
-
// titleless vs titled same-day pair) from duplicating.
|
|
1603
|
-
const headingRe = new RegExp(`^#{1,6} \\[${date}\\]\\s*(.*)$`, 'gm');
|
|
1604
|
-
try {
|
|
1605
|
-
const wroteAny = withFileLock(
|
|
1606
|
-
logFull,
|
|
1607
|
-
() => {
|
|
1608
|
-
let w = false;
|
|
1609
|
-
for (const m of (payload.sessionLog.entry || '').matchAll(headingRe)) {
|
|
1610
|
-
const { heading, block } = rootLogEntry(project, date, m[1]);
|
|
1611
|
-
const wrote = appendIfAbsent(logFull, block, (c) =>
|
|
1612
|
-
(c || '').split(/\r?\n/).includes(heading),
|
|
1613
|
-
);
|
|
1614
|
-
w = w || wrote;
|
|
1615
|
-
}
|
|
1616
|
-
return w;
|
|
1617
|
-
},
|
|
1618
|
-
{ timeoutMs: APPEND_LOCK_TIMEOUT_MS },
|
|
1619
|
-
);
|
|
1620
|
-
(wroteAny ? applied : skipped).push('log (log.md, derived)');
|
|
1621
|
-
if (wroteAny) appliedPaths.push('log.md');
|
|
1622
|
-
} catch (err) {
|
|
1623
|
-
if (err?.code !== 'ELOCKTIMEOUT') throw err;
|
|
1624
|
-
// `derived: true` discriminates this from the payload.log conflict above:
|
|
1625
|
-
// here proposedContent is the session-log entry to RE-DERIVE root-log lines
|
|
1626
|
-
// from (via rootLogEntry over its dated headings), NOT append-ready bytes.
|
|
1627
|
-
// Like every `kind: 'append'` conflict, this is NOT parked as an artifact —
|
|
1628
|
-
// it blocks the close and the next close re-derives + re-appends. The derived
|
|
1629
|
-
// shape only matters to the retry, never to T6's overwrite proposal store.
|
|
1630
|
-
conflicts.push({
|
|
1631
|
-
key: 'log',
|
|
1632
|
-
target: 'log.md',
|
|
1633
|
-
reason: 'append-lock-timeout',
|
|
1634
|
-
kind: 'append',
|
|
1635
|
-
derived: true,
|
|
1636
|
-
baseHash: null,
|
|
1637
|
-
currentHash: null,
|
|
1638
|
-
proposedContent: payload.sessionLog.entry,
|
|
1639
|
-
});
|
|
1640
|
-
}
|
|
1641
|
-
}
|
|
1642
|
-
|
|
1643
|
-
// T6: park drifted OVERWRITE targets as `.cache/proposals/` artifacts.
|
|
1644
|
-
//
|
|
1645
|
-
// Runs regardless of --json AND regardless of args.sessionId: writing the
|
|
1646
|
-
// artifact is a SIDE EFFECT, not output, so it is conditioned on neither the
|
|
1647
|
-
// report format nor a session context. (In practice an overwrite conflict only
|
|
1648
|
-
// arises with a session id, so a session-less apply just finds no overwrite
|
|
1649
|
-
// conflicts to park — but the loop is unconditional to match that contract.)
|
|
1650
|
-
// Only overwrite conflicts (`kind !== 'append'`) become artifacts. An append
|
|
1651
|
-
// conflict is a transient lock-timeout the NEXT close self-heals by
|
|
1652
|
-
// re-appending; parking it as a whole-file artifact and later re-applying it
|
|
1653
|
-
// (T7 replaces the whole target) would drop every OTHER entry in that
|
|
1654
|
-
// append-only history file. Append conflicts still sit in `conflicts`, so the
|
|
1655
|
-
// close still goes proposal-pending — they just get no artifact and no
|
|
1656
|
-
// human-apply step.
|
|
1657
|
-
const proposals = [];
|
|
1658
|
-
const proposalStoreFailures = [];
|
|
1659
|
-
const device = currentDevice();
|
|
1660
|
-
for (const c of conflicts) {
|
|
1661
|
-
if (c.kind === 'append') continue; // append conflicts are never parked (see above)
|
|
1662
|
-
try {
|
|
1663
|
-
const saved = writeProposal(args.hypoDir, {
|
|
1664
|
-
target: c.target,
|
|
1665
|
-
baseHash: c.baseHash,
|
|
1666
|
-
currentAtProposalHash: c.currentHash,
|
|
1667
|
-
proposedContent: c.proposedContent, // internal (pre-drop) full page bytes
|
|
1668
|
-
sessionId: args.sessionId, // may be null; writeProposal coerces it
|
|
1669
|
-
device,
|
|
1670
|
-
});
|
|
1671
|
-
proposals.push({ id: saved.id, target: saved.target, path: saved.path });
|
|
1672
|
-
// Supersede-delete failure is NON-fatal: the new artifact IS parked, only
|
|
1673
|
-
// a stale sibling lingers (superseded next close). Surface it, don't fail.
|
|
1674
|
-
for (const w of saved.supersedeWarnings) {
|
|
1675
|
-
process.stderr.write(`\n⚠️ ${w} (stale artifact left behind, not fatal)\n`);
|
|
1676
|
-
}
|
|
1677
|
-
} catch (err) {
|
|
1678
|
-
// fail-loud: the target was withheld AND its bytes are now on neither disk
|
|
1679
|
-
// NOR a proposal artifact — a genuine data-loss risk. Never swallow this.
|
|
1680
|
-
const error = (err && err.message) || String(err);
|
|
1681
|
-
proposalStoreFailures.push({ target: c.target, key: c.key, error });
|
|
1682
|
-
process.stderr.write(
|
|
1683
|
-
`\n🛑 PROPOSAL STORE FAILED for ${c.key} (${c.target}): ${error}\n` +
|
|
1684
|
-
` This close WITHHELD the target (it drifted from your observed base) but\n` +
|
|
1685
|
-
` could NOT write the .cache/proposals/ artifact either. The payload bytes\n` +
|
|
1686
|
-
` are on NEITHER disk NOR a proposal — re-run the close once the .cache/\n` +
|
|
1687
|
-
` directory is writable so the withheld content is not lost.\n`,
|
|
1688
|
-
);
|
|
1689
|
-
}
|
|
1690
|
-
}
|
|
1691
|
-
const proposalStoreFailed = proposalStoreFailures.length > 0;
|
|
1692
|
-
|
|
1693
|
-
// Same-date-tie fix: verify against the SAME project this apply just wrote
|
|
1694
|
-
// (`project` = payload.project || probe.project, resolved at the top). Without
|
|
1695
|
-
// the override, sessionCloseFileStatus re-derives via resolveActiveProject and,
|
|
1696
|
-
// on a same-date root-hot.md tie, can pick a different project — false-failing
|
|
1697
|
-
// a completed close (the 2026-06-09 security-ops-kb incident).
|
|
1698
|
-
const verification = sessionCloseFileStatus(args.hypoDir, { projectOverride: project });
|
|
1699
|
-
|
|
1700
|
-
// B-4 auto-register: lift unknown (non-forbidden) tags surfaced by the PREFLIGHT
|
|
1701
|
-
// lint into SCHEMA.md's `### Pending` section so the post-apply lint sees them as
|
|
1702
|
-
// known and the close never stalls on a vocabulary gap. The W10 id is hidden in
|
|
1703
|
-
// non-strict --json output (lint.mjs toOut), so the unknown-tag warns are matched
|
|
1704
|
-
// and the tag extracted from the message string itself — kept in lockstep with
|
|
1705
|
-
// lint.mjs's W10 emit (a copy-edit there breaks this; the close-path round-trip
|
|
1706
|
-
// test guards it). Forbidden patterns stay hard errors and are filtered out.
|
|
1707
|
-
// SCOPE (eventual consistency, intended): this registers PRE-EXISTING wiki debt
|
|
1708
|
-
// visible at preflight, NOT a novel tag this very close's payload introduces —
|
|
1709
|
-
// that one would surface only at post-apply and lands on the NEXT close. The
|
|
1710
|
-
// contract is "must not stall", which warns (not errors) already satisfy; the
|
|
1711
|
-
// registration just keeps the vocabulary catching up.
|
|
1712
|
-
// The capture is anchored on the FULL message suffix (not `[^"]+`) so a tag that
|
|
1713
|
-
// itself contains a `"` — non-forbidden, so reachable — is captured whole rather
|
|
1714
|
-
// than truncated at its first quote (codex stage-2 CONCERN).
|
|
1715
|
-
const unknownTagRe = /^Unknown tag: "(.+)" \(not in SCHEMA\.md Tag Vocabulary\)/;
|
|
1716
|
-
const pendingTags = [];
|
|
1717
|
-
for (const w of preflightLint.warns || []) {
|
|
1718
|
-
const m = unknownTagRe.exec(w.message || '');
|
|
1719
|
-
if (m && !checkForbidden(m[1])) pendingTags.push(m[1]);
|
|
1720
|
-
}
|
|
1721
|
-
// Withheld a target? Then register nothing. This close is going to be re-run
|
|
1722
|
-
// once a human resolves the conflict, and its `ok:false` skips the commit below
|
|
1723
|
-
// (`if (ok && args.sessionId)`) — so registering here would leave SCHEMA.md
|
|
1724
|
-
// mutated, uncommitted, and absent from `applied`, i.e. a silent side effect of
|
|
1725
|
-
// a close whose whole point was to write nothing. Registration is
|
|
1726
|
-
// eventually-consistent by design, so deferring it to the next close costs
|
|
1727
|
-
// nothing (codex W2 CONCERN).
|
|
1728
|
-
if (pendingTags.length > 0 && conflicts.length === 0) {
|
|
1729
|
-
appendPendingTags(args.hypoDir, pendingTags);
|
|
1730
|
-
}
|
|
1731
|
-
|
|
1732
|
-
// Post-apply lint: payload may have introduced a malformed body or
|
|
1733
|
-
// bad frontmatter. Surface as a distinct `stage` so caller can tell "lint
|
|
1734
|
-
// broke" apart from "frontmatter stale". This runs even if the freshness gate
|
|
1735
|
-
// also failed — both failure modes are useful to the caller.
|
|
1736
|
-
let postApplyLint;
|
|
1737
|
-
let postApplyCrashed = false;
|
|
1738
|
-
try {
|
|
1739
|
-
postApplyLint = runLint(args.hypoDir);
|
|
1740
|
-
} catch (e) {
|
|
1741
|
-
// A lint crash (unparseable output) after writes is NOT scopeable — there is
|
|
1742
|
-
// no reliable `file` to classify — and must stay a HARD failure, exactly as
|
|
1743
|
-
// before scoping was introduced.
|
|
1744
|
-
postApplyCrashed = true;
|
|
1745
|
-
postApplyLint = {
|
|
1746
|
-
ok: false,
|
|
1747
|
-
errors: [{ file: '(lint crash)', message: e.message }],
|
|
1748
|
-
warns: [],
|
|
1749
|
-
};
|
|
1750
|
-
}
|
|
1751
|
-
|
|
1752
|
-
// Scope post-apply lint to payload files (Bug B): a payload-introduced error
|
|
1753
|
-
// lands in a file this apply wrote, so it blocks; pre-existing debt elsewhere
|
|
1754
|
-
// is a non-blocking notice. A lint crash bypasses scoping and blocks outright.
|
|
1755
|
-
let postBlocking;
|
|
1756
|
-
let postNotice;
|
|
1757
|
-
if (postApplyCrashed) {
|
|
1758
|
-
postBlocking = postApplyLint.errors;
|
|
1759
|
-
postNotice = [];
|
|
1760
|
-
} else {
|
|
1761
|
-
({ blocking: postBlocking, notice: postNotice } = partitionLintScope(
|
|
1762
|
-
postApplyLint.errors || [],
|
|
1763
|
-
payloadScope,
|
|
1764
|
-
));
|
|
1765
|
-
}
|
|
1766
|
-
const postLintOk = !postApplyCrashed && postBlocking.length === 0;
|
|
1767
|
-
// `let` (not const): the close-result invariant self-check below may flip this
|
|
1768
|
-
// to false when the settled close result is internally contradictory.
|
|
1769
|
-
//
|
|
1770
|
-
// A withheld conflict target must fail the close on its own, not merely via the
|
|
1771
|
-
// freshness gate. If the other session already touched that page TODAY, freshness
|
|
1772
|
-
// sees a fresh file and passes — and the close would report ok:true, write the
|
|
1773
|
-
// marker, and drop this session's payload silently. `conflicts` closes that hole.
|
|
1774
|
-
let ok = verification.ok && postLintOk && conflicts.length === 0;
|
|
1775
|
-
|
|
1776
|
-
// Scope the non-blocking notice to the close-target project: debt under
|
|
1777
|
-
// projects/<project>/ stays listed; debt elsewhere folds to a count so the
|
|
1778
|
-
// same untouched-file debt does not re-list its filenames on every close.
|
|
1779
|
-
const closeScopeNotice = postNotice.filter((e) => isUnderProjectDirs(e.file, [project]));
|
|
1780
|
-
const otherDebtCount = postNotice.length - closeScopeNotice.length;
|
|
1781
|
-
|
|
1782
|
-
// Amendment 2026-05-19: auto-write the per-session
|
|
1783
|
-
// closed marker on a verified close. Hook authority is read-only; this is
|
|
1784
|
-
// one of the two writer paths (the other is --mark-session-closed standalone).
|
|
1785
|
-
//
|
|
1786
|
-
// The marker write is governed by the SAME gate as standalone
|
|
1787
|
-
// --mark-session-closed and /compact (precompactGateStatus), NOT just apply's
|
|
1788
|
-
// `ok` + git-clean. Apply's payload preflight/post-apply lint and `ok` still
|
|
1789
|
-
// govern apply SUCCESS (exit code below), but the marker must additionally
|
|
1790
|
-
// clear feedback projection / W8 design-history / hot.md structure, else this
|
|
1791
|
-
// path could issue a marker the standalone path would refuse (the second
|
|
1792
|
-
// divergence codex flagged).
|
|
1793
|
-
//
|
|
1794
|
-
// Apply just wrote the payload, so the tree is dirty by its OWN
|
|
1795
|
-
// writes: the gate's `uncommitted` git blocker would always trip and the
|
|
1796
|
-
// marker would be skipped, deferring the close to a manual --mark-session-closed
|
|
1797
|
-
// ("done but still blocked" regression). Commit the payload HERE, via
|
|
1798
|
-
// the SAME .hypoignore-aware helper the auto-commit Stop hook uses, so the gate sees
|
|
1799
|
-
// a committed tree. Push stays deferred to the Stop hook; the resulting
|
|
1800
|
-
// committed-but-unpushed state is a gate notice, not a blocker, so
|
|
1801
|
-
// this still marks. A commit failure (not a repo / pre-commit reject / git error)
|
|
1802
|
-
// skips the marker WITH a surfaced reason — today's behavior was also "no marker",
|
|
1803
|
-
// but silently.
|
|
1804
|
-
let markerWritten = false;
|
|
1805
|
-
let markerSkipReason = null;
|
|
1806
|
-
// Hoisted so the result JSON below can report it: `null` when this apply never
|
|
1807
|
-
// reached the commit step at all (ok:false before the writes were even
|
|
1808
|
-
// verified), distinct from a commit that ran and reported `committed:false`.
|
|
1809
|
-
let commitOutcome = null;
|
|
1810
|
-
if (ok && args.sessionId) {
|
|
1811
|
-
// IO stays lazy so this preserves the exact side-effect order (codex design
|
|
1812
|
-
// review): commit first (the only mutation), then resolve the
|
|
1813
|
-
// transcript, then run the compact gate with that transcript, then scan the
|
|
1814
|
-
// user-close signal ONLY once the gate passes. planMarkerDecision owns the
|
|
1815
|
-
// branch priority + reason strings; the booleans below are computed in that
|
|
1816
|
-
// same short-circuiting order so no read runs earlier than it does today.
|
|
1817
|
-
// Scope this commit to the paths THIS apply actually wrote
|
|
1818
|
-
// (appliedPaths), never the broader payloadScope, which also
|
|
1819
|
-
// names lint/evidence candidates apply may not have touched a byte of.
|
|
1820
|
-
// Locked against the SAME target the auto-commit Stop hook holds, so a
|
|
1821
|
-
// concurrent Stop-chain commit on this vault can't interleave with this
|
|
1822
|
-
// apply's stage+commit. A lock-timeout is treated exactly like any other
|
|
1823
|
-
// commit failure below (skip the marker, surface the reason) rather than
|
|
1824
|
-
// crashing the apply.
|
|
1825
|
-
try {
|
|
1826
|
-
commitOutcome = withFileLock(vaultCommitLockTarget(args.hypoDir), () =>
|
|
1827
|
-
commitWikiChanges(args.hypoDir, appliedPaths),
|
|
1828
|
-
);
|
|
1829
|
-
} catch (err) {
|
|
1830
|
-
commitOutcome = { committed: false, reason: `vault-commit-lock: ${err?.message || err}` };
|
|
1831
|
-
}
|
|
1832
|
-
let closeTranscript = null;
|
|
1833
|
-
let gateOk = false;
|
|
1834
|
-
if (commitOutcome.committed) {
|
|
1835
|
-
closeTranscript = resolveTranscriptBySessionId(args.sessionId);
|
|
1836
|
-
// closeScope: apply KNOWS which project it just closed, and it wrote
|
|
1837
|
-
// that project's files from inside this process — they never appear in the
|
|
1838
|
-
// transcript as Edit/Write, so `payload.project` is the only signal that puts
|
|
1839
|
-
// this close in scope. Without it, apply's own incomplete close could be demoted
|
|
1840
|
-
// to a foreign-debt notice and marked green.
|
|
1841
|
-
gateOk = precompactGateStatus(args.hypoDir, {
|
|
1842
|
-
closeScope: [project],
|
|
1843
|
-
...(closeTranscript ? { transcriptPath: closeTranscript } : {}),
|
|
1844
|
-
}).ok;
|
|
1845
|
-
}
|
|
1846
|
-
const decision = planMarkerDecision({
|
|
1847
|
-
ok,
|
|
1848
|
-
hasSessionId: true,
|
|
1849
|
-
committed: commitOutcome.committed,
|
|
1850
|
-
commitReason: commitOutcome.reason,
|
|
1851
|
-
gateOk,
|
|
1852
|
-
transcriptResolved: !!closeTranscript,
|
|
1853
|
-
// Scan the signal only when the gate passed AND a transcript resolved —
|
|
1854
|
-
// hasUserCloseSignal never runs earlier than the original nested `else if`.
|
|
1855
|
-
hasUserSignal: gateOk && !!closeTranscript && hasUserCloseSignal(closeTranscript),
|
|
1856
|
-
});
|
|
1857
|
-
markerSkipReason = decision.skipReason;
|
|
1858
|
-
if (decision.write) {
|
|
1859
|
-
// apply KNOWS its authoritative payload.project — stamp it as the v4
|
|
1860
|
-
// evidence set so PreCompact trusts this marker's scope directly (session-close attribution).
|
|
1861
|
-
writeSessionClosedMarker(args.hypoDir, args.sessionId, { project, projects: [project] });
|
|
1862
|
-
// Codex CONCERN: the writer swallows IO errors (best-effort).
|
|
1863
|
-
// Verify the file actually landed — mirroring the standalone path — instead of
|
|
1864
|
-
// asserting markerWritten=true, so a .cache permission/disk problem surfaces
|
|
1865
|
-
// rather than the caller reporting "closed" while the next Stop re-blocks.
|
|
1866
|
-
if (existsSync(sessionClosedMarkerPath(args.hypoDir, args.sessionId))) {
|
|
1867
|
-
markerWritten = true;
|
|
1868
|
-
} else {
|
|
1869
|
-
markerSkipReason = 'marker-did-not-land';
|
|
1870
|
-
}
|
|
1871
|
-
}
|
|
1872
|
-
}
|
|
1873
|
-
// A conflict outranks the downstream gates: verification and lint both describe
|
|
1874
|
-
// a tree this apply declined to finish writing, so naming them would point the
|
|
1875
|
-
// reader at the wrong repair. A proposal-STORE failure outranks even that: the
|
|
1876
|
-
// withheld bytes never reached an artifact, so it is the most urgent repair.
|
|
1877
|
-
let stage = ok
|
|
1878
|
-
? null
|
|
1879
|
-
: proposalStoreFailed
|
|
1880
|
-
? 'proposal-store-failed'
|
|
1881
|
-
: conflicts.length > 0
|
|
1882
|
-
? 'proposal-pending'
|
|
1883
|
-
: !verification.ok && !postLintOk
|
|
1884
|
-
? 'post-apply-verification+lint'
|
|
1885
|
-
: !verification.ok
|
|
1886
|
-
? 'post-apply-verification'
|
|
1887
|
-
: 'post-apply-lint';
|
|
1888
|
-
// Runtime close-result invariant self-check. When a
|
|
1889
|
-
// marker-write path (args.sessionId present) settles into an internally
|
|
1890
|
-
// contradictory shape — ok:true with the marker silently withheld and no
|
|
1891
|
-
// reason, or a written marker that also carries a skip reason — flip ok:false
|
|
1892
|
-
// and stage-tag it so the existing `process.exit(ok ? 0 : 1)` yields exit 1.
|
|
1893
|
-
// That non-zero exit is the discriminator that separates a genuine
|
|
1894
|
-
// contradiction (a code bug) from a legitimate withhold (exit 0, e.g.
|
|
1895
|
-
// no-user-close-signal). apply is idempotent, so a non-zero re-run is safe.
|
|
1896
|
-
// Unreachable today; this is a regression guard for future refactors.
|
|
1897
|
-
if (args.sessionId) {
|
|
1898
|
-
const contradiction = closeResultContradiction({ ok, markerWritten, markerSkipReason });
|
|
1899
|
-
if (contradiction) {
|
|
1900
|
-
ok = false;
|
|
1901
|
-
stage = contradiction;
|
|
1902
|
-
process.stderr.write(
|
|
1903
|
-
`\n🛑 INTERNAL CONTRADICTION in session-close result: ${contradiction}\n` +
|
|
1904
|
-
` markerWritten=${markerWritten}, markerSkipReason=${JSON.stringify(markerSkipReason)}.\n` +
|
|
1905
|
-
` This is a close-pipeline bug, not a normal withhold. Exiting non-zero so it\n` +
|
|
1906
|
-
` cannot masquerade as a successful close. The applied payload files stand;\n` +
|
|
1907
|
-
` re-running apply is idempotent once the pipeline is fixed.\n`,
|
|
1908
|
-
);
|
|
1909
|
-
}
|
|
1910
|
-
}
|
|
1911
|
-
const result = {
|
|
1912
|
-
ok,
|
|
1913
|
-
stage,
|
|
1914
|
-
project,
|
|
1915
|
-
date,
|
|
1916
|
-
applied,
|
|
1917
|
-
skipped,
|
|
1918
|
-
// Was the general-shape sibling of the two early-refusal `committed:null`
|
|
1919
|
-
// fields (no-user-close-signal / session-id-mismatch), which this path never
|
|
1920
|
-
// carried before: a reader of `applied:[]` on a no-op re-run had no
|
|
1921
|
-
// `committed` value to check against and no way to tell it apart from a run
|
|
1922
|
-
// that never reached the commit step. `null` here means exactly that: `ok`
|
|
1923
|
-
// came back false before the commit ever ran (see `stage` for which check
|
|
1924
|
-
// failed: post-apply-verification, post-apply-lint, or proposal-pending). It
|
|
1925
|
-
// does NOT mean nothing was written — an overwrite/append can already be on
|
|
1926
|
-
// disk (see `applied` / `appliedUncommitted`) while `committed` stays `null`.
|
|
1927
|
-
// `true` covers both an actual commit and the legitimate "nothing to stage"
|
|
1928
|
-
// no-op (commitWikiChanges' own contract, see hooks/hypo-shared.mjs); `false`
|
|
1929
|
-
// is a real commit failure, surfaced together with markerSkipReason below.
|
|
1930
|
-
committed: commitOutcome ? commitOutcome.committed : null,
|
|
1931
|
-
// Targets withheld: an overwrite drifted from this session's observed base, or
|
|
1932
|
-
// an append could not take the file lock in time (`kind: 'append'`). Two
|
|
1933
|
-
// channels resolve these, and `proposals` vs `conflicts[].kind` are the sole
|
|
1934
|
-
// discriminators: an OVERWRITE conflict is parked as a `.cache/proposals/`
|
|
1935
|
-
// artifact (below) for a human to review and re-apply; an APPEND conflict gets
|
|
1936
|
-
// NO artifact and is re-tried automatically by the next close. `proposedContent`
|
|
1937
|
-
// is dropped from the reported shape either way (the artifact / the next close
|
|
1938
|
-
// holds the bytes; a whole page or an append entry does not belong in the JSON).
|
|
1939
|
-
conflicts: conflicts.map(({ proposedContent: _drop, ...rest }) => rest),
|
|
1940
|
-
// Parked overwrite proposals (id/target/path), one per drifted overwrite
|
|
1941
|
-
// target. Empty when only append conflicts (or none) occurred. The T7 CLI
|
|
1942
|
-
// lists and applies these; append conflicts never appear here.
|
|
1943
|
-
proposals,
|
|
1944
|
-
...(proposalStoreFailed ? { proposalStoreFailures } : {}),
|
|
1945
|
-
// Partial close: some overwrite direct-writes (and/or appends) already landed
|
|
1946
|
-
// on disk while at least one conflict withheld the rest. Because the close is
|
|
1947
|
-
// ok:false, commitWikiChanges + the marker are skipped — so those written
|
|
1948
|
-
// files sit on disk UNCOMMITTED until the conflict is resolved and the close
|
|
1949
|
-
// re-runs. Named honestly: `appliedUncommitted` covers every write that landed
|
|
1950
|
-
// (overwrite or append), not overwrites alone.
|
|
1951
|
-
...(applied.length > 0 && conflicts.length > 0
|
|
1952
|
-
? { partialConflict: true, appliedUncommitted: [...applied] }
|
|
1953
|
-
: {}),
|
|
1954
|
-
verification,
|
|
1955
|
-
// Surface the marker outcome instead of skipping silently, so the
|
|
1956
|
-
// caller can tell "closed" from "applied but not marked".
|
|
1957
|
-
...(args.sessionId ? { markerWritten, markerSkipReason } : {}),
|
|
1958
|
-
lint: {
|
|
1959
|
-
preflight: summarizeLintForOutput(preflightLint),
|
|
1960
|
-
postApply: summarizeLintForOutput(postApplyLint),
|
|
1961
|
-
},
|
|
1962
|
-
// Pre-existing lint debt in files this close did not author: surfaced for
|
|
1963
|
-
// visibility, never gated. Empty on a clean vault. Scoped to the close-target
|
|
1964
|
-
// project's own dir — debt under projects/<project>/ is this close's
|
|
1965
|
-
// neighborhood and stays listed; debt elsewhere (other projects, shared
|
|
1966
|
-
// pages, root files) folds into otherDebtCount so the same untouched-file
|
|
1967
|
-
// debt does not re-list its filenames on every close (run `node
|
|
1968
|
-
// scripts/lint.mjs` for the full list).
|
|
1969
|
-
notices: [...new Set(closeScopeNotice.map((e) => e.file))],
|
|
1970
|
-
otherDebtCount,
|
|
1971
|
-
};
|
|
1972
|
-
|
|
1973
|
-
if (args.json) {
|
|
1974
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1975
|
-
} else {
|
|
1976
|
-
console.log(`Session-close apply (project: ${project}, date: ${date}):`);
|
|
1977
|
-
for (const a of applied) console.log(` ✓ wrote ${a}`);
|
|
1978
|
-
for (const s of skipped) console.log(` · skipped ${s} (already current)`);
|
|
1979
|
-
// Never let a withheld target read as a skip: `skipped` means "already current",
|
|
1980
|
-
// this means "your bytes are NOT on disk". Overwrite conflicts drifted from base;
|
|
1981
|
-
// an append conflict is a lock-timeout (someone else held the file's lock), which
|
|
1982
|
-
// is transient — the next close re-applies.
|
|
1983
|
-
for (const c of conflicts) {
|
|
1984
|
-
const why =
|
|
1985
|
-
c.kind === 'append'
|
|
1986
|
-
? 'could not acquire the append lock in time; the next close re-applies'
|
|
1987
|
-
: 'the page changed since this session read it';
|
|
1988
|
-
console.log(` ⚠ WITHHELD ${c.key} (${c.target}) — ${c.reason}; ${why}`);
|
|
1989
|
-
}
|
|
1990
|
-
for (const p of proposals) {
|
|
1991
|
-
console.log(
|
|
1992
|
-
` · parked proposal ${p.id} for ${p.target} (review with \`hypomnema proposal\`)`,
|
|
1993
|
-
);
|
|
1994
|
-
}
|
|
1995
|
-
if (applied.length > 0 && conflicts.length > 0) {
|
|
1996
|
-
console.log(
|
|
1997
|
-
'\n· partial close: the writes above ARE on disk but NOT committed — this close is\n' +
|
|
1998
|
-
' ok:false, so no commit and no session-close marker run until the withheld\n' +
|
|
1999
|
-
' target(s) are resolved and the close re-runs.',
|
|
2000
|
-
);
|
|
2001
|
-
}
|
|
2002
|
-
if (ok) {
|
|
2003
|
-
// When the marker was withheld, qualify the success line so a reader scanning
|
|
2004
|
-
// stdout alone cannot mistake "verified" for "fully closed". markerSkipReason
|
|
2005
|
-
// is non-null exactly when args.sessionId is set and the marker did not land.
|
|
2006
|
-
if (markerSkipReason) {
|
|
2007
|
-
console.log(
|
|
2008
|
-
'\n✓ session-close files verified (all 5 mandatory files fresh, lint clean).' +
|
|
2009
|
-
'\n session NOT fully closed: the Stop-chain marker was not written (see warning below).',
|
|
2010
|
-
);
|
|
2011
|
-
} else {
|
|
2012
|
-
console.log('\n✓ session-close verified — all 5 mandatory files fresh, lint clean.');
|
|
2013
|
-
}
|
|
2014
|
-
}
|
|
2015
|
-
// When ok:true but the session-close marker was NOT written, the Stop-chain
|
|
2016
|
-
// still sees an open session and will re-prompt at the next Stop. Surface this
|
|
2017
|
-
// loudly so neither the human nor a skill-following model reads "ok:true" as
|
|
2018
|
-
// "session fully closed". Gate on `!markerWritten` too so this "marker NOT
|
|
2019
|
-
// written" line cannot fire on the contradiction-B path (a written marker that
|
|
2020
|
-
// also carried a skip reason) — there the invariant's own 🛑 line already
|
|
2021
|
-
// explains the failure, and this message would contradict markerWritten:true.
|
|
2022
|
-
if (markerSkipReason && !markerWritten) {
|
|
2023
|
-
process.stderr.write(
|
|
2024
|
-
`\n⚠️ session-close marker NOT written (reason: ${markerSkipReason})\n` +
|
|
2025
|
-
` The 5 mandatory files were applied and verified (ok:true), but the\n` +
|
|
2026
|
-
` per-session Stop-chain marker was withheld. The session is NOT fully\n` +
|
|
2027
|
-
` closed: the Stop hook will re-prompt until the marker is present.\n` +
|
|
2028
|
-
` To fix: re-run with the correct main-conversation --session-id (NOT\n` +
|
|
2029
|
-
` a background task or Agent UUID from a /tmp/... path).\n` +
|
|
2030
|
-
` Example: crystallize.mjs --apply-session-close --payload=<path>\n` +
|
|
2031
|
-
` --session-id=<main-conversation-id> --hypo-dir=<path>\n`,
|
|
2032
|
-
);
|
|
2033
|
-
}
|
|
2034
|
-
if (!ok) {
|
|
2035
|
-
if (!verification.ok) {
|
|
2036
|
-
const bad = [
|
|
2037
|
-
...verification.missing.map((f) => `${f} (missing)`),
|
|
2038
|
-
...verification.stale.map((f) => `${f} (stale)`),
|
|
2039
|
-
].join(', ');
|
|
2040
|
-
console.log(`\n✗ session-close still incomplete after apply: ${bad}`);
|
|
2041
|
-
console.log(' Fix the payload (likely an `updated:` field) and retry.');
|
|
2042
|
-
}
|
|
2043
|
-
if (!postLintOk) {
|
|
2044
|
-
console.log('\n✗ post-apply lint failed:');
|
|
2045
|
-
for (const e of postBlocking) console.log(` ✗ ${e.file}: ${e.message}`);
|
|
2046
|
-
console.log(' Payload introduced a lint blocker — fix the payload content and retry.');
|
|
2047
|
-
}
|
|
2048
|
-
}
|
|
2049
|
-
if (closeScopeNotice.length > 0) {
|
|
2050
|
-
console.log(
|
|
2051
|
-
`\n· ${closeScopeNotice.length} pre-existing lint issue(s) in untouched files (not blocking): ${[
|
|
2052
|
-
...new Set(closeScopeNotice.map((e) => e.file)),
|
|
2053
|
-
]
|
|
2054
|
-
.slice(0, 5)
|
|
2055
|
-
.join(', ')}${closeScopeNotice.length > 5 ? ', …' : ''}`,
|
|
2056
|
-
);
|
|
2057
|
-
}
|
|
2058
|
-
if (otherDebtCount > 0) {
|
|
2059
|
-
console.log(
|
|
2060
|
-
`\n· +${otherDebtCount} pre-existing lint issue(s) elsewhere in the vault (other projects / shared pages, not blocking) — run \`node scripts/lint.mjs\` for the full list.`,
|
|
2061
|
-
);
|
|
2062
|
-
}
|
|
2063
|
-
}
|
|
2064
|
-
process.exit(ok ? 0 : 1);
|
|
2065
|
-
}
|
|
2066
|
-
|
|
2067
|
-
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
2068
|
-
|
|
2069
|
-
// Cap the warn list in MODEL-FACING lint output. `result.lint` is serialized
|
|
2070
|
-
// into the --json apply result the documented close path reads, and lint runs
|
|
2071
|
-
// twice per close (preflight + post-apply), so an un-capped warn list (e.g.
|
|
2072
|
-
// hundreds of broken-wikilink warnings) lands in model context twice on every
|
|
2073
|
-
// close. Errors stay full (few, actionable); warns collapse to a count plus a
|
|
2074
|
-
// small sample for quick diagnosis. The INTERNAL preflightLint / postApplyLint
|
|
2075
|
-
// objects are untouched — the blocking filter and the pending-tag scan still
|
|
2076
|
-
// read the full warn list. lint.mjs --json itself also stays full: its
|
|
2077
|
-
// programmatic consumers (the pending-tag scan, the PreCompact W8 filter, tests)
|
|
2078
|
-
// need every warn.
|
|
2079
|
-
function summarizeLintForOutput(l) {
|
|
2080
|
-
const SAMPLE = 10;
|
|
2081
|
-
const warns = l.warns || [];
|
|
2082
|
-
const out = {
|
|
2083
|
-
ok: l.ok,
|
|
2084
|
-
errors: l.errors || [],
|
|
2085
|
-
warnCount: warns.length,
|
|
2086
|
-
warns: warns.slice(0, SAMPLE),
|
|
2087
|
-
};
|
|
2088
|
-
if (warns.length > SAMPLE) out.warnsTruncated = warns.length - SAMPLE;
|
|
2089
|
-
return out;
|
|
2090
|
-
}
|
|
2091
|
-
|
|
2092
|
-
function parseFrontmatter(content) {
|
|
2093
|
-
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
2094
|
-
if (!m) return null;
|
|
2095
|
-
const fm = {};
|
|
2096
|
-
for (const line of m[1].split('\n')) {
|
|
2097
|
-
const idx = line.indexOf(':');
|
|
2098
|
-
if (idx < 0) continue;
|
|
2099
|
-
fm[line.slice(0, idx).trim()] = line
|
|
2100
|
-
.slice(idx + 1)
|
|
2101
|
-
.trim()
|
|
2102
|
-
.replace(/^["']|["']$/g, '');
|
|
2103
|
-
}
|
|
2104
|
-
return fm;
|
|
2105
|
-
}
|
|
2106
|
-
|
|
2107
|
-
function parseTags(fm) {
|
|
2108
|
-
if (!fm.tags) return [];
|
|
2109
|
-
const raw = fm.tags.trim().replace(/^\[|\]$/g, '');
|
|
2110
|
-
return raw
|
|
2111
|
-
.split(',')
|
|
2112
|
-
.map((t) => t.trim())
|
|
2113
|
-
.filter(Boolean);
|
|
2114
|
-
}
|
|
77
|
+
runMarkSessionClosed,
|
|
78
|
+
applySessionClose,
|
|
79
|
+
planMarkerDecision,
|
|
80
|
+
closeResultContradiction,
|
|
81
|
+
ensureProjectIndex,
|
|
82
|
+
} from './lib/crystallize-close-apply.mjs';
|
|
83
|
+
import { parseFrontmatterLoose, parseTags } from './lib/crystallize-helpers.mjs';
|
|
84
|
+
import { scopeVisible, readVisibilityScope, currentDevice } from '../hooks/hypo-shared.mjs';
|
|
85
|
+
|
|
86
|
+
// Re-exported for tests that exercise the pure close-pipeline pieces directly
|
|
87
|
+
// (tests/close-global.test.mjs, tests/crystallize-apply.test.mjs) without
|
|
88
|
+
// spawning the CLI. Importing this module must expose EXACTLY these three
|
|
89
|
+
// names — see tests/close-global.test.mjs's entry-guard test.
|
|
90
|
+
export { planMarkerDecision, closeResultContradiction, ensureProjectIndex };
|
|
2115
91
|
|
|
2116
92
|
// ── main ─────────────────────────────────────────────────────────────────────
|
|
2117
93
|
|
|
@@ -2156,7 +132,7 @@ function main() {
|
|
|
2156
132
|
continue;
|
|
2157
133
|
}
|
|
2158
134
|
if (!scopeVisible(readVisibilityScope(content), currentDevice())) continue;
|
|
2159
|
-
const fm =
|
|
135
|
+
const fm = parseFrontmatterLoose(content);
|
|
2160
136
|
if (!fm) continue;
|
|
2161
137
|
|
|
2162
138
|
const slug = rel.replace(/\.md$/, '');
|