hypomnema 1.6.2 → 1.7.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 +39 -14
- package/README.md +39 -14
- package/commands/capture.md +8 -6
- package/commands/crystallize.md +39 -20
- package/docs/ARCHITECTURE.md +49 -14
- package/docs/CONTRIBUTING.md +31 -29
- package/hooks/base-store.mjs +265 -0
- package/hooks/hooks.json +7 -1
- package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
- package/hooks/hypo-auto-stage.mjs +30 -0
- package/hooks/hypo-cwd-change.mjs +31 -2
- package/hooks/hypo-file-watch.mjs +21 -2
- package/hooks/hypo-first-prompt.mjs +19 -3
- package/hooks/hypo-lookup.mjs +86 -29
- package/hooks/hypo-personal-check.mjs +24 -3
- package/hooks/hypo-session-record.mjs +2 -3
- package/hooks/hypo-session-start.mjs +89 -7
- package/hooks/hypo-shared.mjs +904 -128
- package/hooks/proposal-store.mjs +513 -0
- package/package.json +40 -14
- package/scripts/capture.mjs +556 -37
- package/scripts/crystallize.mjs +639 -108
- package/scripts/doctor.mjs +304 -9
- package/scripts/feedback-sync.mjs +515 -44
- package/scripts/graph.mjs +9 -2
- package/scripts/init.mjs +230 -34
- package/scripts/lib/extensions.mjs +656 -1
- package/scripts/lib/hypo-ignore.mjs +54 -6
- package/scripts/lib/hypo-root.mjs +56 -6
- package/scripts/lib/page-usage.mjs +15 -2
- package/scripts/lib/pkg-json.mjs +40 -0
- package/scripts/lib/plugin-detect.mjs +96 -6
- package/scripts/lib/wd-match.mjs +23 -5
- package/scripts/lib/wikilink.mjs +32 -6
- package/scripts/lint.mjs +20 -4
- package/scripts/proposal.mjs +1032 -0
- package/scripts/query.mjs +25 -4
- package/scripts/resume.mjs +34 -12
- package/scripts/stats.mjs +28 -8
- package/scripts/uninstall.mjs +141 -6
- package/scripts/upgrade.mjs +197 -15
- package/skills/crystallize/SKILL.md +44 -7
- package/skills/debate/SKILL.md +88 -0
- package/skills/debate/references/orchestration-patterns.md +83 -0
- package/templates/.hyposcanignore +10 -0
- package/templates/SCHEMA.md +12 -0
- package/templates/gitignore +5 -0
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +6 -0
- package/scripts/.gitkeep +0 -0
- package/scripts/check-bilingual.mjs +0 -153
- package/scripts/check-readme-version.mjs +0 -126
- package/scripts/check-tracker-ids.mjs +0 -426
- package/scripts/check-versions.mjs +0 -171
- package/scripts/install-git-hooks.mjs +0 -293
- package/scripts/lib/changelog-classify.mjs +0 -216
- package/scripts/lib/check-bilingual.mjs +0 -244
- package/scripts/lib/check-tracker-ids.mjs +0 -217
- package/scripts/lib/pre-commit-format.mjs +0 -251
- package/scripts/pre-commit-format.mjs +0 -198
package/scripts/crystallize.mjs
CHANGED
|
@@ -75,7 +75,6 @@ import {
|
|
|
75
75
|
realpathSync,
|
|
76
76
|
} from 'fs';
|
|
77
77
|
import { join, dirname } from 'path';
|
|
78
|
-
import { hostname } from 'os';
|
|
79
78
|
import { spawnSync } from 'child_process';
|
|
80
79
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
81
80
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
@@ -102,7 +101,13 @@ import {
|
|
|
102
101
|
resolveTranscriptBySessionId,
|
|
103
102
|
hasUserCloseSignal,
|
|
104
103
|
commitWikiChanges,
|
|
104
|
+
currentDevice,
|
|
105
|
+
scopeVisible,
|
|
106
|
+
readVisibilityScope,
|
|
107
|
+
withFileLock,
|
|
105
108
|
} from '../hooks/hypo-shared.mjs';
|
|
109
|
+
import { hashContent, readBaseEntry, advanceBase } from '../hooks/base-store.mjs';
|
|
110
|
+
import { writeProposal } from '../hooks/proposal-store.mjs';
|
|
106
111
|
|
|
107
112
|
// This script's own absolute path. Used to print copy-pasteable recovery
|
|
108
113
|
// commands as `node <SELF_SCRIPT> ...` rather than a bare `crystallize` bin,
|
|
@@ -169,6 +174,7 @@ function parseArgs(argv) {
|
|
|
169
174
|
else if (arg.startsWith('--session-id=')) args.sessionId = arg.slice(13);
|
|
170
175
|
else if (arg.startsWith('--payload=')) args.payload = arg.slice(10);
|
|
171
176
|
else if (arg.startsWith('--transcript-path=')) args.transcriptPath = expandHome(arg.slice(18));
|
|
177
|
+
else if (arg.startsWith('--session-cwd=')) args.sessionCwd = expandHome(arg.slice(14));
|
|
172
178
|
else if (arg.startsWith('--project=')) args.project = arg.slice(10);
|
|
173
179
|
else if (arg === '--force') args.force = true;
|
|
174
180
|
else if (arg === '--json') args.json = true;
|
|
@@ -240,13 +246,31 @@ function runSessionCloseCheck(args) {
|
|
|
240
246
|
// would re-add that project's files to the lint scope and re-block the scoped
|
|
241
247
|
// check, defeating the point. The global (no --project) check keeps widening.
|
|
242
248
|
if (args.project) requireProjectDir(args, args.project);
|
|
249
|
+
// Resolve the transcript from --session-id when --transcript-path was not given,
|
|
250
|
+
// exactly as --mark and the apply auto-marker already do. The transcript is what
|
|
251
|
+
// attributes the close as well as widening the lint scope, and PreCompact
|
|
252
|
+
// always has one from its hook payload. A check without it would compute an EMPTY
|
|
253
|
+
// close scope, fall back to the global block, and report RED for debt that /compact
|
|
254
|
+
// demotes. Checklist step 14 tells the model to trust this command, so an over-red
|
|
255
|
+
// check is as harmful as an over-green one.
|
|
256
|
+
const checkTranscript =
|
|
257
|
+
args.transcriptPath ||
|
|
258
|
+
(args.sessionId ? resolveTranscriptBySessionId(args.sessionId) : null) ||
|
|
259
|
+
null;
|
|
243
260
|
const status = precompactGateStatus(args.hypoDir, {
|
|
244
261
|
...(args.project
|
|
245
262
|
? { projectOverride: args.project }
|
|
246
|
-
:
|
|
247
|
-
? { transcriptPath:
|
|
263
|
+
: checkTranscript
|
|
264
|
+
? { transcriptPath: checkTranscript }
|
|
248
265
|
: {}),
|
|
249
266
|
...(args.sessionId ? { sessionId: args.sessionId } : {}),
|
|
267
|
+
// The P2 cwd close check (session-close attribution) applies to the GLOBAL gate only. Under
|
|
268
|
+
// --project the check is a project-scoped diagnostic, so a cwd blocker for a
|
|
269
|
+
// DIFFERENT project would muddy that scoped answer — pass sessionCwd only for
|
|
270
|
+
// the global form. process.cwd() is deliberately NOT a fallback: a check run
|
|
271
|
+
// after `cd ~/hypomnema` would map to the vault, not the session (authoritative
|
|
272
|
+
// enforcement lives in the PreCompact/Stop hooks, which carry payload.cwd).
|
|
273
|
+
...(args.sessionCwd && !args.project ? { sessionCwd: args.sessionCwd } : {}),
|
|
250
274
|
});
|
|
251
275
|
const close = status.close;
|
|
252
276
|
|
|
@@ -281,11 +305,17 @@ function runSessionCloseCheck(args) {
|
|
|
281
305
|
JSON.stringify(
|
|
282
306
|
{
|
|
283
307
|
ok: status.ok,
|
|
284
|
-
// flat close fields preserved for back-compat with prior readers
|
|
308
|
+
// flat close fields preserved for back-compat with prior readers. They now
|
|
309
|
+
// describe what BLOCKS: a foreign project's incomplete close is demoted out
|
|
310
|
+
// of stale/missing into close_debt, so a reader that treats a
|
|
311
|
+
// non-empty `missing` as failure still agrees with `ok` instead of
|
|
312
|
+
// contradicting it.
|
|
285
313
|
project: close.project,
|
|
286
314
|
dates: close.dates,
|
|
287
315
|
stale: close.stale,
|
|
288
316
|
missing: close.missing,
|
|
317
|
+
...(close.debt?.length ? { close_debt: close.debt } : {}),
|
|
318
|
+
...(close.scope ? { close_scope: close.scope } : {}),
|
|
289
319
|
blockers: status.blockers,
|
|
290
320
|
notices: status.notices,
|
|
291
321
|
skipped: status.skipped,
|
|
@@ -411,6 +441,14 @@ function readPayload(source) {
|
|
|
411
441
|
}
|
|
412
442
|
}
|
|
413
443
|
|
|
444
|
+
// How long an append waits for its per-target lock before withholding to the
|
|
445
|
+
// proposal-pending gate (see withFileLock). A withheld append blocks the close but
|
|
446
|
+
// is NOT parked as a proposal artifact — the next close re-appends. Default 5s is
|
|
447
|
+
// generous for a real close;
|
|
448
|
+
// the env override exists ONLY so tests can force a fast timeout instead of
|
|
449
|
+
// spinning the full 5s. Not a documented production knob.
|
|
450
|
+
const APPEND_LOCK_TIMEOUT_MS = Number(process.env.HYPO_APPEND_LOCK_TIMEOUT_MS) || 5000;
|
|
451
|
+
|
|
414
452
|
/** Atomic write via tmp+rename. `<path>.<pid>.<rand>.tmp` so concurrent helpers
|
|
415
453
|
* don't fight over the same shared `<path>.tmp` slot. */
|
|
416
454
|
function atomicWrite(path, content) {
|
|
@@ -420,17 +458,51 @@ function atomicWrite(path, content) {
|
|
|
420
458
|
renameSync(tmp, path);
|
|
421
459
|
}
|
|
422
460
|
|
|
423
|
-
/**
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
461
|
+
/**
|
|
462
|
+
* Read a target's current bytes, distinguishing "absent" from "unreadable" the
|
|
463
|
+
* same way base-store's hashFile does. The overwrite guard needs all three
|
|
464
|
+
* answers: content to compare, `null` to know creating is safe, `undefined` to
|
|
465
|
+
* refuse to guess.
|
|
466
|
+
* @returns {string|null|undefined} bytes, `null` if absent, `undefined` if unreadable
|
|
467
|
+
*/
|
|
468
|
+
function readTarget(path) {
|
|
469
|
+
if (!existsSync(path)) return null;
|
|
470
|
+
try {
|
|
471
|
+
return readFileSync(path, 'utf-8');
|
|
472
|
+
} catch {
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Has the target drifted away from what this session observed at start?
|
|
479
|
+
*
|
|
480
|
+
* Branches on base-store's `state` discriminator, never on the truthiness of
|
|
481
|
+
* `hash`: 'absent' and 'unknown' both carry `hash: null`, and collapsing them
|
|
482
|
+
* would read never-observed as safe-to-write, defeating the guard entirely.
|
|
483
|
+
*
|
|
484
|
+
* @param {{state: 'hash'|'absent'|'unknown', hash: string|null}} entry
|
|
485
|
+
* @param {string|null|undefined} disk current bytes / absent / unreadable
|
|
486
|
+
* @returns {string|null} a conflict reason, or null when this session may write
|
|
487
|
+
*/
|
|
488
|
+
function overwriteConflictReason(entry, disk) {
|
|
489
|
+
// Cannot read what we are about to replace: fail safe, never assume unchanged.
|
|
490
|
+
if (disk === undefined) return 'target-unreadable';
|
|
491
|
+
switch (entry.state) {
|
|
492
|
+
case 'unknown':
|
|
493
|
+
// No snapshot for this (session, target). Someone else's edits could be
|
|
494
|
+
// sitting on disk and we would have no way to tell.
|
|
495
|
+
return 'base-unknown';
|
|
496
|
+
case 'absent':
|
|
497
|
+
// We observed no file. Creating it is safe; finding one now means another
|
|
498
|
+
// writer got there first.
|
|
499
|
+
return disk === null ? null : 'base-absent-target-exists';
|
|
500
|
+
case 'hash':
|
|
501
|
+
if (disk === null) return 'base-hash-target-missing';
|
|
502
|
+
return hashContent(disk) === entry.hash ? null : 'base-mismatch';
|
|
503
|
+
default:
|
|
504
|
+
return 'base-unknown';
|
|
431
505
|
}
|
|
432
|
-
atomicWrite(path, content);
|
|
433
|
-
return true;
|
|
434
506
|
}
|
|
435
507
|
|
|
436
508
|
/**
|
|
@@ -444,8 +516,16 @@ function appendIfAbsent(path, entry, alreadyPresent) {
|
|
|
444
516
|
if (existsSync(path)) {
|
|
445
517
|
try {
|
|
446
518
|
content = readFileSync(path, 'utf-8');
|
|
447
|
-
} catch {
|
|
448
|
-
|
|
519
|
+
} catch (err) {
|
|
520
|
+
// ENOENT here is a narrow existsSync-then-readFileSync race (the file
|
|
521
|
+
// vanished between the two calls) — content='' is safe, we would create
|
|
522
|
+
// it fresh anyway. Anything else (EACCES/EISDIR/...) is a PERSISTENT
|
|
523
|
+
// read failure: retrying (the caller's lock-timeout conflict path) would
|
|
524
|
+
// never fix it, and swallowing it here would fall through to the
|
|
525
|
+
// atomicWrite below and silently replace the existing file's bytes with
|
|
526
|
+
// just this one entry — a data-loss overwrite. So rethrow and let the
|
|
527
|
+
// withFileLock/call-site catch hard-fail the close instead.
|
|
528
|
+
if (err?.code !== 'ENOENT') throw err;
|
|
449
529
|
}
|
|
450
530
|
}
|
|
451
531
|
if (alreadyPresent(content)) return false;
|
|
@@ -510,6 +590,13 @@ function validatePayloadShape(payload) {
|
|
|
510
590
|
if (payload.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(payload.date))) {
|
|
511
591
|
errs.push('payload.date, when present, must be YYYY-MM-DD');
|
|
512
592
|
}
|
|
593
|
+
if (
|
|
594
|
+
payload.sessionId !== undefined &&
|
|
595
|
+
payload.sessionId !== null &&
|
|
596
|
+
typeof payload.sessionId !== 'string'
|
|
597
|
+
) {
|
|
598
|
+
errs.push('payload.sessionId, when present, must be a string');
|
|
599
|
+
}
|
|
513
600
|
return errs;
|
|
514
601
|
}
|
|
515
602
|
|
|
@@ -529,11 +616,14 @@ function runMarkSessionClosed(args) {
|
|
|
529
616
|
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
530
617
|
process.exit(1);
|
|
531
618
|
}
|
|
532
|
-
// --project=<slug> on --mark
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
619
|
+
// --project=<slug> on --mark names the project THIS session closed. It sets the
|
|
620
|
+
// marker's `project` field AND enters the close scope, so an incomplete close in a
|
|
621
|
+
// project this session did not touch is demoted to a notice instead of refusing the
|
|
622
|
+
// marker. It is NOT a gate narrow in the old sense: the marker records the same slug
|
|
623
|
+
// it scoped by, and PreCompact re-derives its own scope FROM that marker — so the
|
|
624
|
+
// two can never disagree, which is what the earlier "attribution only, gate stays
|
|
625
|
+
// global" rule was protecting (it assumed PreCompact stayed global; it no longer
|
|
626
|
+
// does). Everything else the gate checks stays global. Validate the slug exists as a
|
|
537
627
|
// directory, exactly as --check does, but only when it is actually used (a
|
|
538
628
|
// --log-only mark attributes to no project, so --project is moot there).
|
|
539
629
|
if (args.project && !args.logOnly) requireProjectDir(args, args.project);
|
|
@@ -558,8 +648,12 @@ function runMarkSessionClosed(args) {
|
|
|
558
648
|
// source for the user-close hard gate below.
|
|
559
649
|
const closeTranscript = resolveTranscriptBySessionId(args.sessionId);
|
|
560
650
|
const gate = precompactGateStatus(args.hypoDir, {
|
|
651
|
+
...(args.project && !args.logOnly ? { closeScope: [args.project] } : {}),
|
|
561
652
|
...(closeTranscript ? { transcriptPath: closeTranscript } : {}),
|
|
562
653
|
...(args.logOnly ? { logOnly: true } : {}),
|
|
654
|
+
// P2 (session-close attribution): the marker gate must refuse to attest compact-ready while the
|
|
655
|
+
// session's cwd project has an unstarted close. logOnly exempts it in-gate.
|
|
656
|
+
...(args.sessionCwd ? { sessionCwd: args.sessionCwd } : {}),
|
|
563
657
|
});
|
|
564
658
|
const status = gate.close;
|
|
565
659
|
if (!gate.ok) {
|
|
@@ -602,12 +696,43 @@ function runMarkSessionClosed(args) {
|
|
|
602
696
|
console.log(args.json ? JSON.stringify(result, null, 2) : `✗ ${reason}`);
|
|
603
697
|
process.exit(1);
|
|
604
698
|
}
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
|
|
699
|
+
// Marker attribution comes from EVIDENCE, never from the gate's global
|
|
700
|
+
// `primary` (which is recency-derived, hypo-shared.mjs). The marker is what
|
|
701
|
+
// PreCompact later re-derives its own scope from, so attributing it to a project
|
|
702
|
+
// this session did not close hands PreCompact a scope the marker never cleared.
|
|
703
|
+
// The evidence set is the close scope — explicit --project ∪ the transcript's
|
|
704
|
+
// touched close files ∪ any prior marker for this session — all of which
|
|
705
|
+
// resolveCloseScope already unioned into status.scope. The recency primary is
|
|
706
|
+
// deliberately excluded: an empty scope means this session has no proof it closed
|
|
707
|
+
// any project (evidence-based close attribution), so we FAIL CLOSED rather than misattribute to recency.
|
|
708
|
+
const closeScope = status.scope || [];
|
|
709
|
+
const markerProjects = [
|
|
710
|
+
...new Set([...(!args.logOnly && args.project ? [args.project] : []), ...closeScope]),
|
|
711
|
+
];
|
|
712
|
+
if (!args.logOnly && markerProjects.length === 0) {
|
|
713
|
+
const err =
|
|
714
|
+
'cannot attribute this close to a project — no evidence (no --project, no transcript close-file edits, no prior marker). ' +
|
|
715
|
+
'Pass --project=<slug> for the project this session closed, or --log-only for a non-project (tooling/wiki-only) session.';
|
|
716
|
+
console.log(
|
|
717
|
+
args.json
|
|
718
|
+
? JSON.stringify(
|
|
719
|
+
{
|
|
720
|
+
ok: false,
|
|
721
|
+
session_id: args.sessionId,
|
|
722
|
+
skipReason: 'no-attribution-evidence',
|
|
723
|
+
error: err,
|
|
724
|
+
},
|
|
725
|
+
null,
|
|
726
|
+
2,
|
|
727
|
+
)
|
|
728
|
+
: `✗ ${err}`,
|
|
729
|
+
);
|
|
730
|
+
process.exit(1);
|
|
731
|
+
}
|
|
732
|
+
const markerProject = !args.logOnly && args.project ? args.project : markerProjects[0];
|
|
609
733
|
writeSessionClosedMarker(args.hypoDir, args.sessionId, {
|
|
610
734
|
project: markerProject,
|
|
735
|
+
projects: args.logOnly ? [] : markerProjects,
|
|
611
736
|
...(args.logOnly ? { scope: 'log-only' } : {}),
|
|
612
737
|
});
|
|
613
738
|
// Marker writer swallows IO errors (best-effort, see hypo-shared.mjs). Verify
|
|
@@ -714,10 +839,76 @@ export function closeResultContradiction({ ok, markerWritten, markerSkipReason }
|
|
|
714
839
|
return null;
|
|
715
840
|
}
|
|
716
841
|
|
|
842
|
+
// What the model should do when the close is refused. Deliberately does NOT name
|
|
843
|
+
// a flag: the way out of this gate cannot be an argument the model can add, or
|
|
844
|
+
// the gate is decorative. The way out is the user, which is the one input the
|
|
845
|
+
// model does not author.
|
|
846
|
+
const CLOSE_REFUSAL_HELP = [
|
|
847
|
+
'Nothing was written and nothing was committed.',
|
|
848
|
+
'',
|
|
849
|
+
'Do NOT add a bypass flag, and do NOT write the close files directly with an editor',
|
|
850
|
+
'or a shell — that is the same close without the check, and it is the thing this gate',
|
|
851
|
+
'exists to stop.',
|
|
852
|
+
'',
|
|
853
|
+
'If the user has not asked to close: do not close. Session-close is not a reward for',
|
|
854
|
+
'finishing a task, and a long session is not a close signal. Keep working, or ask ONCE',
|
|
855
|
+
'whether to wrap up, and take no for an answer.',
|
|
856
|
+
'',
|
|
857
|
+
'If the user HAS asked: pass the current main-conversation --session-id (not a',
|
|
858
|
+
'background-task or agent uuid from a /tmp path) and re-run this exact payload. The',
|
|
859
|
+
'writes are idempotent.',
|
|
860
|
+
].join('\n');
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* May this session apply a close at all?
|
|
864
|
+
*
|
|
865
|
+
* Authority comes from the transcript, because the transcript is the one input the
|
|
866
|
+
* model cannot author: the user's own words are in it, and nothing the model says
|
|
867
|
+
* counts (extractUserMessages drops injected, tool, and hook-feedback text).
|
|
868
|
+
*
|
|
869
|
+
* { ok: true }
|
|
870
|
+
* { ok: false, reason, error } reason: session-id-required | transcript-unresolved
|
|
871
|
+
* | no-user-close-signal
|
|
872
|
+
*/
|
|
873
|
+
function verifyCloseAuthority(sessionId) {
|
|
874
|
+
if (!sessionId) {
|
|
875
|
+
return {
|
|
876
|
+
ok: false,
|
|
877
|
+
reason: 'session-id-required',
|
|
878
|
+
error:
|
|
879
|
+
'session-close apply refused before any wiki write or commit: --session-id is required, ' +
|
|
880
|
+
"because the close signal is verified against that session's transcript. Omitting it does " +
|
|
881
|
+
'not skip the check, it fails it.',
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
const transcript = resolveTranscriptBySessionId(sessionId);
|
|
885
|
+
if (!transcript) {
|
|
886
|
+
return {
|
|
887
|
+
ok: false,
|
|
888
|
+
reason: 'transcript-unresolved',
|
|
889
|
+
error:
|
|
890
|
+
`session-close apply refused before any wiki write or commit: no transcript resolves for ` +
|
|
891
|
+
`session ${sessionId}. Pass the MAIN conversation's session id — a uuid taken from a ` +
|
|
892
|
+
`background-task output path or an agent thread is not it, and --transcript-path is not ` +
|
|
893
|
+
`authority here.`,
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
if (!hasUserCloseSignal(transcript)) {
|
|
897
|
+
return {
|
|
898
|
+
ok: false,
|
|
899
|
+
reason: 'no-user-close-signal',
|
|
900
|
+
error:
|
|
901
|
+
"session-close apply refused before any wiki write or commit: this session's transcript " +
|
|
902
|
+
'carries no user close signal. The user did not ask to close.',
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
return { ok: true };
|
|
906
|
+
}
|
|
907
|
+
|
|
717
908
|
function applySessionClose(args) {
|
|
718
909
|
// Option D: early-exit fires only when NO payload was supplied.
|
|
719
910
|
// Rationale: payload presence is explicit close intent and must always run
|
|
720
|
-
// the full apply path — the per-entry idempotency (
|
|
911
|
+
// the full apply path — the per-entry idempotency (overwrite's step-1 skip +
|
|
721
912
|
// exact-entry append dedup) keeps re-apply cheap without short-circuiting,
|
|
722
913
|
// and avoids silent-success when a same-day second close brings new bytes.
|
|
723
914
|
// Payload-less invocation is treated as a cheap "already complete?" probe.
|
|
@@ -748,6 +939,51 @@ function applySessionClose(args) {
|
|
|
748
939
|
// "payload is required" with the same error shape as before.
|
|
749
940
|
}
|
|
750
941
|
|
|
942
|
+
// ── close authority: BEFORE any write ──────────────────────────────────────
|
|
943
|
+
// A payload is a claim that the user asked to close. Verify it here, ahead of
|
|
944
|
+
// readPayload and every byte that follows, because everything after this point
|
|
945
|
+
// is authoritative: session-state.md and the hot files are overwritten, the
|
|
946
|
+
// session-log and log.md are appended, and hypo-auto-commit lands the lot in git
|
|
947
|
+
// on the next Stop whether or not this command commits.
|
|
948
|
+
//
|
|
949
|
+
// The gate used to stand at the END, next to the marker (planMarkerDecision).
|
|
950
|
+
// It refused the MARKER on a close the user never asked for, and let the writes
|
|
951
|
+
// and the commit through. Worse, it was opt-in BY THE CALLER: with no
|
|
952
|
+
// --session-id there was no transcript to check, so the check simply did not
|
|
953
|
+
// run, and the apply reported ok:true with no skip reason at all. The model
|
|
954
|
+
// chose whether the model got checked. A model that has already decided to close
|
|
955
|
+
// does not reach for the flag that would stop it.
|
|
956
|
+
//
|
|
957
|
+
// So: no verifiable close signal, no close. Zero bytes, no commit, exit 1.
|
|
958
|
+
//
|
|
959
|
+
// What this does and does not promise. It promises that THIS entrypoint refuses
|
|
960
|
+
// a close with no recognized transcript evidence behind it. It does not promise
|
|
961
|
+
// the model cannot write these files at all: Write and Bash remain outside this
|
|
962
|
+
// gate, and the evidence predicate itself is coarse (any close signal anywhere in
|
|
963
|
+
// the transcript counts, so a stale one from earlier in a long session still
|
|
964
|
+
// passes, and it is the model that authors the AskUserQuestion option labels the
|
|
965
|
+
// user picks from). Narrowing that (current-turn binding, revocation, consent
|
|
966
|
+
// that the model did not word) is the follow-up this ADR names; it is not a
|
|
967
|
+
// reason to leave the default open in the meantime.
|
|
968
|
+
// Only a payload-bearing call can write. A payload-less one falls through to the
|
|
969
|
+
// "payload is required" error below without touching a byte, so gating it here
|
|
970
|
+
// would just replace one refusal with a less accurate one.
|
|
971
|
+
const closeAuth = args.payload ? verifyCloseAuthority(args.sessionId) : { ok: true };
|
|
972
|
+
if (!closeAuth.ok) {
|
|
973
|
+
const out = {
|
|
974
|
+
ok: false,
|
|
975
|
+
stage: 'no-user-close-signal',
|
|
976
|
+
reason: closeAuth.reason,
|
|
977
|
+
applied: [],
|
|
978
|
+
committed: false,
|
|
979
|
+
error: closeAuth.error,
|
|
980
|
+
};
|
|
981
|
+
console.log(
|
|
982
|
+
args.json ? JSON.stringify(out, null, 2) : `✗ ${closeAuth.error}\n\n${CLOSE_REFUSAL_HELP}`,
|
|
983
|
+
);
|
|
984
|
+
process.exit(1);
|
|
985
|
+
}
|
|
986
|
+
|
|
751
987
|
let payload;
|
|
752
988
|
try {
|
|
753
989
|
payload = readPayload(args.payload);
|
|
@@ -768,6 +1004,43 @@ function applySessionClose(args) {
|
|
|
768
1004
|
process.exit(1);
|
|
769
1005
|
}
|
|
770
1006
|
|
|
1007
|
+
// Payload↔session binding (cross-session payload collision). The payload temp
|
|
1008
|
+
// file is now written to a session-scoped path (see commands/crystallize.md), so
|
|
1009
|
+
// two same-day sessions no longer share a file. This is the belt to that
|
|
1010
|
+
// suspenders: if the payload names
|
|
1011
|
+
// the session it was authored for, it must be THIS one — the --session-id whose
|
|
1012
|
+
// transcript already cleared close authority above. A mismatch means the file on
|
|
1013
|
+
// disk is not this session's close (a stray or hand-reused path handed us another
|
|
1014
|
+
// session's payload), and applying it would stamp that content with this session's
|
|
1015
|
+
// marker while the original session's record vanishes — the exact loss this
|
|
1016
|
+
// guard exists to prevent. Refuse before a byte is written.
|
|
1017
|
+
//
|
|
1018
|
+
// Absent field → fail open: older payloads predate this field, and Part 1's unique
|
|
1019
|
+
// path already prevents the collision. So the check only ever tightens; it never
|
|
1020
|
+
// rejects a close it would otherwise have allowed on a matching (or absent) id. It
|
|
1021
|
+
// is deliberately identity-based, not cwd-based: closing project B from a session
|
|
1022
|
+
// whose cwd is project A stays supported (payload.project is authoritative), so a
|
|
1023
|
+
// legitimately cross-project close is untouched.
|
|
1024
|
+
if (
|
|
1025
|
+
payload.sessionId !== undefined &&
|
|
1026
|
+
payload.sessionId !== null &&
|
|
1027
|
+
payload.sessionId !== args.sessionId
|
|
1028
|
+
) {
|
|
1029
|
+
const msg =
|
|
1030
|
+
`payload.sessionId ${JSON.stringify(payload.sessionId)} does not match --session-id ` +
|
|
1031
|
+
`${JSON.stringify(args.sessionId)}: this payload was authored for a different session, so ` +
|
|
1032
|
+
`it is not this session's close. Refusing before any write (cross-session guard).`;
|
|
1033
|
+
const out = {
|
|
1034
|
+
ok: false,
|
|
1035
|
+
stage: 'session-id-mismatch',
|
|
1036
|
+
error: msg,
|
|
1037
|
+
applied: [],
|
|
1038
|
+
committed: false,
|
|
1039
|
+
};
|
|
1040
|
+
console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${msg}`);
|
|
1041
|
+
process.exit(1);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
771
1044
|
// Resolve project: payload.project is REQUIRED (B-3, close-gate-hardening). The
|
|
772
1045
|
// old recency fallback (payload.project || probe.project) could, on a same-date
|
|
773
1046
|
// root-hot.md tie, resolve a DIFFERENT project than the one the payload's files
|
|
@@ -883,6 +1156,10 @@ function applySessionClose(args) {
|
|
|
883
1156
|
// Append targets (session-log, log.md) are NOT filtered: appending can't
|
|
884
1157
|
// repair existing corruption, so a corrupt session-log must still block.
|
|
885
1158
|
// Warns are informational (not gated) in either pass.
|
|
1159
|
+
//
|
|
1160
|
+
// The filter says "about to be replaced", and the observed-base guard can later
|
|
1161
|
+
// decline to replace one of these. Preflight runs before the guard, so it cannot
|
|
1162
|
+
// know. Harmless: post-apply lint re-scopes the same file and blocks on it there.
|
|
886
1163
|
const overwriteTargets = new Set();
|
|
887
1164
|
if (payload.sessionState) overwriteTargets.add(join('projects', project, 'session-state.md'));
|
|
888
1165
|
if (payload.projectHot) overwriteTargets.add(join('projects', project, 'hot.md'));
|
|
@@ -952,11 +1229,63 @@ function applySessionClose(args) {
|
|
|
952
1229
|
|
|
953
1230
|
const applied = [];
|
|
954
1231
|
const skipped = [];
|
|
955
|
-
|
|
1232
|
+
// Overwrite targets this apply refused to write because the page moved under
|
|
1233
|
+
// it. T6 turns these into `.cache/proposals/` artifacts; here they are already
|
|
1234
|
+
// enough to withhold the bytes and fail the close.
|
|
1235
|
+
const conflicts = [];
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* Replace a whole page, guarded by the base this session observed at start.
|
|
1239
|
+
*
|
|
1240
|
+
* The step order is load-bearing, not stylistic:
|
|
1241
|
+
*
|
|
1242
|
+
* 1. idempotent skip (disk already equals the payload)
|
|
1243
|
+
* 2. conflict (base unknown, or disk drifted away from base)
|
|
1244
|
+
* 3. direct write, then advance the base
|
|
1245
|
+
*
|
|
1246
|
+
* Step 1 must come first for two reasons. It keeps every existing
|
|
1247
|
+
* `--apply-session-close --session-id` test green (they read the payload
|
|
1248
|
+
* straight off disk, so they land here before any base lookup). And it breaks
|
|
1249
|
+
* the apply-then-reclose loop: once a human applies proposal P, disk == proposed
|
|
1250
|
+
* == payload.content, so the next close skips before it can re-raise a conflict.
|
|
1251
|
+
*
|
|
1252
|
+
* There is no caller here without a `--session-id`. verifyCloseAuthority refuses
|
|
1253
|
+
* that at the door, before a byte is written, so a session id is always present
|
|
1254
|
+
* by the time this runs and the base lookup always has something to look up.
|
|
1255
|
+
*/
|
|
956
1256
|
const overwrite = (key, relPath, field) => {
|
|
957
1257
|
if (!field || typeof field.content !== 'string') return; // optional / absent
|
|
958
|
-
const
|
|
959
|
-
|
|
1258
|
+
const full = join(args.hypoDir, relPath);
|
|
1259
|
+
const disk = readTarget(full);
|
|
1260
|
+
|
|
1261
|
+
// (1) idempotent skip — preserves writeIfChanged's contract
|
|
1262
|
+
if (disk === field.content) {
|
|
1263
|
+
skipped.push(`${key} (${relPath})`);
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// (2) conflict, only where a session context makes a base observable
|
|
1268
|
+
if (args.sessionId) {
|
|
1269
|
+
const entry = readBaseEntry(args.hypoDir, args.sessionId, relPath);
|
|
1270
|
+
const reason = overwriteConflictReason(entry, disk);
|
|
1271
|
+
if (reason) {
|
|
1272
|
+
conflicts.push({
|
|
1273
|
+
key,
|
|
1274
|
+
target: relPath,
|
|
1275
|
+
reason,
|
|
1276
|
+
baseHash: entry.hash,
|
|
1277
|
+
currentHash: typeof disk === 'string' ? hashContent(disk) : null,
|
|
1278
|
+
proposedContent: field.content,
|
|
1279
|
+
});
|
|
1280
|
+
return; // target bytes untouched
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// (3) write, then the content we just wrote IS this session's new base
|
|
1285
|
+
atomicWrite(full, field.content);
|
|
1286
|
+
if (args.sessionId)
|
|
1287
|
+
advanceBase(args.hypoDir, args.sessionId, relPath, hashContent(field.content));
|
|
1288
|
+
applied.push(`${key} (${relPath})`);
|
|
960
1289
|
};
|
|
961
1290
|
|
|
962
1291
|
overwrite('sessionState', join('projects', project, 'session-state.md'), payload.sessionState);
|
|
@@ -976,59 +1305,90 @@ function applySessionClose(args) {
|
|
|
976
1305
|
const rel = join('projects', project, 'session-log', `${date}.md`);
|
|
977
1306
|
const full = join(args.hypoDir, rel);
|
|
978
1307
|
const isPresent = entryAlreadyPresent(payload.sessionLog.entry);
|
|
979
|
-
//
|
|
980
|
-
//
|
|
981
|
-
//
|
|
982
|
-
//
|
|
983
|
-
//
|
|
984
|
-
//
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1308
|
+
// Serialize dedup + create/append on the daily shard so two concurrent
|
|
1309
|
+
// closes never lose an entry: the second closer takes the lock only after
|
|
1310
|
+
// the first committed, re-reads the shard under the lock, and appends onto
|
|
1311
|
+
// the committed bytes (temp+rename write-isolation is preserved — a partial
|
|
1312
|
+
// write never tears the target). Create and append share ONE lock, so the
|
|
1313
|
+
// "seed a new shard" and "append to an existing shard" branches can't race
|
|
1314
|
+
// each other — only one closer is ever in the create path (closes the
|
|
1315
|
+
// wx-window a bare exclusive-create would leave open).
|
|
1316
|
+
try {
|
|
1317
|
+
const outcome = withFileLock(
|
|
1318
|
+
full,
|
|
1319
|
+
() => {
|
|
1320
|
+
// Fallback-aware idempotency (hybrid cutover): during the month the
|
|
1321
|
+
// shard takes over, today's entry may already live in the legacy monthly
|
|
1322
|
+
// file from an earlier (pre-cutover) close. Treat presence in EITHER the
|
|
1323
|
+
// daily shard or the legacy monthly file as "already written" so a same-day
|
|
1324
|
+
// second close does not duplicate an identical entry across both files —
|
|
1325
|
+
// and so an idempotent re-apply stays a true no-op (no shard is created).
|
|
1326
|
+
for (const cand of sessionLogReadCandidates(project, date)) {
|
|
1327
|
+
const cf = join(args.hypoDir, cand);
|
|
1328
|
+
if (!existsSync(cf)) continue;
|
|
1329
|
+
try {
|
|
1330
|
+
if (isPresent(readFileSync(cf, 'utf-8'))) return 'skipped';
|
|
1331
|
+
} catch {
|
|
1332
|
+
/* unreadable candidate — fall through to the write path */
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
if (!existsSync(full)) {
|
|
1336
|
+
// A daily shard is a new file most days. Seed minimal valid frontmatter
|
|
1337
|
+
// (title + type, the two REQUIRED_FIELDS) so the shard is a first-class
|
|
1338
|
+
// wiki page rather than a W1 "no frontmatter" warning, and write the header
|
|
1339
|
+
// AND the first entry in ONE atomic write — never leave a header-only shard
|
|
1340
|
+
// on disk, which freshness would skip (no dated heading) while derive could
|
|
1341
|
+
// otherwise mistake it for the evidence file. The dated `## [date] ...`
|
|
1342
|
+
// heading lives inside the entry, so freshness / derive / design-history
|
|
1343
|
+
// are unchanged.
|
|
1344
|
+
// Audit fields (device, session_id). The shard frontmatter is git-tracked and synced, so
|
|
1345
|
+
// `device` is an INTENTIONAL synced multi-machine identifier (privacy note:
|
|
1346
|
+
// docs/ARCHITECTURE.md). It is a CREATOR-only stamp — only the session/
|
|
1347
|
+
// machine that first seeds the daily shard is recorded; later same-day
|
|
1348
|
+
// appends do not touch it. The per-session-accurate store is the LOCAL
|
|
1349
|
+
// (.cache/, gitignored) index.jsonl written by hypo-session-record.mjs.
|
|
1350
|
+
// `session_id` is honest naming: the value is the Claude session UUID, and
|
|
1351
|
+
// it is present only on the Stop-chain close path that passes --session-id.
|
|
1352
|
+
const device = currentDevice();
|
|
1353
|
+
const auditFm =
|
|
1354
|
+
(args.sessionId
|
|
1355
|
+
? `session_id: ${String(args.sessionId).replace(/[\r\n]/g, '')}\n`
|
|
1356
|
+
: '') + `device: ${device}\n`;
|
|
1357
|
+
const header =
|
|
1358
|
+
`---\ntitle: Session Log ${date} (${project})\n` +
|
|
1359
|
+
`type: session-log\nupdated: ${date}\n${auditFm}---\n\n` +
|
|
1360
|
+
`# Session Log ${date} (${project})\n`;
|
|
1361
|
+
const entry = payload.sessionLog.entry;
|
|
1362
|
+
const body = entry.endsWith('\n') ? entry : `${entry}\n`;
|
|
1363
|
+
atomicWrite(full, `${header}\n${body}`);
|
|
1364
|
+
return 'created';
|
|
1365
|
+
}
|
|
1366
|
+
return appendIfAbsent(full, payload.sessionLog.entry, isPresent) ? 'appended' : 'skipped';
|
|
1367
|
+
},
|
|
1368
|
+
{ timeoutMs: APPEND_LOCK_TIMEOUT_MS },
|
|
1369
|
+
);
|
|
1370
|
+
(outcome === 'skipped' ? skipped : applied).push(`sessionLog (${rel})`);
|
|
1371
|
+
} catch (err) {
|
|
1372
|
+
// Only a lock-TIMEOUT is withheld as a conflict. A real fn() write error
|
|
1373
|
+
// (disk-full, EACCES, mkdir failure) must NOT be masked as a proposal-
|
|
1374
|
+
// pending timeout — rethrow so it hard-fails like the overwrite path does.
|
|
1375
|
+
if (err?.code !== 'ELOCKTIMEOUT') throw err;
|
|
1376
|
+
// Lock-timeout: withhold rather than lose the entry. Recorded as a conflict
|
|
1377
|
+
// so the close goes proposal-pending (ok:false, no marker) and the next
|
|
1378
|
+
// close re-applies. `kind: 'append'` is what T6 branches on to SKIP parking
|
|
1379
|
+
// this: an append conflict never becomes a `.cache/proposals/` artifact —
|
|
1380
|
+
// the lock-timeout is transient and the next close self-heals by
|
|
1381
|
+
// re-appending, whereas a whole-file re-apply would drop this shard's other
|
|
1382
|
+
// entries. It still blocks the close; it just gets no artifact.
|
|
1383
|
+
conflicts.push({
|
|
1384
|
+
key: 'sessionLog',
|
|
1385
|
+
target: rel,
|
|
1386
|
+
reason: 'append-lock-timeout',
|
|
1387
|
+
kind: 'append',
|
|
1388
|
+
baseHash: null,
|
|
1389
|
+
currentHash: null,
|
|
1390
|
+
proposedContent: payload.sessionLog.entry,
|
|
1391
|
+
});
|
|
1032
1392
|
}
|
|
1033
1393
|
}
|
|
1034
1394
|
|
|
@@ -1046,31 +1406,126 @@ function applySessionClose(args) {
|
|
|
1046
1406
|
// append onto a deliberately custom payload.log (codex pre-commit review). The
|
|
1047
1407
|
// two payload paths are mutually exclusive: deriving on top of a present-but-
|
|
1048
1408
|
// malformed payload.log would mask it and weaken the verifier's fail-loud.
|
|
1409
|
+
// log.md is shared across projects and also written by deriveRootLogEntries
|
|
1410
|
+
// (the Stop-hook backfill in hypo-shared.mjs). Both take the SAME lock on
|
|
1411
|
+
// log.md, so a concurrent close's append and this close's append serialize
|
|
1412
|
+
// instead of overwriting each other.
|
|
1413
|
+
const logFull = join(args.hypoDir, 'log.md');
|
|
1049
1414
|
if (payload.log) {
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1415
|
+
try {
|
|
1416
|
+
const wrote = withFileLock(
|
|
1417
|
+
logFull,
|
|
1418
|
+
() => appendIfAbsent(logFull, payload.log.entry, entryAlreadyPresent(payload.log.entry)),
|
|
1419
|
+
{ timeoutMs: APPEND_LOCK_TIMEOUT_MS },
|
|
1420
|
+
);
|
|
1421
|
+
(wrote ? applied : skipped).push('log (log.md)');
|
|
1422
|
+
} catch (err) {
|
|
1423
|
+
if (err?.code !== 'ELOCKTIMEOUT') throw err;
|
|
1424
|
+
// proposedContent is append-ready root-log bytes (the custom log line).
|
|
1425
|
+
conflicts.push({
|
|
1426
|
+
key: 'log',
|
|
1427
|
+
target: 'log.md',
|
|
1428
|
+
reason: 'append-lock-timeout',
|
|
1429
|
+
kind: 'append',
|
|
1430
|
+
baseHash: null,
|
|
1431
|
+
currentHash: null,
|
|
1432
|
+
proposedContent: payload.log.entry,
|
|
1433
|
+
});
|
|
1434
|
+
}
|
|
1056
1435
|
} else {
|
|
1057
1436
|
// matchAll (not exec) mirrors deriveRootLogEntries: a payload that carried
|
|
1058
1437
|
// more than one dated heading derives one canonical line each, symmetric with
|
|
1059
1438
|
// the global path. Exact-line dedup on the heading keeps a second apply (or a
|
|
1060
1439
|
// titleless vs titled same-day pair) from duplicating.
|
|
1061
|
-
const logFull = join(args.hypoDir, 'log.md');
|
|
1062
1440
|
const headingRe = new RegExp(`^#{1,6} \\[${date}\\]\\s*(.*)$`, 'gm');
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1441
|
+
try {
|
|
1442
|
+
const wroteAny = withFileLock(
|
|
1443
|
+
logFull,
|
|
1444
|
+
() => {
|
|
1445
|
+
let w = false;
|
|
1446
|
+
for (const m of (payload.sessionLog.entry || '').matchAll(headingRe)) {
|
|
1447
|
+
const { heading, block } = rootLogEntry(project, date, m[1]);
|
|
1448
|
+
const wrote = appendIfAbsent(logFull, block, (c) =>
|
|
1449
|
+
(c || '').split(/\r?\n/).includes(heading),
|
|
1450
|
+
);
|
|
1451
|
+
w = w || wrote;
|
|
1452
|
+
}
|
|
1453
|
+
return w;
|
|
1454
|
+
},
|
|
1455
|
+
{ timeoutMs: APPEND_LOCK_TIMEOUT_MS },
|
|
1068
1456
|
);
|
|
1069
|
-
wroteAny
|
|
1457
|
+
(wroteAny ? applied : skipped).push('log (log.md, derived)');
|
|
1458
|
+
} catch (err) {
|
|
1459
|
+
if (err?.code !== 'ELOCKTIMEOUT') throw err;
|
|
1460
|
+
// `derived: true` discriminates this from the payload.log conflict above:
|
|
1461
|
+
// here proposedContent is the session-log entry to RE-DERIVE root-log lines
|
|
1462
|
+
// from (via rootLogEntry over its dated headings), NOT append-ready bytes.
|
|
1463
|
+
// Like every `kind: 'append'` conflict, this is NOT parked as an artifact —
|
|
1464
|
+
// it blocks the close and the next close re-derives + re-appends. The derived
|
|
1465
|
+
// shape only matters to the retry, never to T6's overwrite proposal store.
|
|
1466
|
+
conflicts.push({
|
|
1467
|
+
key: 'log',
|
|
1468
|
+
target: 'log.md',
|
|
1469
|
+
reason: 'append-lock-timeout',
|
|
1470
|
+
kind: 'append',
|
|
1471
|
+
derived: true,
|
|
1472
|
+
baseHash: null,
|
|
1473
|
+
currentHash: null,
|
|
1474
|
+
proposedContent: payload.sessionLog.entry,
|
|
1475
|
+
});
|
|
1070
1476
|
}
|
|
1071
|
-
(wroteAny ? applied : skipped).push('log (log.md, derived)');
|
|
1072
1477
|
}
|
|
1073
1478
|
|
|
1479
|
+
// T6: park drifted OVERWRITE targets as `.cache/proposals/` artifacts.
|
|
1480
|
+
//
|
|
1481
|
+
// Runs regardless of --json AND regardless of args.sessionId: writing the
|
|
1482
|
+
// artifact is a SIDE EFFECT, not output, so it is conditioned on neither the
|
|
1483
|
+
// report format nor a session context. (In practice an overwrite conflict only
|
|
1484
|
+
// arises with a session id, so a session-less apply just finds no overwrite
|
|
1485
|
+
// conflicts to park — but the loop is unconditional to match that contract.)
|
|
1486
|
+
// Only overwrite conflicts (`kind !== 'append'`) become artifacts. An append
|
|
1487
|
+
// conflict is a transient lock-timeout the NEXT close self-heals by
|
|
1488
|
+
// re-appending; parking it as a whole-file artifact and later re-applying it
|
|
1489
|
+
// (T7 replaces the whole target) would drop every OTHER entry in that
|
|
1490
|
+
// append-only history file. Append conflicts still sit in `conflicts`, so the
|
|
1491
|
+
// close still goes proposal-pending — they just get no artifact and no
|
|
1492
|
+
// human-apply step.
|
|
1493
|
+
const proposals = [];
|
|
1494
|
+
const proposalStoreFailures = [];
|
|
1495
|
+
const device = currentDevice();
|
|
1496
|
+
for (const c of conflicts) {
|
|
1497
|
+
if (c.kind === 'append') continue; // append conflicts are never parked (see above)
|
|
1498
|
+
try {
|
|
1499
|
+
const saved = writeProposal(args.hypoDir, {
|
|
1500
|
+
target: c.target,
|
|
1501
|
+
baseHash: c.baseHash,
|
|
1502
|
+
currentAtProposalHash: c.currentHash,
|
|
1503
|
+
proposedContent: c.proposedContent, // internal (pre-drop) full page bytes
|
|
1504
|
+
sessionId: args.sessionId, // may be null; writeProposal coerces it
|
|
1505
|
+
device,
|
|
1506
|
+
});
|
|
1507
|
+
proposals.push({ id: saved.id, target: saved.target, path: saved.path });
|
|
1508
|
+
// Supersede-delete failure is NON-fatal: the new artifact IS parked, only
|
|
1509
|
+
// a stale sibling lingers (superseded next close). Surface it, don't fail.
|
|
1510
|
+
for (const w of saved.supersedeWarnings) {
|
|
1511
|
+
process.stderr.write(`\n⚠️ ${w} (stale artifact left behind, not fatal)\n`);
|
|
1512
|
+
}
|
|
1513
|
+
} catch (err) {
|
|
1514
|
+
// fail-loud: the target was withheld AND its bytes are now on neither disk
|
|
1515
|
+
// NOR a proposal artifact — a genuine data-loss risk. Never swallow this.
|
|
1516
|
+
const error = (err && err.message) || String(err);
|
|
1517
|
+
proposalStoreFailures.push({ target: c.target, key: c.key, error });
|
|
1518
|
+
process.stderr.write(
|
|
1519
|
+
`\n🛑 PROPOSAL STORE FAILED for ${c.key} (${c.target}): ${error}\n` +
|
|
1520
|
+
` This close WITHHELD the target (it drifted from your observed base) but\n` +
|
|
1521
|
+
` could NOT write the .cache/proposals/ artifact either. The payload bytes\n` +
|
|
1522
|
+
` are on NEITHER disk NOR a proposal — re-run the close once the .cache/\n` +
|
|
1523
|
+
` directory is writable so the withheld content is not lost.\n`,
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
const proposalStoreFailed = proposalStoreFailures.length > 0;
|
|
1528
|
+
|
|
1074
1529
|
// Same-date-tie fix: verify against the SAME project this apply just wrote
|
|
1075
1530
|
// (`project` = payload.project || probe.project, resolved at the top). Without
|
|
1076
1531
|
// the override, sessionCloseFileStatus re-derives via resolveActiveProject and,
|
|
@@ -1099,7 +1554,16 @@ function applySessionClose(args) {
|
|
|
1099
1554
|
const m = unknownTagRe.exec(w.message || '');
|
|
1100
1555
|
if (m && !checkForbidden(m[1])) pendingTags.push(m[1]);
|
|
1101
1556
|
}
|
|
1102
|
-
|
|
1557
|
+
// Withheld a target? Then register nothing. This close is going to be re-run
|
|
1558
|
+
// once a human resolves the conflict, and its `ok:false` skips the commit below
|
|
1559
|
+
// (`if (ok && args.sessionId)`) — so registering here would leave SCHEMA.md
|
|
1560
|
+
// mutated, uncommitted, and absent from `applied`, i.e. a silent side effect of
|
|
1561
|
+
// a close whose whole point was to write nothing. Registration is
|
|
1562
|
+
// eventually-consistent by design, so deferring it to the next close costs
|
|
1563
|
+
// nothing (codex W2 CONCERN).
|
|
1564
|
+
if (pendingTags.length > 0 && conflicts.length === 0) {
|
|
1565
|
+
appendPendingTags(args.hypoDir, pendingTags);
|
|
1566
|
+
}
|
|
1103
1567
|
|
|
1104
1568
|
// Post-apply lint: payload may have introduced a malformed body or
|
|
1105
1569
|
// bad frontmatter. Surface as a distinct `stage` so caller can tell "lint
|
|
@@ -1138,7 +1602,12 @@ function applySessionClose(args) {
|
|
|
1138
1602
|
const postLintOk = !postApplyCrashed && postBlocking.length === 0;
|
|
1139
1603
|
// `let` (not const): the close-result invariant self-check below may flip this
|
|
1140
1604
|
// to false when the settled close result is internally contradictory.
|
|
1141
|
-
|
|
1605
|
+
//
|
|
1606
|
+
// A withheld conflict target must fail the close on its own, not merely via the
|
|
1607
|
+
// freshness gate. If the other session already touched that page TODAY, freshness
|
|
1608
|
+
// sees a fresh file and passes — and the close would report ok:true, write the
|
|
1609
|
+
// marker, and drop this session's payload silently. `conflicts` closes that hole.
|
|
1610
|
+
let ok = verification.ok && postLintOk && conflicts.length === 0;
|
|
1142
1611
|
|
|
1143
1612
|
// Scope the non-blocking notice to the close-target project: debt under
|
|
1144
1613
|
// projects/<project>/ stays listed; debt elsewhere folds to a count so the
|
|
@@ -1182,10 +1651,15 @@ function applySessionClose(args) {
|
|
|
1182
1651
|
let gateOk = false;
|
|
1183
1652
|
if (commitOutcome.committed) {
|
|
1184
1653
|
closeTranscript = resolveTranscriptBySessionId(args.sessionId);
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1654
|
+
// closeScope: apply KNOWS which project it just closed, and it wrote
|
|
1655
|
+
// that project's files from inside this process — they never appear in the
|
|
1656
|
+
// transcript as Edit/Write, so `payload.project` is the only signal that puts
|
|
1657
|
+
// this close in scope. Without it, apply's own incomplete close could be demoted
|
|
1658
|
+
// to a foreign-debt notice and marked green.
|
|
1659
|
+
gateOk = precompactGateStatus(args.hypoDir, {
|
|
1660
|
+
closeScope: [project],
|
|
1661
|
+
...(closeTranscript ? { transcriptPath: closeTranscript } : {}),
|
|
1662
|
+
}).ok;
|
|
1189
1663
|
}
|
|
1190
1664
|
const decision = planMarkerDecision({
|
|
1191
1665
|
ok,
|
|
@@ -1200,7 +1674,9 @@ function applySessionClose(args) {
|
|
|
1200
1674
|
});
|
|
1201
1675
|
markerSkipReason = decision.skipReason;
|
|
1202
1676
|
if (decision.write) {
|
|
1203
|
-
|
|
1677
|
+
// apply KNOWS its authoritative payload.project — stamp it as the v4
|
|
1678
|
+
// evidence set so PreCompact trusts this marker's scope directly (session-close attribution).
|
|
1679
|
+
writeSessionClosedMarker(args.hypoDir, args.sessionId, { project, projects: [project] });
|
|
1204
1680
|
// Codex CONCERN: the writer swallows IO errors (best-effort).
|
|
1205
1681
|
// Verify the file actually landed — mirroring the standalone path — instead of
|
|
1206
1682
|
// asserting markerWritten=true, so a .cache permission/disk problem surfaces
|
|
@@ -1212,13 +1688,21 @@ function applySessionClose(args) {
|
|
|
1212
1688
|
}
|
|
1213
1689
|
}
|
|
1214
1690
|
}
|
|
1691
|
+
// A conflict outranks the downstream gates: verification and lint both describe
|
|
1692
|
+
// a tree this apply declined to finish writing, so naming them would point the
|
|
1693
|
+
// reader at the wrong repair. A proposal-STORE failure outranks even that: the
|
|
1694
|
+
// withheld bytes never reached an artifact, so it is the most urgent repair.
|
|
1215
1695
|
let stage = ok
|
|
1216
1696
|
? null
|
|
1217
|
-
:
|
|
1218
|
-
? '
|
|
1219
|
-
:
|
|
1220
|
-
? '
|
|
1221
|
-
:
|
|
1697
|
+
: proposalStoreFailed
|
|
1698
|
+
? 'proposal-store-failed'
|
|
1699
|
+
: conflicts.length > 0
|
|
1700
|
+
? 'proposal-pending'
|
|
1701
|
+
: !verification.ok && !postLintOk
|
|
1702
|
+
? 'post-apply-verification+lint'
|
|
1703
|
+
: !verification.ok
|
|
1704
|
+
? 'post-apply-verification'
|
|
1705
|
+
: 'post-apply-lint';
|
|
1222
1706
|
// Runtime close-result invariant self-check. When a
|
|
1223
1707
|
// marker-write path (args.sessionId present) settles into an internally
|
|
1224
1708
|
// contradictory shape — ok:true with the marker silently withheld and no
|
|
@@ -1249,6 +1733,29 @@ function applySessionClose(args) {
|
|
|
1249
1733
|
date,
|
|
1250
1734
|
applied,
|
|
1251
1735
|
skipped,
|
|
1736
|
+
// Targets withheld: an overwrite drifted from this session's observed base, or
|
|
1737
|
+
// an append could not take the file lock in time (`kind: 'append'`). Two
|
|
1738
|
+
// channels resolve these, and `proposals` vs `conflicts[].kind` are the sole
|
|
1739
|
+
// discriminators: an OVERWRITE conflict is parked as a `.cache/proposals/`
|
|
1740
|
+
// artifact (below) for a human to review and re-apply; an APPEND conflict gets
|
|
1741
|
+
// NO artifact and is re-tried automatically by the next close. `proposedContent`
|
|
1742
|
+
// is dropped from the reported shape either way (the artifact / the next close
|
|
1743
|
+
// holds the bytes; a whole page or an append entry does not belong in the JSON).
|
|
1744
|
+
conflicts: conflicts.map(({ proposedContent: _drop, ...rest }) => rest),
|
|
1745
|
+
// Parked overwrite proposals (id/target/path), one per drifted overwrite
|
|
1746
|
+
// target. Empty when only append conflicts (or none) occurred. The T7 CLI
|
|
1747
|
+
// lists and applies these; append conflicts never appear here.
|
|
1748
|
+
proposals,
|
|
1749
|
+
...(proposalStoreFailed ? { proposalStoreFailures } : {}),
|
|
1750
|
+
// Partial close: some overwrite direct-writes (and/or appends) already landed
|
|
1751
|
+
// on disk while at least one conflict withheld the rest. Because the close is
|
|
1752
|
+
// ok:false, commitWikiChanges + the marker are skipped — so those written
|
|
1753
|
+
// files sit on disk UNCOMMITTED until the conflict is resolved and the close
|
|
1754
|
+
// re-runs. Named honestly: `appliedUncommitted` covers every write that landed
|
|
1755
|
+
// (overwrite or append), not overwrites alone.
|
|
1756
|
+
...(applied.length > 0 && conflicts.length > 0
|
|
1757
|
+
? { partialConflict: true, appliedUncommitted: [...applied] }
|
|
1758
|
+
: {}),
|
|
1252
1759
|
verification,
|
|
1253
1760
|
// Surface the marker outcome instead of skipping silently, so the
|
|
1254
1761
|
// caller can tell "closed" from "applied but not marked".
|
|
@@ -1274,6 +1781,29 @@ function applySessionClose(args) {
|
|
|
1274
1781
|
console.log(`Session-close apply (project: ${project}, date: ${date}):`);
|
|
1275
1782
|
for (const a of applied) console.log(` ✓ wrote ${a}`);
|
|
1276
1783
|
for (const s of skipped) console.log(` · skipped ${s} (already current)`);
|
|
1784
|
+
// Never let a withheld target read as a skip: `skipped` means "already current",
|
|
1785
|
+
// this means "your bytes are NOT on disk". Overwrite conflicts drifted from base;
|
|
1786
|
+
// an append conflict is a lock-timeout (someone else held the file's lock), which
|
|
1787
|
+
// is transient — the next close re-applies.
|
|
1788
|
+
for (const c of conflicts) {
|
|
1789
|
+
const why =
|
|
1790
|
+
c.kind === 'append'
|
|
1791
|
+
? 'could not acquire the append lock in time; the next close re-applies'
|
|
1792
|
+
: 'the page changed since this session read it';
|
|
1793
|
+
console.log(` ⚠ WITHHELD ${c.key} (${c.target}) — ${c.reason}; ${why}`);
|
|
1794
|
+
}
|
|
1795
|
+
for (const p of proposals) {
|
|
1796
|
+
console.log(
|
|
1797
|
+
` · parked proposal ${p.id} for ${p.target} (review with \`hypomnema proposal\`)`,
|
|
1798
|
+
);
|
|
1799
|
+
}
|
|
1800
|
+
if (applied.length > 0 && conflicts.length > 0) {
|
|
1801
|
+
console.log(
|
|
1802
|
+
'\n· partial close: the writes above ARE on disk but NOT committed — this close is\n' +
|
|
1803
|
+
' ok:false, so no commit and no session-close marker run until the withheld\n' +
|
|
1804
|
+
' target(s) are resolved and the close re-runs.',
|
|
1805
|
+
);
|
|
1806
|
+
}
|
|
1277
1807
|
if (ok) {
|
|
1278
1808
|
// When the marker was withheld, qualify the success line so a reader scanning
|
|
1279
1809
|
// stdout alone cannot mistake "verified" for "fully closed". markerSkipReason
|
|
@@ -1430,6 +1960,7 @@ function main() {
|
|
|
1430
1960
|
} catch {
|
|
1431
1961
|
continue;
|
|
1432
1962
|
}
|
|
1963
|
+
if (!scopeVisible(readVisibilityScope(content), currentDevice())) continue;
|
|
1433
1964
|
const fm = parseFrontmatter(content);
|
|
1434
1965
|
if (!fm) continue;
|
|
1435
1966
|
|