hypomnema 1.6.1 → 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 +77 -31
- 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 +944 -147
- 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/hooks/hypo-shared.mjs
CHANGED
|
@@ -16,8 +16,12 @@ import {
|
|
|
16
16
|
rmSync,
|
|
17
17
|
readdirSync,
|
|
18
18
|
realpathSync,
|
|
19
|
+
openSync,
|
|
20
|
+
closeSync,
|
|
21
|
+
unlinkSync,
|
|
22
|
+
renameSync,
|
|
19
23
|
} from 'fs';
|
|
20
|
-
import { join, relative, basename } from 'path';
|
|
24
|
+
import { join, relative, basename, dirname } from 'path';
|
|
21
25
|
import { homedir, hostname, tmpdir } from 'os';
|
|
22
26
|
import { spawnSync } from 'child_process';
|
|
23
27
|
|
|
@@ -525,7 +529,12 @@ function _lastSeg(p) {
|
|
|
525
529
|
// declines (null) so the caller falls back to recency. `projects` is the whole
|
|
526
530
|
// universe (for the uniqueness gate); `eligible` restricts the answer.
|
|
527
531
|
export function pickProjectByCwd(projects, cwd, opts = {}) {
|
|
528
|
-
const {
|
|
532
|
+
const {
|
|
533
|
+
eligible = null,
|
|
534
|
+
realpathCwd = null,
|
|
535
|
+
caseInsensitive = isCaseInsensitiveFs(),
|
|
536
|
+
rejectAmbiguous = false,
|
|
537
|
+
} = opts;
|
|
529
538
|
if (!cwd && !realpathCwd) return null;
|
|
530
539
|
const eligibleSet = eligible ? new Set(eligible) : null;
|
|
531
540
|
const isEligible = (slug) => !eligibleSet || eligibleSet.has(slug);
|
|
@@ -545,20 +554,35 @@ export function pickProjectByCwd(projects, cwd, opts = {}) {
|
|
|
545
554
|
if (n && !cwds.includes(n)) cwds.push(n);
|
|
546
555
|
}
|
|
547
556
|
|
|
548
|
-
// Tier 1: first cwd variant with any longest-prefix match wins.
|
|
557
|
+
// Tier 1: first cwd variant with any longest-prefix match wins. With
|
|
558
|
+
// rejectAmbiguous (session-cwd close check), two DISTINCT projects sharing the same
|
|
559
|
+
// longest matching working_dir (a monorepo config with no uniqueness invariant)
|
|
560
|
+
// is a genuine tie we must NOT break arbitrarily — silently picking the first
|
|
561
|
+
// would attribute a close to the wrong project and either mask a real failure
|
|
562
|
+
// (false-green) or block the wrong one (false-block). Decline the tie → null,
|
|
563
|
+
// so the caller degrades to the unresolved-cwd path instead of guessing.
|
|
549
564
|
for (const c of cwds) {
|
|
550
565
|
const cf = _fold(c, caseInsensitive);
|
|
551
566
|
let bestSlug = null;
|
|
552
567
|
let bestLen = -1;
|
|
568
|
+
let bestTied = false;
|
|
553
569
|
for (const e of entries) {
|
|
554
570
|
if (!isEligible(e.slug)) continue;
|
|
555
571
|
const pf = _fold(e.path, caseInsensitive);
|
|
556
|
-
if (
|
|
557
|
-
|
|
558
|
-
|
|
572
|
+
if (cf === pf || cf.startsWith(`${pf}/`)) {
|
|
573
|
+
if (e.path.length > bestLen) {
|
|
574
|
+
bestLen = e.path.length;
|
|
575
|
+
bestSlug = e.slug;
|
|
576
|
+
bestTied = false;
|
|
577
|
+
} else if (e.path.length === bestLen && e.slug !== bestSlug) {
|
|
578
|
+
bestTied = true;
|
|
579
|
+
}
|
|
559
580
|
}
|
|
560
581
|
}
|
|
561
|
-
if (bestSlug)
|
|
582
|
+
if (bestSlug) {
|
|
583
|
+
if (bestTied && rejectAmbiguous) return null;
|
|
584
|
+
return bestSlug;
|
|
585
|
+
}
|
|
562
586
|
}
|
|
563
587
|
|
|
564
588
|
// Tier 2: unique-basename ancestor, but only when the chain points at exactly
|
|
@@ -1130,7 +1154,10 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
|
|
|
1130
1154
|
|
|
1131
1155
|
// primary = the recency project when it is itself today-active, else the first
|
|
1132
1156
|
// today-active slug (stable order from the candidate set). Used only as the
|
|
1133
|
-
// single-slug alias
|
|
1157
|
+
// single-slug alias for the message header and the flat `close.project` field.
|
|
1158
|
+
// It is NEVER an attribution source: the marker is stamped from close evidence
|
|
1159
|
+
// (explicit --project, transcript close-files, apply's payload.project), so this
|
|
1160
|
+
// recency-derived value cannot leak into a marker and back into the next gate.
|
|
1134
1161
|
const primary = recency && todayActive.includes(recency) ? recency : todayActive[0];
|
|
1135
1162
|
const ordered = [primary, ...todayActive.filter((p) => p !== primary)];
|
|
1136
1163
|
|
|
@@ -1203,6 +1230,133 @@ export function rootLogEntry(slug, date, headingTail) {
|
|
|
1203
1230
|
* @param {string} hypoDir
|
|
1204
1231
|
* @returns {number} count of entries appended to log.md
|
|
1205
1232
|
*/
|
|
1233
|
+
// ── append-only file lock ───────────────────────────────────────────────────
|
|
1234
|
+
// Serializes the read → dedup → rebuild → temp+rename sequence on append-only
|
|
1235
|
+
// history files (session-log shards, log.md) so two concurrent session closes
|
|
1236
|
+
// never lose an entry. The lock does NOT replace the existing write-isolation:
|
|
1237
|
+
// each writer still rebuilds the full content and commits via atomicWrite
|
|
1238
|
+
// (temp write + rename), so a partial write lands on a throwaway temp and the
|
|
1239
|
+
// target is never torn. The lock only makes the read-modify-write exclusive, so
|
|
1240
|
+
// the second closer re-reads the first's committed bytes and appends onto them
|
|
1241
|
+
// (last-writer-wins can no longer drop the earlier entry), and exact-entry dedup
|
|
1242
|
+
// becomes precise rather than best-effort.
|
|
1243
|
+
//
|
|
1244
|
+
// Why not O_APPEND: an in-place append that short-writes (ENOSPC / EDQUOT /
|
|
1245
|
+
// RLIMIT_FSIZE / a split write() killed mid-loop) leaves a torn dated heading on
|
|
1246
|
+
// the real file that the freshness gate mis-reads as valid close evidence, and
|
|
1247
|
+
// it cannot be rolled back once a concurrent appender has written past it. That
|
|
1248
|
+
// is a normal-operation regression temp+rename does not have (a failed temp
|
|
1249
|
+
// write never runs the rename, so the target stays untouched). Confirmed against
|
|
1250
|
+
// Node/libuv write-loop behavior and POSIX write(2) partial-write semantics.
|
|
1251
|
+
//
|
|
1252
|
+
// Local-FS only: `openSync(lock, 'wx')` is not atomic on NFS — the same caveat
|
|
1253
|
+
// the vault already carries. Power-loss durability is unchanged from today
|
|
1254
|
+
// (atomicWrite never fsync'd), so it is out of scope here.
|
|
1255
|
+
function sleepSync(ms) {
|
|
1256
|
+
// Synchronous sleep with no busy-spin: block this thread on an Atomics.wait
|
|
1257
|
+
// against a private SharedArrayBuffer that is never signaled, so it always
|
|
1258
|
+
// times out after `ms`. Hooks run in a short-lived sync context.
|
|
1259
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, ms | 0));
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// Commit `content` via temp write + rename so a partial/failed write lands on a
|
|
1263
|
+
// throwaway temp and the target is never torn (mirrors crystallize.mjs's
|
|
1264
|
+
// atomicWrite). Rename atomicity swaps the directory entry; it is NOT power-loss
|
|
1265
|
+
// durable (no fsync) — same as everything else in the vault.
|
|
1266
|
+
function atomicWriteShared(path, content) {
|
|
1267
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1268
|
+
const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
|
|
1269
|
+
writeFileSync(tmp, content);
|
|
1270
|
+
renameSync(tmp, path);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Run `fn` while holding an exclusive lock on `<targetPath>.lock`.
|
|
1275
|
+
*
|
|
1276
|
+
* Acquire is a spin on `openSync(lock, 'wx')`: EEXIST means another writer holds
|
|
1277
|
+
* it, so poll until it frees. A lock whose holder looks dead (mtime older than
|
|
1278
|
+
* `staleMs`) is stolen. Steal is recoverable friction in the normal case (the
|
|
1279
|
+
* stealer re-reads the committed bytes before writing), but NOT loss-free in two
|
|
1280
|
+
* edge cases, both requiring the lock to sit untouched for `staleMs`: (1) a LIVE
|
|
1281
|
+
* holder preempted past `staleMs` gets stolen from, so two writers run the
|
|
1282
|
+
* critical section and one update is lost; (2) between the stale `statSync` and
|
|
1283
|
+
* the `unlinkSync`, the holder can release and a fresh holder grab the same path,
|
|
1284
|
+
* whose lock we then remove. `staleMs` is set well above a normal close (seconds)
|
|
1285
|
+
* to make both extreme-low-probability. If the lock cannot be acquired within
|
|
1286
|
+
* `timeoutMs`, throw so the caller can fall back to the write=proposal gate — for
|
|
1287
|
+
* an append that means blocking the close (proposal-pending) with no artifact; the
|
|
1288
|
+
* next close re-appends (architecturally consistent with the existing fail-safe).
|
|
1289
|
+
*
|
|
1290
|
+
* @param {string} targetPath file being guarded (lock is a sibling `.lock`)
|
|
1291
|
+
* @param {() => T} fn critical section
|
|
1292
|
+
* @param {{timeoutMs?: number, staleMs?: number, pollMs?: number}} [opts]
|
|
1293
|
+
* @returns {T} whatever `fn` returns
|
|
1294
|
+
* @template T
|
|
1295
|
+
*/
|
|
1296
|
+
export function withFileLock(targetPath, fn, opts = {}) {
|
|
1297
|
+
const { timeoutMs = 5000, staleMs = 30000, pollMs = 50 } = opts;
|
|
1298
|
+
const lockPath = `${targetPath}.lock`;
|
|
1299
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
1300
|
+
const start = Date.now();
|
|
1301
|
+
let fd;
|
|
1302
|
+
for (;;) {
|
|
1303
|
+
try {
|
|
1304
|
+
fd = openSync(lockPath, 'wx');
|
|
1305
|
+
break;
|
|
1306
|
+
} catch (err) {
|
|
1307
|
+
if (err.code !== 'EEXIST') throw err;
|
|
1308
|
+
// Held by another writer. Steal ONLY a demonstrably stale lock; otherwise
|
|
1309
|
+
// wait and eventually time out. The stat and the unlink are handled
|
|
1310
|
+
// separately on purpose: an un-removable stale lock (EACCES/EPERM/EBUSY)
|
|
1311
|
+
// and a fresh lock must both fall through to the timeout check — never
|
|
1312
|
+
// `continue` past it, or an un-unlinkable lock spins forever and violates
|
|
1313
|
+
// the timeoutMs → ELOCKTIMEOUT contract (caller falls to the proposal gate).
|
|
1314
|
+
let stale = false;
|
|
1315
|
+
try {
|
|
1316
|
+
// Steal a lock whose holder looks dead. Loss-free unless the holder is
|
|
1317
|
+
// actually live-but-preempted past staleMs (see JSDoc edge cases).
|
|
1318
|
+
stale = Date.now() - statSync(lockPath).mtimeMs > staleMs;
|
|
1319
|
+
} catch (statErr) {
|
|
1320
|
+
if (statErr.code === 'ENOENT') continue; // lock vanished; retry create now
|
|
1321
|
+
throw statErr; // unexpected stat failure — surface it, don't mask
|
|
1322
|
+
}
|
|
1323
|
+
if (stale) {
|
|
1324
|
+
try {
|
|
1325
|
+
unlinkSync(lockPath);
|
|
1326
|
+
continue; // stole it; retry the create immediately
|
|
1327
|
+
} catch (unlinkErr) {
|
|
1328
|
+
if (unlinkErr.code === 'ENOENT') continue; // another stealer won; retry
|
|
1329
|
+
// Cannot remove it: do NOT spin — fall through to timeout/sleep so
|
|
1330
|
+
// acquisition eventually throws ELOCKTIMEOUT instead of hanging.
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
if (Date.now() - start > timeoutMs) {
|
|
1334
|
+
// Tagged so callers can distinguish "could not get the lock" (fall to the
|
|
1335
|
+
// proposal gate) from a real fn() write error (mkdir/openSync/disk-full),
|
|
1336
|
+
// which must NOT be masked as a timeout.
|
|
1337
|
+
const e = new Error(`lock-timeout: ${lockPath}`);
|
|
1338
|
+
e.code = 'ELOCKTIMEOUT';
|
|
1339
|
+
throw e;
|
|
1340
|
+
}
|
|
1341
|
+
sleepSync(pollMs);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
try {
|
|
1345
|
+
return fn();
|
|
1346
|
+
} finally {
|
|
1347
|
+
try {
|
|
1348
|
+
closeSync(fd);
|
|
1349
|
+
} catch {
|
|
1350
|
+
/* fd already gone */
|
|
1351
|
+
}
|
|
1352
|
+
try {
|
|
1353
|
+
unlinkSync(lockPath);
|
|
1354
|
+
} catch {
|
|
1355
|
+
/* lock already stolen/removed */
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1206
1360
|
export function deriveRootLogEntries(hypoDir) {
|
|
1207
1361
|
const logPath = join(hypoDir, 'log.md');
|
|
1208
1362
|
if (!existsSync(logPath)) return 0;
|
|
@@ -1259,19 +1413,44 @@ export function deriveRootLogEntries(hypoDir) {
|
|
|
1259
1413
|
const { heading, block } = rootLogEntry(slug, date, m[1]);
|
|
1260
1414
|
if (seenHeadings.has(heading)) continue; // exact-line dedup (log.md + queued)
|
|
1261
1415
|
seenHeadings.add(heading);
|
|
1262
|
-
additions.push(block);
|
|
1416
|
+
additions.push({ heading, block });
|
|
1263
1417
|
}
|
|
1264
1418
|
}
|
|
1265
1419
|
}
|
|
1266
1420
|
|
|
1267
1421
|
if (additions.length === 0) return 0;
|
|
1268
|
-
|
|
1422
|
+
|
|
1423
|
+
// Serialize the read-modify-write on log.md: a concurrent session close (its
|
|
1424
|
+
// own crystallize apply, or another project's derive) may commit between the
|
|
1425
|
+
// read above and the write below. Under the lock we RE-READ the latest
|
|
1426
|
+
// committed log.md and re-run exact-heading dedup, so this derive appends onto
|
|
1427
|
+
// the other writer's entry instead of a full-file overwrite dropping it. The
|
|
1428
|
+
// same lock guards crystallize.mjs's per-close log.md append, so the two
|
|
1429
|
+
// paths never race. On lock-timeout, skip (best-effort backfill; the next
|
|
1430
|
+
// close re-derives) rather than risk a lost update.
|
|
1269
1431
|
try {
|
|
1270
|
-
|
|
1432
|
+
return withFileLock(logPath, () => {
|
|
1433
|
+
let current;
|
|
1434
|
+
try {
|
|
1435
|
+
current = readFileSync(logPath, 'utf-8');
|
|
1436
|
+
} catch {
|
|
1437
|
+
return 0;
|
|
1438
|
+
}
|
|
1439
|
+
const seen = new Set((current || '').split(/\r?\n/));
|
|
1440
|
+
const fresh = additions.filter(({ heading }) => {
|
|
1441
|
+
if (seen.has(heading)) return false;
|
|
1442
|
+
seen.add(heading);
|
|
1443
|
+
return true;
|
|
1444
|
+
});
|
|
1445
|
+
if (fresh.length === 0) return 0;
|
|
1446
|
+
const sep = current.endsWith('\n') ? '\n' : '\n\n';
|
|
1447
|
+
atomicWriteShared(logPath, current + sep + fresh.map((a) => a.block).join('\n\n') + '\n');
|
|
1448
|
+
return fresh.length;
|
|
1449
|
+
});
|
|
1271
1450
|
} catch {
|
|
1451
|
+
// lock-timeout or unexpected lock error: skip this backfill pass.
|
|
1272
1452
|
return 0;
|
|
1273
1453
|
}
|
|
1274
|
-
return additions.length;
|
|
1275
1454
|
}
|
|
1276
1455
|
|
|
1277
1456
|
// ── sync-state ────────────────────────────────────────────
|
|
@@ -1401,16 +1580,34 @@ export function commitWikiChanges(hypoDir) {
|
|
|
1401
1580
|
spawnSync('git', ['-C', hypoDir, ...args], { encoding: 'utf-8', timeout: 30000 });
|
|
1402
1581
|
if (git('rev-parse', '--is-inside-work-tree').status !== 0)
|
|
1403
1582
|
return { committed: false, reason: `not a git repository: ${hypoDir}` };
|
|
1404
|
-
|
|
1583
|
+
// `-z`: NUL-separated records with verbatim paths — no surrounding quotes and
|
|
1584
|
+
// no octal escaping, so non-ASCII paths (Korean page names are normal input
|
|
1585
|
+
// here) survive intact. Without it, `core.quotepath=true` (the default) yields
|
|
1586
|
+
// `"pages/\355\225\234\352\270\200.md"` and the old quote-strip parser fed that
|
|
1587
|
+
// literal, non-existent path to `git add` — failing the whole commit.
|
|
1588
|
+
// `-z`: NUL-separated records with verbatim paths — no surrounding quotes and
|
|
1589
|
+
// no octal escaping, so non-ASCII paths (Korean page names are normal input
|
|
1590
|
+
// here) survive intact. Without it, `core.quotepath=true` (the default) yields
|
|
1591
|
+
// `"pages/\355\225\234\352\270\200.md"` and the old quote-strip parser fed that
|
|
1592
|
+
// literal, non-existent path to `git add` — failing the whole commit.
|
|
1593
|
+
const porcelain = git('status', '--porcelain', '-uall', '-z');
|
|
1405
1594
|
if (porcelain.status !== 0)
|
|
1406
1595
|
return { committed: false, reason: `git status failed in ${hypoDir}` };
|
|
1407
1596
|
// `.hypoignore` is the project privacy boundary. `git add -A` ignores it, so
|
|
1408
1597
|
// enumerate changed paths, drop ignored ones, then stage explicitly.
|
|
1409
1598
|
const ignorePatterns = loadHypoIgnore(hypoDir);
|
|
1410
1599
|
const paths = [];
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
const
|
|
1600
|
+
const records = (porcelain.stdout || '').split('\0');
|
|
1601
|
+
for (let i = 0; i < records.length; i++) {
|
|
1602
|
+
const rec = records[i];
|
|
1603
|
+
if (!rec) continue;
|
|
1604
|
+
const xy = rec.slice(0, 2);
|
|
1605
|
+
const file = rec.slice(3); // `XY <path>`; the destination path for a rename/copy
|
|
1606
|
+
// A rename OR copy emits two records (`to\0from`); consume the trailing
|
|
1607
|
+
// `from`. Copy `C` records only appear under `status.renames=copies`, but
|
|
1608
|
+
// when they do, missing this skip feeds the `from` path to `git add` as a
|
|
1609
|
+
// bogus pathspec — the exact auto-commit failure this parser fixes.
|
|
1610
|
+
if (xy[0] === 'R' || xy[1] === 'R' || xy[0] === 'C' || xy[1] === 'C') i++;
|
|
1414
1611
|
if (!file) continue;
|
|
1415
1612
|
if (ignorePatterns.length > 0 && isIgnored(join(hypoDir, file), hypoDir, ignorePatterns))
|
|
1416
1613
|
continue;
|
|
@@ -1799,9 +1996,22 @@ export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
|
|
|
1799
1996
|
// attribution). Readers (precompactGateStatus / --check-session-close) key
|
|
1800
1997
|
// the gate semantics on this field, so it must be recorded.
|
|
1801
1998
|
const scope = info.scope === 'log-only' ? 'log-only' : 'project';
|
|
1999
|
+
// v4 attribution discriminator (session-close attribution). `projects` is the evidence-based
|
|
2000
|
+
// set this session actually closed — explicit --project ∪ transcript
|
|
2001
|
+
// close-file edits ∪ apply's authoritative payload.project — and NEVER the
|
|
2002
|
+
// recency primary. Its PRESENCE marks a v4 marker whose scope resolveCloseScope
|
|
2003
|
+
// trusts directly; a pre-v4 marker carries only `project` and is treated as an
|
|
2004
|
+
// uncorroborated legacy hint (see resolveCloseScope). `project` stays as
|
|
2005
|
+
// projects[0] for back-compat with the flat-field readers.
|
|
2006
|
+
const projects = Array.isArray(info.projects)
|
|
2007
|
+
? [...new Set(info.projects.filter(Boolean))]
|
|
2008
|
+
: info.project
|
|
2009
|
+
? [info.project]
|
|
2010
|
+
: [];
|
|
1802
2011
|
const payload = {
|
|
1803
2012
|
session_id: sessionId,
|
|
1804
|
-
project: info.project || null,
|
|
2013
|
+
project: info.project || projects[0] || null,
|
|
2014
|
+
projects,
|
|
1805
2015
|
scope,
|
|
1806
2016
|
transcript_path: info.transcript_path || null,
|
|
1807
2017
|
closed_at: new Date().toISOString(),
|
|
@@ -2170,6 +2380,64 @@ export function isUnderProjectDirs(file, slugs) {
|
|
|
2170
2380
|
return (slugs || []).some((s) => s && (f === `projects/${s}` || f.startsWith(`projects/${s}/`)));
|
|
2171
2381
|
}
|
|
2172
2382
|
|
|
2383
|
+
/**
|
|
2384
|
+
* The session-close FILES of some project, as a path matcher. Used to attribute a
|
|
2385
|
+
* close to a session: a transcript that edited `projects/<slug>/session-state.md`
|
|
2386
|
+
* (or that project's hot.md / a session-log shard) is evidence THIS session was
|
|
2387
|
+
* closing <slug>. Any other file under `projects/<slug>/` is NOT evidence — merely
|
|
2388
|
+
* editing a page or an ADR there says nothing about whose close is whose, and
|
|
2389
|
+
* treating it as attribution would re-block a session for a project it only read
|
|
2390
|
+
* around in (codex design review).
|
|
2391
|
+
*/
|
|
2392
|
+
const CLOSE_FILE_RE = /^projects\/([^/]+)\/(session-state\.md|hot\.md|session-log\/[^/]+\.md)$/;
|
|
2393
|
+
|
|
2394
|
+
/** Slugs whose close files this session edited directly (Write/Edit tool_use). */
|
|
2395
|
+
function projectsFromTouchedCloseFiles(transcriptPath, hypoDir) {
|
|
2396
|
+
const out = new Set();
|
|
2397
|
+
if (!transcriptPath) return out;
|
|
2398
|
+
for (const f of extractTouchedWikiFiles(transcriptPath, hypoDir)) {
|
|
2399
|
+
const m = posixPath(f).match(CLOSE_FILE_RE);
|
|
2400
|
+
if (m) out.add(m[1]);
|
|
2401
|
+
}
|
|
2402
|
+
return out;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
/**
|
|
2406
|
+
* closeScope: the projects THIS session is accountable for closing. The union of
|
|
2407
|
+
* three signals, because no single one covers every close path:
|
|
2408
|
+
*
|
|
2409
|
+
* 1. `opts.closeScope` — the caller states it outright. `--apply-session-close`
|
|
2410
|
+
* passes `payload.project` (it is the authority on what it just closed) and
|
|
2411
|
+
* `--mark-session-closed --project=<slug>` passes its attribution slug. This
|
|
2412
|
+
* signal is LOAD-BEARING, not a convenience: the documented close path writes
|
|
2413
|
+
* its files from inside a Bash call, so they never surface as Edit/Write
|
|
2414
|
+
* `file_path`s and signal 2 cannot see them (the same blind spot that forces
|
|
2415
|
+
* closeFileTargets() to seed the lint scope explicitly).
|
|
2416
|
+
* 2. close files the transcript shows this session editing — the hand-written
|
|
2417
|
+
* close, which bypasses the script entirely.
|
|
2418
|
+
* 3. `marker.project` — after a scripted close marked, this is how a later reader
|
|
2419
|
+
* (PreCompact) recovers signal 1. It is what keeps the marker == compact-ready
|
|
2420
|
+
* invariant: the writer scopes by `payload.project`, PreCompact re-scopes by
|
|
2421
|
+
* the `project` that same writer recorded, so the two can never disagree.
|
|
2422
|
+
*/
|
|
2423
|
+
export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
|
|
2424
|
+
const scope = new Set(opts.closeScope || []);
|
|
2425
|
+
for (const p of projectsFromTouchedCloseFiles(opts.transcriptPath, hypoDir)) scope.add(p);
|
|
2426
|
+
// Marker attribution (session-close attribution). A v4 marker carries `projects` — the
|
|
2427
|
+
// evidence-based set it closed (never recency) — trusted directly. A pre-v4
|
|
2428
|
+
// legacy marker carries only `project` with no provenance, and that value may be
|
|
2429
|
+
// recency-derived (the P1 bug). An uncorroborated legacy attribution must NOT
|
|
2430
|
+
// enable partitioning, or a stale/wrong slug could demote a real failure to
|
|
2431
|
+
// foreign debt. So a legacy `project` enters scope only when the direct signals
|
|
2432
|
+
// above (explicit close scope or transcript close-file edits) already corroborate
|
|
2433
|
+
// the SAME slug; otherwise it stays a display hint. This narrow gap self-heals as
|
|
2434
|
+
// pre-v4 markers expire (7-day TTL).
|
|
2435
|
+
if (Array.isArray(marker?.projects)) {
|
|
2436
|
+
for (const p of marker.projects) if (p) scope.add(p);
|
|
2437
|
+
}
|
|
2438
|
+
return scope;
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2173
2441
|
// ── PreCompact gate — single source of truth ────────────────────────────────
|
|
2174
2442
|
/**
|
|
2175
2443
|
* The full PreCompact gate decision as a READ-ONLY status. This is the single
|
|
@@ -2201,7 +2469,15 @@ export function isUnderProjectDirs(file, slugs) {
|
|
|
2201
2469
|
* ignored.
|
|
2202
2470
|
*
|
|
2203
2471
|
* @param {string} hypoDir
|
|
2204
|
-
*
|
|
2472
|
+
* opts.sessionCwd (session-cwd close check): the authoritative cwd of the session being
|
|
2473
|
+
* gated (hook payload.cwd, or the CLI --session-cwd flag — never process.cwd(),
|
|
2474
|
+
* which is post-`cd` non-authoritative). When set and not log-only, the project
|
|
2475
|
+
* that owns this cwd is checked for close-completeness as an INDEPENDENT blocker,
|
|
2476
|
+
* catching a session whose own project close was never started. Unmatched/ambiguous
|
|
2477
|
+
* cwd yields a best-effort notice, not a block. apply never passes it (its launch
|
|
2478
|
+
* cwd may differ from the authoritative payload.project).
|
|
2479
|
+
*
|
|
2480
|
+
* @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string, projectOverride?: string|null, sessionCwd?: string|null, sessionId?: string|null, logOnly?: boolean, closeScope?: string[]}} [opts]
|
|
2205
2481
|
* @returns {{ok: boolean, close: object, blockers: {type:string,reason:string}[], notices: {type:string,reason:string,file?:string}[], driftTargets: string[], skipped: {lint:boolean, feedback:boolean}}}
|
|
2206
2482
|
*/
|
|
2207
2483
|
export function precompactGateStatus(hypoDir, opts = {}) {
|
|
@@ -2267,6 +2543,72 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
2267
2543
|
// projectOverride narrows the close status to one project (check-only); a
|
|
2268
2544
|
// marker-writing caller never sets it, so the marker path stays global.
|
|
2269
2545
|
close = sessionCloseGlobalStatus(hypoDir, { projectOverride: opts.projectOverride });
|
|
2546
|
+
|
|
2547
|
+
// Attribute the close debt before blocking on it. An incomplete close belongs
|
|
2548
|
+
// to whichever session performed it; charging it to an unrelated session is the
|
|
2549
|
+
// false block this partition exists to stop. The gate already draws exactly this
|
|
2550
|
+
// line for LINT debt (partitionLintScope: errors in files THIS session touched
|
|
2551
|
+
// block, pre-existing debt elsewhere is a notice) — close-file debt was the one
|
|
2552
|
+
// check still hard-blocking globally on another session's work.
|
|
2553
|
+
//
|
|
2554
|
+
// Two fail-closed guards keep the partition from eating a real blocker:
|
|
2555
|
+
// - close.fallback: no project closed today, so this is the "you have not
|
|
2556
|
+
// closed this session AT ALL" path. It must block unconditionally; demoting
|
|
2557
|
+
// it would gut the gate's whole purpose.
|
|
2558
|
+
// - empty scope: no positive attribution signal, so we cannot tell whose debt
|
|
2559
|
+
// it is. Never demote on a guess — fall back to today's global block.
|
|
2560
|
+
// - projectOverride: the caller asked "is THIS project close-complete?".
|
|
2561
|
+
// Demoting the very project it named would answer a question nobody asked.
|
|
2562
|
+
//
|
|
2563
|
+
// Bounded tradeoff (codex design review, accepted rather than closed). A
|
|
2564
|
+
// scripted close that CRASHES mid-write leaves no marker and no transcript trace
|
|
2565
|
+
// (it writes from inside a Bash call), so its own project carries no attribution.
|
|
2566
|
+
// If that same session had also hand-edited SOME OTHER project's close files, the
|
|
2567
|
+
// scope is non-empty but omits the torn project, and its debt is demoted. The
|
|
2568
|
+
// window is narrow and self-limiting: apply writes the project files, then the
|
|
2569
|
+
// session-log, then the root log entry, so the only torn state that reaches this
|
|
2570
|
+
// partition at all is a missing log.md entry — which deriveRootLogEntries
|
|
2571
|
+
// regenerates from the session-log heading. A crash before the session-log is not
|
|
2572
|
+
// detected as today-active by hasTodayCloseActivity in the first place (the
|
|
2573
|
+
// pre-existing tradeoff documented there). And the marker is never written, so the
|
|
2574
|
+
// Stop hook still refuses to end the session. Closing this properly needs a
|
|
2575
|
+
// durable close-attempt record; it is not worth a new state-file lifecycle here.
|
|
2576
|
+
const scopeSet = resolveCloseScope(hypoDir, opts, marker);
|
|
2577
|
+
const failed = (close.projects || []).filter((p) => !p.ok);
|
|
2578
|
+
const partition =
|
|
2579
|
+
!close.fallback && !opts.projectOverride && scopeSet.size > 0 && failed.length > 0;
|
|
2580
|
+
|
|
2581
|
+
close.scope = [...scopeSet];
|
|
2582
|
+
close.debt = [];
|
|
2583
|
+
// Re-project the flat aliases onto what actually BLOCKS, so `ok` and
|
|
2584
|
+
// `stale`/`missing` can never contradict each other (a reader that treats a
|
|
2585
|
+
// non-empty `missing` as failure stays correct). Demoted debt moves to its own
|
|
2586
|
+
// `debt` field instead of masquerading as this session's unfinished work.
|
|
2587
|
+
//
|
|
2588
|
+
// ONLY when partitioning. Deriving `ok` from the per-project rows unconditionally
|
|
2589
|
+
// would silently drop the failures that have NO project row to be derived from:
|
|
2590
|
+
// an unresolvable active project yields `projects: []` with
|
|
2591
|
+
// `missing: ['hot.md (no active project in pointer table)']`, so an empty `failed`
|
|
2592
|
+
// would read as "nothing failed" and flip a red gate green (codex pre-commit
|
|
2593
|
+
// BLOCKER, reproduced on a vault with no active-project row). Outside the
|
|
2594
|
+
// partition the close status stands exactly as sessionCloseGlobalStatus computed it.
|
|
2595
|
+
if (partition) {
|
|
2596
|
+
const mine = failed.filter((p) => scopeSet.has(p.project));
|
|
2597
|
+
const foreign = failed.filter((p) => !scopeSet.has(p.project));
|
|
2598
|
+
close.debt = foreign.map((p) => ({ project: p.project, stale: p.stale, missing: p.missing }));
|
|
2599
|
+
close.stale = [...new Set(mine.flatMap((p) => p.stale))];
|
|
2600
|
+
close.missing = [...new Set(mine.flatMap((p) => p.missing))];
|
|
2601
|
+
close.ok = mine.length === 0;
|
|
2602
|
+
// The flat `project` alias must follow the scope too: it names the project the
|
|
2603
|
+
// rest of this status describes, and every consumer that renders a per-file
|
|
2604
|
+
// checklist builds it from that name. Left as the global `primary` it can point
|
|
2605
|
+
// at a DEMOTED foreign project, and the checklist then reports ✓ for files the
|
|
2606
|
+
// debt list simultaneously calls missing (codex pre-commit CONCERN).
|
|
2607
|
+
if (!scopeSet.has(close.project)) {
|
|
2608
|
+
close.project = mine[0]?.project ?? [...scopeSet][0] ?? close.project;
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2270
2612
|
if (!close.ok) {
|
|
2271
2613
|
blockers.push({
|
|
2272
2614
|
type: 'close',
|
|
@@ -2276,6 +2618,74 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
2276
2618
|
].join(', ')}`,
|
|
2277
2619
|
});
|
|
2278
2620
|
}
|
|
2621
|
+
for (const p of close.debt) {
|
|
2622
|
+
notices.push({
|
|
2623
|
+
type: 'close-debt',
|
|
2624
|
+
project: p.project,
|
|
2625
|
+
reason: `${p.project}: incomplete session close from another session (${[
|
|
2626
|
+
...p.missing.map((f) => `${f} (missing)`),
|
|
2627
|
+
...p.stale.map((f) => `${f} (stale)`),
|
|
2628
|
+
].join(', ')}) — not blocking; that project's next close will fix it`,
|
|
2629
|
+
});
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
// 3b. session-cwd close (session-cwd close check). The current session's cwd project is an
|
|
2634
|
+
// INDEPENDENT close responsibility. sessionCloseGlobalStatus above only sees
|
|
2635
|
+
// projects that left an authoritative close-activity trace (a session-log
|
|
2636
|
+
// heading / log.md entry), so a project whose close was NEVER STARTED is
|
|
2637
|
+
// invisible — and if the recency project was closed the same day, the gate would
|
|
2638
|
+
// go green while this session's real project stays unclosed (the false-green this
|
|
2639
|
+
// closes). Evaluate it here, AFTER the partition and OUTSIDE scopeSet /
|
|
2640
|
+
// close.projects, so it can neither be demoted to foreign debt (partition) nor
|
|
2641
|
+
// spawn a W8 design-history blocker (which derives from close.projects). log-only
|
|
2642
|
+
// sessions are exempt (no project to close). apply never passes sessionCwd (its
|
|
2643
|
+
// launch cwd may differ from the authoritative payload.project — a supported
|
|
2644
|
+
// cross-project close), so this runs only on the read / mark paths.
|
|
2645
|
+
if (!logOnly && opts.sessionCwd) {
|
|
2646
|
+
const cwdProject = pickProjectByCwd(collectProjectWorkingDirs(hypoDir), opts.sessionCwd, {
|
|
2647
|
+
rejectAmbiguous: true,
|
|
2648
|
+
});
|
|
2649
|
+
if (cwdProject) {
|
|
2650
|
+
const s = sessionCloseFileStatus(hypoDir, { projectOverride: cwdProject });
|
|
2651
|
+
if (!s.ok) {
|
|
2652
|
+
// ALWAYS emit the typed close-cwd blocker when the cwd project's close is
|
|
2653
|
+
// incomplete — never suppress it as a duplicate of the global `close`
|
|
2654
|
+
// blocker. The Stop hook keys its marker re-check on this exact type, so
|
|
2655
|
+
// hiding it (even when a `close` blocker names the same project) would let
|
|
2656
|
+
// Stop honor a stale marker and end the session green (codex pre-commit
|
|
2657
|
+
// BLOCKER). A second entry for the same slug is merely noisy, never wrong.
|
|
2658
|
+
blockers.push({
|
|
2659
|
+
type: 'close-cwd',
|
|
2660
|
+
project: cwdProject,
|
|
2661
|
+
reason: `session cwd project '${cwdProject}' has an incomplete session close: ${[
|
|
2662
|
+
...s.missing.map((f) => `${f} (missing)`),
|
|
2663
|
+
...s.stale.map((f) => `${f} (stale)`),
|
|
2664
|
+
].join(', ')}`,
|
|
2665
|
+
});
|
|
2666
|
+
// If the partition demoted this project to foreign debt, it is NOT another
|
|
2667
|
+
// session's work — it is THIS session's, and we just BLOCKED on it. Remove
|
|
2668
|
+
// it from BOTH the debt notice and close.debt so the status cannot report
|
|
2669
|
+
// the same project as non-blocking debt and a blocker at once (codex
|
|
2670
|
+
// pre-commit CONCERN: crystallize renders close_debt from close.debt).
|
|
2671
|
+
for (let i = notices.length - 1; i >= 0; i--) {
|
|
2672
|
+
if (notices[i].type === 'close-debt' && notices[i].project === cwdProject)
|
|
2673
|
+
notices.splice(i, 1);
|
|
2674
|
+
}
|
|
2675
|
+
if (Array.isArray(close.debt))
|
|
2676
|
+
close.debt = close.debt.filter((d) => d.project !== cwdProject);
|
|
2677
|
+
}
|
|
2678
|
+
} else {
|
|
2679
|
+
// cwd is under no project working_dir, or ambiguously under several (a
|
|
2680
|
+
// monorepo tie pickProjectByCwd declined): we cannot attribute a close
|
|
2681
|
+
// responsibility, so we do NOT hard-block (nothing proves there is anything
|
|
2682
|
+
// to close). Surface a best-effort notice so the coverage gap is visible.
|
|
2683
|
+
notices.push({
|
|
2684
|
+
type: 'close-cwd-unresolved',
|
|
2685
|
+
reason:
|
|
2686
|
+
'session cwd did not resolve to a unique project — the P2 cwd close check is best-effort here; pass --project or --log-only to be explicit',
|
|
2687
|
+
});
|
|
2688
|
+
}
|
|
2279
2689
|
}
|
|
2280
2690
|
|
|
2281
2691
|
// 4. lint blockers + W8 design-history (scoped). Mirrors hypo-personal-check.
|
|
@@ -2425,8 +2835,55 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
2425
2835
|
.map(([n]) => n);
|
|
2426
2836
|
const overCapT = entries.filter(([, t]) => t.overCap).map(([n]) => n);
|
|
2427
2837
|
const driftedT = entries.filter(([, t]) => t.dirty).map(([n]) => n);
|
|
2428
|
-
|
|
2429
|
-
|
|
2838
|
+
// A target whose file EXISTS but cannot be projected into (its
|
|
2839
|
+
// <learned_behaviors> container is gone) reports dirty:false with no
|
|
2840
|
+
// conflict flag — it cannot be built, so nothing "would change". That
|
|
2841
|
+
// shape used to fall into the non-actionable branch below and fail OPEN,
|
|
2842
|
+
// the worst possible reading: a projection that loads ZERO rules on this
|
|
2843
|
+
// machine was classified as "nothing to do" and waved through. It is a
|
|
2844
|
+
// blocker, not a shrug.
|
|
2845
|
+
//
|
|
2846
|
+
// ONLY kind 'build-failed'. A 'target-missing' buildError (no ~/.claude/
|
|
2847
|
+
// CLAUDE.md yet) is the ordinary first-run state and must keep failing
|
|
2848
|
+
// OPEN, or the gate blocks every new user on their first /compact.
|
|
2849
|
+
const buildErrT = entries
|
|
2850
|
+
.filter(([, t]) => t.buildError && t.buildErrorKind === 'build-failed')
|
|
2851
|
+
.map(([n]) => n);
|
|
2852
|
+
// Side-file I/O trouble (an unreadable feedback_<slug>.md under the
|
|
2853
|
+
// project memory dir) is a NOTICE, never a blocker: the primary
|
|
2854
|
+
// projection still loads every rule, and the only fix is a permission
|
|
2855
|
+
// bit on that path — `--ensure-container` cannot touch it. A gate that
|
|
2856
|
+
// blocks on a condition its own named remedy cannot clear is a gate that
|
|
2857
|
+
// gets bypassed.
|
|
2858
|
+
const sideWarnT = entries.filter(([, t]) => (t.sideWarnings || []).length);
|
|
2859
|
+
if (
|
|
2860
|
+
!report ||
|
|
2861
|
+
!(
|
|
2862
|
+
conflictedT.length ||
|
|
2863
|
+
overCapT.length ||
|
|
2864
|
+
driftedT.length ||
|
|
2865
|
+
buildErrT.length ||
|
|
2866
|
+
sideWarnT.length
|
|
2867
|
+
)
|
|
2868
|
+
) {
|
|
2869
|
+
skipped.feedback = true; // unparseable / non-actionable → fail-open
|
|
2870
|
+
} else if (buildErrT.length) {
|
|
2871
|
+
// Name the EXACT target file and the remedy for THIS cause. "Restore
|
|
2872
|
+
// the managed container" with no path is a dead end, and so is naming
|
|
2873
|
+
// `--ensure-container` for a permission error or a dangling symlink,
|
|
2874
|
+
// which it cannot fix — a blocker with no way through gets bypassed
|
|
2875
|
+
// rather than obeyed. t.buildError carries the target's absolute path
|
|
2876
|
+
// and t.buildErrorRemedy the cause-specific way out; feedback-sync
|
|
2877
|
+
// makes that judgment once, at the branch that detects the cause.
|
|
2878
|
+
const failed = entries.filter(([n]) => buildErrT.includes(n));
|
|
2879
|
+
const details = failed.map(([n, t]) => `${n}: ${t.buildError}`).join('; ');
|
|
2880
|
+
const remedies = [
|
|
2881
|
+
...new Set(failed.map(([, t]) => t.buildErrorRemedy).filter(Boolean)),
|
|
2882
|
+
].join(' ');
|
|
2883
|
+
blockers.push({
|
|
2884
|
+
type: 'feedback',
|
|
2885
|
+
reason: `feedback projection cannot be built — ${details} — no rules are loaded from it. ${remedies}`,
|
|
2886
|
+
});
|
|
2430
2887
|
} else if (conflictedT.length) {
|
|
2431
2888
|
blockers.push({
|
|
2432
2889
|
type: 'feedback',
|
|
@@ -2437,13 +2894,23 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
2437
2894
|
type: 'feedback',
|
|
2438
2895
|
reason: `feedback projection over cap (${overCapT.join(', ')}) — demote/archive feedback pages`,
|
|
2439
2896
|
});
|
|
2440
|
-
} else {
|
|
2897
|
+
} else if (driftedT.length) {
|
|
2441
2898
|
driftTargets.push(...driftedT); // pure drift → self-healable, not a blocker
|
|
2442
2899
|
notices.push({
|
|
2443
2900
|
type: 'feedback',
|
|
2444
2901
|
reason: `feedback projection drift (${driftedT.join(', ')}) — will self-heal at /compact`,
|
|
2445
2902
|
});
|
|
2446
2903
|
}
|
|
2904
|
+
// Additive, and deliberately outside the chain above: a side-file I/O
|
|
2905
|
+
// problem is orthogonal to the primary target's health, so it is a notice
|
|
2906
|
+
// whatever else is (or is not) going on. It names the path and the
|
|
2907
|
+
// permission fix, because that — not a command — is the way out.
|
|
2908
|
+
for (const [n, t] of sideWarnT) {
|
|
2909
|
+
notices.push({
|
|
2910
|
+
type: 'feedback',
|
|
2911
|
+
reason: `feedback projection side file unreadable (${n}): ${t.sideWarnings.join('; ')} — fix the permissions on that path; the primary projection still loads every rule (\`--ensure-container\` does not fix this)`,
|
|
2912
|
+
});
|
|
2913
|
+
}
|
|
2447
2914
|
}
|
|
2448
2915
|
} catch {
|
|
2449
2916
|
skipped.feedback = true;
|
|
@@ -2516,6 +2983,29 @@ export function isCompactOrClearCommand(prompt) {
|
|
|
2516
2983
|
* @returns {string} newline-joined user text, or '' on any failure (fail-open)
|
|
2517
2984
|
*/
|
|
2518
2985
|
export function extractUserMessages(transcriptPath, tailN = 30) {
|
|
2986
|
+
return extractUserMessageRecords(transcriptPath, tailN, { keepEmpty: true }).join('\n');
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
/**
|
|
2990
|
+
* Same extraction as {@link extractUserMessages}, but ONE STRING PER TRANSCRIPT
|
|
2991
|
+
* RECORD instead of one flat blob. The message boundary is the point: a gate that
|
|
2992
|
+
* asks "did the user say exactly X" cannot ask it of text that has been joined
|
|
2993
|
+
* across turns, because any line of any message then looks like a whole message.
|
|
2994
|
+
* {@link hasTypedUserApproval} needs that boundary; the close-intent readers, which
|
|
2995
|
+
* only ever ask "does this text contain a close phrase", do not.
|
|
2996
|
+
*
|
|
2997
|
+
* @param {string} transcriptPath
|
|
2998
|
+
* @param {number} tailN how many trailing lines to scan (Infinity → whole file)
|
|
2999
|
+
* @param {{keepEmpty?: boolean}} [opts] keepEmpty preserves a '' per dropped record,
|
|
3000
|
+
* so the joined form stays byte-identical to what extractUserMessages always returned.
|
|
3001
|
+
* @returns {string[]} user-typed text per record, or [] on any failure (fail-open)
|
|
3002
|
+
*/
|
|
3003
|
+
export function extractUserMessageRecords(transcriptPath, tailN = 30, { keepEmpty } = {}) {
|
|
3004
|
+
const records = extractUserRecordTexts(transcriptPath, tailN);
|
|
3005
|
+
return keepEmpty ? records : records.filter((t) => t !== '');
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
function extractUserRecordTexts(transcriptPath, tailN) {
|
|
2519
3009
|
try {
|
|
2520
3010
|
const lines = readFileSync(transcriptPath, 'utf-8').split('\n');
|
|
2521
3011
|
// tailN === Infinity → whole transcript (the marker-write hard gate needs the
|
|
@@ -2523,51 +3013,49 @@ export function extractUserMessages(transcriptPath, tailN = 30) {
|
|
|
2523
3013
|
// checklist). The Stop hook keeps the 30-line default so a stale old close
|
|
2524
3014
|
// signal doesn't re-trigger every turn.
|
|
2525
3015
|
const tail = Number.isFinite(tailN) ? lines.slice(-tailN) : lines;
|
|
2526
|
-
return tail
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
return content.startsWith('Stop hook feedback') ? '' : content;
|
|
2551
|
-
}
|
|
2552
|
-
if (Array.isArray(content)) {
|
|
2553
|
-
// Only genuine user-typed text blocks. tool_result blocks are also
|
|
2554
|
-
// recorded with role:'user' in the Claude Code transcript; slurping
|
|
2555
|
-
// them via JSON.stringify let tool output (e.g. close-pattern example
|
|
2556
|
-
// strings read out of code/docs) trip the close-intent gate.
|
|
2557
|
-
// Do NOT recurse into tool_result.content, or the pollution returns.
|
|
2558
|
-
return content
|
|
2559
|
-
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
2560
|
-
.map((b) => b.text)
|
|
2561
|
-
.join('\n');
|
|
2562
|
-
}
|
|
2563
|
-
return '';
|
|
2564
|
-
} catch {
|
|
2565
|
-
return '';
|
|
3016
|
+
return tail.map((line) => {
|
|
3017
|
+
try {
|
|
3018
|
+
const obj = JSON.parse(line);
|
|
3019
|
+
// Skill-injection vector: drop system-injected role:user
|
|
3020
|
+
// messages before they pollute the close-intent signal.
|
|
3021
|
+
// • isMeta:true — slash-command bodies, skill bodies, local-command
|
|
3022
|
+
// caveats. Their text is docs/specs, often full of close vocabulary
|
|
3023
|
+
// (e.g. the /hypo:crystallize spec literally contains close phrases),
|
|
3024
|
+
// which would let the gate self-satisfy the moment the model invokes
|
|
3025
|
+
// a close command. Confirmed isMeta:true in the transcript.
|
|
3026
|
+
// • promptSource system|sdk — task-notifications (system) and
|
|
3027
|
+
// SDK / QA-harness synthetic prompts (sdk). Neither is user-typed.
|
|
3028
|
+
if (obj.isMeta === true) return '';
|
|
3029
|
+
if (obj.promptSource === 'system' || obj.promptSource === 'sdk') return '';
|
|
3030
|
+
const msg = obj.message ?? obj;
|
|
3031
|
+
const role = msg.role ?? obj.role ?? obj.type;
|
|
3032
|
+
if (role !== 'user') return '';
|
|
3033
|
+
const content = msg.content ?? obj.content;
|
|
3034
|
+
if (typeof content === 'string') {
|
|
3035
|
+
// Stop-hook block feedback is recorded as a role:user string. It is
|
|
3036
|
+
// the hook's OWN nudge ("[WIKI_AUTOCLOSE] … Run crystallize …"), not
|
|
3037
|
+
// user intent — counting it would be circular (the hook that prods the
|
|
3038
|
+
// model to close would become proof the user wanted to close).
|
|
3039
|
+
return content.startsWith('Stop hook feedback') ? '' : content;
|
|
2566
3040
|
}
|
|
2567
|
-
|
|
2568
|
-
|
|
3041
|
+
if (Array.isArray(content)) {
|
|
3042
|
+
// Only genuine user-typed text blocks. tool_result blocks are also
|
|
3043
|
+
// recorded with role:'user' in the Claude Code transcript; slurping
|
|
3044
|
+
// them via JSON.stringify let tool output (e.g. close-pattern example
|
|
3045
|
+
// strings read out of code/docs) trip the close-intent gate.
|
|
3046
|
+
// Do NOT recurse into tool_result.content, or the pollution returns.
|
|
3047
|
+
return content
|
|
3048
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
3049
|
+
.map((b) => b.text)
|
|
3050
|
+
.join('\n');
|
|
3051
|
+
}
|
|
3052
|
+
return '';
|
|
3053
|
+
} catch {
|
|
3054
|
+
return '';
|
|
3055
|
+
}
|
|
3056
|
+
});
|
|
2569
3057
|
} catch {
|
|
2570
|
-
return
|
|
3058
|
+
return [];
|
|
2571
3059
|
}
|
|
2572
3060
|
}
|
|
2573
3061
|
|
|
@@ -2678,109 +3166,342 @@ export function resolveTranscriptBySessionId(
|
|
|
2678
3166
|
}
|
|
2679
3167
|
|
|
2680
3168
|
/**
|
|
2681
|
-
* Returns true iff the transcript
|
|
2682
|
-
*
|
|
2683
|
-
*
|
|
2684
|
-
*
|
|
2685
|
-
*
|
|
2686
|
-
*
|
|
2687
|
-
*
|
|
2688
|
-
*
|
|
2689
|
-
*
|
|
2690
|
-
*
|
|
2691
|
-
*
|
|
2692
|
-
*
|
|
2693
|
-
*
|
|
2694
|
-
*
|
|
2695
|
-
*
|
|
2696
|
-
*
|
|
2697
|
-
*
|
|
2698
|
-
*
|
|
2699
|
-
*
|
|
3169
|
+
* Returns true iff the transcript's LATEST live user decision is to close — the
|
|
3170
|
+
* hard gate for the session-closed marker writers. This is a state predicate, not
|
|
3171
|
+
* an existence one: "is close still approved right now", not "did a
|
|
3172
|
+
* close signal ever appear". Scans the FULL transcript in line order, classifies
|
|
3173
|
+
* each record, and tracks the approval as a LEASE.
|
|
3174
|
+
*
|
|
3175
|
+
* Classification (structural fields only — never content heuristics for producer):
|
|
3176
|
+
* • GRANT a genuine user close: an NL close phrase in user text that
|
|
3177
|
+
* survives {@link eventUserText}'s exclusions; a `/compact`
|
|
3178
|
+
* queue-op; a remove-path queued_command attachment carrying a
|
|
3179
|
+
* close with an audited human producer (origin.kind "human"); a
|
|
3180
|
+
* correlated, non-error AskUserQuestion answer naming a close.
|
|
3181
|
+
* • INVALIDATE a fresh user intent that expires the lease: any other genuine
|
|
3182
|
+
* user text, `/clear`, `popAll`, a non-close queued_command, a
|
|
3183
|
+
* non-close AskUserQuestion selection.
|
|
3184
|
+
* • NEUTRAL everything the model can produce or the harness injects: system/
|
|
3185
|
+
* sdk replay, isMeta bodies, sidechain, interruptedMessageId
|
|
3186
|
+
* companions, assistant, tool_result, task-notification.
|
|
3187
|
+
* • FATAL an unparseable line — the transcript is being appended to or is
|
|
3188
|
+
* corrupt, so refuse rather than read a half-written record.
|
|
3189
|
+
*
|
|
3190
|
+
* The last grant wins and a later invalidate expires it, so a stale close (Defect
|
|
3191
|
+
* B), a queued "keep working" after a close, and a non-close AskUserQuestion
|
|
3192
|
+
* selection all correctly read as NOT closed. Abandoned-branch staleness is a
|
|
3193
|
+
* known limit (no leaf pointer exists to resolve it — see the branch note on the
|
|
3194
|
+
* function), mitigated by the lease.
|
|
3195
|
+
*
|
|
3196
|
+
* Threat model: an honest-but-drifting model that does not forge the
|
|
3197
|
+
* transcript. This is a safety interlock, not an authorization boundary — a model
|
|
3198
|
+
* that forges records is out of scope and documented so.
|
|
3199
|
+
*
|
|
3200
|
+
* Fail-closed: any read/parse error, or an unresolvable branch, → false.
|
|
2700
3201
|
*/
|
|
3202
|
+
// Genuine user text of a record, or null when the record is on a channel the
|
|
3203
|
+
// honest-but-drifting model can reach (so it is never user intent — NEUTRAL).
|
|
3204
|
+
// role:user is the boundary the model cannot cross: it authors role:assistant,
|
|
3205
|
+
// so a user record that survives every exclusion below is genuine user text.
|
|
3206
|
+
// The exclusions are exactly the model-reachable channels:
|
|
3207
|
+
// • isMeta slash-command / skill bodies and local-command caveats
|
|
3208
|
+
// • promptSource system|sdk queue replay of injected text, SDK prompts
|
|
3209
|
+
// • isSidechain subagent context (a subagent must not close the session)
|
|
3210
|
+
// • interruptedMessageId the harness cut the model off; user-SHAPED, not a
|
|
3211
|
+
// user decision (e.g. the [Request interrupted by user]
|
|
3212
|
+
// companion of a delivered /compact)
|
|
3213
|
+
// • Stop-hook feedback the hook's own close nudge — circular
|
|
3214
|
+
// • tool_result blocks tool output, not typed text
|
|
3215
|
+
// No promptSource allowlist is required: requiring `typed` would drop the
|
|
3216
|
+
// legacy absent-promptSource close the older gate has always honoured, while the
|
|
3217
|
+
// dangerous replay/injection paths carry system|sdk|isMeta|isSidechain and are
|
|
3218
|
+
// excluded here anyway.
|
|
3219
|
+
function eventUserText(obj) {
|
|
3220
|
+
if (obj.isMeta === true) return null;
|
|
3221
|
+
if (obj.promptSource === 'system' || obj.promptSource === 'sdk') return null;
|
|
3222
|
+
if (obj.isSidechain === true) return null;
|
|
3223
|
+
if (obj.interruptedMessageId) return null;
|
|
3224
|
+
const msg = obj.message ?? obj;
|
|
3225
|
+
const role = msg.role ?? obj.role ?? obj.type;
|
|
3226
|
+
if (role !== 'user') return null;
|
|
3227
|
+
const content = msg.content ?? obj.content;
|
|
3228
|
+
if (typeof content === 'string') {
|
|
3229
|
+
return content.startsWith('Stop hook feedback') ? null : content;
|
|
3230
|
+
}
|
|
3231
|
+
if (Array.isArray(content)) {
|
|
3232
|
+
const texts = content
|
|
3233
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
3234
|
+
.map((b) => b.text);
|
|
3235
|
+
return texts.length ? texts.join('\n') : null;
|
|
3236
|
+
}
|
|
3237
|
+
return null;
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
// A record on a channel the honest-but-drifting model can reach, so it can never
|
|
3241
|
+
// be a user decision. Used to keep injected / replayed / subagent records out of
|
|
3242
|
+
// BOTH the user-text and the AskUserQuestion-answer classifiers.
|
|
3243
|
+
function isModelReachableRecord(obj) {
|
|
3244
|
+
return (
|
|
3245
|
+
obj.isMeta === true ||
|
|
3246
|
+
obj.promptSource === 'system' ||
|
|
3247
|
+
obj.promptSource === 'sdk' ||
|
|
3248
|
+
obj.isSidechain === true
|
|
3249
|
+
);
|
|
3250
|
+
}
|
|
3251
|
+
|
|
2701
3252
|
export function hasUserCloseSignal(transcriptPath) {
|
|
2702
3253
|
if (!transcriptPath) return false;
|
|
2703
|
-
let
|
|
3254
|
+
let raw;
|
|
2704
3255
|
try {
|
|
2705
|
-
|
|
3256
|
+
raw = readFileSync(transcriptPath, 'utf-8');
|
|
2706
3257
|
} catch {
|
|
2707
3258
|
return false;
|
|
2708
3259
|
}
|
|
2709
|
-
//
|
|
2710
|
-
|
|
2711
|
-
//
|
|
2712
|
-
//
|
|
2713
|
-
//
|
|
2714
|
-
//
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
// so a single forward scan suffices.
|
|
2718
|
-
const askIds = new Set();
|
|
2719
|
-
for (const line of lines) {
|
|
3260
|
+
// FATAL: a non-empty line that will not parse means the transcript is being
|
|
3261
|
+
// appended to (a half-written record) or is corrupt. Skipping it would let a
|
|
3262
|
+
// stale prior grant survive past an event we cannot read, so refuse. A line
|
|
3263
|
+
// that parses to a non-object (a bare null / string / number) is valid JSON but
|
|
3264
|
+
// not a record — noise, not corruption — so it is skipped, not fatal, and never
|
|
3265
|
+
// reaches the field reads below.
|
|
3266
|
+
const recs = [];
|
|
3267
|
+
for (const line of raw.split('\n')) {
|
|
2720
3268
|
if (!line.trim()) continue;
|
|
2721
|
-
let
|
|
3269
|
+
let o;
|
|
2722
3270
|
try {
|
|
2723
|
-
|
|
3271
|
+
o = JSON.parse(line);
|
|
2724
3272
|
} catch {
|
|
3273
|
+
return false;
|
|
3274
|
+
}
|
|
3275
|
+
if (o === null || typeof o !== 'object') continue;
|
|
3276
|
+
recs.push(o);
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
// The approval is a LEASE, not an existence fact: walk the transcript in line
|
|
3280
|
+
// order and track whether the LATEST user decision is a grant. Each grant sets
|
|
3281
|
+
// it true, each invalidate sets it false, neutral leaves it — so at the end
|
|
3282
|
+
// `granted` is "the most recent user decision was to close, and nothing has
|
|
3283
|
+
// expired it since", which is how a stale close and a queued change-of-mind
|
|
3284
|
+
// read as NOT closed.
|
|
3285
|
+
//
|
|
3286
|
+
// Branch note: line order mixes an abandoned branch's records with the live
|
|
3287
|
+
// ones. A leaf-pointer ancestry filter was tried and withdrawn — the transcript
|
|
3288
|
+
// carries no authoritative leaf pointer (measured: 0 leafUuid / summary
|
|
3289
|
+
// records), so a heuristic leaf can skip the real invalidator and PRESERVE a
|
|
3290
|
+
// stale grant (a fail-open, not a conservative filter). Until such a pointer
|
|
3291
|
+
// exists, a close on a branch abandoned under a neutral tail is a known
|
|
3292
|
+
// staleness limit, mitigated by the lease: any later live user intent, on any
|
|
3293
|
+
// branch, still expires it.
|
|
3294
|
+
const askIds = new Set();
|
|
3295
|
+
let granted = false;
|
|
3296
|
+
|
|
3297
|
+
for (const o of recs) {
|
|
3298
|
+
// Queue operations. The queue carries no correlation key (measured), so the
|
|
3299
|
+
// ENQUEUE content is the decision — not a later contentless dequeue, which
|
|
3300
|
+
// would need pairing we cannot do. Reading the enqueue also keeps the live
|
|
3301
|
+
// PreCompact gate working (it sees the enqueue) and avoids double-counting the
|
|
3302
|
+
// replay companion of an already-decided item (the /compact replay is not a
|
|
3303
|
+
// fresh decision). popAll cancels the queue → invalidate. Delivery ops
|
|
3304
|
+
// (dequeue, remove) carry no fresh decision here — a content-bearing remove of
|
|
3305
|
+
// an NL queued command is handled by its queued_command attachment below.
|
|
3306
|
+
if (o.type === 'queue-operation') {
|
|
3307
|
+
if (o.operation === 'popAll') {
|
|
3308
|
+
granted = false;
|
|
3309
|
+
continue;
|
|
3310
|
+
}
|
|
3311
|
+
if (o.operation !== 'enqueue') continue;
|
|
3312
|
+
const c = typeof o.content === 'string' ? o.content.trim() : '';
|
|
3313
|
+
if (/^\/compact(?:\s|$)/.test(c))
|
|
3314
|
+
granted = true; // a user compaction preserves the work → grant
|
|
3315
|
+
else if (/^\/clear(?:\s|$)/.test(c))
|
|
3316
|
+
granted = false; // abandons context → invalidate
|
|
3317
|
+
else if (!c || c.startsWith('<task-notification>')) {
|
|
3318
|
+
/* model-caused / empty — neutral */
|
|
3319
|
+
} else if (isClosePattern(c)) {
|
|
3320
|
+
/* NL close via the queue — the open dequeue gap: the producer cannot be
|
|
3321
|
+
attributed (a peer/model enqueue wears the same shape), so no grant */
|
|
3322
|
+
} else granted = false; // a queued non-close user intent → invalidate (change of mind)
|
|
2725
3323
|
continue;
|
|
2726
3324
|
}
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
)
|
|
2733
|
-
|
|
3325
|
+
|
|
3326
|
+
// remove-path delivery of a queued natural-language command (measured: the
|
|
3327
|
+
// item leaves the queue as it is handed to the model, landing as an
|
|
3328
|
+
// `attachment` of type queued_command with the prompt verbatim). A close here
|
|
3329
|
+
// grants ONLY with an audited human producer — origin.kind "human", present
|
|
3330
|
+
// on every 2.1.181+ user delivery (measured). A legacy origin-absent delivery
|
|
3331
|
+
// cannot attest a producer, so it does not grant (fail-closed). A NON-close
|
|
3332
|
+
// queued command (e.g. "keep working") is a fresh user intent and INVALIDATES
|
|
3333
|
+
// a prior grant regardless of origin — that is what closes the re-close hole
|
|
3334
|
+
// where a queued "continue" after a close leaves the stale lease live.
|
|
3335
|
+
if (o.type === 'attachment' && o.attachment && o.attachment.type === 'queued_command') {
|
|
3336
|
+
const prompt = typeof o.attachment.prompt === 'string' ? o.attachment.prompt : '';
|
|
3337
|
+
const humanOrigin = !!(o.attachment.origin && o.attachment.origin.kind === 'human');
|
|
3338
|
+
if (isClosePattern(prompt)) {
|
|
3339
|
+
if (humanOrigin) granted = true;
|
|
3340
|
+
} else if (prompt) {
|
|
3341
|
+
granted = false;
|
|
3342
|
+
}
|
|
3343
|
+
continue;
|
|
2734
3344
|
}
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
3345
|
+
|
|
3346
|
+
// Record AskUserQuestion tool_use ids (assistant record, always precedes its
|
|
3347
|
+
// answer in line order).
|
|
3348
|
+
const content = (o.message ?? o).content;
|
|
3349
|
+
if (Array.isArray(content)) {
|
|
3350
|
+
for (const b of content) {
|
|
3351
|
+
if (
|
|
3352
|
+
b &&
|
|
3353
|
+
typeof b === 'object' &&
|
|
3354
|
+
b.type === 'tool_use' &&
|
|
3355
|
+
b.name === 'AskUserQuestion' &&
|
|
3356
|
+
b.id
|
|
3357
|
+
)
|
|
3358
|
+
askIds.add(b.id);
|
|
2743
3359
|
}
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
3360
|
+
}
|
|
3361
|
+
|
|
3362
|
+
// Genuine user text → grant on a close phrase, invalidate on anything else.
|
|
3363
|
+
const text = eventUserText(o);
|
|
3364
|
+
if (text != null && text !== '') {
|
|
3365
|
+
granted = isClosePattern(text);
|
|
3366
|
+
continue;
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
// AskUserQuestion answer, correlated to a recorded AskUserQuestion, EXCLUDED
|
|
3370
|
+
// and HARDENED. isModelReachableRecord keeps an injected / sdk / sidechain
|
|
3371
|
+
// record from reaching the answer parser. is_error:false AND the host's
|
|
3372
|
+
// success marker are required because a malformed AskUserQuestion echoes the
|
|
3373
|
+
// raw input back in an is_error result, and the model authors the option
|
|
3374
|
+
// labels. A close selection grants; any other real selection invalidates.
|
|
3375
|
+
if (Array.isArray(content) && !isModelReachableRecord(o)) {
|
|
3376
|
+
for (const b of content) {
|
|
3377
|
+
if (!b || typeof b !== 'object') continue;
|
|
3378
|
+
if (b.type !== 'tool_result' || !b.tool_use_id || !askIds.has(b.tool_use_id)) continue;
|
|
3379
|
+
if (b.is_error === true) continue;
|
|
2750
3380
|
const s = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
|
|
3381
|
+
if (!/have been answered/.test(s)) continue;
|
|
3382
|
+
let sawAnswer = false;
|
|
3383
|
+
let sawClose = false;
|
|
2751
3384
|
for (const m of s.matchAll(/="([^"]*)"/g)) {
|
|
2752
|
-
|
|
3385
|
+
sawAnswer = true;
|
|
3386
|
+
if (isClosePattern(m[1])) sawClose = true;
|
|
2753
3387
|
}
|
|
3388
|
+
if (sawClose) granted = true;
|
|
3389
|
+
else if (sawAnswer) granted = false;
|
|
2754
3390
|
}
|
|
2755
3391
|
}
|
|
2756
3392
|
}
|
|
2757
|
-
return
|
|
3393
|
+
return granted;
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
/** The literal the user must type to approve a parked-overwrite batch. */
|
|
3397
|
+
export const APPROVAL_PHRASE = 'apply-proposals';
|
|
3398
|
+
|
|
3399
|
+
/**
|
|
3400
|
+
* True iff the transcript carries a user's TYPED approval of the batch this nonce
|
|
3401
|
+
* was minted for — the authorization gate for a transcript-approved apply.
|
|
3402
|
+
*
|
|
3403
|
+
* Deliberately NOT hasUserCloseSignal. That function answers "did the user want to
|
|
3404
|
+
* end the session", and it accepts a correlated AskUserQuestion answer as evidence
|
|
3405
|
+
* (see above). Reusing it here would let a SESSION-CLOSE approval spend itself as an
|
|
3406
|
+
* OVERWRITE approval: different authority, different question. Two gates, two
|
|
3407
|
+
* matchers.
|
|
3408
|
+
*
|
|
3409
|
+
* Typed text only, and an AskUserQuestion click is refused ON PURPOSE. The model
|
|
3410
|
+
* authors the option labels, and it authors them AFTER it has seen the nonce — so
|
|
3411
|
+
* it could place `apply-proposals <nonce>` on a "no" option, on every option, or
|
|
3412
|
+
* under a question that asks something else entirely. A click would prove a click.
|
|
3413
|
+
* Typing the nonce proves the user produced THIS phrase.
|
|
3414
|
+
*
|
|
3415
|
+
* The nonce carries the freshness: it is minted (crypto-random) only once the diff
|
|
3416
|
+
* has been re-read from disk and shown, so a turn that predates the diff cannot
|
|
3417
|
+
* contain it, and `resolve` spends the challenge BEFORE it writes, so it cannot be
|
|
3418
|
+
* replayed.
|
|
3419
|
+
*
|
|
3420
|
+
* extractUserMessageRecords does the de-pollution: it drops `isMeta` bodies
|
|
3421
|
+
* (slash-command and skill text, so a doc that quotes this phrase cannot satisfy the
|
|
3422
|
+
* gate), `promptSource: system|sdk`, Stop-hook feedback, and `tool_result` blocks (so
|
|
3423
|
+
* neither a tool's output nor a Read of a file that contains the phrase counts).
|
|
3424
|
+
* The model's own words are role:assistant and never reach it.
|
|
3425
|
+
*
|
|
3426
|
+
* Fail-closed: no path, a nonce that is not the minted shape, or an unreadable
|
|
3427
|
+
* transcript all return false.
|
|
3428
|
+
*
|
|
3429
|
+
* @param {string} transcriptPath
|
|
3430
|
+
* @param {string} nonce hex, as minted by `proposal challenge`
|
|
3431
|
+
*/
|
|
3432
|
+
export function hasTypedUserApproval(transcriptPath, nonce) {
|
|
3433
|
+
if (!transcriptPath || typeof nonce !== 'string') return false;
|
|
3434
|
+
// Pin the shape rather than accept any string: a caller that passed '' or a
|
|
3435
|
+
// regex-ish value would otherwise turn the match into a wildcard.
|
|
3436
|
+
if (!/^[a-f0-9]{32,}$/.test(nonce)) return false;
|
|
3437
|
+
// A MESSAGE that IS the phrase, not a message that has the phrase somewhere in it.
|
|
3438
|
+
// Line-exactness is not enough, because the user is TOLD to type this phrase and so
|
|
3439
|
+
// it is natural to quote it back while hesitating:
|
|
3440
|
+
//
|
|
3441
|
+
// I do not consent; I am only quoting the command:
|
|
3442
|
+
// apply-proposals <nonce>
|
|
3443
|
+
//
|
|
3444
|
+
// Every line-level matcher reads that as approval, and the user has authorized an
|
|
3445
|
+
// overwrite by refusing one. The whole eligible message must be the phrase and
|
|
3446
|
+
// nothing else, which is the bar the TTY channel has always held (`apply <id>`,
|
|
3447
|
+
// alone, on the prompt). The two channels must not disagree about what consent
|
|
3448
|
+
// looks like.
|
|
3449
|
+
//
|
|
3450
|
+
// Measured, not assumed: across 468 eligible user records in this project's
|
|
3451
|
+
// transcripts, none carried a second text block and none carried injected
|
|
3452
|
+
// system-reminder text, so a turn whose only content is the phrase survives
|
|
3453
|
+
// extraction as exactly the phrase. A false negative costs a retype; a false
|
|
3454
|
+
// positive costs the user's file.
|
|
3455
|
+
const want = `${APPROVAL_PHRASE} ${nonce}`;
|
|
3456
|
+
const records = extractUserMessageRecords(transcriptPath, Infinity);
|
|
3457
|
+
return records.some((msg) => msg.trim() === want);
|
|
2758
3458
|
}
|
|
2759
3459
|
|
|
2760
3460
|
/**
|
|
2761
|
-
* True iff the Stop payload
|
|
2762
|
-
*
|
|
2763
|
-
* reconfirm trigger (see
|
|
2764
|
-
*
|
|
3461
|
+
* True iff the Stop payload shows work still pending in the background —
|
|
3462
|
+
* either a non-terminal `background_tasks` entry OR a scheduled `session_crons`
|
|
3463
|
+
* wake. Read-only: used to widen the autoclose reconfirm trigger (see
|
|
3464
|
+
* hypo-auto-minimal-crystallize.mjs) to "work is demonstrably still running",
|
|
3465
|
+
* not just "the wiki has uncommitted changes".
|
|
3466
|
+
*
|
|
3467
|
+
* ANY non-terminal `background_tasks` entry counts, not just delegated
|
|
3468
|
+
* subagents. A Stop payload captured from a real session confirmed the field
|
|
3469
|
+
* is genuinely sent (the older "grep turns up nothing / may not be sent" note
|
|
3470
|
+
* is retired): a background Bash appears as
|
|
3471
|
+
* `{id, type:'shell', status:'running', description, command}`, alongside
|
|
3472
|
+
* `type:'subagent'` for delegations. Restricting to `type==='subagent'` missed
|
|
3473
|
+
* shell background work (e.g. a `git push` / CI wait deferred behind a close
|
|
3474
|
+
* signal), letting the reconfirm branch mis-classify the session as idle and
|
|
3475
|
+
* re-nag every Stop turn.
|
|
2765
3476
|
*
|
|
2766
3477
|
* Defined as the NEGATION of a terminal status, not an enumeration of
|
|
2767
|
-
* "running" states
|
|
2768
|
-
*
|
|
2769
|
-
*
|
|
2770
|
-
*
|
|
2771
|
-
*
|
|
2772
|
-
*
|
|
2773
|
-
*
|
|
2774
|
-
*
|
|
2775
|
-
*
|
|
3478
|
+
* "running" states: the observed non-terminal status is "running", and a
|
|
3479
|
+
* finished task is removed from the array entirely (it does NOT linger in a
|
|
3480
|
+
* terminal state — the next Stop simply reports `background_tasks:[]`). An
|
|
3481
|
+
* unrecognized/absent status is therefore treated as in-flight; only a
|
|
3482
|
+
* recognized terminal status clears an entry that is still present.
|
|
3483
|
+
*
|
|
3484
|
+
* `session_crons`: a non-empty array means the session is waiting on a
|
|
3485
|
+
* scheduled wake, which is itself pending work. A missing / non-array
|
|
3486
|
+
* `session_crons` is ignored (fail-open).
|
|
3487
|
+
*
|
|
3488
|
+
* Fail-open overall: a missing or non-array `background_tasks` contributes no
|
|
3489
|
+
* pending signal, so detection degrades gracefully to the git-uncommitted
|
|
3490
|
+
* signal alone rather than spuriously blocking.
|
|
2776
3491
|
*/
|
|
2777
|
-
export function
|
|
2778
|
-
|
|
2779
|
-
if (!Array.isArray(tasks)) return false;
|
|
3492
|
+
export function hasPendingBackgroundWork(payload) {
|
|
3493
|
+
if (!payload || typeof payload !== 'object') return false;
|
|
2780
3494
|
const TERMINAL = /^(completed|complete|done|finished|failed|error|errored|cancelled|canceled)$/i;
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
3495
|
+
const tasks = payload.background_tasks;
|
|
3496
|
+
if (
|
|
3497
|
+
Array.isArray(tasks) &&
|
|
3498
|
+
tasks.some((t) => t && (t.status == null || !TERMINAL.test(String(t.status))))
|
|
3499
|
+
) {
|
|
3500
|
+
return true;
|
|
3501
|
+
}
|
|
3502
|
+
const crons = payload.session_crons;
|
|
3503
|
+
if (Array.isArray(crons) && crons.length > 0) return true;
|
|
3504
|
+
return false;
|
|
2784
3505
|
}
|
|
2785
3506
|
|
|
2786
3507
|
// The reconfirm reason (hypo-auto-minimal-crystallize.mjs emitBlock's
|
|
@@ -2855,7 +3576,8 @@ export function isCloseReconfirmDeclined(transcriptPath) {
|
|
|
2855
3576
|
// system/sdk synthetic prompts, and (via the array-content branch below)
|
|
2856
3577
|
// tool_result blocks, which are role:'user' in the transcript but are NOT
|
|
2857
3578
|
// user-typed text.
|
|
2858
|
-
const isInjected =
|
|
3579
|
+
const isInjected =
|
|
3580
|
+
obj.isMeta === true || obj.promptSource === 'system' || obj.promptSource === 'sdk';
|
|
2859
3581
|
const role = msg.role ?? obj.role ?? obj.type;
|
|
2860
3582
|
if (!isInjected && role === 'user') {
|
|
2861
3583
|
if (typeof content === 'string') {
|
|
@@ -2941,7 +3663,15 @@ export function computeSessionGrowth(hypoDir) {
|
|
|
2941
3663
|
// more expensive `git diff HEAD --unified=0` — Stop hook P95 win.
|
|
2942
3664
|
// `-uall` expands untracked directories so a brand-new `pages/new.md`
|
|
2943
3665
|
// isn't hidden behind a single `?? pages/` line.
|
|
2944
|
-
|
|
3666
|
+
// `-z`: NUL-separated, verbatim paths (see commitWikiChanges). The old
|
|
3667
|
+
// newline/quote-strip parser left octal escapes in place, so Korean page
|
|
3668
|
+
// names silently failed the `pages/`·`projects/` scope match and dropped
|
|
3669
|
+
// out of the growth count.
|
|
3670
|
+
// `-z`: NUL-separated, verbatim paths (see commitWikiChanges). The old
|
|
3671
|
+
// newline/quote-strip parser left octal escapes in place, so Korean page
|
|
3672
|
+
// names silently failed the `pages/`·`projects/` scope match and dropped
|
|
3673
|
+
// out of the growth count.
|
|
3674
|
+
const porcelain = spawnSync('git', ['-C', hypoDir, 'status', '--porcelain', '-uall', '-z'], {
|
|
2945
3675
|
encoding: 'utf-8',
|
|
2946
3676
|
timeout: 5000,
|
|
2947
3677
|
});
|
|
@@ -2955,10 +3685,14 @@ export function computeSessionGrowth(hypoDir) {
|
|
|
2955
3685
|
// intentionally excluded — they're scaffolding, not page growth.
|
|
2956
3686
|
const inPagesScope = (file) =>
|
|
2957
3687
|
file.endsWith('.md') && (file.startsWith('pages/') || file.startsWith('projects/'));
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
const
|
|
2961
|
-
|
|
3688
|
+
const records = (porcelain.stdout || '').split('\0');
|
|
3689
|
+
for (let i = 0; i < records.length; i++) {
|
|
3690
|
+
const rec = records[i];
|
|
3691
|
+
if (!rec) continue;
|
|
3692
|
+
const xy = rec.slice(0, 2);
|
|
3693
|
+
const file = rec.slice(3); // destination path for a rename/copy
|
|
3694
|
+
// A rename OR copy emits two records (`to\0from`); consume the trailing `from`.
|
|
3695
|
+
if (xy[0] === 'R' || xy[1] === 'R' || xy[0] === 'C' || xy[1] === 'C') i++;
|
|
2962
3696
|
if (!inPagesScope(file)) continue;
|
|
2963
3697
|
if (xy === '??') {
|
|
2964
3698
|
untrackedMd.push(file);
|
|
@@ -2966,7 +3700,8 @@ export function computeSessionGrowth(hypoDir) {
|
|
|
2966
3700
|
continue;
|
|
2967
3701
|
}
|
|
2968
3702
|
hasTrackedMdChange = true;
|
|
2969
|
-
|
|
3703
|
+
// A copy's destination is a brand-new page, so it counts as added like `A`.
|
|
3704
|
+
if (xy.includes('A') || xy.includes('C')) addedPages++;
|
|
2970
3705
|
else if (xy.includes('M') || xy.includes('R')) updatedPages++;
|
|
2971
3706
|
}
|
|
2972
3707
|
if (!hasTrackedMdChange && untrackedMd.length === 0) return empty;
|
|
@@ -3058,3 +3793,65 @@ export function isIgnored(filePath, hypoDir, patterns) {
|
|
|
3058
3793
|
}
|
|
3059
3794
|
return false;
|
|
3060
3795
|
}
|
|
3796
|
+
|
|
3797
|
+
// ── visibility scope ─────────────────────────────────────────────────────────
|
|
3798
|
+
// The machine-scoped visibility namespace (`visibility_scope: machine:<device>`)
|
|
3799
|
+
// requires that the SAME device string is produced at write time (audit device
|
|
3800
|
+
// stamps) and at lookup time (the visibility filter) — otherwise a page never
|
|
3801
|
+
// matches its own machine. So this is the single source for both. NOT cached:
|
|
3802
|
+
// each call reads env fresh so in-process tests can override via HYPO_DEVICE
|
|
3803
|
+
// (os.hostname is not mockable). CR/LF are stripped so the value stays a single
|
|
3804
|
+
// frontmatter token.
|
|
3805
|
+
export function currentDevice() {
|
|
3806
|
+
// Strip CR/LF BEFORE the fallback chain, not after: a HYPO_DEVICE of only
|
|
3807
|
+
// CR/LF is truthy, so stripping after `||` would yield '' and make
|
|
3808
|
+
// scopeVisible('machine:', '') pass — the empty-owner page must hide
|
|
3809
|
+
// everywhere. Stripping first collapses such a value to '' so it falls through
|
|
3810
|
+
// to hostname, keeping the result non-empty on every path.
|
|
3811
|
+
const env = String(process.env.HYPO_DEVICE || '').replace(/[\r\n]/g, '');
|
|
3812
|
+
if (env) return env;
|
|
3813
|
+
const host = String(hostname() || '').replace(/[\r\n]/g, '');
|
|
3814
|
+
return host || 'unknown';
|
|
3815
|
+
}
|
|
3816
|
+
|
|
3817
|
+
// Extract the top-level `visibility_scope` from a page's raw content. Mirrors
|
|
3818
|
+
// scripts/lib/frontmatter.mjs normalization (top-level only, first-wins, strip a
|
|
3819
|
+
// whitespace-led trailing YAML comment, strip surrounding quotes) rather than
|
|
3820
|
+
// importing it: hooks deploy to ~/.claude/hooks/ with no external imports. The
|
|
3821
|
+
// five consumers must call this instead of a local last-wins/no-comment parser,
|
|
3822
|
+
// else a `machine:devA # note` value fails to match on its own machine. Returns
|
|
3823
|
+
// '' when absent (which scopeVisible treats as shared).
|
|
3824
|
+
export function readVisibilityScope(raw) {
|
|
3825
|
+
const m = String(raw || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
3826
|
+
if (!m) return '';
|
|
3827
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
3828
|
+
if (/^\s/.test(line) || /^-(\s|$)/.test(line)) continue; // nested / list item
|
|
3829
|
+
const idx = line.indexOf(':');
|
|
3830
|
+
if (idx < 0) continue;
|
|
3831
|
+
if (line.slice(0, idx).trim() !== 'visibility_scope') continue;
|
|
3832
|
+
return line
|
|
3833
|
+
.slice(idx + 1)
|
|
3834
|
+
.trim()
|
|
3835
|
+
.replace(/\s+#.*$/, '')
|
|
3836
|
+
.replace(/^["']|["']$/g, '');
|
|
3837
|
+
}
|
|
3838
|
+
return '';
|
|
3839
|
+
}
|
|
3840
|
+
|
|
3841
|
+
// The single visibility decision, shared by lookup / query / file-watch /
|
|
3842
|
+
// page-usage / crystallize. `scopeValue` is a readVisibilityScope() output,
|
|
3843
|
+
// `device` a currentDevice() output. Prefix dispatch, fail-open on anything
|
|
3844
|
+
// unrecognized so the field is purely additive:
|
|
3845
|
+
// ''/'shared' → visible (the implicit default of every pre-existing page)
|
|
3846
|
+
// 'machine:<owner>' → visible only on the owning machine. Empty owner
|
|
3847
|
+
// (`machine:`) hides everywhere: '' can never equal
|
|
3848
|
+
// currentDevice()'s non-empty fallback.
|
|
3849
|
+
// 'agent:<id>' → visible; value space reserved, no writer yet (forward-compat)
|
|
3850
|
+
// anything else → visible (fail-open)
|
|
3851
|
+
export function scopeVisible(scopeValue, device) {
|
|
3852
|
+
const v = String(scopeValue || '').trim();
|
|
3853
|
+
if (v === '' || v === 'shared') return true;
|
|
3854
|
+
if (v.startsWith('machine:')) return v.slice('machine:'.length) === device;
|
|
3855
|
+
if (v.startsWith('agent:')) return true;
|
|
3856
|
+
return true;
|
|
3857
|
+
}
|