hypomnema 1.4.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/commands/audit.md +1 -1
- package/commands/crystallize.md +11 -9
- package/commands/doctor.md +1 -1
- package/commands/feedback.md +2 -2
- package/commands/graph.md +1 -1
- package/commands/ingest.md +1 -1
- package/commands/init.md +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 +4 -2
- package/commands/upgrade.md +1 -1
- package/commands/verify.md +1 -1
- package/docs/ARCHITECTURE.md +2 -2
- package/docs/CONTRIBUTING.md +20 -12
- package/hooks/hypo-auto-commit.mjs +2 -2
- package/hooks/hypo-auto-minimal-crystallize.mjs +30 -18
- package/hooks/hypo-compact-guard.mjs +2 -2
- package/hooks/hypo-cwd-change.mjs +27 -37
- package/hooks/hypo-personal-check.mjs +37 -22
- package/hooks/hypo-session-end.mjs +1 -1
- package/hooks/hypo-session-record.mjs +2 -2
- package/hooks/hypo-session-start.mjs +34 -40
- package/hooks/hypo-shared.mjs +364 -82
- package/hooks/version-check.mjs +1 -1
- package/package.json +5 -1
- package/scripts/check-tracker-ids.mjs +69 -31
- package/scripts/crystallize.mjs +322 -70
- package/scripts/doctor.mjs +7 -7
- package/scripts/feedback-sync.mjs +5 -5
- package/scripts/feedback.mjs +9 -10
- package/scripts/init.mjs +7 -7
- package/scripts/lib/check-tracker-ids.mjs +90 -32
- package/scripts/lib/design-history-stale.mjs +1 -1
- package/scripts/lib/extensions.mjs +7 -7
- package/scripts/lib/failure-type.mjs +1 -1
- package/scripts/lib/feedback-scope.mjs +1 -1
- package/scripts/lib/project-create.mjs +25 -7
- package/scripts/lib/schema-vocab.mjs +105 -1
- package/scripts/lib/template-schema-version.mjs +1 -1
- package/scripts/lib/wd-match.mjs +181 -0
- package/scripts/lib/wikilink.mjs +1 -1
- package/scripts/lint.mjs +14 -6
- package/scripts/rename.mjs +1 -1
- package/scripts/resume.mjs +20 -22
- package/scripts/session-audit.mjs +1 -1
- package/scripts/stats.mjs +3 -3
- package/scripts/uninstall.mjs +3 -3
- package/scripts/upgrade.mjs +17 -18
- package/skills/crystallize/SKILL.md +16 -8
- package/skills/graph/SKILL.md +2 -2
- package/skills/ingest/SKILL.md +4 -4
- package/skills/lint/SKILL.md +2 -2
- package/skills/query/SKILL.md +2 -2
- package/skills/verify/SKILL.md +2 -2
- package/templates/SCHEMA.md +12 -4
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +24 -15
- package/templates/projects/_template/hot.md +1 -1
- package/scripts/fix-status-verify.mjs +0 -256
- package/scripts/lib/adr-corpus.mjs +0 -79
- package/scripts/lib/fix-manifest.mjs +0 -109
- package/scripts/lib/fix-status-verify.mjs +0 -439
package/scripts/crystallize.mjs
CHANGED
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
* --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
|
|
14
14
|
* --min-group=<n> Min pages per tag group to report (default: 2)
|
|
15
15
|
* --check-session-close Verify the strict session-close memory files — 5 mandatory + open-questions conditional
|
|
16
|
+
* --project=<slug> Override the recency-inferred project on --check / --mark (single segment
|
|
17
|
+
* [A-Za-z0-9._-]+, projects/<slug>/ must exist). On --check it NARROWS the
|
|
18
|
+
* gate to that one project — a project-scoped diagnostic, NOT a global
|
|
19
|
+
* compact-ready verdict. On --mark it is ATTRIBUTION only; the gate stays
|
|
20
|
+
* global (the marker == compact-ready invariant). Ignored on --apply.
|
|
16
21
|
* --apply-session-close Apply a JSON payload that updates the 5 mandatory memory files
|
|
17
22
|
* (+ optional open-questions). Idempotent — re-running with the same
|
|
18
23
|
* payload is a no-op. Always finishes with the strict gate check.
|
|
@@ -31,13 +36,13 @@
|
|
|
31
36
|
*
|
|
32
37
|
* Payload schema:
|
|
33
38
|
* {
|
|
34
|
-
* "project": "<slug>", //
|
|
39
|
+
* "project": "<slug>", // REQUIRED — single segment [A-Za-z0-9._-]+ (≥1 alnum, not dot-only), projects/<slug>/ dir must exist (B-3: no recency fallback for apply)
|
|
35
40
|
* "date": "YYYY-MM-DD", // optional — defaults to today (local)
|
|
36
41
|
* "sessionState": { "content": "<full file>" }, // overwrite (idempotent: identical bytes → skip)
|
|
37
42
|
* "projectHot": { "content": "<full file>" }, // overwrite
|
|
38
43
|
* "rootHot": { "content": "<full file>" }, // overwrite
|
|
39
44
|
* "sessionLog": { "entry": "## [date] ..." }, // append, skip if heading already present
|
|
40
|
-
* "log": { "entry": "## [date] session | <project> ..." }, //
|
|
45
|
+
* "log": { "entry": "## [date] session | <project> ..." }, // OPTIONAL (B-1): omit it and apply derives the root log.md entry from this close's sessionLog heading; supply it only for a deliberately custom log line
|
|
41
46
|
* "openQuestions":{ "content": "<full file>" } // optional overwrite
|
|
42
47
|
* }
|
|
43
48
|
*
|
|
@@ -60,7 +65,7 @@
|
|
|
60
65
|
* hard-fails regardless of scope.
|
|
61
66
|
*/
|
|
62
67
|
|
|
63
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs';
|
|
68
|
+
import { existsSync, statSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs';
|
|
64
69
|
import { join, dirname } from 'path';
|
|
65
70
|
import { hostname } from 'os';
|
|
66
71
|
import { spawnSync } from 'child_process';
|
|
@@ -68,6 +73,8 @@ import { fileURLToPath } from 'url';
|
|
|
68
73
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
69
74
|
import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
|
|
70
75
|
import { collectPagesCrystallize, extractWikilinks } from './lib/wikilink.mjs';
|
|
76
|
+
import { isValidProjectName } from './lib/project-create.mjs';
|
|
77
|
+
import { appendPendingTags, checkForbidden } from './lib/schema-vocab.mjs';
|
|
71
78
|
import {
|
|
72
79
|
sessionCloseFileStatus,
|
|
73
80
|
sessionCloseGlobalStatus,
|
|
@@ -76,15 +83,21 @@ import {
|
|
|
76
83
|
sessionClosedMarkerPath,
|
|
77
84
|
readSessionClosedMarker,
|
|
78
85
|
partitionLintScope,
|
|
86
|
+
isUnderProjectDirs,
|
|
79
87
|
sessionLogShardPath,
|
|
80
88
|
sessionLogReadCandidates,
|
|
81
89
|
sessionLogScopePath,
|
|
90
|
+
rootLogEntry,
|
|
82
91
|
resolveTranscriptBySessionId,
|
|
83
92
|
hasUserCloseSignal,
|
|
84
93
|
commitWikiChanges,
|
|
85
94
|
} from '../hooks/hypo-shared.mjs';
|
|
86
95
|
|
|
87
|
-
|
|
96
|
+
// This script's own absolute path. Used to print copy-pasteable recovery
|
|
97
|
+
// commands as `node <SELF_SCRIPT> ...` rather than a bare `crystallize` bin,
|
|
98
|
+
// which is not on PATH in a Claude Code plugin install (only in an npm global).
|
|
99
|
+
const SELF_SCRIPT = fileURLToPath(import.meta.url);
|
|
100
|
+
const LINT_SCRIPT = join(dirname(SELF_SCRIPT), 'lint.mjs');
|
|
88
101
|
|
|
89
102
|
// Spawn lint.mjs --json against `hypoDir` and return parsed result.
|
|
90
103
|
// We shell out instead of refactoring lint.mjs into a library because lint.mjs
|
|
@@ -133,6 +146,7 @@ function parseArgs(argv) {
|
|
|
133
146
|
payload: null,
|
|
134
147
|
force: false,
|
|
135
148
|
transcriptPath: null,
|
|
149
|
+
project: null,
|
|
136
150
|
};
|
|
137
151
|
for (const arg of argv.slice(2)) {
|
|
138
152
|
if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
@@ -144,14 +158,27 @@ function parseArgs(argv) {
|
|
|
144
158
|
else if (arg.startsWith('--session-id=')) args.sessionId = arg.slice(13);
|
|
145
159
|
else if (arg.startsWith('--payload=')) args.payload = arg.slice(10);
|
|
146
160
|
else if (arg.startsWith('--transcript-path=')) args.transcriptPath = expandHome(arg.slice(18));
|
|
161
|
+
else if (arg.startsWith('--project=')) args.project = arg.slice(10);
|
|
147
162
|
else if (arg === '--force') args.force = true;
|
|
148
163
|
else if (arg === '--json') args.json = true;
|
|
149
164
|
}
|
|
150
165
|
if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
|
|
166
|
+
// --project=<slug> override (check/mark only). Validate the SYNTAX here so a
|
|
167
|
+
// traversal/charset attack (`--project=../x`) is rejected before any path is
|
|
168
|
+
// built from it — sessionCloseFileStatus(projectOverride) joins it directly.
|
|
169
|
+
// isValidProjectName is the SHARED validator (project-create.mjs), so the
|
|
170
|
+
// override accepts exactly the namespace createProject can scaffold. Existence
|
|
171
|
+
// (a real projects/<slug>/ directory) is checked in the run functions, where
|
|
172
|
+
// hypoDir is resolved and only the check/mark paths consume --project.
|
|
173
|
+
if (args.project != null && !isValidProjectName(args.project)) {
|
|
174
|
+
const msg = `--project "${args.project}" is not a valid project name (need a single segment with ≥1 alnum, charset A-Za-z0-9._-, not "."/"..")`;
|
|
175
|
+
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
151
178
|
return args;
|
|
152
179
|
}
|
|
153
180
|
|
|
154
|
-
// ── session-close hard gate
|
|
181
|
+
// ── session-close hard gate ───────────────────────────────────────────────────
|
|
155
182
|
// The marker attests "the USER closed this session". Its evidence transcript is
|
|
156
183
|
// resolved STRICTLY from the session id (a globally-unique UUID) by globbing the
|
|
157
184
|
// Claude project dirs — never from a CLI arg. A model owns the whole subprocess
|
|
@@ -163,12 +190,26 @@ function parseArgs(argv) {
|
|
|
163
190
|
// closed.) `--transcript-path` survives ONLY for `--check-session-close`'s lint
|
|
164
191
|
// scope, which writes no marker and so cannot cause an over-close.
|
|
165
192
|
|
|
193
|
+
// Validate that an explicit --project=<slug> override names a real project
|
|
194
|
+
// DIRECTORY. Syntax was already checked in parseArgs; this is the existence half,
|
|
195
|
+
// mirroring apply's payload.project check — a regular file or an absent dir at
|
|
196
|
+
// projects/<slug> is a hard error so the override never silently resolves to an
|
|
197
|
+
// all-missing status (which a reader would misread as "exists but incomplete").
|
|
198
|
+
function requireProjectDir(args, slug) {
|
|
199
|
+
const projectDir = join(args.hypoDir, 'projects', slug);
|
|
200
|
+
if (!existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
|
|
201
|
+
const msg = `--project "${slug}" does not exist as a directory (no projects/${slug}/ directory)`;
|
|
202
|
+
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
166
207
|
// ── session-close check (spec §5.2.7 / §8.3) ────────────────────────
|
|
167
208
|
// Mirrors the hard gate in hypo-personal-check.mjs so the /hypo:crystallize
|
|
168
209
|
// flow can self-verify before /compact triggers PreCompact.
|
|
169
210
|
|
|
170
211
|
function runSessionCloseCheck(args) {
|
|
171
|
-
//
|
|
212
|
+
// The check mirrors the FULL PreCompact gate via the shared
|
|
172
213
|
// precompactGateStatus (close files + lint + design-history + feedback
|
|
173
214
|
// projection), not just the close files — so a green check means /compact
|
|
174
215
|
// won't block on a human-fixable issue. Pass --transcript-path to widen the
|
|
@@ -179,15 +220,28 @@ function runSessionCloseCheck(args) {
|
|
|
179
220
|
// (marker_present:true) while `ok` still reflected the stale active project —
|
|
180
221
|
// the completion-signal trio (PreCompact / --check / marker) would diverge
|
|
181
222
|
// (codex design Finding 2).
|
|
223
|
+
//
|
|
224
|
+
// --project=<slug> narrows BOTH the close status and the lint scope to that one
|
|
225
|
+
// project: a project-scoped DIAGNOSTIC, NOT the global compact-ready verdict.
|
|
226
|
+
// It is check-only: the marker writers stay global so
|
|
227
|
+
// the marker == compact-ready invariant holds. When narrowed, the
|
|
228
|
+
// transcript widening is suppressed: a transcript touch in some OTHER project
|
|
229
|
+
// would re-add that project's files to the lint scope and re-block the scoped
|
|
230
|
+
// check, defeating the point. The global (no --project) check keeps widening.
|
|
231
|
+
if (args.project) requireProjectDir(args, args.project);
|
|
182
232
|
const status = precompactGateStatus(args.hypoDir, {
|
|
183
|
-
...(args.
|
|
233
|
+
...(args.project
|
|
234
|
+
? { projectOverride: args.project }
|
|
235
|
+
: args.transcriptPath
|
|
236
|
+
? { transcriptPath: args.transcriptPath }
|
|
237
|
+
: {}),
|
|
184
238
|
...(args.sessionId ? { sessionId: args.sessionId } : {}),
|
|
185
239
|
});
|
|
186
240
|
const close = status.close;
|
|
187
241
|
|
|
188
|
-
//
|
|
242
|
+
// When a --session-id is supplied, report whether THIS session's
|
|
189
243
|
// per-session marker (the Stop-chain completion signal) exists. This is a
|
|
190
|
-
// separate field, NOT folded into `ok
|
|
244
|
+
// separate field, NOT folded into `ok`: `ok` stays the compact-
|
|
191
245
|
// readiness verdict. A green gate with marker_present=false is exactly the
|
|
192
246
|
// hand-edit close state: close is compact-ready but the Stop hook will
|
|
193
247
|
// still block until the marker is written.
|
|
@@ -198,9 +252,18 @@ function runSessionCloseCheck(args) {
|
|
|
198
252
|
// while /compact's Stop still blocks — the exact incoherence this ADR closes
|
|
199
253
|
// (codex pre-commit CONCERN). readSessionClosedMarker unlinks an invalid
|
|
200
254
|
// marker as it reads, matching the hook's behavior on the next Stop.
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
255
|
+
const markerObj = args.sessionId ? readSessionClosedMarker(args.hypoDir, args.sessionId) : null;
|
|
256
|
+
const markerPresent = args.sessionId ? markerObj !== null : null;
|
|
257
|
+
|
|
258
|
+
// Scope of this check (codex design review finding 2 — the scope must be
|
|
259
|
+
// explicit in JSON + prose, not implied). `global` = the full PreCompact mirror
|
|
260
|
+
// (green ⇒ compact-ready). `project` = narrowed to --project=<slug> (green ⇒
|
|
261
|
+
// only THAT project is close-complete, NOT global compact-readiness). When a
|
|
262
|
+
// log-only marker governs the session, the gate runs in log-only mode and the
|
|
263
|
+
// --project override is IGNORED — surface that rather than implying X was
|
|
264
|
+
// checked (it was not).
|
|
265
|
+
const logOnlyWon = args.project != null && markerObj?.scope === 'log-only';
|
|
266
|
+
const scope = args.project ? (logOnlyWon ? 'log-only' : 'project') : 'global';
|
|
204
267
|
|
|
205
268
|
if (args.json) {
|
|
206
269
|
console.log(
|
|
@@ -215,6 +278,14 @@ function runSessionCloseCheck(args) {
|
|
|
215
278
|
blockers: status.blockers,
|
|
216
279
|
notices: status.notices,
|
|
217
280
|
skipped: status.skipped,
|
|
281
|
+
// scope is additive; `global` keeps prior semantics for existing readers
|
|
282
|
+
scope,
|
|
283
|
+
...(args.project
|
|
284
|
+
? {
|
|
285
|
+
scoped_project: args.project,
|
|
286
|
+
...(logOnlyWon ? { project_override_ignored: true } : {}),
|
|
287
|
+
}
|
|
288
|
+
: {}),
|
|
218
289
|
...(args.sessionId ? { session_id: args.sessionId, marker_present: markerPresent } : {}),
|
|
219
290
|
},
|
|
220
291
|
null,
|
|
@@ -224,8 +295,20 @@ function runSessionCloseCheck(args) {
|
|
|
224
295
|
process.exit(status.ok ? 0 : 1);
|
|
225
296
|
}
|
|
226
297
|
|
|
298
|
+
if (logOnlyWon) {
|
|
299
|
+
console.log(
|
|
300
|
+
`Note: a log-only session-closed marker governs session ${args.sessionId}, so the gate ran in log-only mode and --project=${args.project} was IGNORED (no project was checked).\n`,
|
|
301
|
+
);
|
|
302
|
+
} else if (scope === 'project') {
|
|
303
|
+
console.log(
|
|
304
|
+
`Note: --project=${args.project} — this is a PROJECT-SCOPED diagnostic, not the global /compact gate. A green result means only ${args.project} is close-complete; another project can still block /compact.\n`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
227
308
|
const proj = close.project || '(unresolved)';
|
|
228
|
-
console.log(
|
|
309
|
+
console.log(
|
|
310
|
+
`Compact-ready check (${scope === 'global' ? `project: ${proj}` : `scope: ${scope}, project: ${proj}`}, date: ${close.dates.join(' / ')}):\n`,
|
|
311
|
+
);
|
|
229
312
|
|
|
230
313
|
const required = close.project
|
|
231
314
|
? [
|
|
@@ -246,7 +329,7 @@ function runSessionCloseCheck(args) {
|
|
|
246
329
|
}
|
|
247
330
|
// Beyond the close files: the rest of the PreCompact gate (lint, design-history,
|
|
248
331
|
// feedback over-cap/conflict). These are what made a "close-complete" check
|
|
249
|
-
// disagree with the real /compact gate before
|
|
332
|
+
// disagree with the real /compact gate before this check was added.
|
|
250
333
|
for (const b of status.blockers) {
|
|
251
334
|
if (b.type !== 'close') console.log(` ✗ ${b.reason}`);
|
|
252
335
|
}
|
|
@@ -254,22 +337,32 @@ function runSessionCloseCheck(args) {
|
|
|
254
337
|
console.log('');
|
|
255
338
|
for (const n of status.notices) console.log(` · ${n.reason}`);
|
|
256
339
|
}
|
|
257
|
-
//
|
|
340
|
+
// Surface the per-session marker state (separate from compact-
|
|
258
341
|
// readiness) so a green-but-unmarked close is visible at verify time.
|
|
259
342
|
if (args.sessionId) {
|
|
260
343
|
console.log('');
|
|
261
344
|
console.log(
|
|
262
345
|
markerPresent
|
|
263
346
|
? ` ✓ session-closed marker present (session_id: ${args.sessionId}).`
|
|
264
|
-
: ` · session-closed marker absent (session_id: ${args.sessionId}) — the Stop hook will block until it is written. Run \`
|
|
347
|
+
: ` · session-closed marker absent (session_id: ${args.sessionId}) — the Stop hook will block until it is written. Run \`node "${SELF_SCRIPT}" --mark-session-closed --session-id=${args.sessionId}${args.transcriptPath ? ` --transcript-path="${args.transcriptPath}"` : ''}\`.`,
|
|
265
348
|
);
|
|
266
349
|
}
|
|
267
350
|
console.log('');
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
351
|
+
if (scope === 'project') {
|
|
352
|
+
// Project-scoped diagnostic: green means ONLY this project is close-complete.
|
|
353
|
+
// Do NOT claim global compact-readiness (the whole point of the narrow).
|
|
354
|
+
console.log(
|
|
355
|
+
status.ok
|
|
356
|
+
? `✓ ${args.project} is close-complete (project-scoped). This is NOT a global /compact guarantee — run \`--check-session-close\` without --project for that.`
|
|
357
|
+
: `✗ ${args.project} is not close-complete — resolve the ✗ items above.`,
|
|
358
|
+
);
|
|
359
|
+
} else {
|
|
360
|
+
console.log(
|
|
361
|
+
status.ok
|
|
362
|
+
? '✓ Compact-ready — no PreCompact gate blocker needs a human fix. (open-questions.md: conditional, not checked. The live /compact can still differ on a context-≥70% prompt, HYPO_SKIP_GATE, or a transcript-scoped lint error this check did not see — pass --transcript-path to include the latter.)'
|
|
363
|
+
: '✗ Not compact-ready — resolve the ✗ items above, then retry. /compact would block on these.',
|
|
364
|
+
);
|
|
365
|
+
}
|
|
273
366
|
process.exit(status.ok ? 0 : 1);
|
|
274
367
|
}
|
|
275
368
|
|
|
@@ -358,18 +451,19 @@ function todayLocal() {
|
|
|
358
451
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
359
452
|
}
|
|
360
453
|
|
|
361
|
-
// Spec §5.2.7 / §8.3
|
|
362
|
-
// shape MUST mirror that contract — missing a mandatory field is a
|
|
363
|
-
// bug, not a no-op. Caller is the LLM session-close flow, which composes
|
|
364
|
-
// payload deliberately; partial payloads must fail loudly so caller fixes
|
|
365
|
-
// rather than silently relying on yesterday's freshness state. (Codex review
|
|
366
|
-
// of the apply path — Worker 1 finding 1.)
|
|
454
|
+
// Spec §5.2.7 / §8.3: 4 mandatory + 2 optional (`log`, `openQuestions`).
|
|
455
|
+
// The payload shape MUST mirror that contract — missing a mandatory field is a
|
|
456
|
+
// payload bug, not a no-op. Caller is the LLM session-close flow, which composes
|
|
457
|
+
// the payload deliberately; partial payloads must fail loudly so caller fixes
|
|
458
|
+
// them rather than silently relying on yesterday's freshness state. (Codex review
|
|
459
|
+
// of the apply path — Worker 1 finding 1.) `log` left the mandatory set in B-1:
|
|
460
|
+
// the root log.md entry is a DERIVABLE artifact (rootLogEntry over this close's
|
|
461
|
+
// sessionLog heading), so apply auto-fills it when the field is absent.
|
|
367
462
|
const REQUIRED_PAYLOAD_FIELDS = [
|
|
368
463
|
['sessionState', 'content'],
|
|
369
464
|
['projectHot', 'content'],
|
|
370
465
|
['rootHot', 'content'],
|
|
371
466
|
['sessionLog', 'entry'],
|
|
372
|
-
['log', 'entry'],
|
|
373
467
|
];
|
|
374
468
|
|
|
375
469
|
function validatePayloadShape(payload) {
|
|
@@ -397,20 +491,25 @@ function validatePayloadShape(payload) {
|
|
|
397
491
|
errs.push('payload.openQuestions, when present, must be { content: string }');
|
|
398
492
|
}
|
|
399
493
|
}
|
|
494
|
+
if (payload.log !== undefined) {
|
|
495
|
+
if (!payload.log || typeof payload.log !== 'object' || typeof payload.log.entry !== 'string') {
|
|
496
|
+
errs.push('payload.log, when present, must be { entry: string }');
|
|
497
|
+
}
|
|
498
|
+
}
|
|
400
499
|
if (payload.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(payload.date))) {
|
|
401
500
|
errs.push('payload.date, when present, must be YYYY-MM-DD');
|
|
402
501
|
}
|
|
403
502
|
return errs;
|
|
404
503
|
}
|
|
405
504
|
|
|
406
|
-
// ── session-close marker (
|
|
505
|
+
// ── session-close marker (amendment 2026-05-19) ───────────────
|
|
407
506
|
// Standalone marker writer. Used when the LLM closes the session via direct
|
|
408
507
|
// Write tool calls (not --apply-session-close). Hook `hypo-auto-minimal-
|
|
409
508
|
// crystallize` is the only Reader; writer authority is intentionally split
|
|
410
509
|
// between this CLI and the auto-write at the tail of applySessionClose.
|
|
411
510
|
//
|
|
412
511
|
// Contract: the marker is written only when the FULL /compact gate
|
|
413
|
-
// (precompactGateStatus
|
|
512
|
+
// (precompactGateStatus) is green. A failed gate exits 1 with no
|
|
414
513
|
// marker — the next Stop hook re-blocks.
|
|
415
514
|
|
|
416
515
|
function runMarkSessionClosed(args) {
|
|
@@ -419,7 +518,15 @@ function runMarkSessionClosed(args) {
|
|
|
419
518
|
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
420
519
|
process.exit(1);
|
|
421
520
|
}
|
|
422
|
-
//
|
|
521
|
+
// --project=<slug> on --mark is ATTRIBUTION ONLY (the marker's `project` field).
|
|
522
|
+
// The gate stays GLOBAL — never narrowed — because a marker that narrowed its
|
|
523
|
+
// gate could attest compact-ready while PreCompact re-checks all of today's
|
|
524
|
+
// projects and stays red (the marker == compact-ready invariant,
|
|
525
|
+
// codex design finding 1/2). Validate the attribution slug exists as a
|
|
526
|
+
// directory, exactly as --check does, but only when it is actually used (a
|
|
527
|
+
// --log-only mark attributes to no project, so --project is moot there).
|
|
528
|
+
if (args.project && !args.logOnly) requireProjectDir(args, args.project);
|
|
529
|
+
// The per-session marker is the THIRD session-close completion
|
|
423
530
|
// signal (after the PreCompact gate and `--check-session-close`). It must use
|
|
424
531
|
// the SAME gate that governs /compact — precompactGateStatus — so the marker
|
|
425
532
|
// can never attest "closed" while /compact would still block. This subsumes
|
|
@@ -464,7 +571,7 @@ function runMarkSessionClosed(args) {
|
|
|
464
571
|
}
|
|
465
572
|
process.exit(1);
|
|
466
573
|
}
|
|
467
|
-
// User-close hard gate
|
|
574
|
+
// User-close hard gate: the compact gate above only proves the wiki
|
|
468
575
|
// is compact-ready; it does NOT prove the USER asked to close. Refuse the marker
|
|
469
576
|
// unless the transcript carries a genuine user close signal (NL close phrase,
|
|
470
577
|
// /compact, or an AskUserQuestion close answer). This is the hard backstop for
|
|
@@ -484,8 +591,12 @@ function runMarkSessionClosed(args) {
|
|
|
484
591
|
console.log(args.json ? JSON.stringify(result, null, 2) : `✗ ${reason}`);
|
|
485
592
|
process.exit(1);
|
|
486
593
|
}
|
|
594
|
+
// --project attributes the marker to that slug (gate stayed global); falls back
|
|
595
|
+
// to the gate's resolved primary. log-only marks attribute to no project. Used
|
|
596
|
+
// for the marker, the JSON result, and the success message so all three agree.
|
|
597
|
+
const markerProject = !args.logOnly && args.project ? args.project : status.project;
|
|
487
598
|
writeSessionClosedMarker(args.hypoDir, args.sessionId, {
|
|
488
|
-
project:
|
|
599
|
+
project: markerProject,
|
|
489
600
|
...(args.logOnly ? { scope: 'log-only' } : {}),
|
|
490
601
|
});
|
|
491
602
|
// Marker writer swallows IO errors (best-effort, see hypo-shared.mjs). Verify
|
|
@@ -504,11 +615,11 @@ function runMarkSessionClosed(args) {
|
|
|
504
615
|
const result = {
|
|
505
616
|
ok: true,
|
|
506
617
|
session_id: args.sessionId,
|
|
507
|
-
project:
|
|
618
|
+
project: markerProject,
|
|
508
619
|
scope: args.logOnly ? 'log-only' : 'project',
|
|
509
620
|
date: status.dates[0],
|
|
510
621
|
notices: gate.notices,
|
|
511
|
-
//
|
|
622
|
+
// pure feedback-projection drift is a non-blocker: the marker
|
|
512
623
|
// attests "compact-ready (no human-fixable blocker)", and the PreCompact
|
|
513
624
|
// hook self-heals the projection (feedback-sync --write) at /compact. Surface
|
|
514
625
|
// the deferral so the caller knows MEMORY/CLAUDE sync is pending, not lost.
|
|
@@ -520,7 +631,7 @@ function runMarkSessionClosed(args) {
|
|
|
520
631
|
console.log(
|
|
521
632
|
args.logOnly
|
|
522
633
|
? `✓ session-closed marker written (session_id: ${args.sessionId}, scope: log-only — no project attribution).`
|
|
523
|
-
: `✓ session-closed marker written (session_id: ${args.sessionId}, project: ${
|
|
634
|
+
: `✓ session-closed marker written (session_id: ${args.sessionId}, project: ${markerProject}).`,
|
|
524
635
|
);
|
|
525
636
|
if (gate.driftTargets.length > 0) {
|
|
526
637
|
console.log(
|
|
@@ -542,7 +653,7 @@ function applySessionClose(args) {
|
|
|
542
653
|
// for any actual apply work (readPayload below surfaces "payload is
|
|
543
654
|
// required" the same way it always has).
|
|
544
655
|
if (!args.force && !args.payload) {
|
|
545
|
-
//
|
|
656
|
+
// No-payload "already complete?" probe uses the
|
|
546
657
|
// global invariant, not a recency pick.
|
|
547
658
|
const probe = sessionCloseGlobalStatus(args.hypoDir);
|
|
548
659
|
if (probe.ok) {
|
|
@@ -585,20 +696,48 @@ function applySessionClose(args) {
|
|
|
585
696
|
process.exit(1);
|
|
586
697
|
}
|
|
587
698
|
|
|
588
|
-
// Resolve project:
|
|
589
|
-
//
|
|
590
|
-
//
|
|
699
|
+
// Resolve project: payload.project is REQUIRED (B-3, close-gate-hardening). The
|
|
700
|
+
// old recency fallback (payload.project || probe.project) could, on a same-date
|
|
701
|
+
// root-hot.md tie, resolve a DIFFERENT project than the one the payload's files
|
|
702
|
+
// belong to — apply would then write the close into the wrong project (silent
|
|
703
|
+
// data loss). Validate fail-fast, BEFORE the probe is consulted:
|
|
704
|
+
// - missing → no target to write; abort rather than infer.
|
|
705
|
+
// - invalid name → reject (non-string, wrong charset, or dot-only) BEFORE the
|
|
706
|
+
// existsSync(join(...)) path build, so a `../`-style value
|
|
707
|
+
// never reaches a path builder (traversal guard — order is
|
|
708
|
+
// the guard). isValidProjectName is SHARED with createProject
|
|
709
|
+
// so apply accepts exactly the namespace the repo can
|
|
710
|
+
// scaffold (A-Za-z0-9._-, single segment) — no narrower.
|
|
711
|
+
// - non-existent → projects/<slug>/ absent; abort rather than create.
|
|
712
|
+
// A payload.project that merely DIFFERS from the inferred active project is NOT an
|
|
713
|
+
// error — it is surfaced as a stderr note below and the close proceeds.
|
|
714
|
+
if (payload.project === undefined || payload.project === null) {
|
|
715
|
+
const msg = 'payload.project is required (apply must not infer the close target project)';
|
|
716
|
+
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
717
|
+
process.exit(1);
|
|
718
|
+
}
|
|
719
|
+
if (!isValidProjectName(payload.project)) {
|
|
720
|
+
const msg = `payload.project ${JSON.stringify(payload.project)} is not a valid project name (single segment, charset A-Za-z0-9._-, ≥1 alnum, not "."/"..")`;
|
|
721
|
+
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
722
|
+
process.exit(1);
|
|
723
|
+
}
|
|
724
|
+
// existsSync alone is not enough: a regular FILE at projects/<slug> would pass,
|
|
725
|
+
// then apply would build child paths under it and fail with an unstructured
|
|
726
|
+
// filesystem error (codex re-review). Require it to be a directory.
|
|
727
|
+
const projectDir = join(args.hypoDir, 'projects', payload.project);
|
|
728
|
+
if (!existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
|
|
729
|
+
const msg = `payload.project "${payload.project}" does not exist as a directory (no projects/${payload.project}/ directory)`;
|
|
730
|
+
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
731
|
+
process.exit(1);
|
|
732
|
+
}
|
|
733
|
+
const project = payload.project;
|
|
734
|
+
// probe (the recency-inferred active project) is now consulted ONLY to surface a
|
|
735
|
+
// divergence note — never to resolve the target. Computed AFTER validation so a
|
|
736
|
+
// malformed/missing payload.project fails fast without a pointer-table read.
|
|
591
737
|
// Resolved BEFORE preflight because preflight needs overwrite-target paths
|
|
592
738
|
// (which require the project slug) to filter out errors in files this apply
|
|
593
739
|
// is about to replace — see the filter rationale below.
|
|
594
740
|
const probe = sessionCloseFileStatus(args.hypoDir);
|
|
595
|
-
const project = payload.project || probe.project;
|
|
596
|
-
if (!project) {
|
|
597
|
-
const msg =
|
|
598
|
-
'no project resolved (payload.project missing and root hot.md has no active-project row)';
|
|
599
|
-
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
600
|
-
process.exit(1);
|
|
601
|
-
}
|
|
602
741
|
// The freshness verification below (and at the post-apply check) already honors
|
|
603
742
|
// payload.project — `project` wins over the inferred active project, and the
|
|
604
743
|
// post-apply sessionCloseFileStatus call passes it as projectOverride. But when the
|
|
@@ -606,7 +745,7 @@ function applySessionClose(args) {
|
|
|
606
745
|
// (probe.project), that divergence used to be silent, so an operator couldn't tell
|
|
607
746
|
// which project the close actually verified. Surface it on stderr (the stdout JSON
|
|
608
747
|
// contract is untouched) so the verified/closed project is always explicit.
|
|
609
|
-
if (
|
|
748
|
+
if (probe.project && probe.project !== payload.project) {
|
|
610
749
|
process.stderr.write(
|
|
611
750
|
`note: payload.project="${payload.project}" differs from the inferred active ` +
|
|
612
751
|
`project "${probe.project}"; verifying and closing "${payload.project}".\n`,
|
|
@@ -614,6 +753,23 @@ function applySessionClose(args) {
|
|
|
614
753
|
}
|
|
615
754
|
const date = payload.date || todayLocal();
|
|
616
755
|
|
|
756
|
+
// B-1 derive precondition: when `log` is omitted, apply reconstructs the root
|
|
757
|
+
// log.md entry from THIS close's sessionLog heading. If sessionLog.entry has no
|
|
758
|
+
// `## [<date>] …` heading there is nothing to derive — and on a same-day SECOND
|
|
759
|
+
// close the date-level freshness verifier would still pass on the earlier
|
|
760
|
+
// close's entry, so the no-write would slip through as ok:true. Fail loud here,
|
|
761
|
+
// before any writes (codex pre-commit review).
|
|
762
|
+
if (
|
|
763
|
+
!payload.log &&
|
|
764
|
+
!new RegExp(`^#{1,6} \\[${date}\\]`, 'm').test(payload.sessionLog.entry || '')
|
|
765
|
+
) {
|
|
766
|
+
const msg =
|
|
767
|
+
`payload.sessionLog.entry has no "## [${date}] …" heading to derive the log.md ` +
|
|
768
|
+
`entry from. Give it a dated heading, or supply payload.log explicitly.`;
|
|
769
|
+
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
770
|
+
process.exit(1);
|
|
771
|
+
}
|
|
772
|
+
|
|
617
773
|
// Preflight: lint the wiki BEFORE writing any payload bytes. If lint
|
|
618
774
|
// has blockers (errors) in files this apply WON'T overwrite, the wiki is in
|
|
619
775
|
// a degraded state and apply would mask the root cause — abort fail-fast.
|
|
@@ -638,7 +794,7 @@ function applySessionClose(args) {
|
|
|
638
794
|
// payloadScope = every file this apply writes or appends. Both lint passes are
|
|
639
795
|
// judged against it; errors elsewhere are surfaced as notices, never blocking.
|
|
640
796
|
//
|
|
641
|
-
// session-log needs TWO entries
|
|
797
|
+
// session-log needs TWO entries: the daily WRITE target (what this
|
|
642
798
|
// apply creates/appends, judged by post-apply lint) AND the freshness EVIDENCE
|
|
643
799
|
// file. They coincide except in the hybrid cutover month, where a fallback-
|
|
644
800
|
// aware no-op (the identical entry already lives in the legacy monthly file)
|
|
@@ -682,7 +838,7 @@ function applySessionClose(args) {
|
|
|
682
838
|
ok: false,
|
|
683
839
|
stage: 'preflight-lint',
|
|
684
840
|
error: 'lint preflight failed — apply aborted (no payload bytes written)',
|
|
685
|
-
lint: { ...preflightLint, blockingErrors },
|
|
841
|
+
lint: { ...summarizeLintForOutput(preflightLint), blockingErrors },
|
|
686
842
|
};
|
|
687
843
|
if (args.json) {
|
|
688
844
|
console.log(JSON.stringify(out, null, 2));
|
|
@@ -720,7 +876,7 @@ function applySessionClose(args) {
|
|
|
720
876
|
const rel = join('projects', project, 'session-log', `${date}.md`);
|
|
721
877
|
const full = join(args.hypoDir, rel);
|
|
722
878
|
const isPresent = entryAlreadyPresent(payload.sessionLog.entry);
|
|
723
|
-
// Fallback-aware idempotency (
|
|
879
|
+
// Fallback-aware idempotency (hybrid cutover): during the month the
|
|
724
880
|
// shard takes over, today's entry may already live in the legacy monthly
|
|
725
881
|
// file from an earlier (pre-cutover) close. Treat presence in EITHER the
|
|
726
882
|
// daily shard or the legacy monthly file as "already written" so a same-day
|
|
@@ -750,7 +906,7 @@ function applySessionClose(args) {
|
|
|
750
906
|
// otherwise mistake it for the evidence file. The dated `## [date] ...`
|
|
751
907
|
// heading lives inside the entry, so freshness / derive / design-history
|
|
752
908
|
// are unchanged.
|
|
753
|
-
//
|
|
909
|
+
// Audit fields (device, session_id). The shard frontmatter is git-tracked and synced, so
|
|
754
910
|
// `device` is an INTENTIONAL synced multi-machine identifier (privacy note:
|
|
755
911
|
// docs/ARCHITECTURE.md). It is a CREATOR-only stamp — only the session/
|
|
756
912
|
// machine that first seeds the daily shard is recorded; later same-day
|
|
@@ -776,13 +932,43 @@ function applySessionClose(args) {
|
|
|
776
932
|
}
|
|
777
933
|
}
|
|
778
934
|
|
|
779
|
-
|
|
935
|
+
// log.md: `payload.log` is OPTIONAL (B-1). When the caller supplies it, keep
|
|
936
|
+
// the explicit appendIfAbsent path (backward-compat: a custom log line, with
|
|
937
|
+
// the same idempotent dedup). When it is ABSENT, the root log.md entry is a
|
|
938
|
+
// DERIVABLE artifact: reconstruct the canonical `## [date] session | <project>`
|
|
939
|
+
// line directly from THIS close's session-log heading (`payload.sessionLog`),
|
|
940
|
+
// not by re-reading the session-log files. Deriving from the payload is what
|
|
941
|
+
// makes the per-close entry exact: a same-day second close lands its distinct
|
|
942
|
+
// heading, and a hybrid daily/monthly session-log split can't hide it (apply
|
|
943
|
+
// never reads those files for this). The global scan-based deriveRootLogEntries
|
|
944
|
+
// (the Stop hook) still backfills OTHER projects; calling it here would either
|
|
945
|
+
// miss the current entry (single-candidate read) or, with a loosened guard,
|
|
946
|
+
// append onto a deliberately custom payload.log (codex pre-commit review). The
|
|
947
|
+
// two payload paths are mutually exclusive: deriving on top of a present-but-
|
|
948
|
+
// malformed payload.log would mask it and weaken the verifier's fail-loud.
|
|
949
|
+
if (payload.log) {
|
|
780
950
|
const wrote = appendIfAbsent(
|
|
781
951
|
join(args.hypoDir, 'log.md'),
|
|
782
952
|
payload.log.entry,
|
|
783
953
|
entryAlreadyPresent(payload.log.entry),
|
|
784
954
|
);
|
|
785
955
|
(wrote ? applied : skipped).push('log (log.md)');
|
|
956
|
+
} else {
|
|
957
|
+
// matchAll (not exec) mirrors deriveRootLogEntries: a payload that carried
|
|
958
|
+
// more than one dated heading derives one canonical line each, symmetric with
|
|
959
|
+
// the global path. Exact-line dedup on the heading keeps a second apply (or a
|
|
960
|
+
// titleless vs titled same-day pair) from duplicating.
|
|
961
|
+
const logFull = join(args.hypoDir, 'log.md');
|
|
962
|
+
const headingRe = new RegExp(`^#{1,6} \\[${date}\\]\\s*(.*)$`, 'gm');
|
|
963
|
+
let wroteAny = false;
|
|
964
|
+
for (const m of (payload.sessionLog.entry || '').matchAll(headingRe)) {
|
|
965
|
+
const { heading, block } = rootLogEntry(project, date, m[1]);
|
|
966
|
+
const wrote = appendIfAbsent(logFull, block, (c) =>
|
|
967
|
+
(c || '').split(/\r?\n/).includes(heading),
|
|
968
|
+
);
|
|
969
|
+
wroteAny = wroteAny || wrote;
|
|
970
|
+
}
|
|
971
|
+
(wroteAny ? applied : skipped).push('log (log.md, derived)');
|
|
786
972
|
}
|
|
787
973
|
|
|
788
974
|
// Same-date-tie fix: verify against the SAME project this apply just wrote
|
|
@@ -792,6 +978,29 @@ function applySessionClose(args) {
|
|
|
792
978
|
// a completed close (the 2026-06-09 security-ops-kb incident).
|
|
793
979
|
const verification = sessionCloseFileStatus(args.hypoDir, { projectOverride: project });
|
|
794
980
|
|
|
981
|
+
// B-4 auto-register: lift unknown (non-forbidden) tags surfaced by the PREFLIGHT
|
|
982
|
+
// lint into SCHEMA.md's `### Pending` section so the post-apply lint sees them as
|
|
983
|
+
// known and the close never stalls on a vocabulary gap. The W10 id is hidden in
|
|
984
|
+
// non-strict --json output (lint.mjs toOut), so the unknown-tag warns are matched
|
|
985
|
+
// and the tag extracted from the message string itself — kept in lockstep with
|
|
986
|
+
// lint.mjs's W10 emit (a copy-edit there breaks this; the close-path round-trip
|
|
987
|
+
// test guards it). Forbidden patterns stay hard errors and are filtered out.
|
|
988
|
+
// SCOPE (eventual consistency, intended): this registers PRE-EXISTING wiki debt
|
|
989
|
+
// visible at preflight, NOT a novel tag this very close's payload introduces —
|
|
990
|
+
// that one would surface only at post-apply and lands on the NEXT close. The
|
|
991
|
+
// contract is "must not stall", which warns (not errors) already satisfy; the
|
|
992
|
+
// registration just keeps the vocabulary catching up.
|
|
993
|
+
// The capture is anchored on the FULL message suffix (not `[^"]+`) so a tag that
|
|
994
|
+
// itself contains a `"` — non-forbidden, so reachable — is captured whole rather
|
|
995
|
+
// than truncated at its first quote (codex stage-2 CONCERN).
|
|
996
|
+
const unknownTagRe = /^Unknown tag: "(.+)" \(not in SCHEMA\.md Tag Vocabulary\)/;
|
|
997
|
+
const pendingTags = [];
|
|
998
|
+
for (const w of preflightLint.warns || []) {
|
|
999
|
+
const m = unknownTagRe.exec(w.message || '');
|
|
1000
|
+
if (m && !checkForbidden(m[1])) pendingTags.push(m[1]);
|
|
1001
|
+
}
|
|
1002
|
+
if (pendingTags.length > 0) appendPendingTags(args.hypoDir, pendingTags);
|
|
1003
|
+
|
|
795
1004
|
// Post-apply lint: payload may have introduced a malformed body or
|
|
796
1005
|
// bad frontmatter. Surface as a distinct `stage` so caller can tell "lint
|
|
797
1006
|
// broke" apart from "frontmatter stale". This runs even if the freshness gate
|
|
@@ -829,11 +1038,17 @@ function applySessionClose(args) {
|
|
|
829
1038
|
const postLintOk = !postApplyCrashed && postBlocking.length === 0;
|
|
830
1039
|
const ok = verification.ok && postLintOk;
|
|
831
1040
|
|
|
832
|
-
//
|
|
1041
|
+
// Scope the non-blocking notice to the close-target project: debt under
|
|
1042
|
+
// projects/<project>/ stays listed; debt elsewhere folds to a count so the
|
|
1043
|
+
// same untouched-file debt does not re-list its filenames on every close.
|
|
1044
|
+
const closeScopeNotice = postNotice.filter((e) => isUnderProjectDirs(e.file, [project]));
|
|
1045
|
+
const otherDebtCount = postNotice.length - closeScopeNotice.length;
|
|
1046
|
+
|
|
1047
|
+
// Amendment 2026-05-19: auto-write the per-session
|
|
833
1048
|
// closed marker on a verified close. Hook authority is read-only; this is
|
|
834
1049
|
// one of the two writer paths (the other is --mark-session-closed standalone).
|
|
835
1050
|
//
|
|
836
|
-
//
|
|
1051
|
+
// The marker write is governed by the SAME gate as standalone
|
|
837
1052
|
// --mark-session-closed and /compact (precompactGateStatus), NOT just apply's
|
|
838
1053
|
// `ok` + git-clean. Apply's payload preflight/post-apply lint and `ok` still
|
|
839
1054
|
// govern apply SUCCESS (exit code below), but the marker must additionally
|
|
@@ -841,13 +1056,13 @@ function applySessionClose(args) {
|
|
|
841
1056
|
// path could issue a marker the standalone path would refuse (the second
|
|
842
1057
|
// divergence codex flagged).
|
|
843
1058
|
//
|
|
844
|
-
//
|
|
845
|
-
// writes
|
|
1059
|
+
// Apply just wrote the payload, so the tree is dirty by its OWN
|
|
1060
|
+
// writes: the gate's `uncommitted` git blocker would always trip and the
|
|
846
1061
|
// marker would be skipped, deferring the close to a manual --mark-session-closed
|
|
847
|
-
// (
|
|
1062
|
+
// ("done but still blocked" regression). Commit the payload HERE, via
|
|
848
1063
|
// the SAME .hypoignore-aware helper the auto-commit Stop hook uses, so the gate sees
|
|
849
1064
|
// a committed tree. Push stays deferred to the Stop hook; the resulting
|
|
850
|
-
// committed-but-unpushed state is a gate notice, not a blocker
|
|
1065
|
+
// committed-but-unpushed state is a gate notice, not a blocker, so
|
|
851
1066
|
// this still marks. A commit failure (not a repo / pre-commit reject / git error)
|
|
852
1067
|
// skips the marker WITH a surfaced reason — today's behavior was also "no marker",
|
|
853
1068
|
// but silently.
|
|
@@ -869,14 +1084,14 @@ function applySessionClose(args) {
|
|
|
869
1084
|
// (feedback/W8/hot/lint — git is now committed) is resolved.
|
|
870
1085
|
markerSkipReason = 'compact-gate-not-ok';
|
|
871
1086
|
} else if (!closeTranscript || !hasUserCloseSignal(closeTranscript)) {
|
|
872
|
-
// User-close hard gate
|
|
1087
|
+
// User-close hard gate: apply succeeded (payload files written)
|
|
873
1088
|
// but the user never signalled session close, so the marker — which attests
|
|
874
1089
|
// "user closed" — is withheld. The wiki record stands; the session is simply
|
|
875
1090
|
// not marked closed. Surfaced (not silent) so the caller knows.
|
|
876
1091
|
markerSkipReason = closeTranscript ? 'no-user-close-signal' : 'transcript-unresolved';
|
|
877
1092
|
} else {
|
|
878
1093
|
writeSessionClosedMarker(args.hypoDir, args.sessionId, { project });
|
|
879
|
-
// Codex CONCERN
|
|
1094
|
+
// Codex CONCERN: the writer swallows IO errors (best-effort).
|
|
880
1095
|
// Verify the file actually landed — mirroring the standalone path — instead of
|
|
881
1096
|
// asserting markerWritten=true, so a .cache permission/disk problem surfaces
|
|
882
1097
|
// rather than the caller reporting "closed" while the next Stop re-blocks.
|
|
@@ -903,13 +1118,22 @@ function applySessionClose(args) {
|
|
|
903
1118
|
applied,
|
|
904
1119
|
skipped,
|
|
905
1120
|
verification,
|
|
906
|
-
//
|
|
1121
|
+
// Surface the marker outcome instead of skipping silently, so the
|
|
907
1122
|
// caller can tell "closed" from "applied but not marked".
|
|
908
1123
|
...(args.sessionId ? { markerWritten, markerSkipReason } : {}),
|
|
909
|
-
lint: {
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1124
|
+
lint: {
|
|
1125
|
+
preflight: summarizeLintForOutput(preflightLint),
|
|
1126
|
+
postApply: summarizeLintForOutput(postApplyLint),
|
|
1127
|
+
},
|
|
1128
|
+
// Pre-existing lint debt in files this close did not author: surfaced for
|
|
1129
|
+
// visibility, never gated. Empty on a clean vault. Scoped to the close-target
|
|
1130
|
+
// project's own dir — debt under projects/<project>/ is this close's
|
|
1131
|
+
// neighborhood and stays listed; debt elsewhere (other projects, shared
|
|
1132
|
+
// pages, root files) folds into otherDebtCount so the same untouched-file
|
|
1133
|
+
// debt does not re-list its filenames on every close (run `node
|
|
1134
|
+
// scripts/lint.mjs` for the full list).
|
|
1135
|
+
notices: [...new Set(closeScopeNotice.map((e) => e.file))],
|
|
1136
|
+
otherDebtCount,
|
|
913
1137
|
};
|
|
914
1138
|
|
|
915
1139
|
if (args.json) {
|
|
@@ -963,13 +1187,18 @@ function applySessionClose(args) {
|
|
|
963
1187
|
console.log(' Payload introduced a lint blocker — fix the payload content and retry.');
|
|
964
1188
|
}
|
|
965
1189
|
}
|
|
966
|
-
if (
|
|
1190
|
+
if (closeScopeNotice.length > 0) {
|
|
967
1191
|
console.log(
|
|
968
|
-
`\n· ${
|
|
969
|
-
...new Set(
|
|
1192
|
+
`\n· ${closeScopeNotice.length} pre-existing lint issue(s) in untouched files (not blocking): ${[
|
|
1193
|
+
...new Set(closeScopeNotice.map((e) => e.file)),
|
|
970
1194
|
]
|
|
971
1195
|
.slice(0, 5)
|
|
972
|
-
.join(', ')}${
|
|
1196
|
+
.join(', ')}${closeScopeNotice.length > 5 ? ', …' : ''}`,
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
if (otherDebtCount > 0) {
|
|
1200
|
+
console.log(
|
|
1201
|
+
`\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.`,
|
|
973
1202
|
);
|
|
974
1203
|
}
|
|
975
1204
|
}
|
|
@@ -978,6 +1207,29 @@ function applySessionClose(args) {
|
|
|
978
1207
|
|
|
979
1208
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
980
1209
|
|
|
1210
|
+
// Cap the warn list in MODEL-FACING lint output. `result.lint` is serialized
|
|
1211
|
+
// into the --json apply result the documented close path reads, and lint runs
|
|
1212
|
+
// twice per close (preflight + post-apply), so an un-capped warn list (e.g.
|
|
1213
|
+
// hundreds of broken-wikilink warnings) lands in model context twice on every
|
|
1214
|
+
// close. Errors stay full (few, actionable); warns collapse to a count plus a
|
|
1215
|
+
// small sample for quick diagnosis. The INTERNAL preflightLint / postApplyLint
|
|
1216
|
+
// objects are untouched — the blocking filter and the pending-tag scan still
|
|
1217
|
+
// read the full warn list. lint.mjs --json itself also stays full: its
|
|
1218
|
+
// programmatic consumers (the pending-tag scan, the PreCompact W8 filter, tests)
|
|
1219
|
+
// need every warn.
|
|
1220
|
+
function summarizeLintForOutput(l) {
|
|
1221
|
+
const SAMPLE = 10;
|
|
1222
|
+
const warns = l.warns || [];
|
|
1223
|
+
const out = {
|
|
1224
|
+
ok: l.ok,
|
|
1225
|
+
errors: l.errors || [],
|
|
1226
|
+
warnCount: warns.length,
|
|
1227
|
+
warns: warns.slice(0, SAMPLE),
|
|
1228
|
+
};
|
|
1229
|
+
if (warns.length > SAMPLE) out.warnsTruncated = warns.length - SAMPLE;
|
|
1230
|
+
return out;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
981
1233
|
function parseFrontmatter(content) {
|
|
982
1234
|
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
983
1235
|
if (!m) return null;
|