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