session-orchestrator 5.5.0 → 5.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/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +46 -0
- package/README.md +27 -13
- package/docs/USER-GUIDE.md +1 -1
- package/docs/rule-authoring.md +10 -0
- package/docs/scope-collision-guard.md +5 -3
- package/docs/session-config-reference.md +1 -1
- package/docs/session-config-template.md +1 -1
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-stop.mjs +8 -2
- package/hooks/post-bash-write-verify.mjs +121 -7
- package/hooks/post-tool-batch-wave-signal.mjs +11 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -8
- package/hooks/pre-task-scope-disjoint.mjs +20 -35
- package/hooks/wave-scope-commit-guard.mjs +117 -20
- package/package.json +8 -3
- package/scripts/backfill-abandoned-sessions.mjs +5 -7
- package/scripts/backfill-learnings-from-vault.mjs +17 -6
- package/scripts/lib/dispatcher/rank.mjs +5 -1
- package/scripts/lib/events.mjs +62 -49
- package/scripts/lib/io.mjs +9 -4
- package/scripts/lib/learnings/candidates.mjs +7 -16
- package/scripts/lib/memory-proposals/store.mjs +9 -2
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +52 -0
- package/scripts/lib/scope-gate.mjs +8 -1
- package/scripts/lib/session-close-backfill.mjs +104 -13
- package/scripts/lib/validate/check-test-value-bans.mjs +9 -0
- package/scripts/lib/vault-mirror/process.mjs +388 -56
- package/scripts/lib/vault-mirror/prune.mjs +413 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +13 -5
- package/scripts/lib/vault-mirror/retention.mjs +388 -0
- package/scripts/lib/vault-status/board-writer.mjs +6 -6
- package/scripts/lib/wave-transcript-tail.mjs +11 -2
- package/scripts/print-applicable-rules.mjs +1 -25
- package/scripts/release.mjs +4 -0
- package/scripts/validate-wave-scope.mjs +39 -4
- package/scripts/vault-mirror-prune.mjs +145 -0
- package/scripts/vault-mirror.mjs +77 -12
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +6 -16
- package/skills/vault-mirror/SKILL.md +27 -2
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +3 -1
- package/hooks/agent-teams-h3-test.sh +0 -362
- package/scripts/spikes/h3-agent-teams/preflight.sh +0 -53
- package/scripts/spikes/h3-agent-teams/run-h3.sh +0 -112
- package/scripts/spikes/h3-agent-teams/setup.sh +0 -137
- package/scripts/spikes/h3-agent-teams/toggle.sh +0 -38
|
@@ -413,7 +413,10 @@ export async function appendProposal({
|
|
|
413
413
|
* Reads proposals.jsonl without acquiring the lock — suitable for diagnostics
|
|
414
414
|
* and reads where eventual consistency is acceptable.
|
|
415
415
|
*
|
|
416
|
-
* Returns 0 when the file does not exist
|
|
416
|
+
* Returns 0 when the file does not exist (silently — `countWaveLines` maps
|
|
417
|
+
* ENOENT to 0). Any other read failure also returns 0, but with a stderr WARN
|
|
418
|
+
* so a 0 caused by an unreadable file is distinguishable from an empty queue
|
|
419
|
+
* (#1216).
|
|
417
420
|
*
|
|
418
421
|
* @param {object} opts
|
|
419
422
|
* @param {string} opts.repoRoot
|
|
@@ -424,7 +427,11 @@ export async function countProposalsForWave({ repoRoot, waveId }) {
|
|
|
424
427
|
const jsonlPath = jsonlPathFor(repoRoot);
|
|
425
428
|
try {
|
|
426
429
|
return countWaveLines(jsonlPath, waveId);
|
|
427
|
-
} catch {
|
|
430
|
+
} catch (err) {
|
|
431
|
+
process.stderr.write(
|
|
432
|
+
`[memory-proposals] WARN: countProposalsForWave: ` +
|
|
433
|
+
`${err?.code ?? '?'}: ${err?.message ?? String(err)} — returning 0\n`,
|
|
434
|
+
);
|
|
428
435
|
return 0;
|
|
429
436
|
}
|
|
430
437
|
}
|
|
@@ -590,6 +590,58 @@ export async function planRuleExpirySweep(opts = {}) {
|
|
|
590
590
|
const advisory = headerAdvisory(parsed, expiryById);
|
|
591
591
|
const raiseTo = headerRaiseTarget(parsed, expiryById);
|
|
592
592
|
|
|
593
|
+
if (
|
|
594
|
+
parsed.counterLine === -1 &&
|
|
595
|
+
parsed.entries.length === 0 &&
|
|
596
|
+
parsed.pairs.length === 1 &&
|
|
597
|
+
substantive.length === 1 &&
|
|
598
|
+
parsed.expiresAt !== null &&
|
|
599
|
+
Number.isFinite(Date.parse(parsed.expiresAt))
|
|
600
|
+
) {
|
|
601
|
+
// SINGLE-ENTRY file (#1513): one learning, one pair, no `### ` entries,
|
|
602
|
+
// no counter sentence — the shape the reconciliation engine writes per
|
|
603
|
+
// learning in consumer repos (measured 2026-10-03: all 21 generated rules
|
|
604
|
+
// of the Meta-Vault). The fail-closed branch below skipped every one of
|
|
605
|
+
// them as `no-counter-sentence`, so they stayed on disk forever after
|
|
606
|
+
// expiry. Here the date is unambiguous — the file's own frontmatter
|
|
607
|
+
// `expires-at`, the one `rule-loader.mjs` acts on — and there is no
|
|
608
|
+
// body sentence to keep in agreement. The later of that date and the
|
|
609
|
+
// learning's store `expires_at` (when it resolves) decides, so a
|
|
610
|
+
// re-stamped learning never loses its rule. Expired → the same `delete`
|
|
611
|
+
// as a fully expired consolidated file: the pair is stamped terminal
|
|
612
|
+
// before the unlink, so `/reconcile` does not re-propose it.
|
|
613
|
+
const pair = substantive[0];
|
|
614
|
+
const storeAt = expiryById.has(pair.id) ? Date.parse(expiryById.get(pair.id)) : Number.NaN;
|
|
615
|
+
const fileAt = Date.parse(parsed.expiresAt);
|
|
616
|
+
const effectiveAt = Number.isFinite(storeAt) ? Math.max(storeAt, fileAt) : fileAt;
|
|
617
|
+
const base = {
|
|
618
|
+
file: rule.file,
|
|
619
|
+
expiredPairIds: [],
|
|
620
|
+
keptPairIds: [pair.id],
|
|
621
|
+
unresolvedPairIds: expiryById.has(pair.id) ? [] : [pair.id],
|
|
622
|
+
bytesBefore: Buffer.byteLength(content, 'utf8'),
|
|
623
|
+
headings: 0,
|
|
624
|
+
substantivePairs: 1,
|
|
625
|
+
};
|
|
626
|
+
if (effectiveAt < cutoffMs) {
|
|
627
|
+
plans.push({
|
|
628
|
+
...base,
|
|
629
|
+
expiredPairIds: [pair.id],
|
|
630
|
+
keptPairIds: [],
|
|
631
|
+
unresolvedPairIds: [],
|
|
632
|
+
action: 'delete',
|
|
633
|
+
reason: 'single-entry-expired',
|
|
634
|
+
newExpiresAt: null,
|
|
635
|
+
newAbsorbedCount: 0,
|
|
636
|
+
bytesAfter: 0,
|
|
637
|
+
stampKeys: [pair.key],
|
|
638
|
+
});
|
|
639
|
+
} else {
|
|
640
|
+
plans.push({ ...base, action: 'keep', newExpiresAt: null, newAbsorbedCount: 1, bytesAfter: base.bytesBefore });
|
|
641
|
+
}
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
|
|
593
645
|
if (parsed.counterLine === -1) {
|
|
594
646
|
// FAIL-CLOSED (GH#70). Every write this module performs moves the
|
|
595
647
|
// frontmatter `expires-at` AND the body sentence that restates it. When
|
|
@@ -1295,10 +1295,17 @@ function normalizeAgentScopes(agentScopes) {
|
|
|
1295
1295
|
* NAMED CEILING: `..` segments are left alone — `a/../b` is not provably `b`
|
|
1296
1296
|
* when `a` is a glob segment. Revisit if a plan ever declares a `..` entry.
|
|
1297
1297
|
*
|
|
1298
|
+
* EXPORTED as the ONE spelling rule (#1510 g): `hooks/pre-task-scope-disjoint.mjs`
|
|
1299
|
+
* applies it (late-bound) before its own dedupe and dir-promotion, instead of a
|
|
1300
|
+
* hand-copied twin that had already drifted (`a/././b` kept a `./` segment).
|
|
1301
|
+
* Whitespace is NOT trimmed here: no writer of a scope entry produces padding
|
|
1302
|
+
* (the hook's prose cleaner trims before this rule runs; sidecar entries are
|
|
1303
|
+
* JSON strings the plan writes), and trimming would rewrite a declared path.
|
|
1304
|
+
*
|
|
1298
1305
|
* @param {string} entry - a non-empty scope entry
|
|
1299
1306
|
* @returns {string}
|
|
1300
1307
|
*/
|
|
1301
|
-
function scopeEntrySpelling(entry) {
|
|
1308
|
+
export function scopeEntrySpelling(entry) {
|
|
1302
1309
|
const spelled = entry.replace(/\/{2,}/g, '/').replace(/(^|\/)(?:\.\/)+/g, '$1');
|
|
1303
1310
|
// `./` alone would spell as '' — keep the entry rather than invent a meaning.
|
|
1304
1311
|
return spelled.length > 0 ? spelled : entry;
|
|
@@ -118,6 +118,22 @@ const EVENTS_REL = ['.orchestrator', 'metrics', 'events.jsonl'];
|
|
|
118
118
|
const SESSIONS_REL = ['.orchestrator', 'metrics', 'sessions.jsonl'];
|
|
119
119
|
const BACKFILL_LOG_REL = ['.orchestrator', 'metrics', 'session-close-backfill.log'];
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Which outcomes reach the side-log (#1513). Only EVENTS — `backfilled`,
|
|
123
|
+
* `would-backfill`, `error` and any other non-skip action — are written; every
|
|
124
|
+
* `skipped-*` outcome is the steady state of a session close and stays out.
|
|
125
|
+
* Measured in the Meta-Vault 2026-10-03: 5.786 lines / 723 KB, 86 %
|
|
126
|
+
* `skipped-already-recorded`, one commit-worthy diff per close. The skips lose
|
|
127
|
+
* nothing: on-session-end emits every outcome, skips included, as a queryable
|
|
128
|
+
* event (#1068 AC2), and an injected `log` callback still receives them all.
|
|
129
|
+
*
|
|
130
|
+
* @param {unknown} action
|
|
131
|
+
* @returns {boolean}
|
|
132
|
+
*/
|
|
133
|
+
export function isLoggedBackfillAction(action) {
|
|
134
|
+
return typeof action === 'string' && !action.startsWith('skipped-');
|
|
135
|
+
}
|
|
136
|
+
|
|
121
137
|
// ---------------------------------------------------------------------------
|
|
122
138
|
// Helpers
|
|
123
139
|
// ---------------------------------------------------------------------------
|
|
@@ -348,8 +364,11 @@ export const REQUIRED_EVENTS_WINDOW_DAYS = null;
|
|
|
348
364
|
* @param {string} eventsPath
|
|
349
365
|
* @param {string} id
|
|
350
366
|
* @param {number|null} deadlineMs — epoch ms the scan must end by; `null` = unbounded
|
|
351
|
-
* @returns {Array<object
|
|
352
|
-
* scan or cut it short
|
|
367
|
+
* @returns {{records: Array<object>, lostFields: string[]}|null} `null` when the
|
|
368
|
+
* deadline had passed before the scan or cut it short, or a recoverable cut
|
|
369
|
+
* lies in the session's window — a partial history is never handed on as a
|
|
370
|
+
* whole one. `lostFields` names the record fields a PERMANENTLY pruned archive
|
|
371
|
+
* inside the window may have changed (see {@link windowGapVerdict}).
|
|
353
372
|
*/
|
|
354
373
|
function recordsMentioning(eventsPath, id, deadlineMs) {
|
|
355
374
|
let budgetMs;
|
|
@@ -358,7 +377,7 @@ function recordsMentioning(eventsPath, id, deadlineMs) {
|
|
|
358
377
|
if (!(budgetMs > 0)) return null;
|
|
359
378
|
}
|
|
360
379
|
const newestFirst = [];
|
|
361
|
-
const { truncated, unreadable } = scanEventsBackwards({
|
|
380
|
+
const { truncated, unreadable, gaps } = scanEventsBackwards({
|
|
362
381
|
filePath: eventsPath,
|
|
363
382
|
filter: id,
|
|
364
383
|
budgetMs,
|
|
@@ -371,13 +390,73 @@ function recordsMentioning(eventsPath, id, deadlineMs) {
|
|
|
371
390
|
// deadline does — its records are simply absent — so it defers the candidate
|
|
372
391
|
// the same way. Ceiling (BV-004): a PERMANENTLY unreadable archive defers its
|
|
373
392
|
// candidates on every run (`skipped-history-unread`, visible, never a record
|
|
374
|
-
// dated NOW).
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
393
|
+
// dated NOW).
|
|
394
|
+
if (truncated || (Array.isArray(unreadable) && unreadable.length > 0)) return null;
|
|
395
|
+
const verdict = windowGapVerdict(gaps, newestFirst);
|
|
396
|
+
if (verdict.defer) return null;
|
|
397
|
+
return { records: newestFirst.reverse(), lostFields: verdict.lostFields };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* What a pruned/unindexed archive (`gaps` of {@link scanEventsBackwards}) means
|
|
402
|
+
* for the session whose found records are `records` (#1512).
|
|
403
|
+
*
|
|
404
|
+
* The walk reports every gap of the WHOLE ledger, mostly far older than the
|
|
405
|
+
* candidate; deferring on any of them would skip the candidate forever. So a
|
|
406
|
+
* gap counts only when its `[first_ts, last_ts]` intersects the session's
|
|
407
|
+
* window `[earliest, latest]` timestamp of the records the walk found.
|
|
408
|
+
*
|
|
409
|
+
* A counted gap's KIND then decides. `unindexed-archive` — the file still sits
|
|
410
|
+
* in `_archive/` under a name the walk does not index — can resolve (a rename
|
|
411
|
+
* puts it back), so it DEFERS (`skipped-history-unread`). `missing-archive` —
|
|
412
|
+
* pruned, its bytes gone — never resolves, so deferring would skip the candidate
|
|
413
|
+
* on every run forever; the record is written instead, naming in `lostFields`
|
|
414
|
+
* what the lost span may have held: `session_type` and `session_profile` (the
|
|
415
|
+
* LAST `shape_resolved` decides both) always, `completed_at` when the gap reaches past the latest found
|
|
416
|
+
* record. An unknown kind defers (fail toward not writing a wrong record).
|
|
417
|
+
*
|
|
418
|
+
* Fail direction (BV-004): a gap missing either bound, or a session with no
|
|
419
|
+
* dated record, is NOT counted — an unbounded gap would intersect every later
|
|
420
|
+
* session and recreate the defer-forever bug. Such a candidate is written as
|
|
421
|
+
* before #1512, its unread start listed in `_backfill_incomplete_fields`.
|
|
422
|
+
* Likewise a gap that ate the session's START lies just before the earliest
|
|
423
|
+
* found record and stays outside the window — same incomplete-start answer.
|
|
424
|
+
* Symmetrically a gap that starts AFTER the newest found record (it ate the
|
|
425
|
+
* session's END) is not counted either, so a `shape_resolved` or stop event
|
|
426
|
+
* lost there goes unnamed in `lostFields`; revisit if a backfilled record's
|
|
427
|
+
* `completed_at` is later contradicted by a recovered archive.
|
|
428
|
+
* Ceiling: an `unindexed-archive` nobody renames back defers its candidate on
|
|
429
|
+
* every run, visibly. Revisit if tombstones without `first_ts`/`last_ts` still
|
|
430
|
+
* appear in ledgers, or if `skipped-history-unread` persists across many runs.
|
|
431
|
+
*
|
|
432
|
+
* @param {Array<{kind?: string, first_ts?: string|null, last_ts?: string|null}>|undefined} gaps
|
|
433
|
+
* @param {Array<object>} records
|
|
434
|
+
* @returns {{defer: boolean, lostFields: string[]}}
|
|
435
|
+
*/
|
|
436
|
+
function windowGapVerdict(gaps, records) {
|
|
437
|
+
const none = { defer: false, lostFields: [] };
|
|
438
|
+
if (!Array.isArray(gaps) || gaps.length === 0) return none;
|
|
439
|
+
let lo = Infinity;
|
|
440
|
+
let hi = -Infinity;
|
|
441
|
+
for (const r of records) {
|
|
442
|
+
const ts = typeof r?.timestamp === 'string' ? Date.parse(r.timestamp) : NaN;
|
|
443
|
+
if (Number.isNaN(ts)) continue;
|
|
444
|
+
lo = Math.min(lo, ts);
|
|
445
|
+
hi = Math.max(hi, ts);
|
|
446
|
+
}
|
|
447
|
+
if (lo > hi) return none;
|
|
448
|
+
const lost = new Set();
|
|
449
|
+
for (const g of gaps) {
|
|
450
|
+
const first = typeof g?.first_ts === 'string' ? Date.parse(g.first_ts) : NaN;
|
|
451
|
+
const last = typeof g?.last_ts === 'string' ? Date.parse(g.last_ts) : NaN;
|
|
452
|
+
if (Number.isNaN(first) || Number.isNaN(last)) continue;
|
|
453
|
+
if (!(first <= hi && last >= lo)) continue;
|
|
454
|
+
if (g.kind !== 'missing-archive') return { defer: true, lostFields: [] };
|
|
455
|
+
lost.add('session_type');
|
|
456
|
+
lost.add('session_profile'); // same latest-wins `shape_resolved` record
|
|
457
|
+
if (last > hi) lost.add('completed_at');
|
|
458
|
+
}
|
|
459
|
+
return { defer: false, lostFields: [...lost] };
|
|
381
460
|
}
|
|
382
461
|
|
|
383
462
|
/**
|
|
@@ -414,19 +493,21 @@ function recordsMentioning(eventsPath, id, deadlineMs) {
|
|
|
414
493
|
* @param {string} eventsPath
|
|
415
494
|
* @param {{ sessionId: string|null, semanticSessionId: string|null }} ids
|
|
416
495
|
* @param {number|null} [deadlineMs=null] — shared scan deadline (epoch ms); `null` = unbounded
|
|
417
|
-
* @returns {ReturnType<typeof collectSessionEvents> & { historyUnread?: true }}
|
|
496
|
+
* @returns {ReturnType<typeof collectSessionEvents> & { historyUnread?: true, historyLostFields?: string[] }}
|
|
418
497
|
*/
|
|
419
498
|
function gatherSessionEvents(readFileSync, eventsPath, ids, deadlineMs = null) {
|
|
420
499
|
const gathered = collectSessionEvents(readJsonlSafe(readFileSync, eventsPath), ids);
|
|
421
500
|
if (gathered.startedAt !== null) return gathered;
|
|
422
501
|
try {
|
|
423
502
|
let uuid = isUuid(ids.sessionId) ? ids.sessionId : null;
|
|
503
|
+
let bridgeLost = [];
|
|
424
504
|
if (uuid === null) {
|
|
425
505
|
const label = ids.semanticSessionId || ids.sessionId;
|
|
426
506
|
if (!label) return gathered;
|
|
427
507
|
const bridge = recordsMentioning(eventsPath, label, deadlineMs);
|
|
428
508
|
if (bridge === null) return { ...gathered, historyUnread: true };
|
|
429
|
-
|
|
509
|
+
bridgeLost = bridge.lostFields;
|
|
510
|
+
const bridged = collectSessionEvents(bridge.records, ids).uuids;
|
|
430
511
|
if (bridged.size !== 1) return gathered;
|
|
431
512
|
[uuid] = bridged;
|
|
432
513
|
}
|
|
@@ -434,7 +515,12 @@ function gatherSessionEvents(readFileSync, eventsPath, ids, deadlineMs = null) {
|
|
|
434
515
|
if (history === null) return { ...gathered, historyUnread: true };
|
|
435
516
|
// Empty only when the scan found no source at all — e.g. a reader seam the
|
|
436
517
|
// filesystem does not back; the active answer stands.
|
|
437
|
-
|
|
518
|
+
if (history.records.length === 0) return gathered;
|
|
519
|
+
const collected = collectSessionEvents(history.records, ids);
|
|
520
|
+
// #1512: a permanently pruned archive inside the window — written, with
|
|
521
|
+
// what it may have held named instead of deferring forever.
|
|
522
|
+
const lostFields = [...new Set([...bridgeLost, ...history.lostFields])];
|
|
523
|
+
return lostFields.length > 0 ? { ...collected, historyLostFields: lostFields } : collected;
|
|
438
524
|
} catch {
|
|
439
525
|
return gathered;
|
|
440
526
|
}
|
|
@@ -559,6 +645,9 @@ function synthesizeRecord({ recordId, synthetic, gathered, nowMs, status = 'aban
|
|
|
559
645
|
// no terminal event was found, so mark it incomplete so downstream duration
|
|
560
646
|
// consumers can tell an events-attested end apart from an estimate (#914 R1).
|
|
561
647
|
if (completedEstimated) incomplete.push('completed_at');
|
|
648
|
+
// #1512: fields a permanently pruned archive inside the session window may
|
|
649
|
+
// have held (`windowGapVerdict`), named rather than deferring forever.
|
|
650
|
+
for (const f of gathered.historyLostFields ?? []) if (!incomplete.includes(f)) incomplete.push(f);
|
|
562
651
|
|
|
563
652
|
const record = {
|
|
564
653
|
session_id: recordId,
|
|
@@ -1096,6 +1185,7 @@ export async function backfillAbandonedSession({
|
|
|
1096
1185
|
return;
|
|
1097
1186
|
}
|
|
1098
1187
|
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return;
|
|
1188
|
+
if (!isLoggedBackfillAction(res.action)) return;
|
|
1099
1189
|
const logPath = path.join(repoRoot, ...BACKFILL_LOG_REL);
|
|
1100
1190
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
1101
1191
|
fs.appendFileSync(
|
|
@@ -1359,6 +1449,7 @@ export async function backfillCompletedFromStateMd({
|
|
|
1359
1449
|
return;
|
|
1360
1450
|
}
|
|
1361
1451
|
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return;
|
|
1452
|
+
if (!isLoggedBackfillAction(res.action)) return;
|
|
1362
1453
|
const logPath = path.join(repoRoot, ...BACKFILL_LOG_REL);
|
|
1363
1454
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
1364
1455
|
fs.appendFileSync(
|
|
@@ -406,6 +406,15 @@ const SEAM_CLOCK_ARG = new RegExp(`${CLOCK_NAME}:`);
|
|
|
406
406
|
* `{ now }` or a positional `now` loses its seam proof and its bombs go unseen.
|
|
407
407
|
* REVISIT on the first B5 finding whose subject is a helper, or the first CI
|
|
408
408
|
* `test-value-bans` B5 finding the ±days clock-shift probe shows to be false.
|
|
409
|
+
*
|
|
410
|
+
* CEILING (#1507 item 3) — three known blind spots of the SCREAMING_CASE forms,
|
|
411
|
+
* left deliberately: (a) a bare `NOW)` / `NOW,` counts as a handover, whatever
|
|
412
|
+
* the constant holds; (b) arithmetic on a collected clock (`NOW - DAY`) is NOT
|
|
413
|
+
* seen as a handover, the terminator follows the operand, not the name; (c) an
|
|
414
|
+
* IMPORTED clock constant is not checked for provenance — `NOW` from a helper
|
|
415
|
+
* module that holds `Date.now()` reads as controlled. Measured impact
|
|
416
|
+
* 2026-10-03: 0 changed findings over 722 test files.
|
|
417
|
+
* REVISIT on the first B5 miss or false finding traced to (a), (b) or (c).
|
|
409
418
|
*/
|
|
410
419
|
const CONTROL_CLOCK_ARG = new RegExp(String.raw`${CLOCK_NAME}(:|,|\}|\))`, 'g');
|
|
411
420
|
|