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.
@@ -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
+ }