great-cto 2.96.0 → 2.97.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/board/.claude-plugin/plugin.json +5 -1
- package/board/packages/board/lib/data-readers.mjs +53 -8
- package/board/packages/board/lib/docs.mjs +39 -0
- package/board/packages/board/lib/routes.mjs +107 -0
- package/board/packages/board/public/index.html +194 -4
- package/board/scripts/lib/freshness.mjs +175 -0
- package/board/scripts/lib/gate-tier.mjs +279 -0
- package/board/scripts/lib/pipeline-wake.mjs +120 -0
- package/board/scripts/lib/receipt.mjs +386 -0
- package/board/scripts/lib/stand-down.mjs +147 -0
- package/board/scripts/lib/system-map.mjs +206 -0
- package/dist/detect.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* freshness — judges whether an artifact is fresh, stale, or unknown.
|
|
3
|
+
*
|
|
4
|
+
* Two axes feed the verdict:
|
|
5
|
+
*
|
|
6
|
+
* 1. DECLARED — an author-chosen `stale_after: YYYY-MM-DD`, in YAML
|
|
7
|
+
* frontmatter or an inline `**Stale after:** YYYY-MM-DD` marker. When
|
|
8
|
+
* present it is authoritative: past that date the doc is stale, future
|
|
9
|
+
* of it the doc is fresh — with NO reference to when the file was last
|
|
10
|
+
* touched. This is the fix for a re-dated typo rejuvenating content that
|
|
11
|
+
* stopped being true months earlier (measured: 13/159 docs, worst case
|
|
12
|
+
* 76 days — see docs/plans/PLAN-2026-08-15-stale-after.md).
|
|
13
|
+
*
|
|
14
|
+
* 2. mtime (label only) — the fallback when `stale_after` is absent. The
|
|
15
|
+
* house-rule word for this axis is "mtime", but it does NOT read the
|
|
16
|
+
* filesystem's modified time: it reads the doc's own declared
|
|
17
|
+
* `**Date:**` / frontmatter `date:` and compares that age to a
|
|
18
|
+
* threshold. We keep the label because that's the vocabulary the third
|
|
19
|
+
* state (`unknown`) is named in throughout the ARCH/ADR; this comment is
|
|
20
|
+
* the correction — what's actually measured is edit-age-as-declared, not
|
|
21
|
+
* inode mtime.
|
|
22
|
+
*
|
|
23
|
+
* Three states, never two: 'fresh' | 'stale' | 'unknown'. Absence of
|
|
24
|
+
* `stale_after` is never silently "fresh forever" — it falls through to the
|
|
25
|
+
* mtime rule, which still fires past the threshold, and a doc with neither
|
|
26
|
+
* field reads as 'unknown', not fresh.
|
|
27
|
+
*
|
|
28
|
+
* `now` is always an injected parameter (`nowMs`) — nothing in this module
|
|
29
|
+
* calls Date.now(). The caller (artifact-lint.mjs) resolves one NOW_MS at
|
|
30
|
+
* startup from --now / GREAT_CTO_NOW / the real clock, so every state is
|
|
31
|
+
* reachable in tests without a timer hack.
|
|
32
|
+
*
|
|
33
|
+
* `dateType` ('any' | 'optional') is accepted for signature completeness with
|
|
34
|
+
* the ARCH's freshness contract but is NOT used to change the verdict here:
|
|
35
|
+
* the verdict is a fact about the document, not a policy about whether to
|
|
36
|
+
* warn. Whether an 'unknown' verdict becomes a printed WARN for a
|
|
37
|
+
* date:'optional' type (PLAN, TM) — vs staying silent, as it does today — is
|
|
38
|
+
* a report-time decision the caller makes in its lint loop, matching the
|
|
39
|
+
* ARCH's own "Warn mapping in the lint loop" section. Keeping that policy out
|
|
40
|
+
* of this pure function means `freshness[]` (the JSON audit trail) can record
|
|
41
|
+
* every checked artifact's true verdict, warned or not.
|
|
42
|
+
*
|
|
43
|
+
* ARCH: docs/architecture/ARCH-stale-after.md
|
|
44
|
+
* ADR: docs/adr/ADR-011-stale-after-precedence.md
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
const ISO_DATE = /\d{4}-\d{2}-\d{2}/;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Strip a UTF-8 BOM. A file that starts with one has no `^---` at position 0,
|
|
51
|
+
* so its frontmatter is invisible to every anchored match below and the
|
|
52
|
+
* author's declared date is silently lost.
|
|
53
|
+
*/
|
|
54
|
+
function stripBom(s) {
|
|
55
|
+
return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* True if `iso` (YYYY-MM-DD) is a real calendar date, not just digit-shaped.
|
|
60
|
+
*
|
|
61
|
+
* `Date.parse` alone is not enough: it rejects an impossible MONTH
|
|
62
|
+
* (`2026-13-40` → NaN) but silently rolls an impossible DAY forward, so
|
|
63
|
+
* `2026-02-30` becomes March 2 rather than failing. Round-tripping the parsed
|
|
64
|
+
* value back to its components and requiring they match what the author typed
|
|
65
|
+
* discards such a date instead of quietly judging the document against a
|
|
66
|
+
* nearby one it never wrote.
|
|
67
|
+
*/
|
|
68
|
+
function isValidIsoDate(iso) {
|
|
69
|
+
const ms = Date.parse(`${iso}T00:00:00Z`);
|
|
70
|
+
if (Number.isNaN(ms)) return false;
|
|
71
|
+
const [y, m, d] = iso.split('-').map(Number);
|
|
72
|
+
const back = new Date(ms);
|
|
73
|
+
return back.getUTCFullYear() === y && back.getUTCMonth() + 1 === m && back.getUTCDate() === d;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parse a declared `stale_after` date from frontmatter or the inline
|
|
78
|
+
* `**Stale after:**` marker. Defensive by construction: the regex is
|
|
79
|
+
* anchored to `\d{4}-\d{2}-\d{2}` so a non-date value (`stale_after: soon`)
|
|
80
|
+
* never matches at all, and a shape-valid but impossible calendar date — an
|
|
81
|
+
* impossible month (`2026-13-40`) or an impossible day (`2026-02-30`) — is
|
|
82
|
+
* caught by `isValidIsoDate` and discarded, never coerced, never read as
|
|
83
|
+
* fresh. Frontmatter takes precedence when both forms appear; the field
|
|
84
|
+
* describes a single intended value, not two independent ones.
|
|
85
|
+
*
|
|
86
|
+
* CRLF and a leading BOM are tolerated: a file normalised by a Windows
|
|
87
|
+
* checkout or an editor still declares the date its author wrote, and losing
|
|
88
|
+
* it to an invisible byte would silently demote the document to the mtime
|
|
89
|
+
* rule while looking exactly like a document that never declared one.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} text
|
|
92
|
+
* @returns {string|null} 'YYYY-MM-DD' or null
|
|
93
|
+
*/
|
|
94
|
+
export function parseStaleAfter(text) {
|
|
95
|
+
const fm = stripBom(String(text ?? '')).match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
96
|
+
if (fm) {
|
|
97
|
+
const m = fm[1].match(/^stale_after:\s*(\d{4}-\d{2}-\d{2})\b/im);
|
|
98
|
+
if (m && isValidIsoDate(m[1])) return m[1];
|
|
99
|
+
}
|
|
100
|
+
const inline = text.match(/\*\*stale[ _-]?after:?\*\*\s*(\d{4}-\d{2}-\d{2})\b/i);
|
|
101
|
+
if (inline && isValidIsoDate(inline[1])) return inline[1];
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Extract the most recent date (YYYY-MM-DD) from YAML frontmatter or inline
|
|
107
|
+
* `**Date:**` / `**Last reviewed:**` / `**Updated:**` markers. Moved here
|
|
108
|
+
* from artifact-lint.mjs per ARCH Risk R3: keeping both date parsers in one
|
|
109
|
+
* module means they cannot drift apart silently — which is why the CRLF/BOM
|
|
110
|
+
* tolerance added to `parseStaleAfter` is applied here too rather than only
|
|
111
|
+
* where it was reported.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} text
|
|
114
|
+
* @returns {string|null}
|
|
115
|
+
*/
|
|
116
|
+
export function extractDate(text) {
|
|
117
|
+
const found = [];
|
|
118
|
+
const fm = stripBom(String(text ?? '')).match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
119
|
+
if (fm) {
|
|
120
|
+
for (const m of fm[1].matchAll(/^(?:date|last[_-]?reviewed|updated):\s*(\d{4}-\d{2}-\d{2})/gim)) {
|
|
121
|
+
found.push(m[1]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const m of text.matchAll(/\*\*(?:date|last[_-]?reviewed|updated)[:\s]*\*\*\s*(\d{4}-\d{2}-\d{2})/gi)) {
|
|
125
|
+
found.push(m[1]);
|
|
126
|
+
}
|
|
127
|
+
if (!found.length) return null;
|
|
128
|
+
return found.sort().at(-1); // most recent
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Age in whole days of an ISO date against an injected `nowMs` — never
|
|
133
|
+
* `Date.now()`. Returns null for an unparseable date.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} iso
|
|
136
|
+
* @param {number} nowMs
|
|
137
|
+
* @returns {number|null}
|
|
138
|
+
*/
|
|
139
|
+
export function ageDays(iso, nowMs) {
|
|
140
|
+
const then = Date.parse(`${iso}T00:00:00Z`);
|
|
141
|
+
if (Number.isNaN(then)) return null;
|
|
142
|
+
return Math.floor((nowMs - then) / 86_400_000);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Judge one artifact's freshness.
|
|
147
|
+
*
|
|
148
|
+
* @param {{text: string, dateType: 'any'|'optional', nowMs: number, staleDays: number}} args
|
|
149
|
+
* @returns {{
|
|
150
|
+
* verdict: 'fresh'|'stale'|'unknown',
|
|
151
|
+
* basis: 'declared'|'mtime',
|
|
152
|
+
* staleAfter: string|null,
|
|
153
|
+
* date: string|null,
|
|
154
|
+
* ageDays: number|null,
|
|
155
|
+
* }}
|
|
156
|
+
*/
|
|
157
|
+
export function judgeFreshness({ text, dateType: _dateType, nowMs, staleDays }) {
|
|
158
|
+
const staleAfter = parseStaleAfter(text);
|
|
159
|
+
const date = extractDate(text);
|
|
160
|
+
const age = date ? ageDays(date, nowMs) : null;
|
|
161
|
+
|
|
162
|
+
if (staleAfter) {
|
|
163
|
+
const staleAfterMs = Date.parse(`${staleAfter}T00:00:00Z`);
|
|
164
|
+
// "Stale on/after that date" (ADR-011 decision #1) — equality counts as stale.
|
|
165
|
+
const verdict = nowMs >= staleAfterMs ? 'stale' : 'fresh';
|
|
166
|
+
return { verdict, basis: 'declared', staleAfter, date, ageDays: age };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!date) {
|
|
170
|
+
return { verdict: 'unknown', basis: 'mtime', staleAfter: null, date: null, ageDays: null };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const verdict = age !== null && age > staleDays ? 'stale' : 'fresh';
|
|
174
|
+
return { verdict, basis: 'mtime', staleAfter: null, date, ageDays: age };
|
|
175
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// When a gate may stop asking, and — more usefully — why it may not.
|
|
2
|
+
//
|
|
3
|
+
// The parent plan's phase 5: once an agent's holdout conclusively clears its
|
|
4
|
+
// bar, its gate drops to notify-only. The pipeline proceeds, the entry still
|
|
5
|
+
// appears in the board's inbox, the human may intervene and need not.
|
|
6
|
+
//
|
|
7
|
+
// Why this could not be written until today
|
|
8
|
+
// -----------------------------------------
|
|
9
|
+
// Every earlier "conclusively passes" rested on single-sample runs, and a single
|
|
10
|
+
// sample of a three-case eval can only score 0, 0.33, 0.67 or 1.00 — its own
|
|
11
|
+
// history swings 0.83 → 1.00 → 0.83 with nothing changing. The holdout×3
|
|
12
|
+
// baseline finished on 2026-08-11, 75 of 75, three samples with a majority
|
|
13
|
+
// judge. That is the first evidence in this repository that could carry a
|
|
14
|
+
// decision to stop asking a human.
|
|
15
|
+
//
|
|
16
|
+
// The refusals are the substance
|
|
17
|
+
// ------------------------------
|
|
18
|
+
// Thirty evals clear the interval. Only some of those may drop a gate, and the
|
|
19
|
+
// three cuts are mechanical rather than judgement:
|
|
20
|
+
//
|
|
21
|
+
// - `sharedExpanded` non-empty: the harness inlined `_shared` contracts into
|
|
22
|
+
// the actor's prompt, so the agent was handed something it may never fetch
|
|
23
|
+
// on a live run. On 2026-08-07 architect did not fetch the one whose command
|
|
24
|
+
// sat verbatim in its own file. An eval that inlines the handoff measures an
|
|
25
|
+
// agent that does not exist.
|
|
26
|
+
// - `actorSource` not `agent:<name>`: the generic actor measures the eval, not
|
|
27
|
+
// the agent.
|
|
28
|
+
// - Class A: `devops` and `infra-provisioner` both clear the interval today —
|
|
29
|
+
// devops at 82%, [72%, 89%], n=77 — and both stay gated. The question at a
|
|
30
|
+
// production deploy is not competence (ADR-009). A tier that cannot refuse
|
|
31
|
+
// those two is not a tier, it is a rubber stamp.
|
|
32
|
+
//
|
|
33
|
+
// Each refusal names itself. "Inconclusive" and "fixture-inlined" are different
|
|
34
|
+
// work: one needs more samples, the other needs the eval rewritten.
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Agents whose gate never drops, whatever the evidence says.
|
|
38
|
+
*
|
|
39
|
+
* The same list `pipeline-tick` refuses to dispatch unattended, and for the same
|
|
40
|
+
* reason: a gate is configuration, and this is not. An operation that escapes
|
|
41
|
+
* the machine, costs money, or cannot be undone needs a human in the loop, and
|
|
42
|
+
* "it scored well" is not the same as "a human decided".
|
|
43
|
+
*/
|
|
44
|
+
export const CLASS_A = Object.freeze(['devops', 'infra-provisioner']);
|
|
45
|
+
|
|
46
|
+
/** The run shape a tier decision may be based on. Anything else is not evidence. */
|
|
47
|
+
export const REQUIRED_SPLIT = 'holdout';
|
|
48
|
+
export const REQUIRED_SAMPLES = 3;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The eval count above which the evidence is at least plural.
|
|
52
|
+
*
|
|
53
|
+
* Found by reading our own output rather than the code: of the fifteen agents
|
|
54
|
+
* whose gate stands down today, FOURTEEN qualified on a single eval file. One
|
|
55
|
+
* file is roughly six holdout cases at three samples — about eighteen trials —
|
|
56
|
+
* and that was the entire basis for deciding a human need not be asked.
|
|
57
|
+
*
|
|
58
|
+
* The statistics were never wrong. `power.status === 'passed'` means the
|
|
59
|
+
* interval clears the bar ON THE CASES THAT EXIST, and it says nothing about
|
|
60
|
+
* whether those cases span what the agent is responsible for. `insurance-
|
|
61
|
+
* reviewer` answers for NAIC, IFRS 17, Solvency II, ACORD and actuarial
|
|
62
|
+
* auditability; one passing eval dropped its gate for all of it.
|
|
63
|
+
*
|
|
64
|
+
* So this threshold does NOT measure coverage — nothing here can, and claiming
|
|
65
|
+
* otherwise would be the same defect one layer up. It marks the case where
|
|
66
|
+
* coverage is UNMEASURED, so that a narrow result stops reading exactly like a
|
|
67
|
+
* broad one.
|
|
68
|
+
*/
|
|
69
|
+
export const BROAD_EVALS = 2;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The newest qualifying row per eval, for one agent.
|
|
73
|
+
*
|
|
74
|
+
* Newest rather than best: an agent that improved and then regressed is at its
|
|
75
|
+
* regression, and picking the best row would be choosing the evidence to suit
|
|
76
|
+
* the conclusion.
|
|
77
|
+
*/
|
|
78
|
+
export function evidenceFor(agent, rows = []) {
|
|
79
|
+
const mine = rows.filter((r) =>
|
|
80
|
+
r?.agent === agent &&
|
|
81
|
+
r.split === REQUIRED_SPLIT &&
|
|
82
|
+
Number(r.samples || 1) === REQUIRED_SAMPLES &&
|
|
83
|
+
!r.dropout?.severe);
|
|
84
|
+
|
|
85
|
+
const newest = new Map();
|
|
86
|
+
for (const r of mine) {
|
|
87
|
+
const prev = newest.get(r.eval);
|
|
88
|
+
if (!prev || String(r.run_id || '') >= String(prev.run_id || '')) newest.set(r.eval, r);
|
|
89
|
+
}
|
|
90
|
+
return [...newest.values()];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* May this agent's gate drop to notify-only?
|
|
95
|
+
*
|
|
96
|
+
* Three answers, not two — the same rule this repository applies everywhere
|
|
97
|
+
* else. `notify` is a stand-down on plural evidence; `notify-thin` is a
|
|
98
|
+
* stand-down on ONE eval, where the statistics are sound and the coverage is
|
|
99
|
+
* simply unmeasured. Collapsing the two would put "we checked one thing and it
|
|
100
|
+
* was fine" and "we checked broadly and it was fine" behind one word, which is
|
|
101
|
+
* how an absent measurement comes to wear a result's clothes.
|
|
102
|
+
*
|
|
103
|
+
* `notify-thin` is a real tier, not a warning label: whether it stands down or
|
|
104
|
+
* keeps its gate is the CALLER's decision (see `notifyOnlyAgents`), because
|
|
105
|
+
* that is a policy about risk appetite and this is a fact about evidence.
|
|
106
|
+
*
|
|
107
|
+
* @returns {{tier:'gated'|'notify'|'notify-thin', why:string, evals:number}}
|
|
108
|
+
*/
|
|
109
|
+
export function tierFor(agent, { rows = [], classA = CLASS_A } = {}) {
|
|
110
|
+
if (classA.includes(agent)) {
|
|
111
|
+
return { tier: 'gated', evals: 0, why: 'Class A — the question at a production deploy is not competence (ADR-009), so no score drops this gate' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const ev = evidenceFor(agent, rows);
|
|
115
|
+
if (!ev.length) {
|
|
116
|
+
return { tier: 'gated', evals: 0, why: `no holdout run at ${REQUIRED_SAMPLES} samples — unmeasured, which is not the same as failing` };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Every eval bound to this agent must clear. An agent that passes one of its
|
|
120
|
+
// three evals has not shown it can be left alone; it has shown one thing it
|
|
121
|
+
// can do.
|
|
122
|
+
const failing = ev.filter((r) => r.power?.status !== 'passed');
|
|
123
|
+
if (failing.length) {
|
|
124
|
+
const kinds = [...new Set(failing.map((r) => r.power?.status ?? 'unknown'))].join(', ');
|
|
125
|
+
return { tier: 'gated', evals: ev.length, why: `${failing.length} of ${ev.length} eval(s) not conclusively passed (${kinds}) — the interval, not the point` };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const inlined = ev.filter((r) => (r.sharedExpanded || []).length);
|
|
129
|
+
if (inlined.length) {
|
|
130
|
+
const names = [...new Set(inlined.flatMap((r) => r.sharedExpanded))].slice(0, 3).join(', ');
|
|
131
|
+
return { tier: 'gated', evals: ev.length, why: `the fixture inlined ${names} — this measures an agent that was handed contracts it may never fetch, not the handoff` };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const generic = ev.filter((r) => !String(r.actorSource || '').startsWith('agent:'));
|
|
135
|
+
if (generic.length) {
|
|
136
|
+
return { tier: 'gated', evals: ev.length, why: 'ran against the generic actor — that measures the eval, not this agent\'s prompt' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const passed = `${ev.length} eval(s) conclusively passed at ${REQUIRED_SPLIT}×${REQUIRED_SAMPLES}, none fixture-inlined`;
|
|
140
|
+
|
|
141
|
+
if (ev.length < BROAD_EVALS) {
|
|
142
|
+
return {
|
|
143
|
+
tier: 'notify-thin',
|
|
144
|
+
evals: ev.length,
|
|
145
|
+
why: `${passed} — but on a single eval, so how much of this agent's responsibility was exercised is unmeasured, not broad`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { tier: 'notify', evals: ev.length, why: passed };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Every agent that appears in the evidence, tiered. */
|
|
153
|
+
export function tierAll(rows = [], opts = {}) {
|
|
154
|
+
const agents = [...new Set(rows.map((r) => r?.agent).filter(Boolean))].sort();
|
|
155
|
+
return agents.map((a) => ({ agent: a, ...tierFor(a, { rows, ...opts }) }));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The agents whose gate may stand down, as a Set the pipeline can consult.
|
|
161
|
+
*
|
|
162
|
+
* OFF unless the project asks for it. Tiering changes when a human is asked to
|
|
163
|
+
* decide, and shipping that on by default would change behaviour for every
|
|
164
|
+
* project that never saw the evidence — the ADR-009 "crosses a project
|
|
165
|
+
* boundary" case. A project opts in with `gate-tiering: evidence` in its
|
|
166
|
+
* PROJECT.md.
|
|
167
|
+
*
|
|
168
|
+
* Derived at read time, never a list. A stored list of blessed agents is a
|
|
169
|
+
* snapshot that rots silently: an agent that regresses keeps its pass until
|
|
170
|
+
* someone remembers to revoke it, which is `ARCHITECTURE.md` saying "34 agents"
|
|
171
|
+
* for three months. Computed from the history, a regression restores the gate on
|
|
172
|
+
* the next measurement without anybody acting.
|
|
173
|
+
*/
|
|
174
|
+
export function notifyOnlyAgents(rows = [], { enabled = false, classA = CLASS_A, thin = 'notify' } = {}) {
|
|
175
|
+
if (!enabled) return new Set();
|
|
176
|
+
const stands = thin === 'gated' ? ['notify'] : ['notify', 'notify-thin'];
|
|
177
|
+
return new Set(tierAll(rows, { classA }).filter((a) => stands.includes(a.tier)).map((a) => a.agent));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* How much evidence this project wants before a gate stops asking.
|
|
182
|
+
*
|
|
183
|
+
* off — every gate stands. The default, and what a project that
|
|
184
|
+
* never saw the evidence gets.
|
|
185
|
+
* evidence — a conclusive holdout stands a gate down, including on a
|
|
186
|
+
* single eval. What `gate-tiering: evidence` has meant
|
|
187
|
+
* since it shipped; unchanged, so no project's behaviour
|
|
188
|
+
* moves under it without the owner asking.
|
|
189
|
+
* evidence-broad — the same, except a single-eval pass (`notify-thin`) keeps
|
|
190
|
+
* its gate. Costs fourteen of today's fifteen stand-downs
|
|
191
|
+
* on this repository; buys not treating one eval as breadth.
|
|
192
|
+
*
|
|
193
|
+
* Anything unrecognised is `off`. A misspelt mode must not be read as the more
|
|
194
|
+
* permissive one — the failure of a mis-read here is a gate that quietly
|
|
195
|
+
* stopped asking.
|
|
196
|
+
*/
|
|
197
|
+
export function tieringMode(projectMdText) {
|
|
198
|
+
const m = String(projectMdText ?? '').match(/^\s*gate-tiering:\s*(evidence-broad|evidence)\s*$/mi);
|
|
199
|
+
return m ? m[1].toLowerCase() : 'off';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Does this project want evidence-based tiering at all? Default: no. */
|
|
203
|
+
export function tieringEnabled(projectMdText) {
|
|
204
|
+
return tieringMode(projectMdText) !== 'off';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The notify-only set for a project, assembled from its own opt-in and the
|
|
210
|
+
* measured history. One place, so callers cannot each get it subtly wrong.
|
|
211
|
+
*
|
|
212
|
+
* Fails closed: any problem reading either file leaves every gate standing. The
|
|
213
|
+
* failure mode of a mis-read here is a gate that quietly stops asking, and that
|
|
214
|
+
* is the one outcome this must never produce by accident.
|
|
215
|
+
*/
|
|
216
|
+
export async function notifyOnlyForProject(cwd = process.cwd()) {
|
|
217
|
+
try {
|
|
218
|
+
const { readFileSync, existsSync } = await import('node:fs');
|
|
219
|
+
const { join, dirname } = await import('node:path');
|
|
220
|
+
const { fileURLToPath } = await import('node:url');
|
|
221
|
+
|
|
222
|
+
const projectMd = join(cwd, '.great_cto', 'PROJECT.md');
|
|
223
|
+
if (!existsSync(projectMd)) return new Set();
|
|
224
|
+
const mode = tieringMode(readFileSync(projectMd, 'utf8'));
|
|
225
|
+
if (mode === 'off') return new Set();
|
|
226
|
+
|
|
227
|
+
const history = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'tests', 'eval', 'results-history.jsonl');
|
|
228
|
+
if (!existsSync(history)) return new Set();
|
|
229
|
+
|
|
230
|
+
const rows = [];
|
|
231
|
+
for (const line of readFileSync(history, 'utf8').split('\n')) {
|
|
232
|
+
if (!line.trim()) continue;
|
|
233
|
+
try { rows.push(JSON.parse(line)); } catch { /* a bad row is not evidence */ }
|
|
234
|
+
}
|
|
235
|
+
return notifyOnlyAgents(rows, { enabled: true, thin: mode === 'evidence-broad' ? 'gated' : 'notify' });
|
|
236
|
+
} catch {
|
|
237
|
+
return new Set(); // fail closed — every gate stands
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── CLI ─────────────────────────────────────────────────────────────────────
|
|
242
|
+
//
|
|
243
|
+
// Prints who qualifies and, for everyone else, the specific reason — because
|
|
244
|
+
// "inconclusive" and "fixture-inlined" are different work.
|
|
245
|
+
|
|
246
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
247
|
+
const { readFileSync, existsSync } = await import('node:fs');
|
|
248
|
+
const { join, dirname } = await import('node:path');
|
|
249
|
+
const { fileURLToPath } = await import('node:url');
|
|
250
|
+
|
|
251
|
+
const HISTORY = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'tests', 'eval', 'results-history.jsonl');
|
|
252
|
+
if (!existsSync(HISTORY)) { console.log('gate-tier: no results-history.jsonl — nothing measured.'); process.exit(0); }
|
|
253
|
+
|
|
254
|
+
const rows = [];
|
|
255
|
+
for (const line of readFileSync(HISTORY, 'utf8').split('\n')) {
|
|
256
|
+
if (!line.trim()) continue;
|
|
257
|
+
try { rows.push(JSON.parse(line)); } catch { /* skip */ }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const all = tierAll(rows);
|
|
261
|
+
const notify = all.filter((a) => a.tier === 'notify');
|
|
262
|
+
const thin = all.filter((a) => a.tier === 'notify-thin');
|
|
263
|
+
|
|
264
|
+
if (process.argv.includes('--json')) {
|
|
265
|
+
console.log(JSON.stringify(all, null, 2));
|
|
266
|
+
} else {
|
|
267
|
+
const projectMd = join(process.cwd(), '.great_cto', 'PROJECT.md');
|
|
268
|
+
const mode = existsSync(projectMd) ? tieringMode(readFileSync(projectMd, 'utf8')) : 'off';
|
|
269
|
+
|
|
270
|
+
console.log(`gate-tier: ${notify.length} broad, ${thin.length} thin, ${all.length - notify.length - thin.length} gated — of ${all.length} agent(s)`);
|
|
271
|
+
console.log(` this project: gate-tiering: ${mode}${mode === 'evidence' ? ' (thin stands down too — `evidence-broad` would keep those gates)' : ''}\n`);
|
|
272
|
+
|
|
273
|
+
for (const a of notify) console.log(` notify ${a.agent.padEnd(28)} ${a.why}`);
|
|
274
|
+
if (notify.length) console.log('');
|
|
275
|
+
for (const a of thin) console.log(` notify-thin ${a.agent.padEnd(28)} ${a.why}`);
|
|
276
|
+
if (thin.length) console.log('');
|
|
277
|
+
for (const a of all.filter((x) => x.tier === 'gated')) console.log(` gated ${a.agent.padEnd(28)} ${a.why}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// An approval is evidence that work is waiting. Record it where the next
|
|
2
|
+
// session will look.
|
|
3
|
+
//
|
|
4
|
+
// The hole
|
|
5
|
+
// --------
|
|
6
|
+
// `session-pipeline-resume` opens with a cheap freshness question: if the newest
|
|
7
|
+
// verdict is more than a day old, the pipeline is "history, not work waiting",
|
|
8
|
+
// and the hook returns before reading a single gate. That is the right default
|
|
9
|
+
// for the case it was written for — most sessions start on a project with
|
|
10
|
+
// nothing in flight, and reading gate state costs half a second of shelling out
|
|
11
|
+
// to `bd`.
|
|
12
|
+
//
|
|
13
|
+
// But it answers the wrong question when a gate is approved late. Approve
|
|
14
|
+
// `gate:arch` on a stage that ran three days ago and the strongest possible
|
|
15
|
+
// evidence that the pipeline is waiting — a human just said "go" — is the one
|
|
16
|
+
// thing the hook never consults. It stats the verdict logs, sees three days,
|
|
17
|
+
// and calls the whole thing history.
|
|
18
|
+
//
|
|
19
|
+
// So the board records the approval as a fact when it happens, and the hook
|
|
20
|
+
// treats that fact as the freshness signal it is.
|
|
21
|
+
//
|
|
22
|
+
// What this does NOT do
|
|
23
|
+
// ---------------------
|
|
24
|
+
// It does not decide anything. `tickDecision` still runs with every refusal it
|
|
25
|
+
// has: only `ready-to-dispatch` moves, `devops` and `infra-provisioner` are
|
|
26
|
+
// never dispatched unattended whatever the gates say (ADR-009), the same
|
|
27
|
+
// transition is never dispatched twice, and the minimum interval still applies.
|
|
28
|
+
// A wake only says "look properly, the freshness shortcut does not apply here".
|
|
29
|
+
//
|
|
30
|
+
// It also does not spawn anything. The board cannot — this repository's own
|
|
31
|
+
// `orchestrator-check` hook treats `claude -p` from a tool as an anti-pattern
|
|
32
|
+
// and blocks it. What this removes is the second DECISION, not the second
|
|
33
|
+
// action: you approve, and the next session already knows what it is for.
|
|
34
|
+
|
|
35
|
+
import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* How long an approval stays a reason to look.
|
|
40
|
+
*
|
|
41
|
+
* Long enough that approving on Friday still works on Monday; short enough that
|
|
42
|
+
* an approval nobody acted on in a fortnight stops re-announcing itself. A wake
|
|
43
|
+
* that never expires becomes a permanent "there is work waiting" that stops
|
|
44
|
+
* meaning anything.
|
|
45
|
+
*/
|
|
46
|
+
export const WAKE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
47
|
+
|
|
48
|
+
const FILE = '.pipeline-wake';
|
|
49
|
+
|
|
50
|
+
function wakePath(cwd, projDir = '.great_cto') {
|
|
51
|
+
return path.join(cwd, projDir, FILE);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Record that a human approved a gate.
|
|
56
|
+
*
|
|
57
|
+
* Best-effort by design: a board that cannot write this must still complete the
|
|
58
|
+
* approval. The approval is the decision; this is only a note about where to
|
|
59
|
+
* look for its consequence.
|
|
60
|
+
*
|
|
61
|
+
* @returns {{ok: boolean, why?: string, wake?: object}}
|
|
62
|
+
*/
|
|
63
|
+
export function recordWake(cwd, { gate, id, at = Date.now(), by = 'board' } = {}) {
|
|
64
|
+
const wake = { gate: gate || null, id: id || null, at, by };
|
|
65
|
+
try {
|
|
66
|
+
mkdirSync(path.join(cwd, '.great_cto'), { recursive: true });
|
|
67
|
+
writeFileSync(wakePath(cwd), `${JSON.stringify(wake)}\n`);
|
|
68
|
+
return { ok: true, wake };
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return { ok: false, why: String(e?.message || e) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The pending approval, or why there is none.
|
|
76
|
+
*
|
|
77
|
+
* Returns a reason rather than null-for-everything: "no approval recorded",
|
|
78
|
+
* "the record is unreadable" and "the approval is three weeks old" are three
|
|
79
|
+
* different states, and a caller that collapses them is the defect this
|
|
80
|
+
* repository keeps removing.
|
|
81
|
+
*/
|
|
82
|
+
export function readWake(cwd, { now = Date.now(), ttlMs = WAKE_TTL_MS } = {}) {
|
|
83
|
+
let raw;
|
|
84
|
+
try {
|
|
85
|
+
raw = readFileSync(wakePath(cwd), 'utf8');
|
|
86
|
+
} catch {
|
|
87
|
+
return { pending: false, why: 'no approval recorded' };
|
|
88
|
+
}
|
|
89
|
+
let wake;
|
|
90
|
+
try {
|
|
91
|
+
wake = JSON.parse(raw.trim());
|
|
92
|
+
} catch {
|
|
93
|
+
return { pending: false, why: 'the approval record could not be parsed', unreadable: true };
|
|
94
|
+
}
|
|
95
|
+
if (!wake || typeof wake.at !== 'number' || !Number.isFinite(wake.at)) {
|
|
96
|
+
return { pending: false, why: 'the approval record has no usable timestamp', unreadable: true };
|
|
97
|
+
}
|
|
98
|
+
const age = now - wake.at;
|
|
99
|
+
if (age > ttlMs) {
|
|
100
|
+
return { pending: false, why: `the approval is ${Math.round(age / 86400_000)} days old — past the ${Math.round(ttlMs / 86400_000)}-day window`, wake, expired: true };
|
|
101
|
+
}
|
|
102
|
+
if (age < 0) {
|
|
103
|
+
// A clock that moved backwards, or a file written by another machine. Treat
|
|
104
|
+
// it as pending rather than discarding a human's decision over arithmetic.
|
|
105
|
+
return { pending: true, wake, age: 0, why: 'approval recorded in the future — honouring it anyway' };
|
|
106
|
+
}
|
|
107
|
+
return { pending: true, wake, age, why: `approved ${Math.round(age / 60_000)} minute(s) ago` };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Consume the approval.
|
|
112
|
+
*
|
|
113
|
+
* Called once the decision has been handed to a session, so the same approval
|
|
114
|
+
* does not re-announce itself at every session start for a week. Failure to
|
|
115
|
+
* clear is not fatal — `tickDecision`'s own marker still refuses to dispatch the
|
|
116
|
+
* same transition twice.
|
|
117
|
+
*/
|
|
118
|
+
export function clearWake(cwd) {
|
|
119
|
+
try { rmSync(wakePath(cwd), { force: true }); return true; } catch { return false; }
|
|
120
|
+
}
|