ruvnet-brain 4.0.7 → 4.0.12
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/README.md +1 -1
- package/bin/install.mjs +115 -44
- package/console/app.js +32 -11
- package/console/style.css +5 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +17 -17
- package/plugin/scripts/codex-hook-wrapper.mjs +72 -1
- package/plugin/scripts/continuation-gate.mjs +41 -1
- package/plugin/scripts/learn-flush.mjs +37 -2
- package/plugin/scripts/project-identity.mjs +89 -0
- package/plugin/scripts/ruflo-bin.mjs +81 -0
- package/plugin/scripts/session-snapshot-hook.mjs +4 -1
- package/plugin/scripts/session-start-core.mjs +19 -2
- package/plugin/skills/release-proof/scripts/release-proof.mjs +12 -5
- package/scripts/candidate-host-evidence.mjs +49 -0
- package/scripts/ci/learning-replay-codex-adapter.mjs +13 -1
- package/scripts/console-runtime-identity.mjs +74 -0
- package/scripts/distill-project.mjs +9 -1
- package/scripts/doc-currency.mjs +15 -2
- package/scripts/health-repair.mjs +88 -11
- package/scripts/host-install-matrix.mjs +155 -0
- package/scripts/learning-replay-contract.mjs +14 -4
- package/scripts/learning-replay-execution.mjs +4 -3
- package/scripts/learning-replay-fixture.mjs +23 -2
- package/scripts/memory-doctor.mjs +26 -23
- package/scripts/model-router-catalog.mjs +34 -0
- package/scripts/onboarding-console.mjs +34 -12
- package/scripts/publication-receipt.mjs +135 -34
- package/scripts/release-evidence-aggregate.mjs +55 -0
- package/scripts/release-payload.mjs +100 -0
- package/scripts/release-transaction-provider.mjs +260 -55
- package/scripts/release-transaction.mjs +295 -105
- package/scripts/release.mjs +41 -12
- package/scripts/rvf-generation.mjs +17 -0
- package/scripts/selfcheck.mjs +20 -5
- package/scripts/staged-host-verifier.mjs +27 -57
- package/scripts/sync-version.mjs +10 -10
|
@@ -160,7 +160,47 @@ const nowMs = Date.now();
|
|
|
160
160
|
// force. This closes the empty-stdin hole that LOOP-SAFETY 1's `__source` check does not cover.
|
|
161
161
|
if (!hookInput.session_id) process.exit(EXIT_ALLOW);
|
|
162
162
|
|
|
163
|
-
|
|
163
|
+
/**
|
|
164
|
+
* ARTIFACT-DERIVED OPEN WORK — the half that cannot be forgotten.
|
|
165
|
+
*
|
|
166
|
+
* WHY THIS EXISTS, measured rather than supposed. On 2026-08-04 the owner asked why the model had
|
|
167
|
+
* gone back to stopping early, and the ledger answered: 25 items, ZERO open, last written 2026-07-25.
|
|
168
|
+
* The gate had been structurally silent for ten days. Not broken — starved.
|
|
169
|
+
*
|
|
170
|
+
* The cause is the design, not the drift. Until now the ONLY source of "is work outstanding" was
|
|
171
|
+
* `--commit-to`, i.e. the model noticing its own commitment and recording it. So the guard against
|
|
172
|
+
* the model stopping early depended on the model remembering to arm it, and the failure mode is
|
|
173
|
+
* silent in exactly the sessions where it matters most. That is this project's oldest rule broken
|
|
174
|
+
* inside the mechanism meant to enforce it: status must be DERIVED FROM A VERIFIABLE ARTIFACT,
|
|
175
|
+
* never asserted.
|
|
176
|
+
*
|
|
177
|
+
* So the gate now also reads work that exists whether or not anyone remembered to write it down.
|
|
178
|
+
* `open-issues.json` is produced by the issue-watch pipeline against the real repo; an issue past
|
|
179
|
+
* its response SLA is outstanding work by definition, and no amount of forgetting can erase it.
|
|
180
|
+
*
|
|
181
|
+
* Deliberately narrow: ONLY SLA breaches, never the full backlog — a permanently non-empty backlog
|
|
182
|
+
* would make this fire forever, which is nagging, not enforcement. And only a FRESH observation
|
|
183
|
+
* (<6h, the same window session-start-core uses), because a stale file is not evidence of anything.
|
|
184
|
+
*/
|
|
185
|
+
function artifactOpenWork() {
|
|
186
|
+
try {
|
|
187
|
+
const file = process.env.RUVNET_OPEN_ISSUES_FILE
|
|
188
|
+
|| path.join(HOME, '.cache', 'ruvnet-brain', 'open-issues.json');
|
|
189
|
+
const status = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
190
|
+
const observedAt = Date.parse(status?.at || '');
|
|
191
|
+
if (!Number.isFinite(observedAt) || nowMs - observedAt > 6 * 3600_000) return [];
|
|
192
|
+
return (Array.isArray(status.issues) ? status.issues : [])
|
|
193
|
+
.filter((issue) => issue?.breach)
|
|
194
|
+
.map((issue) => ({
|
|
195
|
+
text: `issue #${issue.number} on ${status.repo} is ${issue.ageHours}h past its response SLA — ${String(issue.title || '').slice(0, 80)}`,
|
|
196
|
+
done: false,
|
|
197
|
+
at: new Date(observedAt).toISOString(),
|
|
198
|
+
derived: true,
|
|
199
|
+
}));
|
|
200
|
+
} catch { return []; }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const open = [...led.items.filter((i) => !i.done), ...artifactOpenWork()];
|
|
164
204
|
if (!open.length) process.exit(EXIT_ALLOW); // nothing outstanding: silence is correct
|
|
165
205
|
|
|
166
206
|
/**
|
|
@@ -14,6 +14,13 @@ import path from 'node:path';
|
|
|
14
14
|
import { execFileSync } from 'node:child_process';
|
|
15
15
|
import { readStdinBounded } from './hook-input.mjs';
|
|
16
16
|
import { loadRuntimePreferences } from './runtime-preferences.mjs';
|
|
17
|
+
import { resolveRuflo, RUFLO_MISSING } from './ruflo-bin.mjs';
|
|
18
|
+
|
|
19
|
+
// ONE BOUNDED LINE ON STDERR. stderr because a SessionEnd hook's stdout is not surfaced, and bounded
|
|
20
|
+
// because a hook that prints a stack trace on every `/clear` gets muted — and a muted diagnostic is
|
|
21
|
+
// no diagnostic at all (the same lesson as the session-start line that reported its own defect every
|
|
22
|
+
// session for eight days and went unread).
|
|
23
|
+
const warn = (msg) => { try { process.stderr.write(`learn-flush: ${msg}\n`); } catch { /* stderr gone */ } };
|
|
17
24
|
|
|
18
25
|
const HOME = os.homedir();
|
|
19
26
|
const PROJECT = process.env.RUVNET_BRAIN_PROJECT_DIR || process.cwd();
|
|
@@ -48,7 +55,11 @@ const QUEUE_ROOT = LEARNING_SCOPE === 'user'
|
|
|
48
55
|
? path.join(HOME, '.cache', 'ruvnet-brain', 'learn')
|
|
49
56
|
: path.join(PROJECT, '.swarm', 'ruvnet-brain-learn');
|
|
50
57
|
const QUEUE = process.env.LEARN_QUEUE || path.join(QUEUE_ROOT, `session-${SID}.jsonl`);
|
|
51
|
-
|
|
58
|
+
// Issue #105: this was a hardcoded `path.join(HOME, '.npm-global/bin/ruflo')` — the owner's npm
|
|
59
|
+
// prefix. On any other prefix (Homebrew, nvm, Volta, plain `npm -g`) the path simply did not exist,
|
|
60
|
+
// every feed below threw ENOENT, and every throw landed in a `catch {}` that said nothing. One
|
|
61
|
+
// resolver, shared with distill-project.mjs and health-repair.mjs's original — see ruflo-bin.mjs.
|
|
62
|
+
const RUFLO = resolveRuflo();
|
|
52
63
|
const RUFLO_ENV = { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' };
|
|
53
64
|
const MAX_ACTIONS = 8; // bound the work so SessionEnd stays fast
|
|
54
65
|
|
|
@@ -98,7 +109,16 @@ for (const line of lines) {
|
|
|
98
109
|
const actions = allDistinct.slice(0, MAX_ACTIONS);
|
|
99
110
|
const deferred = allDistinct.slice(MAX_ACTIONS);
|
|
100
111
|
|
|
112
|
+
// ruflo is genuinely not on this machine. SAY SO — once — and keep the queue. Exiting 0 keeps the
|
|
113
|
+
// best-effort contract (an absent optional learner must never break SessionEnd); saying nothing at
|
|
114
|
+
// all is what turned #105 into eight invisible ENOENTs and a queue that never drained.
|
|
115
|
+
if (!RUFLO && actions.length) {
|
|
116
|
+
warn(`0/${actions.length} fed — ${RUFLO_MISSING}. The queue is KEPT for retry.`);
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
|
|
101
120
|
let fed = 0;
|
|
121
|
+
const failures = []; // WHY each feed failed — the thing `catch {}` used to destroy
|
|
102
122
|
let stoppedAt = actions.length; // how far the feed actually got before the deadline
|
|
103
123
|
for (let i = 0; i < actions.length; i++) {
|
|
104
124
|
const remaining = DEADLINE - Date.now();
|
|
@@ -120,7 +140,22 @@ for (let i = 0; i < actions.length; i++) {
|
|
|
120
140
|
timeout: Math.min(6000, remaining),
|
|
121
141
|
});
|
|
122
142
|
fed++;
|
|
123
|
-
} catch {
|
|
143
|
+
} catch (e) {
|
|
144
|
+
// BEST-EFFORT, NOT SILENT. The old `catch { /* best-effort */ }` swallowed the reason, so a
|
|
145
|
+
// machine where every call failed looked exactly like one where every call worked: exit 0,
|
|
146
|
+
// no output, and the only trace a queue that never shrank. "Reports success while doing
|
|
147
|
+
// nothing" is the defect class this project treats as the worst thing it can ship. Keep going
|
|
148
|
+
// (one bad record must not stall session end), but keep the reason.
|
|
149
|
+
failures.push(String(e?.message || e).split('\n')[0].slice(0, 120));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// SURFACE IT. Distinct reasons only, at most two: eight copies of the same ENOENT is noise, and the
|
|
153
|
+
// second distinct reason is usually where the real information is.
|
|
154
|
+
if (failures.length) {
|
|
155
|
+
const distinct = [...new Set(failures)];
|
|
156
|
+
warn(`${failures.length}/${actions.length} feed call(s) FAILED via ${RUFLO}`
|
|
157
|
+
+ ` — ${distinct.slice(0, 2).join(' | ')}${distinct.length > 2 ? ` (+${distinct.length - 2} more kind(s))` : ''}`
|
|
158
|
+
+ (fed === 0 ? '. Nothing was learned; the queue is KEPT for retry.' : `. ${fed} succeeded.`));
|
|
124
159
|
}
|
|
125
160
|
// Whatever the deadline cut off is WORK, not waste: it goes back on the front of the queue so the
|
|
126
161
|
// next flush continues from there. Dropping it would turn a time limit into the same silent data
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// project-identity.mjs — ONE answer to "which directory is this, and have I seen it already?"
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. Two user-filed bugs, #85 and #107, are the same defect: the product derives a
|
|
4
|
+
// project's location independently at each site and the sites then disagree.
|
|
5
|
+
//
|
|
6
|
+
// #85 The PreCompact producer wrote its receipt under `CLAUDE_PROJECT_DIR`; the Console probe
|
|
7
|
+
// that reports whether a receipt exists looked under `process.cwd()`. Launch the Console
|
|
8
|
+
// from a subdirectory of the project and it warns "no supported PreCompact snapshot found"
|
|
9
|
+
// about a snapshot it had itself just written. Nothing was broken except the agreement.
|
|
10
|
+
//
|
|
11
|
+
// #107 `candidateRoots()` returned BOTH `~/Code` and `~/code`, which on APFS are one directory
|
|
12
|
+
// with one inode. Every project under them was scanned, counted and summed twice, so the
|
|
13
|
+
// machine-wide memory total read exactly 2x — silently, confidently, in the one figure the
|
|
14
|
+
// Console exists to make trustworthy. `path.resolve()` was the guard, and it normalises
|
|
15
|
+
// `.`/`..` only: it case-folds nothing and resolves no symlinks, which the guard's own
|
|
16
|
+
// comment ("e.g. a symlink") shows it was believed to do.
|
|
17
|
+
//
|
|
18
|
+
// WHY DEVICE+INODE RATHER THAN LOWER-CASING. Lower-casing is a guess about the filesystem, and it
|
|
19
|
+
// is wrong on the machines that would suffer most: Linux, and case-sensitive APFS volumes, where
|
|
20
|
+
// `Code` and `code` really are two projects and folding them would DELETE one from the report.
|
|
21
|
+
// `st_dev` + `st_ino` is not a guess — it is the filesystem's own answer, correct in both
|
|
22
|
+
// directions on every volume, and it settles symlinks and bind mounts in the same stroke. The
|
|
23
|
+
// case-insensitivity probe the reporter suggested (write a temp file, stat the other spelling)
|
|
24
|
+
// would also work, but it writes to the user's disk to learn something `stat` already knows.
|
|
25
|
+
//
|
|
26
|
+
// `fs.realpathSync.native` is the OS canonicaliser: it resolves symlinks AND returns the case as
|
|
27
|
+
// stored on disk. Plain `fs.realpathSync` is a JavaScript reimplementation that does symlinks only
|
|
28
|
+
// — measured on this repo's own machine, `realpathSync('~/code')` stays `~/code` while
|
|
29
|
+
// `realpathSync.native('~/code')` returns `~/Code`. That one missing word is the whole of #107.
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
|
|
33
|
+
const defaultRealpath = (value) => fs.realpathSync.native(value);
|
|
34
|
+
const defaultStat = (value) => fs.statSync(value, { bigint: true });
|
|
35
|
+
|
|
36
|
+
/** The operating system's own spelling of an existing path, or null when it cannot be resolved. */
|
|
37
|
+
export function canonicalPath(value, { realpath = defaultRealpath } = {}) {
|
|
38
|
+
if (typeof value !== 'string' || !value) return null;
|
|
39
|
+
try { return realpath(value); } catch { return null; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A key that is equal for two names of one directory and different for two directories.
|
|
44
|
+
* Falls back to the canonical path when the volume reports no usable inode (some Windows and
|
|
45
|
+
* network mounts report 0) — never worse than the raw string compare it replaces.
|
|
46
|
+
*/
|
|
47
|
+
export function pathIdentity(value, { realpath = defaultRealpath, stat = defaultStat } = {}) {
|
|
48
|
+
const canonical = canonicalPath(value, { realpath });
|
|
49
|
+
if (!canonical) return null;
|
|
50
|
+
try {
|
|
51
|
+
const info = stat(canonical);
|
|
52
|
+
if (info?.ino) return `${info.dev}:${info.ino}`;
|
|
53
|
+
} catch { /* unreadable — the canonical spelling is still a better key than the raw one */ }
|
|
54
|
+
return canonical;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** True when both names denote the same existing directory or file. */
|
|
58
|
+
export function sameLocation(a, b, options = {}) {
|
|
59
|
+
const left = pathIdentity(a, options);
|
|
60
|
+
return left !== null && left === pathIdentity(b, options);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when `child` is `root` or lies beneath it, compared canonically rather than by raw string. */
|
|
64
|
+
export function contains(root, child, options = {}) {
|
|
65
|
+
const canonicalRoot = canonicalPath(root, options);
|
|
66
|
+
const canonicalChild = canonicalPath(child, options);
|
|
67
|
+
if (!canonicalRoot || !canonicalChild) return false;
|
|
68
|
+
if (sameLocation(canonicalRoot, canonicalChild, options)) return true;
|
|
69
|
+
const relative = path.relative(canonicalRoot, canonicalChild);
|
|
70
|
+
return Boolean(relative) && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* THE project directory. The PreCompact snapshot producer and the Console probe that detects the
|
|
75
|
+
* snapshot both call this, so they cannot disagree about where the project is (#85).
|
|
76
|
+
*
|
|
77
|
+
* `CLAUDE_PROJECT_DIR` wins only when the current directory actually lies inside it. Containment,
|
|
78
|
+
* not mere presence, is what makes the two sides agree by construction: a hook and a Console
|
|
79
|
+
* launched anywhere within one project resolve to that project's root, while a caller that hands
|
|
80
|
+
* over an unrelated directory (a test fixture, an explicit `--project`) is never overruled by an
|
|
81
|
+
* environment variable it knows nothing about.
|
|
82
|
+
*/
|
|
83
|
+
export function projectDirectory({ env = process.env, cwd = process.cwd(), ...options } = {}) {
|
|
84
|
+
const here = canonicalPath(cwd, options) || path.resolve(cwd);
|
|
85
|
+
const declared = typeof env.CLAUDE_PROJECT_DIR === 'string' && env.CLAUDE_PROJECT_DIR.trim()
|
|
86
|
+
? canonicalPath(env.CLAUDE_PROJECT_DIR, options)
|
|
87
|
+
: null;
|
|
88
|
+
return declared && contains(declared, here, options) ? declared : here;
|
|
89
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ruflo-bin.mjs — the ONE place this repo decides WHERE the global `ruflo` binary is.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS FILE EXISTS. Issues #99 and #105 are one defect filed twice: `scripts/distill-project.mjs`
|
|
5
|
+
* and `plugin/scripts/learn-flush.mjs` each hardcoded `~/.npm-global/bin/ruflo` — the owner's npm
|
|
6
|
+
* prefix, nobody else's. On a Homebrew, nvm, Volta, or plain `npm -g` install that path does not
|
|
7
|
+
* exist, so #99 died with "ruflo is not at ~/.npm-global/bin/ruflo" and #105 failed every feed
|
|
8
|
+
* silently — both on machines where ruflo was installed and sitting on PATH the whole time.
|
|
9
|
+
*
|
|
10
|
+
* `health-repair.mjs` had already fixed exactly this, and its header says why: "Telling someone their
|
|
11
|
+
* tool is missing when it is on their PATH is the product lying, and it is unfalsifiable from their
|
|
12
|
+
* side: they cannot see why we looked in one place." That fix lived as a private function inside one
|
|
13
|
+
* file, so its two siblings kept the bug — the identical shape ADR-021 was written about ("One class,
|
|
14
|
+
* fixed in one file, regenerated in the sibling"). So the resolver moves here instead of being pasted
|
|
15
|
+
* a third time.
|
|
16
|
+
*
|
|
17
|
+
* MECHANISM — the hardened form, not the first draft. Two resolvers already existed in this repo:
|
|
18
|
+
* • health-repair.mjs (2026-07-21) preferred path, then `sh -lc 'command -v ruflo'`
|
|
19
|
+
* • capability-registry.mjs (2026-07-22) preferred path, then a plain PATH walk, NO shell
|
|
20
|
+
* The second IS the first one, hardened, and it says so in place: `-l` sources the user's entire
|
|
21
|
+
* profile — every export, shim, and one-off line anyone has ever pasted into .profile — as the price
|
|
22
|
+
* of answering "where is ruflo?". Resolving a name against directories is all `command -v` was ever
|
|
23
|
+
* wanted for here, and it needs no shell at all. This module takes that version. Adopting the older
|
|
24
|
+
* mechanism would mean re-introducing, in a shared module, a bug the repo had already fixed one file
|
|
25
|
+
* over.
|
|
26
|
+
*
|
|
27
|
+
* ORDER, and each step earns its place:
|
|
28
|
+
* 1. RUFLO_BIN, if set, is AUTHORITATIVE — returned as given, whether or not it exists. An explicit
|
|
29
|
+
* override that quietly falls back to some other ruflo is not an override, and the caller's
|
|
30
|
+
* "not at <path>" message has to name the path the user actually asked for.
|
|
31
|
+
* 2. ~/.npm-global/bin/ruflo — Rule 21's ONE global binary, checked first so a machine that has it
|
|
32
|
+
* never depends on PATH ordering.
|
|
33
|
+
* 3. A PATH walk — the entire point of #99/#105: an installed ruflo living anywhere else.
|
|
34
|
+
* 4. null — "I could not find it", said plainly. Never guess a path and then blame the user for it.
|
|
35
|
+
*
|
|
36
|
+
* Rule 21 is untouched by this: still ONE ruflo, still the global one, never `npx ruflo@latest`. This
|
|
37
|
+
* resolves WHERE that one global binary is rather than assuming everyone's prefix matches the owner's.
|
|
38
|
+
*/
|
|
39
|
+
import fs from 'node:fs';
|
|
40
|
+
import os from 'node:os';
|
|
41
|
+
import path from 'node:path';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Locate the global ruflo. LOCATES, NEVER EXECUTES — no shell, no daemon, no side effects.
|
|
45
|
+
*
|
|
46
|
+
* @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
|
|
47
|
+
* @returns {string|null} path to ruflo, or null when it is genuinely not on this machine.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveRuflo({ env = process.env, home = os.homedir() } = {}) {
|
|
50
|
+
if (env.RUFLO_BIN) return env.RUFLO_BIN;
|
|
51
|
+
|
|
52
|
+
// On Windows a global npm ruflo is `ruflo.cmd`; the extensionless sibling is a POSIX shell wrapper
|
|
53
|
+
// that Node cannot exec (and refuses to spawn without a shell since CVE-2024-27980).
|
|
54
|
+
const exts = process.platform === 'win32' ? ['.cmd', '.exe', ''] : [''];
|
|
55
|
+
|
|
56
|
+
// The preferred path needs that SAME rule. It first checked a bare `ruflo` only, so on Windows it
|
|
57
|
+
// either missed the real `ruflo.cmd` entirely or returned the POSIX wrapper the comment above
|
|
58
|
+
// says is unrunnable — and the caller then reported "ruflo is not at ~/.npm-global/bin/ruflo" to
|
|
59
|
+
// someone who had ruflo installed. That is the exact complaint #99 and #105 were filed about,
|
|
60
|
+
// reintroduced one platform over.
|
|
61
|
+
const preferredDir = path.join(home, '.npm-global', 'bin');
|
|
62
|
+
for (const ext of exts) {
|
|
63
|
+
const cand = path.join(preferredDir, `ruflo${ext}`);
|
|
64
|
+
try { if (fs.existsSync(cand) && fs.statSync(cand).isFile()) return cand; } catch { /* unreadable */ }
|
|
65
|
+
}
|
|
66
|
+
for (const dir of String(env.PATH || '').split(path.delimiter)) {
|
|
67
|
+
if (!dir) continue;
|
|
68
|
+
for (const ext of exts) {
|
|
69
|
+
const cand = path.join(dir, `ruflo${ext}`);
|
|
70
|
+
try { if (fs.existsSync(cand) && fs.statSync(cand).isFile()) return cand; } catch { /* unreadable PATH entry */ }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* What to say when resolveRuflo() returns null. ONE wording, so every caller reports the same thing
|
|
78
|
+
* and names both places it looked — the diagnostic #99 and #105 never gave anyone.
|
|
79
|
+
*/
|
|
80
|
+
export const RUFLO_MISSING = 'ruflo was not found in ~/.npm-global/bin or anywhere on your PATH'
|
|
81
|
+
+ ' — install it with `npm i -g ruflo@latest`, or set RUFLO_BIN to its full path';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { createSessionSnapshot } from './session-snapshot-contract.mjs';
|
|
4
|
+
import { projectDirectory } from './project-identity.mjs';
|
|
4
5
|
|
|
5
6
|
function regularOrAbsent(file) {
|
|
6
7
|
try {
|
|
@@ -30,5 +31,7 @@ export function writeSessionSnapshot(projectDir, event) {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')) {
|
|
33
|
-
|
|
34
|
+
// projectDirectory() is the SAME derivation the Console's detector uses. Deriving it here
|
|
35
|
+
// independently is what let this hook write a receipt the Console then reported as missing (#85).
|
|
36
|
+
writeSessionSnapshot(projectDirectory(), process.argv[2] || 'SessionEnd');
|
|
34
37
|
}
|
|
@@ -472,11 +472,28 @@ export async function runSessionStart({
|
|
|
472
472
|
emit(`[RuvNet Brain v${bannerVersion} — active this session, RETRIEVAL DOWN]`);
|
|
473
473
|
emit(`The plugin and its hooks are running, but the brain itself is broken (see the alarm above). Do not claim grounding works. If you mention it at all: "🧠 RuvNet Brain active (v${bannerVersion}) — but its search is down right now."`);
|
|
474
474
|
} else {
|
|
475
|
-
|
|
475
|
+
// ONE VERSION, CUSTOMER-FACING (issue #77, restated from the customer's side).
|
|
476
|
+
//
|
|
477
|
+
// This printed the plugin version and the knowledge bundle's tag side by side —
|
|
478
|
+
// "RuvNet Brain active (v4.0.8, brain v4.0.7)". Those are two internal artefacts of ONE
|
|
479
|
+
// product. Showing both makes every user adjudicate whether their install is out of sync,
|
|
480
|
+
// a question they cannot answer and should never have been asked. Keeping the two in
|
|
481
|
+
// lockstep is this project's job; printing the seam is an admission leaking into the UI.
|
|
482
|
+
//
|
|
483
|
+
// The divergence is NOT hidden — when the tags differ it goes to the maintainer on the
|
|
484
|
+
// same private entitlement that gates open-issue alerts, because a bundle behind its
|
|
485
|
+
// plugin is a RELEASE defect to fix, not a banner to annotate.
|
|
486
|
+
emit(`[RuvNet Brain v${bannerVersion} — active this session${updated ? ` · updated ${updated}` : ''}]`);
|
|
487
|
+
const bundleTag = String(kbVersion).replace(/^v/, '');
|
|
488
|
+
if (bundleTag && bannerVersion !== 'unknown' && bundleTag !== bannerVersion
|
|
489
|
+
&& maintainerIssueEntitlement(env, home, 'stuinfla/ruvnet-brain')) {
|
|
490
|
+
emit('[RuvNet Brain — MAINTAINER ONLY: the shipped generation is split. Do NOT surface this to the user.]');
|
|
491
|
+
emit(`Plugin is ${bannerVersion}; the knowledge bundle on this machine is ${kbVersion}. Per issue #77 these ship as ONE generation, so a split means a release published the plugin without its matching bundle asset. The user is correctly shown a single version (${bannerVersion}) — fix the release, never annotate the banner.`);
|
|
492
|
+
}
|
|
476
493
|
let confidenceInstruction;
|
|
477
494
|
if (readiness.state === 'ready') {
|
|
478
495
|
emit('USER-LEVEL: one brain (~/.cache/ruvnet-brain/kb) shared by every project and window here — nothing to reinstall per project. search_ruvnet is ready and live; the grounding hooks are active.');
|
|
479
|
-
confidenceInstruction = `Open your FIRST response with ONE short, warm confirmation in your own words (2-3 lines, then move on; never repeat it this session). It must say "🧠 RuvNet Brain active (v${bannerVersion}
|
|
496
|
+
confidenceInstruction = `Open your FIRST response with ONE short, warm confirmation in your own words (2-3 lines, then move on; never repeat it this session). It must say "🧠 RuvNet Brain active (v${bannerVersion})" — ONE version, in parentheses, always; never a second number, never a bundle tag beside it — and convey: it grounds rUv's stack (RVF, Ruflo, AgentDB, SPARC, agentic-flow…) in his real source rather than guessing; npx github:stuinfla/ruvnet-brain --doctor checks it; ${consoleInvoke} opens a visual settings page.`;
|
|
480
497
|
} else if (readiness.state === 'degraded') {
|
|
481
498
|
const receipt = readiness.receipt || {};
|
|
482
499
|
emit(`USER-LEVEL: one brain (~/.cache/ruvnet-brain/kb) shared by every project and window here — nothing to reinstall per project. search_ruvnet is registered but degraded (${receipt.phase || 'startup'}: ${receipt.error || 'readiness failed'}); the grounding hooks remain active.`);
|
|
@@ -145,8 +145,11 @@ export function evaluatePublicationReceipt(candidate, publication) {
|
|
|
145
145
|
const failures = [...candidateResult.failures];
|
|
146
146
|
const sha = candidateResult.sha;
|
|
147
147
|
const digest = candidateResult.artifactSha256;
|
|
148
|
-
if (publication?.schemaVersion !==
|
|
148
|
+
if (publication?.schemaVersion !== 2 || publication?.phase !== 'publication') failures.push(fail('INVALID_PUBLICATION_RECEIPT', 'schemaVersion=2 and phase=publication are required'));
|
|
149
149
|
if (publication?.sha !== sha || publication?.artifactSha256 !== digest) failures.push(fail('PUBLICATION_SEAL_MISMATCH', 'publication does not reference candidate seal'));
|
|
150
|
+
if (!cleanHex(publication?.payloadId, 64) || !cleanHex(publication?.bundleArtifactSha256, 64)) {
|
|
151
|
+
failures.push(fail('PUBLIC_PAYLOAD_BINDING_MISSING', 'signed payload and public bundle digests are required'));
|
|
152
|
+
}
|
|
150
153
|
const version = versionField(candidate?.version);
|
|
151
154
|
const tag = version ? `v${version}` : null;
|
|
152
155
|
const identityMismatches = versionIdentityFailures(version, candidate?.tag, [
|
|
@@ -155,8 +158,9 @@ export function evaluatePublicationReceipt(candidate, publication) {
|
|
|
155
158
|
['GitHub release tag', publication?.githubRelease?.tag, tag],
|
|
156
159
|
['bundle brainVersion', publication?.bundle?.brainVersion],
|
|
157
160
|
['bundle releaseTag', publication?.bundle?.releaseTag, tag],
|
|
158
|
-
['
|
|
159
|
-
['
|
|
161
|
+
['Claude-only host', publication?.installed?.claudeOnly?.version],
|
|
162
|
+
['Codex-only host', publication?.installed?.codexOnly?.version],
|
|
163
|
+
['dual host', publication?.installed?.dual?.version],
|
|
160
164
|
]);
|
|
161
165
|
if (identityMismatches.length > 0) {
|
|
162
166
|
failures.push(fail('PUBLIC_VERSION_IDENTITY_MISMATCH', `public surfaces must identify candidate ${version ?? 'UNKNOWN'}: ${identityMismatches.join(', ')}`));
|
|
@@ -165,9 +169,12 @@ export function evaluatePublicationReceipt(candidate, publication) {
|
|
|
165
169
|
const item = publication?.[surface];
|
|
166
170
|
if (item?.sha !== sha || item?.artifactSha256 !== digest) failures.push(fail('PUBLIC_ARTIFACT_MISMATCH', `${surface} differs from candidate seal`));
|
|
167
171
|
}
|
|
168
|
-
for (const hostName of ['
|
|
172
|
+
for (const hostName of ['claudeOnly', 'codexOnly', 'dual']) {
|
|
169
173
|
const host = publication?.installed?.[hostName];
|
|
170
|
-
if (host?.status !== 'PASS' || host?.
|
|
174
|
+
if (host?.status !== 'PASS' || host?.doctorExit !== 0 || host?.artifactSha256 !== digest
|
|
175
|
+
|| host?.functionalSearch !== true || !(host?.searchMs <= Number(publication?.brain?.deadlineMs) * 0.8)) {
|
|
176
|
+
failures.push(fail('PUBLIC_HOST_NOT_PASS', `${hostName} is not running a doctor-clean functional sealed public artifact`));
|
|
177
|
+
}
|
|
171
178
|
}
|
|
172
179
|
const brain = publication?.brain || {};
|
|
173
180
|
if (brain.status !== 'PASS' || brain.selfStore !== true || !(brain.broadMs <= Number(brain.deadlineMs) * 0.8)) failures.push(fail('PUBLIC_BRAIN_NOT_PASS', 'public installed Brain acceptance failed'));
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { stagedHostVerifier } from './staged-host-verifier.mjs';
|
|
6
|
+
import { payloadIdFor } from './release-payload.mjs';
|
|
7
|
+
|
|
8
|
+
const arg = (name) => {
|
|
9
|
+
const index = process.argv.indexOf(name);
|
|
10
|
+
return index >= 0 ? process.argv[index + 1] : null;
|
|
11
|
+
};
|
|
12
|
+
const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
13
|
+
|
|
14
|
+
const manifestPath = path.resolve(arg('--manifest'));
|
|
15
|
+
const packagePath = path.resolve(arg('--package'));
|
|
16
|
+
const bundlePath = path.resolve(arg('--bundle'));
|
|
17
|
+
const out = path.resolve(arg('--out'));
|
|
18
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
19
|
+
const payloadId = payloadIdFor(manifest);
|
|
20
|
+
const identity = { version: manifest.version, candidateSha: manifest.candidateSha, payloadId };
|
|
21
|
+
const result = await stagedHostVerifier({ assets: { packagePath, bundlePath }, identity })
|
|
22
|
+
.verify({ source: 'candidate', assets: { packagePath, bundlePath } });
|
|
23
|
+
if (result.verdict !== 'PASS') throw new Error(`candidate host matrix failed: ${result.error || 'unknown'}`);
|
|
24
|
+
|
|
25
|
+
const modeNames = { claude: 'claude-only', codex: 'codex-only', dual: 'dual-host' };
|
|
26
|
+
const leaves = Object.entries(modeNames).map(([mode, name]) => {
|
|
27
|
+
const fixture = result.fixtures?.[mode];
|
|
28
|
+
if (fixture?.status !== 'PASS' || fixture?.doctorExit !== 0) throw new Error(`${name} did not produce a clean doctor receipt`);
|
|
29
|
+
return {
|
|
30
|
+
name,
|
|
31
|
+
sha: manifest.candidateSha,
|
|
32
|
+
payloadId,
|
|
33
|
+
status: fixture.status === 'PASS' ? 'completed' : 'failed',
|
|
34
|
+
conclusion: 'success',
|
|
35
|
+
verdict: 'PASS',
|
|
36
|
+
source: 'candidate-host-evidence',
|
|
37
|
+
mode,
|
|
38
|
+
doctorExit: fixture.doctorExit,
|
|
39
|
+
artifactSha256: sha256(packagePath),
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
fs.writeFileSync(out, `${JSON.stringify({
|
|
43
|
+
schemaVersion: 1,
|
|
44
|
+
sha: manifest.candidateSha,
|
|
45
|
+
payloadId,
|
|
46
|
+
artifactSha256: sha256(packagePath),
|
|
47
|
+
leaves,
|
|
48
|
+
}, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
49
|
+
console.log(JSON.stringify({ verdict: 'PASS', payloadId, leaves: leaves.map(({ name }) => name) }));
|
|
@@ -23,8 +23,20 @@ const probe = process.env.RUVNET_REPLAY_LESSON_PROBE || '';
|
|
|
23
23
|
if (event === 'PreToolUse') {
|
|
24
24
|
const recorder = process.env.RUVNET_REPLAY_RECORDER || '';
|
|
25
25
|
if (!recorder) process.exit(2);
|
|
26
|
+
// Codex emits exec_command with tool_input.cmd; the shared Claude/Ruflo boundary consumes
|
|
27
|
+
// Bash with tool_input.command. Normalize the real host envelope before the recorder parses it.
|
|
28
|
+
const recorderInput = Buffer.from(JSON.stringify({
|
|
29
|
+
...input,
|
|
30
|
+
tool_name: /^(?:functions[._]{1,2})?exec_command$/.test(String(input.tool_name || ''))
|
|
31
|
+
? 'Bash'
|
|
32
|
+
: input.tool_name,
|
|
33
|
+
tool_input: {
|
|
34
|
+
...(input.tool_input || {}),
|
|
35
|
+
command: input.tool_input?.command ?? input.tool_input?.cmd ?? input.command ?? '',
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
26
38
|
const result = spawnSync(process.execPath, [recorder, attemptsFile, sequenceFile], {
|
|
27
|
-
input:
|
|
39
|
+
input: recorderInput,
|
|
28
40
|
encoding: 'utf8',
|
|
29
41
|
env: process.env,
|
|
30
42
|
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// ONE definition of "what the persistent Console runtime is" and "which generation is running".
|
|
2
|
+
//
|
|
3
|
+
// #79 reported a Console that survived an update: the browser was served the new frontend from disk
|
|
4
|
+
// while the Node process kept its pre-update router in memory, and every relaunch said "already
|
|
5
|
+
// running". The reuse check compared a `sourceSha256` computed from a SINGLE file —
|
|
6
|
+
// scripts/onboarding-console.mjs — while the runtime the server actually executes is this whole
|
|
7
|
+
// surface: ~230 modules it imports plus the frontend it serves. Two genuinely different candidates
|
|
8
|
+
// whose entrypoint bytes happened to match produced byte-identical identities, so an update that
|
|
9
|
+
// changed console-engine.mjs, capability-registry.mjs or console/app.js was invisible to the
|
|
10
|
+
// launcher. The identity has to derive from something an update cannot leave behind, which is the
|
|
11
|
+
// installed bytes themselves — all of them.
|
|
12
|
+
//
|
|
13
|
+
// #76 reported the same disease at the asset boundary: the runtime copied plugin/scripts without
|
|
14
|
+
// the sibling docs/ and manifest that plugin/scripts/whats-new.mjs reads, so the executable
|
|
15
|
+
// travelled and its assets did not. The copy list and the identity list were two separate
|
|
16
|
+
// enumerations of one payload; they are one list now, so an asset added to the runtime is part of
|
|
17
|
+
// its generation by construction.
|
|
18
|
+
//
|
|
19
|
+
// The installer hashes the staged tree with this function and the running server hashes its own
|
|
20
|
+
// root with this function, over this list. Neither side can define the fact differently.
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import crypto from 'node:crypto';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Every path the persistent Console runtime carries, relative to both the candidate source root and
|
|
28
|
+
* the installed runtime root (they are laid out identically). This is simultaneously the installer's
|
|
29
|
+
* copy list and the generation's digest input — one enumeration, so they cannot drift.
|
|
30
|
+
*/
|
|
31
|
+
export const CONSOLE_RUNTIME_SURFACE = Object.freeze([
|
|
32
|
+
'console',
|
|
33
|
+
'scripts',
|
|
34
|
+
'plugin/scripts',
|
|
35
|
+
// whats-new.mjs resolves its version manifest and curated notes relative to its own plugin root
|
|
36
|
+
// (#76). Shipping the executable without them is how the installed skill lost its release notes.
|
|
37
|
+
'plugin/docs',
|
|
38
|
+
'plugin/.claude-plugin',
|
|
39
|
+
'data/model-catalog.json',
|
|
40
|
+
'kb/brain-profile.mjs',
|
|
41
|
+
'bin/install.mjs',
|
|
42
|
+
'package.json',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/** The runtime's own identity file, written by the installer INTO the tree — never part of its own digest. */
|
|
46
|
+
export const CONSOLE_RUNTIME_IDENTITY_FILE = 'runtime-identity.json';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Deterministic content digest of the runtime surface under `root`.
|
|
50
|
+
*
|
|
51
|
+
* A path that is absent is hashed as absent rather than skipped: a runtime that lost a file is a
|
|
52
|
+
* different generation, not the same one, and a launcher must be able to see that.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} root runtime root (an installed `.console-runtime`, a marketplace clone, or a checkout)
|
|
55
|
+
* @returns {string} hex sha256
|
|
56
|
+
*/
|
|
57
|
+
export function consoleRuntimeDigest(root) {
|
|
58
|
+
const hash = crypto.createHash('sha256');
|
|
59
|
+
const visit = (absolute, relative) => {
|
|
60
|
+
let stat;
|
|
61
|
+
try { stat = fs.lstatSync(absolute); } catch { hash.update(`absent\0${relative}\0`); return; }
|
|
62
|
+
if (stat.isDirectory()) {
|
|
63
|
+
hash.update(`d\0${relative}\0`);
|
|
64
|
+
let entries = [];
|
|
65
|
+
try { entries = fs.readdirSync(absolute).sort(); } catch { hash.update(`unreadable\0${relative}\0`); return; }
|
|
66
|
+
for (const entry of entries) visit(path.join(absolute, entry), `${relative}/${entry}`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
hash.update(`f\0${relative}\0`);
|
|
70
|
+
try { hash.update(fs.readFileSync(absolute)); } catch { hash.update(`unreadable\0${relative}\0`); }
|
|
71
|
+
};
|
|
72
|
+
for (const relative of CONSOLE_RUNTIME_SURFACE) visit(path.join(root, relative), relative);
|
|
73
|
+
return hash.digest('hex');
|
|
74
|
+
}
|
|
@@ -37,9 +37,13 @@ import fs from 'node:fs';
|
|
|
37
37
|
import os from 'node:os';
|
|
38
38
|
import path from 'node:path';
|
|
39
39
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
40
|
+
import { resolveRuflo, RUFLO_MISSING } from '../plugin/scripts/ruflo-bin.mjs';
|
|
40
41
|
|
|
41
42
|
const HOME = os.homedir();
|
|
42
|
-
|
|
43
|
+
// Issue #99: this was `process.env.RUFLO_BIN || path.join(HOME, '.npm-global/bin/ruflo')`, so every
|
|
44
|
+
// install whose npm prefix is not the owner's (Homebrew, nvm, Volta, plain `npm -g`) was told its
|
|
45
|
+
// tool was missing while ruflo sat on their PATH. One resolver, shared — see ruflo-bin.mjs.
|
|
46
|
+
const RUFLO = resolveRuflo();
|
|
43
47
|
const RUFLO_ENV = { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' };
|
|
44
48
|
|
|
45
49
|
const argv = process.argv.slice(2);
|
|
@@ -126,6 +130,10 @@ if (has('--restore')) {
|
|
|
126
130
|
}
|
|
127
131
|
|
|
128
132
|
// ── PRE-FLIGHT ─────────────────────────────────────────────────────────────────────────────────────
|
|
133
|
+
// Two different facts, so two different sentences. `null` means we looked everywhere and found
|
|
134
|
+
// nothing; a path that does not exist means the user pointed RUFLO_BIN somewhere empty, and naming
|
|
135
|
+
// that exact path back to them is the whole value of the message.
|
|
136
|
+
if (!RUFLO) die(RUFLO_MISSING);
|
|
129
137
|
if (!fs.existsSync(RUFLO)) die(`ruflo is not at ${RUFLO.replace(HOME, '~')} — install it with \`npm i -g ruflo@latest\``);
|
|
130
138
|
if (!fs.existsSync(DB)) die(`no memory store at ${DB.replace(HOME, '~')} — nothing to distill for this project`);
|
|
131
139
|
|
package/scripts/doc-currency.mjs
CHANGED
|
@@ -408,6 +408,7 @@ export function evaluateDoc(root, rel, opts = {}) {
|
|
|
408
408
|
status: k.status ?? null,
|
|
409
409
|
date: k.date ?? null,
|
|
410
410
|
updated: k.updated ?? null,
|
|
411
|
+
updatedPinned: k.updated_pinned === true || k.updated_pinned === 'true',
|
|
411
412
|
implStored: k.impl ?? null,
|
|
412
413
|
verifiedStamp: k.verified ?? null,
|
|
413
414
|
verifiedDigestStored: k.verified_digest ?? null,
|
|
@@ -455,7 +456,19 @@ export function evaluateDoc(root, rel, opts = {}) {
|
|
|
455
456
|
if (Number.isFinite(d) && Number.isFinite(g)) {
|
|
456
457
|
const deltaDays = Math.round((g - d) / 86400000);
|
|
457
458
|
if (deltaDays >= 1) {
|
|
458
|
-
|
|
459
|
+
// `updated_pinned: true` — the date is a HISTORICAL RECORD, not a currency stamp.
|
|
460
|
+
//
|
|
461
|
+
// Some documents record WHEN SOMETHING HAPPENED, not when the file was last edited.
|
|
462
|
+
// ADR-050's `updated: 2026-08-02` is an incident cutoff and is asserted by
|
|
463
|
+
// tests/unit/fix-workstream-guidance. A blanket "make the stamp match the last commit"
|
|
464
|
+
// rule cannot tell those apart, so it silently rewrote the pinned date — putting two gates
|
|
465
|
+
// into direct contradiction, one demanding the pinned value and one demanding the commit
|
|
466
|
+
// date, with no way to satisfy both. The document itself is the only thing that knows
|
|
467
|
+
// which kind of date it carries, so it now says so, and --fix must never overwrite it.
|
|
468
|
+
if (doc.updatedPinned) {
|
|
469
|
+
add(WARN, 'stamp-pinned',
|
|
470
|
+
`updated: ${doc.updated} is PINNED (updated_pinned: true) and deliberately does not track the last commit ${docCommit.date} — it records an event, not the edit`);
|
|
471
|
+
} else add(BLOCK, 'stamp-lags-doc',
|
|
459
472
|
`updated: ${doc.updated} but the document's own last commit is ${docCommit.date} (${docCommit.sha.slice(0, 8)}) — edited without touching its own stamp`);
|
|
460
473
|
} else if (deltaDays <= -1) {
|
|
461
474
|
// Typed ahead. WARN only: a stamp dated today on a change not yet committed is CORRECT, and
|
|
@@ -610,7 +623,7 @@ export function planFix(root, doc) {
|
|
|
610
623
|
if (doc.dirty) blocked.push({ code: 'missing-updated', why: 'working tree is modified — the last commit date is not the date of the current contents' });
|
|
611
624
|
else if (doc.docCommit) changes.push({ key: 'updated', value: doc.docCommit.date, source: 'derived-from-git', evidence: `git log -1 ${doc.file} → ${doc.docCommit.sha.slice(0, 8)}` });
|
|
612
625
|
else blocked.push({ code: 'missing-updated', why: 'git reports no commit touching this path' });
|
|
613
|
-
} else if (doc.findings.some((f) => f.code === 'stamp-lags-doc') && doc.docCommit && !doc.dirty) {
|
|
626
|
+
} else if (!doc.updatedPinned && doc.findings.some((f) => f.code === 'stamp-lags-doc') && doc.docCommit && !doc.dirty) {
|
|
614
627
|
changes.push({ key: 'updated', value: doc.docCommit.date, source: 'derived-from-git', replaces: doc.updated, evidence: `git log -1 ${doc.file} → ${doc.docCommit.sha.slice(0, 8)}` });
|
|
615
628
|
}
|
|
616
629
|
return { changes, blocked };
|