session-orchestrator 5.4.0 → 5.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/060-evolve.mdc +5 -5
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +95 -3
- package/README.md +11 -11
- package/docs/ci-setup.md +120 -23
- package/docs/components.md +7 -7
- package/docs/events-schema.md +17 -14
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/scope-collision-guard.md +15 -13
- package/docs/session-config-reference.md +7 -7
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +17 -8
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +4 -2
- package/hooks/on-stop.mjs +70 -170
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -154
- package/hooks/pre-bash-memory-propose-audit.mjs +19 -7
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-task-scope-disjoint.mjs +584 -207
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/scripts/apply-session-learnings.mjs +35 -14
- package/scripts/archive-closed-prds.mjs +38 -11
- package/scripts/autopilot.mjs +8 -1
- package/scripts/backfill-abandoned-sessions.mjs +80 -5
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +46 -2
- package/scripts/emit-session.mjs +5 -3
- package/scripts/export-hw-learnings.mjs +18 -7
- package/scripts/fleet-checkin.mjs +26 -3
- package/scripts/lib/autopilot/loop.mjs +25 -10
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config.mjs +5 -1
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +51 -3
- package/scripts/lib/fleet-protocol.mjs +20 -2
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +8 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
- package/scripts/lib/learnings/io.mjs +125 -16
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/named-vault-resolver.mjs +48 -5
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
- package/scripts/lib/orphan-reaper/scan.mjs +4 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +5 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +83 -22
- package/scripts/lib/scope-baseline.mjs +116 -25
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-registry.mjs +41 -16
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +39 -34
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +5 -2
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +77 -2
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
- package/scripts/lib/vault-status/board-writer.mjs +5 -1
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/release.mjs +1 -0
- package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
- package/scripts/run-quality-gate.mjs +8 -5
- package/scripts/self-update.mjs +229 -0
- package/scripts/sweep-expired-learnings.mjs +560 -28
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-mirror.mjs +170 -33
- package/skills/_shared/fleet-protocol.md +74 -71
- package/skills/_shared/state-ownership.md +38 -14
- package/skills/autopilot/SKILL.md +1 -1
- package/skills/bootstrap/SKILL.md +5 -3
- package/skills/bootstrap/_shared-template.md +96 -1
- package/skills/bootstrap/fast-template.md +3 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +11 -2
- package/skills/evolve/references/evolve-analyze-mode.md +37 -17
- package/skills/session-end/learning-patterns.md +3 -3
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
- package/skills/session-end/session-metrics-write.md +45 -10
- package/skills/session-start/SKILL.md +3 -3
- package/skills/session-start/references/operations-contract.md +5 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +2 -2
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/.agents/skills/navigator/SKILL.md +0 -22
- package/.agents/skills/navigator/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/navigator/SKILL.md +0 -22
- package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
- package/.cursor/commands/navigator.md +0 -15
- package/.cursor/skills/navigator/SKILL.md +0 -13
- package/pi/prompts/navigator.md +0 -12
- package/skills/navigator/SKILL.md +0 -193
- package/skills/navigator/references/account-check.sh +0 -87
- package/skills/navigator/references/fleet-decisions.mjs +0 -231
- package/skills/navigator/references/slot-check.sh +0 -114
|
@@ -14,12 +14,14 @@ import {
|
|
|
14
14
|
copyFile,
|
|
15
15
|
readdir,
|
|
16
16
|
unlink,
|
|
17
|
+
open,
|
|
17
18
|
} from 'node:fs/promises';
|
|
18
19
|
import { existsSync, realpathSync } from 'node:fs';
|
|
19
20
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
20
21
|
import { randomUUID } from 'node:crypto';
|
|
21
22
|
import path from 'node:path';
|
|
22
23
|
import { withFileLock } from '../file-lock.mjs';
|
|
24
|
+
import { digestSha256 } from '../crypto-digest-utils.mjs';
|
|
23
25
|
import {
|
|
24
26
|
validateLearning,
|
|
25
27
|
normalizeLearning,
|
|
@@ -219,26 +221,69 @@ function serializeLearningLineChecked(validated, { legacyTolerant = false } = {}
|
|
|
219
221
|
/**
|
|
220
222
|
* Read all learnings from the given JSONL path. Returns normalized entries
|
|
221
223
|
* (missing extended fields are defaulted). Malformed lines are skipped with
|
|
222
|
-
* their raw text preserved in the result's `malformed` array
|
|
224
|
+
* their raw text preserved in the result's `malformed` array, and their
|
|
225
|
+
* 1-based line numbers in `malformedLineNumbers` (same order).
|
|
223
226
|
*
|
|
224
227
|
* @param {string} filePath — absolute or project-relative path to learnings.jsonl
|
|
225
|
-
* @returns {Promise<{entries: object[], malformed: string[]}>}
|
|
228
|
+
* @returns {Promise<{entries: object[], malformed: string[], malformedLineNumbers: number[]}>}
|
|
226
229
|
*/
|
|
227
230
|
export async function readLearnings(filePath) {
|
|
228
|
-
if (!existsSync(filePath)) return { entries: [], malformed: [] };
|
|
229
|
-
|
|
230
|
-
|
|
231
|
+
if (!existsSync(filePath)) return { entries: [], malformed: [], malformedLineNumbers: [] };
|
|
232
|
+
return parseLearningsText(await readFile(filePath, 'utf8'));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Read a learnings store together with its generation token (#1486), both
|
|
237
|
+
* from ONE read, so the token describes exactly the records returned.
|
|
238
|
+
*
|
|
239
|
+
* The token is `sha256:<hex>` over the file's full text: any append, rewrite
|
|
240
|
+
* or sweep changes it. An absent store reads as empty text — zero records
|
|
241
|
+
* either way, so the two states share a token.
|
|
242
|
+
*
|
|
243
|
+
* @param {string} filePath
|
|
244
|
+
* @returns {Promise<{entries: object[], malformed: string[], malformedLineNumbers: number[],
|
|
245
|
+
* generation: string}>}
|
|
246
|
+
*/
|
|
247
|
+
export async function readLearningsSnapshot(filePath) {
|
|
248
|
+
const raw = existsSync(filePath) ? await readFile(filePath, 'utf8') : '';
|
|
249
|
+
return { ...parseLearningsText(raw), generation: `sha256:${digestSha256(raw)}` };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Parse learnings JSONL text — the line parser behind {@link readLearnings},
|
|
254
|
+
* exported for callers that must split a header off the text first.
|
|
255
|
+
*
|
|
256
|
+
* A blank line — empty, whitespace-only, or a lone `\r` from a CRLF blank line
|
|
257
|
+
* — carries no record and is skipped, never counted as malformed: malformed
|
|
258
|
+
* lines are kept verbatim by every rewrite and WARNed on every close, so a
|
|
259
|
+
* stray blank would otherwise be reported forever (#1489).
|
|
260
|
+
*
|
|
261
|
+
* A line that parses but is not a JSON object (`null`, `42`, `[]`, `"x"`) is
|
|
262
|
+
* malformed too (#1500): `normalizeLearning` turned it into a record of
|
|
263
|
+
* defaults alone, and every rewrite wrote that invented record back.
|
|
264
|
+
*
|
|
265
|
+
* @param {string} raw
|
|
266
|
+
* @returns {{entries: object[], malformed: string[], malformedLineNumbers: number[]}}
|
|
267
|
+
* `malformedLineNumbers[i]` is the 1-based line of `malformed[i]` in `raw`
|
|
268
|
+
*/
|
|
269
|
+
export function parseLearningsText(raw) {
|
|
231
270
|
const entries = [];
|
|
232
271
|
const malformed = [];
|
|
233
|
-
|
|
272
|
+
const malformedLineNumbers = [];
|
|
273
|
+
raw.split('\n').forEach((line, i) => {
|
|
274
|
+
if (line.trim().length === 0) return;
|
|
234
275
|
try {
|
|
235
276
|
const parsed = JSON.parse(line);
|
|
277
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
278
|
+
throw new TypeError('not a JSON object');
|
|
279
|
+
}
|
|
236
280
|
entries.push(normalizeLearning(parsed));
|
|
237
281
|
} catch {
|
|
238
282
|
malformed.push(line);
|
|
283
|
+
malformedLineNumbers.push(i + 1);
|
|
239
284
|
}
|
|
240
|
-
}
|
|
241
|
-
return { entries, malformed };
|
|
285
|
+
});
|
|
286
|
+
return { entries, malformed, malformedLineNumbers };
|
|
242
287
|
}
|
|
243
288
|
|
|
244
289
|
// ---------------------------------------------------------------------------
|
|
@@ -263,6 +308,11 @@ export async function readLearnings(filePath) {
|
|
|
263
308
|
* that landed between a rewriter's read and its rename was silently lost.
|
|
264
309
|
* Validation runs first, outside the lock — a rejected record never waits.
|
|
265
310
|
*
|
|
311
|
+
* A store whose last line is torn (a crash mid-append left no trailing
|
|
312
|
+
* newline) gets a `\n` written first, inside the same append: without it the
|
|
313
|
+
* new record fused onto the torn text into ONE unparseable line, and no reader
|
|
314
|
+
* ever saw the record (#1489).
|
|
315
|
+
*
|
|
266
316
|
* @param {string} filePath
|
|
267
317
|
* @param {object} entry
|
|
268
318
|
* @param {{ lockTimeoutMs?: number }} [opts] — store-lock wait, default
|
|
@@ -296,13 +346,46 @@ export async function appendLearning(filePath, entry, { lockTimeoutMs } = {}) {
|
|
|
296
346
|
// parses back AND re-validates before any append touches disk. Throws
|
|
297
347
|
// ValidationError on a non-round-tripping record — file is left untouched.
|
|
298
348
|
const line = serializeLearningLineChecked(validated);
|
|
299
|
-
// The lock path resolution creates the parent directory.
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
349
|
+
// The lock path resolution creates the parent directory. The torn-tail check
|
|
350
|
+
// reads inside the lock, so no other writer can change the tail in between.
|
|
351
|
+
await withLearningsLock(
|
|
352
|
+
filePath,
|
|
353
|
+
async () => {
|
|
354
|
+
const lead = (await endsWithoutNewline(filePath)) ? '\n' : '';
|
|
355
|
+
await appendFile(filePath, lead + line, 'utf8');
|
|
356
|
+
},
|
|
357
|
+
{ timeoutMs: lockTimeoutMs }
|
|
358
|
+
);
|
|
303
359
|
return validated;
|
|
304
360
|
}
|
|
305
361
|
|
|
362
|
+
/**
|
|
363
|
+
* True when `filePath` is a non-empty file whose last byte is not `\n`. An
|
|
364
|
+
* absent file is `false` (the append creates it). Reads one byte, so the cost
|
|
365
|
+
* does not grow with the store.
|
|
366
|
+
*
|
|
367
|
+
* @param {string} filePath
|
|
368
|
+
* @returns {Promise<boolean>}
|
|
369
|
+
*/
|
|
370
|
+
async function endsWithoutNewline(filePath) {
|
|
371
|
+
let fh;
|
|
372
|
+
try {
|
|
373
|
+
fh = await open(filePath, 'r');
|
|
374
|
+
} catch (err) {
|
|
375
|
+
if (err?.code === 'ENOENT') return false;
|
|
376
|
+
throw err;
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
const { size } = await fh.stat();
|
|
380
|
+
if (size === 0) return false;
|
|
381
|
+
const buf = Buffer.alloc(1);
|
|
382
|
+
await fh.read(buf, 0, 1, size - 1);
|
|
383
|
+
return buf[0] !== 0x0a;
|
|
384
|
+
} finally {
|
|
385
|
+
await fh.close();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
306
389
|
/**
|
|
307
390
|
* Number of `${basename}.bak-<ISO>` siblings retained after a backup rotation.
|
|
308
391
|
* The atomic rewrite is destructive on a gitignored store (no VCS restore), so
|
|
@@ -423,17 +506,35 @@ async function rotateBackups(dir, baseName, keep = BACKUP_KEEP) {
|
|
|
423
506
|
* `NaN`) is still caught by the #662 checked serializer regardless of this
|
|
424
507
|
* flag — see {@link serializeLearningLineChecked}. Pass `false` to restore
|
|
425
508
|
* the fully-strict behaviour from before EventDrop #386.
|
|
509
|
+
* - `malformedLines` (#1489): raw text of store lines that did not parse —
|
|
510
|
+
* the `malformed` array {@link readLearnings} returns. Written back
|
|
511
|
+
* verbatim, one per line, AFTER the validated records. A round-trip writer
|
|
512
|
+
* that rewrites from parsed entries alone deletes every such line with no
|
|
513
|
+
* archive record (only the keep-3 `.bak` still holds it). OMITTED (the
|
|
514
|
+
* default), the store's OWN malformed lines are re-read under the lock and
|
|
515
|
+
* kept — so a caller that forgets this option no longer deletes them
|
|
516
|
+
* (`export-hw-learnings` did). Pass an array to write exactly those lines;
|
|
517
|
+
* `[]` is the explicit opt-out that drops them. Each entry must be a
|
|
518
|
+
* non-empty string without a newline; anything else throws a TypeError
|
|
519
|
+
* before any disk access, dry run included.
|
|
426
520
|
*
|
|
427
521
|
* @param {string} filePath
|
|
428
522
|
* @param {object[]} entries
|
|
429
|
-
* @param {{dryRun?: boolean, backup?: boolean, legacyTolerant?: boolean}} [opts]
|
|
523
|
+
* @param {{dryRun?: boolean, backup?: boolean, legacyTolerant?: boolean, malformedLines?: string[]}} [opts]
|
|
430
524
|
* @returns {Promise<object[]>} validated entries (always returned, even dryRun)
|
|
431
525
|
*/
|
|
432
526
|
export async function rewriteLearnings(
|
|
433
527
|
filePath,
|
|
434
528
|
entries,
|
|
435
|
-
{ dryRun = false, backup = true, legacyTolerant = true } = {}
|
|
529
|
+
{ dryRun = false, backup = true, legacyTolerant = true, malformedLines } = {}
|
|
436
530
|
) {
|
|
531
|
+
if (
|
|
532
|
+
malformedLines !== undefined &&
|
|
533
|
+
(!Array.isArray(malformedLines) ||
|
|
534
|
+
malformedLines.some((l) => typeof l !== 'string' || l.length === 0 || l.includes('\n')))
|
|
535
|
+
) {
|
|
536
|
+
throw new TypeError('rewriteLearnings: malformedLines must be an array of non-empty single-line strings');
|
|
537
|
+
}
|
|
437
538
|
const validated = entries.map((e) =>
|
|
438
539
|
validateLearning(
|
|
439
540
|
{
|
|
@@ -454,12 +555,20 @@ export async function rewriteLearnings(
|
|
|
454
555
|
// rewrite, no backup — and hand the validated entries back to the caller.
|
|
455
556
|
if (dryRun) return validated;
|
|
456
557
|
|
|
457
|
-
const body = lines.join('');
|
|
458
|
-
|
|
459
558
|
// Under the store lock (#1447 point 8) so no append lands between the backup
|
|
460
559
|
// and the rename. The CALLER's read must sit inside the same lock for a
|
|
461
560
|
// read-modify-write to be safe — this call joins it reentrantly.
|
|
462
561
|
await withLearningsLock(filePath, async () => {
|
|
562
|
+
// Default: the store's own malformed lines as they stand right now, read
|
|
563
|
+
// under the lock that guards the rename below.
|
|
564
|
+
const kept =
|
|
565
|
+
malformedLines ??
|
|
566
|
+
(existsSync(filePath) ? parseLearningsText(await readFile(filePath, 'utf8')).malformed : []);
|
|
567
|
+
// Malformed lines last, each newline-terminated: a truncated final line
|
|
568
|
+
// otherwise fuses with the next append into one more unparseable line.
|
|
569
|
+
const body = lines.join('') + kept.map((l) => `${l}\n`).join('');
|
|
570
|
+
|
|
571
|
+
|
|
463
572
|
// Backup-before-rewrite (#721): snapshot the current store to a timestamped
|
|
464
573
|
// sidecar BEFORE the destructive rename, then prune to keep-N. Only meaningful
|
|
465
574
|
// when the target already exists (a first-time write has nothing to lose).
|
|
@@ -66,7 +66,7 @@ import os from 'node:os';
|
|
|
66
66
|
import path from 'node:path';
|
|
67
67
|
|
|
68
68
|
import { enumerateCandidates } from './dispatcher/enumerate.mjs';
|
|
69
|
-
import { readLock, isLockLive, isPidAliveOnHost, LOCK_PATH, DEFAULT_TTL_HOURS } from './session-lock.mjs';
|
|
69
|
+
import { readLock, isLockLive, isPidAliveOnHost, LOCK_PATH, DEFAULT_TTL_HOURS, FUTURE_HEARTBEAT_TOLERANCE_MS } from './session-lock.mjs';
|
|
70
70
|
import { emitEvent } from './events.mjs';
|
|
71
71
|
import { hostnamesMatch, lockHostCandidate } from './host-identity.mjs';
|
|
72
72
|
|
|
@@ -109,21 +109,42 @@ function lockPathFor(repoRoot) {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
/**
|
|
112
|
-
* Fractional hours since a lock's last heartbeat
|
|
113
|
-
*
|
|
114
|
-
*
|
|
112
|
+
* Fractional hours since a lock's last heartbeat — the figure the reaper
|
|
113
|
+
* REPORTS (candidate list `ageHours`, `orchestrator.session.lock.reaped`
|
|
114
|
+
* `age_hours`). Display only: the reap decision is `isLockLive()`.
|
|
115
|
+
*
|
|
116
|
+
* Reads `last_heartbeat` only, like `isLockLive()` since the #595 sunset
|
|
117
|
+
* (2026-10-02). A lock without one has no heartbeat age, so this returns
|
|
118
|
+
* `null` (the CLI renders "unknown age") instead of passing off the age since
|
|
119
|
+
* `started_at` as heartbeat staleness.
|
|
115
120
|
*
|
|
116
121
|
* @param {object} lock
|
|
117
122
|
* @param {number} nowMs
|
|
118
|
-
* @returns {number|null}
|
|
123
|
+
* @returns {number|null} null when `last_heartbeat` is absent or unparseable.
|
|
119
124
|
*/
|
|
120
125
|
function ageHoursOf(lock, nowMs) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
: lock.started_at;
|
|
124
|
-
const ms = Date.parse(hb);
|
|
126
|
+
if (typeof lock.last_heartbeat !== 'string' || lock.last_heartbeat.length === 0) return null;
|
|
127
|
+
const ms = Date.parse(lock.last_heartbeat);
|
|
125
128
|
if (Number.isNaN(ms)) return null;
|
|
126
|
-
|
|
129
|
+
const hours = futureSafeAgeHours(ms, nowMs);
|
|
130
|
+
return hours === null ? null : Number(hours.toFixed(2));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Hours from `ms` to `nowMs`, or `null` when `ms` lies further in the future
|
|
135
|
+
* than {@link FUTURE_HEARTBEAT_TOLERANCE_MS} (#1505.2). Such a stamp measures
|
|
136
|
+
* no age, and a negative one is worse than none: it reads as "younger than
|
|
137
|
+
* every TTL" and was emitted as a negative `age_hours`. `null` — not 0 — so an
|
|
138
|
+
* unmeasurable age never looks like a fresh one. Within the tolerance (clock
|
|
139
|
+
* skew) the age clamps to 0.
|
|
140
|
+
*
|
|
141
|
+
* @param {number} ms
|
|
142
|
+
* @param {number} nowMs
|
|
143
|
+
* @returns {number|null}
|
|
144
|
+
*/
|
|
145
|
+
function futureSafeAgeHours(ms, nowMs) {
|
|
146
|
+
if (ms > nowMs + FUTURE_HEARTBEAT_TOLERANCE_MS) return null;
|
|
147
|
+
return Math.max(0, nowMs - ms) / (3600 * 1000);
|
|
127
148
|
}
|
|
128
149
|
|
|
129
150
|
/**
|
|
@@ -223,12 +244,15 @@ function currentSessionPathFor(repoRoot) {
|
|
|
223
244
|
function currentSessionAgeHours(parsed, filePath, nowMs, fsDep) {
|
|
224
245
|
const ts = typeof parsed?.timestamp === 'string' ? parsed.timestamp : null;
|
|
225
246
|
const ms = ts ? Date.parse(ts) : NaN;
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
247
|
+
// #1505.2: a `timestamp` beyond the future tolerance measures nothing — it
|
|
248
|
+
// fell through as a negative age, always under the TTL, and kept the file
|
|
249
|
+
// 'fresh' forever. It is treated like an absent one: the mtime decides.
|
|
250
|
+
const fromTimestamp = Number.isNaN(ms) ? null : futureSafeAgeHours(ms, nowMs);
|
|
251
|
+
if (fromTimestamp !== null) return fromTimestamp;
|
|
229
252
|
try {
|
|
230
253
|
const info = fsDep.statSync(filePath);
|
|
231
|
-
|
|
254
|
+
// A future mtime too → null → 'fresh': kept until the clock passes it.
|
|
255
|
+
return futureSafeAgeHours(info.mtimeMs, nowMs);
|
|
232
256
|
} catch {
|
|
233
257
|
return null;
|
|
234
258
|
}
|
|
@@ -132,6 +132,14 @@ const MS_PER_DAY = 86_400_000;
|
|
|
132
132
|
/** The event name whose ABSENCE is the `evolve` signal. */
|
|
133
133
|
const EVOLVE_EVENT = 'orchestrator.evolve.completed';
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Events-ledger window for the #1401 census (`events-retention-banner.mjs`):
|
|
137
|
+
* `null` — the one ledger question here ("was {@link EVOLVE_EVENT} EVER
|
|
138
|
+
* recorded?") is about every line ever written, so no day count bounds it; a
|
|
139
|
+
* pruned history turns the answer `undeterminable`, never "never" (#1489).
|
|
140
|
+
*/
|
|
141
|
+
export const REQUIRED_EVENTS_WINDOW_DAYS = null;
|
|
142
|
+
|
|
135
143
|
/** Pending-proposal sidecars, repo-relative (owned by auto-dream / auto-dialectic). */
|
|
136
144
|
const PENDING_SIDECARS = ['.orchestrator/pending-dream.md', '.orchestrator/dialectic-pending.md'];
|
|
137
145
|
|
|
@@ -187,11 +195,13 @@ export const EVOLVE_SCAN_BUDGET_MS = 1000;
|
|
|
187
195
|
* /evolve, which is the HR-101 failure mode this module exists to avoid.
|
|
188
196
|
*
|
|
189
197
|
* @param {string} repoRoot
|
|
190
|
-
* @returns {{ok: boolean, lastAt: string|null, truncated: boolean
|
|
191
|
-
* ⇒ a source exists but could not be read; `truncated: true` ⇒
|
|
192
|
-
* out before the walk finished. In BOTH cases the caller must
|
|
193
|
-
* `undeterminable`, never clean — a walk that did not finish has not
|
|
194
|
-
* "never".
|
|
198
|
+
* @returns {{ok: boolean, lastAt: string|null, truncated: boolean, historyCut: boolean}}
|
|
199
|
+
* `ok: false` ⇒ a source exists but could not be read; `truncated: true` ⇒
|
|
200
|
+
* the budget ran out before the walk finished. In BOTH cases the caller must
|
|
201
|
+
* record `undeterminable`, never clean — a walk that did not finish has not
|
|
202
|
+
* proven "never". `historyCut: true` ⇒ the walk finished but the history it
|
|
203
|
+
* read begins after an archive that is gone (#1489: pruned by
|
|
204
|
+
* `events-rotation.max-backups`), so a miss has not proven "never" either.
|
|
195
205
|
*/
|
|
196
206
|
function readLastEvolveRun(repoRoot) {
|
|
197
207
|
const file = path.join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
|
|
@@ -209,8 +219,10 @@ function readLastEvolveRun(repoRoot) {
|
|
|
209
219
|
return true;
|
|
210
220
|
},
|
|
211
221
|
});
|
|
212
|
-
if (scan.unreadable.length > 0)
|
|
213
|
-
|
|
222
|
+
if (scan.unreadable.length > 0) {
|
|
223
|
+
return { ok: false, lastAt: null, truncated: false, historyCut: false };
|
|
224
|
+
}
|
|
225
|
+
return { ok: true, lastAt, truncated: scan.truncated, historyCut: scan.gaps.length > 0 };
|
|
214
226
|
}
|
|
215
227
|
|
|
216
228
|
/**
|
|
@@ -301,7 +313,10 @@ export async function computeMaintenanceDue(opts = {}) {
|
|
|
301
313
|
if (!evolve.ok || evolve.truncated) {
|
|
302
314
|
undeterminable.push('evolve');
|
|
303
315
|
} else if (evolve.lastAt === null && nudge.activeLearnings >= MAINTENANCE_MIN_LEARNINGS) {
|
|
304
|
-
|
|
316
|
+
// #1489: judged only where it would flip the verdict, so a cut history
|
|
317
|
+
// adds no row to a repo the floor already keeps silent (HR-101).
|
|
318
|
+
if (evolve.historyCut) undeterminable.push('evolve');
|
|
319
|
+
else markDue('evolve', `never, ${nudge.activeLearnings} active learnings`);
|
|
305
320
|
}
|
|
306
321
|
|
|
307
322
|
// --- reconcile (S3) ----------------------------------------------------
|
|
@@ -555,9 +555,26 @@ export function normalizeRemote(url) {
|
|
|
555
555
|
* - `file:` URL, or a bare/relative path (git's rule: no `://` and no `:`
|
|
556
556
|
* before the first `/`, a Windows drive letter counting as a path)
|
|
557
557
|
* → `file://<local path>`.
|
|
558
|
-
* -
|
|
559
|
-
*
|
|
560
|
-
*
|
|
558
|
+
* - Login rule, both remote forms: a userinfo other than the forge-shared `git`
|
|
559
|
+
* is the operator's account (or a password-shaped `user:pass`) → `***@`, the
|
|
560
|
+
* same marker {@link redactUrlCredentials} uses; it never takes part in the
|
|
561
|
+
* suffix match, so dropping it costs the line no diagnostic value.
|
|
562
|
+
* - A URL form (`ssh://`, `https://`, … #1490 item 5) applies the login rule,
|
|
563
|
+
* keeps its host/port, and keeps its path UNLESS the path has the absolute
|
|
564
|
+
* home-dir shape (`/home/<u>…`, `/Users/<u>…`, `/root…`, `/~…`) →
|
|
565
|
+
* `/<remote path>`. In URL form every path starts with `/`, so a forge's
|
|
566
|
+
* `/org/repo` and a plain `/srv/vault` cannot be told from a filesystem path;
|
|
567
|
+
* only the home-dir shape is the owner leak, and the forge shape is what the
|
|
568
|
+
* suffix comparison is about. The result still goes through
|
|
569
|
+
* {@link redactUrlCredentials}, so a bare `https://git@` (a token slot there)
|
|
570
|
+
* is redacted as before. A `://` with no scheme before it (`/srv/a://b`,
|
|
571
|
+
* `host:/p://q`) is no URL form: the path and scp rules read it (#1496).
|
|
572
|
+
* - A scp-like `[user@]host:path` (#1487 item 10) applies the login rule and
|
|
573
|
+
* keeps the host and a RELATIVE path — the repo shape the canonical-suffix
|
|
574
|
+
* comparison is about. Here a path starting with `/` or `~` is unambiguous: a
|
|
575
|
+
* filesystem path on that host (typically a home dir) → `<remote path>`. A
|
|
576
|
+
* bracketed IPv6 host (`[::1]:path`, `user@[::1]:path`, `[user@::1]:path`)
|
|
577
|
+
* follows the same two rules.
|
|
561
578
|
*
|
|
562
579
|
* Returns `''` for an empty/absent origin; callers supply their own wording.
|
|
563
580
|
* @param {string|undefined|null} url
|
|
@@ -567,9 +584,35 @@ export function describeOriginForLog(url) {
|
|
|
567
584
|
const s = String(url ?? '').trim();
|
|
568
585
|
if (!s) return '';
|
|
569
586
|
if (/^file:/i.test(s)) return 'file://<local path>';
|
|
570
|
-
|
|
587
|
+
const login = (u) => (u === undefined ? '' : u === 'git' ? 'git@' : '***@');
|
|
588
|
+
// A `://` with no scheme in front of it is no URL — git's is_url() reads
|
|
589
|
+
// such a string by the scp/local rules below, and so does this (#1496: it
|
|
590
|
+
// used to be printed verbatim, home dir and all).
|
|
591
|
+
const m = /^([a-z][a-z0-9+.-]*:\/\/)([^/]*)(.*)$/is.exec(s);
|
|
592
|
+
if (m) {
|
|
593
|
+
const [, scheme, authority, urlPath] = m;
|
|
594
|
+
// userinfo runs to the LAST '@' of the authority (a password may hold one).
|
|
595
|
+
const at = authority.lastIndexOf('@');
|
|
596
|
+
const user = at === -1 ? undefined : authority.slice(0, at);
|
|
597
|
+
const homeShaped = /^\/(?:~|(?:home|Users)\/[^/]+(?:\/|$)|root(?:\/|$))/.test(urlPath);
|
|
598
|
+
return redactUrlCredentials(
|
|
599
|
+
`${scheme}${login(user)}${authority.slice(at + 1)}${homeShaped ? '/<remote path>' : urlPath}`,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
571
602
|
const scpLike = /^[^/\\]*:/.test(s) && !/^[a-z]:[\\/]/i.test(s);
|
|
572
|
-
|
|
603
|
+
if (!scpLike) return 'file://<local path>';
|
|
604
|
+
// Split host from path the way git's connect.c host_end() does: a host in
|
|
605
|
+
// brackets — leading, or right after `user@` — runs to its `]`, so the ':'
|
|
606
|
+
// inside an IPv6 literal (`[::1]`, `[alice@::1]`) is never the separator.
|
|
607
|
+
// Otherwise the userinfo runs to the last '@' and the host to the next ':';
|
|
608
|
+
// `scpLike` guarantees a ':' with no '/' or '\' before it, so that second
|
|
609
|
+
// pattern always matches.
|
|
610
|
+
const [, user, host, path] =
|
|
611
|
+
/^(?:([^[]*)@)?(\[[^\]]*\]):(.*)$/s.exec(s) ?? /^(?:(.*)@)?([^@:]*):(.*)$/s.exec(s);
|
|
612
|
+
// A login inside the brackets is the same account as one before them.
|
|
613
|
+
const shownHost = host.replace(/^\[([^\]]*)@/, (_, u) => `[${login(u)}`);
|
|
614
|
+
const where = /^[/~]/.test(path) ? '<remote path>' : path;
|
|
615
|
+
return redactUrlCredentials(`${login(user)}${shownHost}:${where}`);
|
|
573
616
|
}
|
|
574
617
|
|
|
575
618
|
/**
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* orphan-reaper/no-follow-append.mjs — the ONE hardened JSONL append the
|
|
3
|
+
* reaper's two ledgers share: the B5 kill audit (`reaper-audit.mjs`) and the
|
|
4
|
+
* gate-process ledger (`../process-group.mjs`). It was inline in the audit
|
|
5
|
+
* writer; the gate ledger still used a plain `appendFileSync(path)` (#1489).
|
|
6
|
+
*
|
|
7
|
+
* Imports nothing but `node:fs`, so `process-group.mjs` takes it without
|
|
8
|
+
* growing its closure. The read-side twin is `readTailWindow(file, n,
|
|
9
|
+
* { noFollow: true })` in `../tail-window.mjs`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { appendFileSync, closeSync, constants as fsConstants, fstatSync, openSync } from 'node:fs';
|
|
13
|
+
|
|
14
|
+
/** The one refusal every hardened ledger writer prints — the appends here and
|
|
15
|
+
* the atomic prune rewrites (`replaceRegularFile`) in both ledger modules. */
|
|
16
|
+
export const NOT_A_REGULAR_FILE = 'not a regular file (a symlink is never written through) — left untouched';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `open(2)` flags of the append. `O_NOFOLLOW` makes the kernel refuse a
|
|
20
|
+
* symlink as the last path component (ELOOP) in the same call that opens the
|
|
21
|
+
* file, so there is no lstat-then-open window; `O_NONBLOCK` turns a planted FIFO
|
|
22
|
+
* with no reader into ENXIO instead of blocking in `open` (a plain append open
|
|
23
|
+
* of a FIFO waits for a reader that never comes — reproduced 2026-10-02, #1487).
|
|
24
|
+
* On a regular file `O_NONBLOCK` changes nothing.
|
|
25
|
+
* Named ceiling (BV-004): both are POSIX-only — Node leaves them undefined on
|
|
26
|
+
* Windows, where a linked ledger is still followed; revisit if the reaper or the
|
|
27
|
+
* gate ledger ever runs there.
|
|
28
|
+
*/
|
|
29
|
+
const APPEND_FLAGS = fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT
|
|
30
|
+
| (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
|
|
31
|
+
|
|
32
|
+
/** @returns {Error & {code: string}} */
|
|
33
|
+
function notARegularFile() {
|
|
34
|
+
return Object.assign(new Error(NOT_A_REGULAR_FILE), { code: 'ERR_NOT_REGULAR_FILE' });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Append `data` to `target`, creating it if absent — but never THROUGH a link
|
|
39
|
+
* and never blocked by a FIFO.
|
|
40
|
+
*
|
|
41
|
+
* The check is the open itself ({@link APPEND_FLAGS}) plus an `fstat` of the
|
|
42
|
+
* descriptor that was opened — never a separate lstat. That `fstat` refuses
|
|
43
|
+
* what the open let through: a FIFO that HAD a reader, a directory, a device,
|
|
44
|
+
* and a HARD-linked file (`nlink !== 1`): `ln <victim> <ledger>` passes every
|
|
45
|
+
* symlink check, and each append would then land in the victim (reproduced
|
|
46
|
+
* 2026-10-02 against the audit).
|
|
47
|
+
*
|
|
48
|
+
* THROWS — mapping a failure to a WARN line is the caller's job, as with
|
|
49
|
+
* `readTailWindow`. A symlink or any non-regular file throws
|
|
50
|
+
* `code: 'ERR_NOT_REGULAR_FILE'` with message {@link NOT_A_REGULAR_FILE}; a
|
|
51
|
+
* hard-linked file throws `code: 'ERR_HARD_LINKED'`; a FIFO without a reader
|
|
52
|
+
* throws the kernel's ENXIO unchanged; every other fs error passes through.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} target Absolute path; its directory must already exist.
|
|
55
|
+
* @param {string} data
|
|
56
|
+
* @returns {void}
|
|
57
|
+
*/
|
|
58
|
+
export function appendNoFollow(target, data) {
|
|
59
|
+
let fd;
|
|
60
|
+
try {
|
|
61
|
+
fd = openSync(target, APPEND_FLAGS);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw err?.code === 'ELOOP' ? notARegularFile() : err;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const stats = fstatSync(fd);
|
|
67
|
+
if (!stats.isFile()) throw notARegularFile();
|
|
68
|
+
// nlink 0: the name was replaced between open and fstat (a concurrent prune
|
|
69
|
+
// renames a fresh inode over it). Not a planted link — say so, so the
|
|
70
|
+
// operator does not go hunting for one.
|
|
71
|
+
if (stats.nlink === 0) {
|
|
72
|
+
throw Object.assign(
|
|
73
|
+
new Error('replaced while being opened (a concurrent rewrite renamed a new file over it) — this record was not written'),
|
|
74
|
+
{ code: 'ERR_UNLINKED' },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (stats.nlink !== 1) {
|
|
78
|
+
throw Object.assign(
|
|
79
|
+
new Error(`hard-linked (${stats.nlink} names) — an append would write into every one of them; left untouched`),
|
|
80
|
+
{ code: 'ERR_HARD_LINKED' },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
appendFileSync(fd, data, 'utf8');
|
|
84
|
+
} finally {
|
|
85
|
+
try { closeSync(fd); } catch { /* nothing left to release */ }
|
|
86
|
+
}
|
|
87
|
+
}
|