hypomnema 1.4.0 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +151 -153
- package/README.md +121 -123
- package/commands/audit.md +4 -4
- package/commands/crystallize.md +22 -7
- package/commands/doctor.md +3 -3
- package/commands/feedback.md +5 -3
- package/commands/graph.md +3 -3
- package/commands/ingest.md +6 -4
- package/commands/init.md +5 -3
- package/commands/lint.md +3 -3
- package/commands/query.md +3 -3
- package/commands/rename.md +4 -4
- package/commands/resume.md +4 -2
- package/commands/stats.md +3 -3
- package/commands/upgrade.md +4 -4
- package/commands/verify.md +3 -3
- package/docs/CONTRIBUTING.md +107 -16
- package/hooks/hypo-auto-minimal-crystallize.mjs +23 -11
- package/hooks/hypo-shared.mjs +96 -14
- package/package.json +6 -1
- package/scripts/check-bilingual.mjs +49 -11
- package/scripts/check-tracker-ids.mjs +60 -1
- package/scripts/crystallize.mjs +275 -39
- package/scripts/lib/changelog-classify.mjs +216 -0
- package/scripts/lib/check-bilingual.mjs +125 -22
- package/scripts/lib/check-tracker-ids.mjs +19 -0
- package/scripts/lib/project-create.mjs +23 -5
- package/scripts/lib/schema-vocab.mjs +105 -1
- package/scripts/lint.mjs +9 -1
- package/scripts/weekly-report.mjs +9 -3
- package/skills/crystallize/SKILL.md +30 -2
- package/templates/SCHEMA.md +11 -3
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +4 -1
- package/scripts/bump-version.mjs +0 -77
- package/scripts/smoke-pack.mjs +0 -261
- package/scripts/smoke-plugin.mjs +0 -194
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,
|
|
@@ -79,12 +86,17 @@ import {
|
|
|
79
86
|
sessionLogShardPath,
|
|
80
87
|
sessionLogReadCandidates,
|
|
81
88
|
sessionLogScopePath,
|
|
89
|
+
rootLogEntry,
|
|
82
90
|
resolveTranscriptBySessionId,
|
|
83
91
|
hasUserCloseSignal,
|
|
84
92
|
commitWikiChanges,
|
|
85
93
|
} from '../hooks/hypo-shared.mjs';
|
|
86
94
|
|
|
87
|
-
|
|
95
|
+
// This script's own absolute path. Used to print copy-pasteable recovery
|
|
96
|
+
// commands as `node <SELF_SCRIPT> ...` rather than a bare `crystallize` bin,
|
|
97
|
+
// which is not on PATH in a Claude Code plugin install (only in an npm global).
|
|
98
|
+
const SELF_SCRIPT = fileURLToPath(import.meta.url);
|
|
99
|
+
const LINT_SCRIPT = join(dirname(SELF_SCRIPT), 'lint.mjs');
|
|
88
100
|
|
|
89
101
|
// Spawn lint.mjs --json against `hypoDir` and return parsed result.
|
|
90
102
|
// We shell out instead of refactoring lint.mjs into a library because lint.mjs
|
|
@@ -133,6 +145,7 @@ function parseArgs(argv) {
|
|
|
133
145
|
payload: null,
|
|
134
146
|
force: false,
|
|
135
147
|
transcriptPath: null,
|
|
148
|
+
project: null,
|
|
136
149
|
};
|
|
137
150
|
for (const arg of argv.slice(2)) {
|
|
138
151
|
if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
@@ -144,10 +157,23 @@ function parseArgs(argv) {
|
|
|
144
157
|
else if (arg.startsWith('--session-id=')) args.sessionId = arg.slice(13);
|
|
145
158
|
else if (arg.startsWith('--payload=')) args.payload = arg.slice(10);
|
|
146
159
|
else if (arg.startsWith('--transcript-path=')) args.transcriptPath = expandHome(arg.slice(18));
|
|
160
|
+
else if (arg.startsWith('--project=')) args.project = arg.slice(10);
|
|
147
161
|
else if (arg === '--force') args.force = true;
|
|
148
162
|
else if (arg === '--json') args.json = true;
|
|
149
163
|
}
|
|
150
164
|
if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
|
|
165
|
+
// --project=<slug> override (check/mark only). Validate the SYNTAX here so a
|
|
166
|
+
// traversal/charset attack (`--project=../x`) is rejected before any path is
|
|
167
|
+
// built from it — sessionCloseFileStatus(projectOverride) joins it directly.
|
|
168
|
+
// isValidProjectName is the SHARED validator (project-create.mjs), so the
|
|
169
|
+
// override accepts exactly the namespace createProject can scaffold. Existence
|
|
170
|
+
// (a real projects/<slug>/ directory) is checked in the run functions, where
|
|
171
|
+
// hypoDir is resolved and only the check/mark paths consume --project.
|
|
172
|
+
if (args.project != null && !isValidProjectName(args.project)) {
|
|
173
|
+
const msg = `--project "${args.project}" is not a valid project name (need a single segment with ≥1 alnum, charset A-Za-z0-9._-, not "."/"..")`;
|
|
174
|
+
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
151
177
|
return args;
|
|
152
178
|
}
|
|
153
179
|
|
|
@@ -163,6 +189,20 @@ function parseArgs(argv) {
|
|
|
163
189
|
// closed.) `--transcript-path` survives ONLY for `--check-session-close`'s lint
|
|
164
190
|
// scope, which writes no marker and so cannot cause an over-close.
|
|
165
191
|
|
|
192
|
+
// Validate that an explicit --project=<slug> override names a real project
|
|
193
|
+
// DIRECTORY. Syntax was already checked in parseArgs; this is the existence half,
|
|
194
|
+
// mirroring apply's payload.project check — a regular file or an absent dir at
|
|
195
|
+
// projects/<slug> is a hard error so the override never silently resolves to an
|
|
196
|
+
// all-missing status (which a reader would misread as "exists but incomplete").
|
|
197
|
+
function requireProjectDir(args, slug) {
|
|
198
|
+
const projectDir = join(args.hypoDir, 'projects', slug);
|
|
199
|
+
if (!existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
|
|
200
|
+
const msg = `--project "${slug}" does not exist as a directory (no projects/${slug}/ directory)`;
|
|
201
|
+
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
166
206
|
// ── session-close check (spec §5.2.7 / §8.3) ────────────────────────
|
|
167
207
|
// Mirrors the hard gate in hypo-personal-check.mjs so the /hypo:crystallize
|
|
168
208
|
// flow can self-verify before /compact triggers PreCompact.
|
|
@@ -179,8 +219,21 @@ function runSessionCloseCheck(args) {
|
|
|
179
219
|
// (marker_present:true) while `ok` still reflected the stale active project —
|
|
180
220
|
// the completion-signal trio (PreCompact / --check / marker) would diverge
|
|
181
221
|
// (codex design Finding 2).
|
|
222
|
+
//
|
|
223
|
+
// --project=<slug> narrows BOTH the close status and the lint scope to that one
|
|
224
|
+
// project: a project-scoped DIAGNOSTIC, NOT the global compact-ready verdict
|
|
225
|
+
// (ADR 0046 caveat below). It is check-only — the marker writers stay global so
|
|
226
|
+
// the marker == compact-ready invariant holds (ADR 0047). When narrowed, the
|
|
227
|
+
// transcript widening is suppressed: a transcript touch in some OTHER project
|
|
228
|
+
// would re-add that project's files to the lint scope and re-block the scoped
|
|
229
|
+
// check, defeating the point. The global (no --project) check keeps widening.
|
|
230
|
+
if (args.project) requireProjectDir(args, args.project);
|
|
182
231
|
const status = precompactGateStatus(args.hypoDir, {
|
|
183
|
-
...(args.
|
|
232
|
+
...(args.project
|
|
233
|
+
? { projectOverride: args.project }
|
|
234
|
+
: args.transcriptPath
|
|
235
|
+
? { transcriptPath: args.transcriptPath }
|
|
236
|
+
: {}),
|
|
184
237
|
...(args.sessionId ? { sessionId: args.sessionId } : {}),
|
|
185
238
|
});
|
|
186
239
|
const close = status.close;
|
|
@@ -198,9 +251,18 @@ function runSessionCloseCheck(args) {
|
|
|
198
251
|
// while /compact's Stop still blocks — the exact incoherence this ADR closes
|
|
199
252
|
// (codex pre-commit CONCERN). readSessionClosedMarker unlinks an invalid
|
|
200
253
|
// marker as it reads, matching the hook's behavior on the next Stop.
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
254
|
+
const markerObj = args.sessionId ? readSessionClosedMarker(args.hypoDir, args.sessionId) : null;
|
|
255
|
+
const markerPresent = args.sessionId ? markerObj !== null : null;
|
|
256
|
+
|
|
257
|
+
// Scope of this check (codex design review finding 2 — the scope must be
|
|
258
|
+
// explicit in JSON + prose, not implied). `global` = the full PreCompact mirror
|
|
259
|
+
// (green ⇒ compact-ready). `project` = narrowed to --project=<slug> (green ⇒
|
|
260
|
+
// only THAT project is close-complete, NOT global compact-readiness). When a
|
|
261
|
+
// log-only marker governs the session, the gate runs in log-only mode and the
|
|
262
|
+
// --project override is IGNORED — surface that rather than implying X was
|
|
263
|
+
// checked (it was not).
|
|
264
|
+
const logOnlyWon = args.project != null && markerObj?.scope === 'log-only';
|
|
265
|
+
const scope = args.project ? (logOnlyWon ? 'log-only' : 'project') : 'global';
|
|
204
266
|
|
|
205
267
|
if (args.json) {
|
|
206
268
|
console.log(
|
|
@@ -215,6 +277,14 @@ function runSessionCloseCheck(args) {
|
|
|
215
277
|
blockers: status.blockers,
|
|
216
278
|
notices: status.notices,
|
|
217
279
|
skipped: status.skipped,
|
|
280
|
+
// scope is additive; `global` keeps prior semantics for existing readers
|
|
281
|
+
scope,
|
|
282
|
+
...(args.project
|
|
283
|
+
? {
|
|
284
|
+
scoped_project: args.project,
|
|
285
|
+
...(logOnlyWon ? { project_override_ignored: true } : {}),
|
|
286
|
+
}
|
|
287
|
+
: {}),
|
|
218
288
|
...(args.sessionId ? { session_id: args.sessionId, marker_present: markerPresent } : {}),
|
|
219
289
|
},
|
|
220
290
|
null,
|
|
@@ -224,8 +294,20 @@ function runSessionCloseCheck(args) {
|
|
|
224
294
|
process.exit(status.ok ? 0 : 1);
|
|
225
295
|
}
|
|
226
296
|
|
|
297
|
+
if (logOnlyWon) {
|
|
298
|
+
console.log(
|
|
299
|
+
`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`,
|
|
300
|
+
);
|
|
301
|
+
} else if (scope === 'project') {
|
|
302
|
+
console.log(
|
|
303
|
+
`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`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
227
307
|
const proj = close.project || '(unresolved)';
|
|
228
|
-
console.log(
|
|
308
|
+
console.log(
|
|
309
|
+
`Compact-ready check (${scope === 'global' ? `project: ${proj}` : `scope: ${scope}, project: ${proj}`}, date: ${close.dates.join(' / ')}):\n`,
|
|
310
|
+
);
|
|
229
311
|
|
|
230
312
|
const required = close.project
|
|
231
313
|
? [
|
|
@@ -261,15 +343,25 @@ function runSessionCloseCheck(args) {
|
|
|
261
343
|
console.log(
|
|
262
344
|
markerPresent
|
|
263
345
|
? ` ✓ 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 \`
|
|
346
|
+
: ` · 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
347
|
);
|
|
266
348
|
}
|
|
267
349
|
console.log('');
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
350
|
+
if (scope === 'project') {
|
|
351
|
+
// Project-scoped diagnostic: green means ONLY this project is close-complete.
|
|
352
|
+
// Do NOT claim global compact-readiness (the whole point of the narrow).
|
|
353
|
+
console.log(
|
|
354
|
+
status.ok
|
|
355
|
+
? `✓ ${args.project} is close-complete (project-scoped). This is NOT a global /compact guarantee — run \`--check-session-close\` without --project for that.`
|
|
356
|
+
: `✗ ${args.project} is not close-complete — resolve the ✗ items above.`,
|
|
357
|
+
);
|
|
358
|
+
} else {
|
|
359
|
+
console.log(
|
|
360
|
+
status.ok
|
|
361
|
+
? '✓ 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.)'
|
|
362
|
+
: '✗ Not compact-ready — resolve the ✗ items above, then retry. /compact would block on these.',
|
|
363
|
+
);
|
|
364
|
+
}
|
|
273
365
|
process.exit(status.ok ? 0 : 1);
|
|
274
366
|
}
|
|
275
367
|
|
|
@@ -358,18 +450,19 @@ function todayLocal() {
|
|
|
358
450
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
359
451
|
}
|
|
360
452
|
|
|
361
|
-
// Spec §5.2.7 / §8.3 + ADR 0029:
|
|
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.)
|
|
453
|
+
// Spec §5.2.7 / §8.3 + ADR 0029: 4 mandatory + 2 optional (`log`, `openQuestions`).
|
|
454
|
+
// The payload shape MUST mirror that contract — missing a mandatory field is a
|
|
455
|
+
// payload bug, not a no-op. Caller is the LLM session-close flow, which composes
|
|
456
|
+
// the payload deliberately; partial payloads must fail loudly so caller fixes
|
|
457
|
+
// them rather than silently relying on yesterday's freshness state. (Codex review
|
|
458
|
+
// of the apply path — Worker 1 finding 1.) `log` left the mandatory set in B-1:
|
|
459
|
+
// the root log.md entry is a DERIVABLE artifact (rootLogEntry over this close's
|
|
460
|
+
// sessionLog heading), so apply auto-fills it when the field is absent.
|
|
367
461
|
const REQUIRED_PAYLOAD_FIELDS = [
|
|
368
462
|
['sessionState', 'content'],
|
|
369
463
|
['projectHot', 'content'],
|
|
370
464
|
['rootHot', 'content'],
|
|
371
465
|
['sessionLog', 'entry'],
|
|
372
|
-
['log', 'entry'],
|
|
373
466
|
];
|
|
374
467
|
|
|
375
468
|
function validatePayloadShape(payload) {
|
|
@@ -397,6 +490,11 @@ function validatePayloadShape(payload) {
|
|
|
397
490
|
errs.push('payload.openQuestions, when present, must be { content: string }');
|
|
398
491
|
}
|
|
399
492
|
}
|
|
493
|
+
if (payload.log !== undefined) {
|
|
494
|
+
if (!payload.log || typeof payload.log !== 'object' || typeof payload.log.entry !== 'string') {
|
|
495
|
+
errs.push('payload.log, when present, must be { entry: string }');
|
|
496
|
+
}
|
|
497
|
+
}
|
|
400
498
|
if (payload.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(payload.date))) {
|
|
401
499
|
errs.push('payload.date, when present, must be YYYY-MM-DD');
|
|
402
500
|
}
|
|
@@ -419,6 +517,14 @@ function runMarkSessionClosed(args) {
|
|
|
419
517
|
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
420
518
|
process.exit(1);
|
|
421
519
|
}
|
|
520
|
+
// --project=<slug> on --mark is ATTRIBUTION ONLY (the marker's `project` field).
|
|
521
|
+
// The gate stays GLOBAL — never narrowed — because a marker that narrowed its
|
|
522
|
+
// gate could attest compact-ready while PreCompact re-checks all of today's
|
|
523
|
+
// projects and stays red (the marker == compact-ready invariant, ADR 0047 /
|
|
524
|
+
// codex design finding 1/2). Validate the attribution slug exists as a
|
|
525
|
+
// directory, exactly as --check does, but only when it is actually used (a
|
|
526
|
+
// --log-only mark attributes to no project, so --project is moot there).
|
|
527
|
+
if (args.project && !args.logOnly) requireProjectDir(args, args.project);
|
|
422
528
|
// ADR 0047: the per-session marker is the THIRD session-close completion
|
|
423
529
|
// signal (after the PreCompact gate and `--check-session-close`). It must use
|
|
424
530
|
// the SAME gate that governs /compact — precompactGateStatus — so the marker
|
|
@@ -484,8 +590,12 @@ function runMarkSessionClosed(args) {
|
|
|
484
590
|
console.log(args.json ? JSON.stringify(result, null, 2) : `✗ ${reason}`);
|
|
485
591
|
process.exit(1);
|
|
486
592
|
}
|
|
593
|
+
// --project attributes the marker to that slug (gate stayed global); falls back
|
|
594
|
+
// to the gate's resolved primary. log-only marks attribute to no project. Used
|
|
595
|
+
// for the marker, the JSON result, and the success message so all three agree.
|
|
596
|
+
const markerProject = !args.logOnly && args.project ? args.project : status.project;
|
|
487
597
|
writeSessionClosedMarker(args.hypoDir, args.sessionId, {
|
|
488
|
-
project:
|
|
598
|
+
project: markerProject,
|
|
489
599
|
...(args.logOnly ? { scope: 'log-only' } : {}),
|
|
490
600
|
});
|
|
491
601
|
// Marker writer swallows IO errors (best-effort, see hypo-shared.mjs). Verify
|
|
@@ -504,7 +614,7 @@ function runMarkSessionClosed(args) {
|
|
|
504
614
|
const result = {
|
|
505
615
|
ok: true,
|
|
506
616
|
session_id: args.sessionId,
|
|
507
|
-
project:
|
|
617
|
+
project: markerProject,
|
|
508
618
|
scope: args.logOnly ? 'log-only' : 'project',
|
|
509
619
|
date: status.dates[0],
|
|
510
620
|
notices: gate.notices,
|
|
@@ -520,7 +630,7 @@ function runMarkSessionClosed(args) {
|
|
|
520
630
|
console.log(
|
|
521
631
|
args.logOnly
|
|
522
632
|
? `✓ session-closed marker written (session_id: ${args.sessionId}, scope: log-only — no project attribution).`
|
|
523
|
-
: `✓ session-closed marker written (session_id: ${args.sessionId}, project: ${
|
|
633
|
+
: `✓ session-closed marker written (session_id: ${args.sessionId}, project: ${markerProject}).`,
|
|
524
634
|
);
|
|
525
635
|
if (gate.driftTargets.length > 0) {
|
|
526
636
|
console.log(
|
|
@@ -585,20 +695,48 @@ function applySessionClose(args) {
|
|
|
585
695
|
process.exit(1);
|
|
586
696
|
}
|
|
587
697
|
|
|
588
|
-
// Resolve project:
|
|
589
|
-
//
|
|
590
|
-
//
|
|
698
|
+
// Resolve project: payload.project is REQUIRED (B-3, close-gate-hardening). The
|
|
699
|
+
// old recency fallback (payload.project || probe.project) could, on a same-date
|
|
700
|
+
// root-hot.md tie, resolve a DIFFERENT project than the one the payload's files
|
|
701
|
+
// belong to — apply would then write the close into the wrong project (silent
|
|
702
|
+
// data loss). Validate fail-fast, BEFORE the probe is consulted:
|
|
703
|
+
// - missing → no target to write; abort rather than infer.
|
|
704
|
+
// - invalid name → reject (non-string, wrong charset, or dot-only) BEFORE the
|
|
705
|
+
// existsSync(join(...)) path build, so a `../`-style value
|
|
706
|
+
// never reaches a path builder (traversal guard — order is
|
|
707
|
+
// the guard). isValidProjectName is SHARED with createProject
|
|
708
|
+
// so apply accepts exactly the namespace the repo can
|
|
709
|
+
// scaffold (A-Za-z0-9._-, single segment) — no narrower.
|
|
710
|
+
// - non-existent → projects/<slug>/ absent; abort rather than create.
|
|
711
|
+
// A payload.project that merely DIFFERS from the inferred active project is NOT an
|
|
712
|
+
// error — it is surfaced as a stderr note below and the close proceeds.
|
|
713
|
+
if (payload.project === undefined || payload.project === null) {
|
|
714
|
+
const msg = 'payload.project is required (apply must not infer the close target project)';
|
|
715
|
+
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
716
|
+
process.exit(1);
|
|
717
|
+
}
|
|
718
|
+
if (!isValidProjectName(payload.project)) {
|
|
719
|
+
const msg = `payload.project ${JSON.stringify(payload.project)} is not a valid project name (single segment, charset A-Za-z0-9._-, ≥1 alnum, not "."/"..")`;
|
|
720
|
+
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
721
|
+
process.exit(1);
|
|
722
|
+
}
|
|
723
|
+
// existsSync alone is not enough: a regular FILE at projects/<slug> would pass,
|
|
724
|
+
// then apply would build child paths under it and fail with an unstructured
|
|
725
|
+
// filesystem error (codex re-review). Require it to be a directory.
|
|
726
|
+
const projectDir = join(args.hypoDir, 'projects', payload.project);
|
|
727
|
+
if (!existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
|
|
728
|
+
const msg = `payload.project "${payload.project}" does not exist as a directory (no projects/${payload.project}/ directory)`;
|
|
729
|
+
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
730
|
+
process.exit(1);
|
|
731
|
+
}
|
|
732
|
+
const project = payload.project;
|
|
733
|
+
// probe (the recency-inferred active project) is now consulted ONLY to surface a
|
|
734
|
+
// divergence note — never to resolve the target. Computed AFTER validation so a
|
|
735
|
+
// malformed/missing payload.project fails fast without a pointer-table read.
|
|
591
736
|
// Resolved BEFORE preflight because preflight needs overwrite-target paths
|
|
592
737
|
// (which require the project slug) to filter out errors in files this apply
|
|
593
738
|
// is about to replace — see the filter rationale below.
|
|
594
739
|
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
740
|
// The freshness verification below (and at the post-apply check) already honors
|
|
603
741
|
// payload.project — `project` wins over the inferred active project, and the
|
|
604
742
|
// post-apply sessionCloseFileStatus call passes it as projectOverride. But when the
|
|
@@ -606,7 +744,7 @@ function applySessionClose(args) {
|
|
|
606
744
|
// (probe.project), that divergence used to be silent, so an operator couldn't tell
|
|
607
745
|
// which project the close actually verified. Surface it on stderr (the stdout JSON
|
|
608
746
|
// contract is untouched) so the verified/closed project is always explicit.
|
|
609
|
-
if (
|
|
747
|
+
if (probe.project && probe.project !== payload.project) {
|
|
610
748
|
process.stderr.write(
|
|
611
749
|
`note: payload.project="${payload.project}" differs from the inferred active ` +
|
|
612
750
|
`project "${probe.project}"; verifying and closing "${payload.project}".\n`,
|
|
@@ -614,6 +752,23 @@ function applySessionClose(args) {
|
|
|
614
752
|
}
|
|
615
753
|
const date = payload.date || todayLocal();
|
|
616
754
|
|
|
755
|
+
// B-1 derive precondition: when `log` is omitted, apply reconstructs the root
|
|
756
|
+
// log.md entry from THIS close's sessionLog heading. If sessionLog.entry has no
|
|
757
|
+
// `## [<date>] …` heading there is nothing to derive — and on a same-day SECOND
|
|
758
|
+
// close the date-level freshness verifier would still pass on the earlier
|
|
759
|
+
// close's entry, so the no-write would slip through as ok:true. Fail loud here,
|
|
760
|
+
// before any writes (codex pre-commit review).
|
|
761
|
+
if (
|
|
762
|
+
!payload.log &&
|
|
763
|
+
!new RegExp(`^#{1,6} \\[${date}\\]`, 'm').test(payload.sessionLog.entry || '')
|
|
764
|
+
) {
|
|
765
|
+
const msg =
|
|
766
|
+
`payload.sessionLog.entry has no "## [${date}] …" heading to derive the log.md ` +
|
|
767
|
+
`entry from. Give it a dated heading, or supply payload.log explicitly.`;
|
|
768
|
+
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
769
|
+
process.exit(1);
|
|
770
|
+
}
|
|
771
|
+
|
|
617
772
|
// Preflight: lint the wiki BEFORE writing any payload bytes. If lint
|
|
618
773
|
// has blockers (errors) in files this apply WON'T overwrite, the wiki is in
|
|
619
774
|
// a degraded state and apply would mask the root cause — abort fail-fast.
|
|
@@ -776,13 +931,43 @@ function applySessionClose(args) {
|
|
|
776
931
|
}
|
|
777
932
|
}
|
|
778
933
|
|
|
779
|
-
|
|
934
|
+
// log.md: `payload.log` is OPTIONAL (B-1). When the caller supplies it, keep
|
|
935
|
+
// the explicit appendIfAbsent path (backward-compat: a custom log line, with
|
|
936
|
+
// the same idempotent dedup). When it is ABSENT, the root log.md entry is a
|
|
937
|
+
// DERIVABLE artifact: reconstruct the canonical `## [date] session | <project>`
|
|
938
|
+
// line directly from THIS close's session-log heading (`payload.sessionLog`),
|
|
939
|
+
// not by re-reading the session-log files. Deriving from the payload is what
|
|
940
|
+
// makes the per-close entry exact: a same-day second close lands its distinct
|
|
941
|
+
// heading, and a hybrid daily/monthly session-log split can't hide it (apply
|
|
942
|
+
// never reads those files for this). The global scan-based deriveRootLogEntries
|
|
943
|
+
// (the Stop hook) still backfills OTHER projects; calling it here would either
|
|
944
|
+
// miss the current entry (single-candidate read) or, with a loosened guard,
|
|
945
|
+
// append onto a deliberately custom payload.log (codex pre-commit review). The
|
|
946
|
+
// two payload paths are mutually exclusive: deriving on top of a present-but-
|
|
947
|
+
// malformed payload.log would mask it and weaken the verifier's fail-loud.
|
|
948
|
+
if (payload.log) {
|
|
780
949
|
const wrote = appendIfAbsent(
|
|
781
950
|
join(args.hypoDir, 'log.md'),
|
|
782
951
|
payload.log.entry,
|
|
783
952
|
entryAlreadyPresent(payload.log.entry),
|
|
784
953
|
);
|
|
785
954
|
(wrote ? applied : skipped).push('log (log.md)');
|
|
955
|
+
} else {
|
|
956
|
+
// matchAll (not exec) mirrors deriveRootLogEntries: a payload that carried
|
|
957
|
+
// more than one dated heading derives one canonical line each, symmetric with
|
|
958
|
+
// the global path. Exact-line dedup on the heading keeps a second apply (or a
|
|
959
|
+
// titleless vs titled same-day pair) from duplicating.
|
|
960
|
+
const logFull = join(args.hypoDir, 'log.md');
|
|
961
|
+
const headingRe = new RegExp(`^#{1,6} \\[${date}\\]\\s*(.*)$`, 'gm');
|
|
962
|
+
let wroteAny = false;
|
|
963
|
+
for (const m of (payload.sessionLog.entry || '').matchAll(headingRe)) {
|
|
964
|
+
const { heading, block } = rootLogEntry(project, date, m[1]);
|
|
965
|
+
const wrote = appendIfAbsent(logFull, block, (c) =>
|
|
966
|
+
(c || '').split(/\r?\n/).includes(heading),
|
|
967
|
+
);
|
|
968
|
+
wroteAny = wroteAny || wrote;
|
|
969
|
+
}
|
|
970
|
+
(wroteAny ? applied : skipped).push('log (log.md, derived)');
|
|
786
971
|
}
|
|
787
972
|
|
|
788
973
|
// Same-date-tie fix: verify against the SAME project this apply just wrote
|
|
@@ -792,6 +977,29 @@ function applySessionClose(args) {
|
|
|
792
977
|
// a completed close (the 2026-06-09 security-ops-kb incident).
|
|
793
978
|
const verification = sessionCloseFileStatus(args.hypoDir, { projectOverride: project });
|
|
794
979
|
|
|
980
|
+
// B-4 auto-register: lift unknown (non-forbidden) tags surfaced by the PREFLIGHT
|
|
981
|
+
// lint into SCHEMA.md's `### Pending` section so the post-apply lint sees them as
|
|
982
|
+
// known and the close never stalls on a vocabulary gap. The W10 id is hidden in
|
|
983
|
+
// non-strict --json output (lint.mjs toOut), so the unknown-tag warns are matched
|
|
984
|
+
// and the tag extracted from the message string itself — kept in lockstep with
|
|
985
|
+
// lint.mjs's W10 emit (a copy-edit there breaks this; the close-path round-trip
|
|
986
|
+
// test guards it). Forbidden patterns stay hard errors and are filtered out.
|
|
987
|
+
// SCOPE (eventual consistency, intended): this registers PRE-EXISTING wiki debt
|
|
988
|
+
// visible at preflight, NOT a novel tag this very close's payload introduces —
|
|
989
|
+
// that one would surface only at post-apply and lands on the NEXT close. The
|
|
990
|
+
// contract is "must not stall", which warns (not errors) already satisfy; the
|
|
991
|
+
// registration just keeps the vocabulary catching up.
|
|
992
|
+
// The capture is anchored on the FULL message suffix (not `[^"]+`) so a tag that
|
|
993
|
+
// itself contains a `"` — non-forbidden, so reachable — is captured whole rather
|
|
994
|
+
// than truncated at its first quote (codex stage-2 CONCERN).
|
|
995
|
+
const unknownTagRe = /^Unknown tag: "(.+)" \(not in SCHEMA\.md Tag Vocabulary\)/;
|
|
996
|
+
const pendingTags = [];
|
|
997
|
+
for (const w of preflightLint.warns || []) {
|
|
998
|
+
const m = unknownTagRe.exec(w.message || '');
|
|
999
|
+
if (m && !checkForbidden(m[1])) pendingTags.push(m[1]);
|
|
1000
|
+
}
|
|
1001
|
+
if (pendingTags.length > 0) appendPendingTags(args.hypoDir, pendingTags);
|
|
1002
|
+
|
|
795
1003
|
// Post-apply lint: payload may have introduced a malformed body or
|
|
796
1004
|
// bad frontmatter. Surface as a distinct `stage` so caller can tell "lint
|
|
797
1005
|
// broke" apart from "frontmatter stale". This runs even if the freshness gate
|
|
@@ -919,8 +1127,36 @@ function applySessionClose(args) {
|
|
|
919
1127
|
for (const a of applied) console.log(` ✓ wrote ${a}`);
|
|
920
1128
|
for (const s of skipped) console.log(` · skipped ${s} (already current)`);
|
|
921
1129
|
if (ok) {
|
|
922
|
-
|
|
923
|
-
|
|
1130
|
+
// When the marker was withheld, qualify the success line so a reader scanning
|
|
1131
|
+
// stdout alone cannot mistake "verified" for "fully closed". markerSkipReason
|
|
1132
|
+
// is non-null exactly when args.sessionId is set and the marker did not land.
|
|
1133
|
+
if (markerSkipReason) {
|
|
1134
|
+
console.log(
|
|
1135
|
+
'\n✓ session-close files verified (all 5 mandatory files fresh, lint clean).' +
|
|
1136
|
+
'\n session NOT fully closed: the Stop-chain marker was not written (see warning below).',
|
|
1137
|
+
);
|
|
1138
|
+
} else {
|
|
1139
|
+
console.log('\n✓ session-close verified — all 5 mandatory files fresh, lint clean.');
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
// When ok:true but the session-close marker was NOT written, the Stop-chain
|
|
1143
|
+
// still sees an open session and will re-prompt at the next Stop. Surface this
|
|
1144
|
+
// loudly so neither the human nor a skill-following model reads "ok:true" as
|
|
1145
|
+
// "session fully closed". Gate on markerSkipReason (non-null exactly when
|
|
1146
|
+
// args.sessionId is present and the marker was withheld).
|
|
1147
|
+
if (markerSkipReason) {
|
|
1148
|
+
process.stderr.write(
|
|
1149
|
+
`\n⚠️ session-close marker NOT written (reason: ${markerSkipReason})\n` +
|
|
1150
|
+
` The 5 mandatory files were applied and verified (ok:true), but the\n` +
|
|
1151
|
+
` per-session Stop-chain marker was withheld. The session is NOT fully\n` +
|
|
1152
|
+
` closed: the Stop hook will re-prompt until the marker is present.\n` +
|
|
1153
|
+
` To fix: re-run with the correct main-conversation --session-id (NOT\n` +
|
|
1154
|
+
` a background task or Agent UUID from a /tmp/... path).\n` +
|
|
1155
|
+
` Example: crystallize.mjs --apply-session-close --payload=<path>\n` +
|
|
1156
|
+
` --session-id=<main-conversation-id> --hypo-dir=<path>\n`,
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
if (!ok) {
|
|
924
1160
|
if (!verification.ok) {
|
|
925
1161
|
const bad = [
|
|
926
1162
|
...verification.missing.map((f) => `${f} (missing)`),
|