faberun 0.3.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/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The journal folded into current state: what is open, what is blocked, who is
|
|
3
|
+
* attached, what was decided.
|
|
4
|
+
*
|
|
5
|
+
* A projection is a cache and is always rebuildable -- it is read incrementally
|
|
6
|
+
* from the last complete journal byte, and a torn write is discarded rather than
|
|
7
|
+
* half-folded. Nothing in the control path depends on it being present.
|
|
8
|
+
*/
|
|
9
|
+
import { JOURNAL_FILE, JOURNAL_TAIL_BYTES, PROJECTION_ACTIVE_CAP, PROJECTION_FILE, PROJECTION_LIST_CAP } from "./layout.mjs";
|
|
10
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, statSync } from "node:fs";
|
|
11
|
+
import { errorCode } from "../util.mjs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { readJournal, validateJournalEntry, withoutLegacyLivenessFields } from "./journal.mjs";
|
|
14
|
+
|
|
15
|
+
/** @typedef {import("./index.mjs").Campaign} Campaign */
|
|
16
|
+
/** @typedef {import("./index.mjs").JournalEntry} JournalEntry */
|
|
17
|
+
/** @typedef {import("../notify/index.mjs").JsonObject} JsonObject */
|
|
18
|
+
/** @typedef {import("./index.mjs").Projection} Projection */
|
|
19
|
+
/** @typedef {import("./index.mjs").ProjectionRecord} ProjectionRecord */
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} campaignPath
|
|
23
|
+
* @param {Campaign} campaign
|
|
24
|
+
* @returns {{state: Projection, cursor: number, byte: number, size: number, changed: boolean}}
|
|
25
|
+
*/
|
|
26
|
+
export function readProjectionState(campaignPath, campaign) {
|
|
27
|
+
const path = join(campaignPath, PROJECTION_FILE);
|
|
28
|
+
let stored = null;
|
|
29
|
+
try {
|
|
30
|
+
stored = /** @type {unknown} */ (JSON.parse(readFileSync(path, "utf8")));
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (errorCode(error) !== "ENOENT" && !(error instanceof SyntaxError)) throw error;
|
|
33
|
+
}
|
|
34
|
+
const journalPath = join(campaignPath, JOURNAL_FILE);
|
|
35
|
+
const journalSize = existsSync(journalPath) ? statSync(journalPath).size : 0;
|
|
36
|
+
if (validProjectionRecord(stored) && stored.size <= journalSize) {
|
|
37
|
+
try {
|
|
38
|
+
const { entries, nextByte } = readJournalDelta(campaignPath, stored.byte);
|
|
39
|
+
if (!entries.length) return { state: stored.projection, cursor: stored.cursor, byte: nextByte, size: stored.size, changed: false };
|
|
40
|
+
return {
|
|
41
|
+
state: foldEntries(stored.projection, entries),
|
|
42
|
+
cursor: stored.cursor + entries.length,
|
|
43
|
+
byte: nextByte,
|
|
44
|
+
size: journalSize,
|
|
45
|
+
changed: true,
|
|
46
|
+
};
|
|
47
|
+
} catch {
|
|
48
|
+
// A delta that cannot be read means the projection is untrustworthy;
|
|
49
|
+
// fall back to a full reparse of the append-only journal.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const journal = readJournal(campaignPath);
|
|
53
|
+
return {
|
|
54
|
+
state: projectState(journal),
|
|
55
|
+
cursor: journal.length,
|
|
56
|
+
byte: completeJournalByte(campaignPath),
|
|
57
|
+
size: journalSize,
|
|
58
|
+
changed: true,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* @param {unknown} stored
|
|
63
|
+
* @returns {stored is ProjectionRecord}
|
|
64
|
+
*/
|
|
65
|
+
function validProjectionRecord(stored) {
|
|
66
|
+
if (!stored || typeof stored !== "object" || Array.isArray(stored)) return false;
|
|
67
|
+
const record = /** @type {JsonObject} */ (stored);
|
|
68
|
+
if (!Number.isInteger(record.cursor) || /** @type {number} */ (record.cursor) < 0) return false;
|
|
69
|
+
if (!Number.isInteger(record.byte) || /** @type {number} */ (record.byte) < 0) return false;
|
|
70
|
+
if (!Number.isInteger(record.size) || /** @type {number} */ (record.size) < 0) return false;
|
|
71
|
+
// A record that claims folded entries but has no complete journal bytes is
|
|
72
|
+
// the signature of a stale checkpoint written before the tail scan handled
|
|
73
|
+
// partial lines beyond its window; re-folding from byte 0 would duplicate
|
|
74
|
+
// list-based state. Treat it as untrustworthy and reparse from the journal.
|
|
75
|
+
if (record.byte === 0 && /** @type {number} */ (record.cursor) > 0) return false;
|
|
76
|
+
const state = record.projection;
|
|
77
|
+
if (!state || typeof state !== "object" || Array.isArray(state)) return false;
|
|
78
|
+
const projection = /** @type {JsonObject} */ (state);
|
|
79
|
+
return (
|
|
80
|
+
typeof projection.decisions === "object" && projection.decisions !== null && !Array.isArray(projection.decisions) &&
|
|
81
|
+
typeof projection.questions === "object" && projection.questions !== null && !Array.isArray(projection.questions) &&
|
|
82
|
+
Array.isArray(projection.constraints) &&
|
|
83
|
+
Array.isArray(projection.intents) &&
|
|
84
|
+
Array.isArray(projection.outcomes) &&
|
|
85
|
+
Array.isArray(projection.sessions)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} campaignPath
|
|
90
|
+
* @param {number} fromByte
|
|
91
|
+
* @returns {{entries: JournalEntry[], nextByte: number}}
|
|
92
|
+
*/
|
|
93
|
+
function readJournalDelta(campaignPath, fromByte) {
|
|
94
|
+
const path = join(campaignPath, JOURNAL_FILE);
|
|
95
|
+
let size;
|
|
96
|
+
try {
|
|
97
|
+
size = statSync(path).size;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (errorCode(error) === "ENOENT") return { entries: [], nextByte: fromByte };
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
if (fromByte >= size) return { entries: [], nextByte: fromByte };
|
|
103
|
+
const descriptor = openSync(path, "r");
|
|
104
|
+
try {
|
|
105
|
+
const buffer = Buffer.alloc(size - fromByte);
|
|
106
|
+
readSync(descriptor, buffer, 0, buffer.length, fromByte);
|
|
107
|
+
/** @type {JournalEntry[]} */
|
|
108
|
+
const entries = [];
|
|
109
|
+
let start = 0;
|
|
110
|
+
while (start < buffer.length) {
|
|
111
|
+
const newline = buffer.indexOf(0x0a, start);
|
|
112
|
+
if (newline === -1) break;
|
|
113
|
+
let line = buffer.toString("utf8", start, newline);
|
|
114
|
+
if (line.endsWith("\r")) line = line.slice(0, -1);
|
|
115
|
+
if (line.trim()) {
|
|
116
|
+
const entry = withoutLegacyLivenessFields(/** @type {JournalEntry} */ (JSON.parse(line)));
|
|
117
|
+
validateJournalEntry(entry);
|
|
118
|
+
entries.push(entry);
|
|
119
|
+
}
|
|
120
|
+
start = newline + 1;
|
|
121
|
+
}
|
|
122
|
+
return { entries, nextByte: fromByte + start };
|
|
123
|
+
} finally {
|
|
124
|
+
closeSync(descriptor);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* @param {string} campaignPath
|
|
129
|
+
* @returns {number}
|
|
130
|
+
*/
|
|
131
|
+
function completeJournalByte(campaignPath) {
|
|
132
|
+
const path = join(campaignPath, JOURNAL_FILE);
|
|
133
|
+
let size;
|
|
134
|
+
try {
|
|
135
|
+
size = statSync(path).size;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (errorCode(error) === "ENOENT") return 0;
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
if (!size) return 0;
|
|
141
|
+
const descriptor = openSync(path, "r");
|
|
142
|
+
try {
|
|
143
|
+
// Scan backward in bounded windows so a partial trailing line longer than
|
|
144
|
+
// the window cannot hide the boundary of the last complete line.
|
|
145
|
+
let start = Math.max(0, size - JOURNAL_TAIL_BYTES);
|
|
146
|
+
while (true) {
|
|
147
|
+
const length = size - start;
|
|
148
|
+
const buffer = Buffer.alloc(length);
|
|
149
|
+
readSync(descriptor, buffer, 0, length, start);
|
|
150
|
+
if (buffer[buffer.length - 1] === 0x0a) return size;
|
|
151
|
+
const newline = buffer.lastIndexOf(0x0a);
|
|
152
|
+
if (newline !== -1) return start + newline + 1;
|
|
153
|
+
if (start === 0) return 0;
|
|
154
|
+
start = Math.max(0, start - JOURNAL_TAIL_BYTES);
|
|
155
|
+
}
|
|
156
|
+
} finally {
|
|
157
|
+
closeSync(descriptor);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* @returns {Projection}
|
|
162
|
+
*/
|
|
163
|
+
function emptyProjection() {
|
|
164
|
+
return {
|
|
165
|
+
updatedAt: null,
|
|
166
|
+
decisions: {},
|
|
167
|
+
questions: {},
|
|
168
|
+
constraints: [],
|
|
169
|
+
intents: [],
|
|
170
|
+
outcomes: [],
|
|
171
|
+
sessions: [],
|
|
172
|
+
next: null,
|
|
173
|
+
evicted: {},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* @param {JournalEntry[]} journal
|
|
178
|
+
* @returns {Projection}
|
|
179
|
+
*/
|
|
180
|
+
function projectState(journal) {
|
|
181
|
+
return foldEntries(emptyProjection(), journal);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* @param {Projection} state
|
|
185
|
+
* @param {JournalEntry[]} entries
|
|
186
|
+
* @returns {Projection}
|
|
187
|
+
*/
|
|
188
|
+
function foldEntries(state, entries) {
|
|
189
|
+
/** @type {Projection} */
|
|
190
|
+
const next = {
|
|
191
|
+
updatedAt: state.updatedAt,
|
|
192
|
+
decisions: { ...state.decisions },
|
|
193
|
+
questions: { ...state.questions },
|
|
194
|
+
constraints: [...state.constraints],
|
|
195
|
+
intents: [...state.intents],
|
|
196
|
+
outcomes: [...state.outcomes],
|
|
197
|
+
sessions: [...state.sessions],
|
|
198
|
+
next: state.next,
|
|
199
|
+
evicted: { ...state.evicted },
|
|
200
|
+
};
|
|
201
|
+
for (const entry of entries) {
|
|
202
|
+
if (entry.type === "liveness") continue;
|
|
203
|
+
next.updatedAt = entry.at;
|
|
204
|
+
if (entry.type === "session.attached") next.sessions = pushCapped(next.sessions, entry, "sessions", next.evicted);
|
|
205
|
+
else if (entry.type === "intent") next.intents = pushCapped(next.intents, entry, "intents", next.evicted);
|
|
206
|
+
else if (entry.type === "decision" && entry.decisionId !== undefined) next.decisions = setCapped(next.decisions, entry.decisionId, entry, next.evicted, "decisions");
|
|
207
|
+
else if (entry.type === "supersede" && entry.supersedes !== undefined) delete next.decisions[entry.supersedes];
|
|
208
|
+
else if (entry.type === "constraint") next.constraints = pushCapped(next.constraints, entry, "constraints", next.evicted);
|
|
209
|
+
else if (entry.type === "outcome" || entry.type === "retrospective") next.outcomes = pushCapped(next.outcomes, entry, "outcomes", next.evicted);
|
|
210
|
+
else if (entry.type === "next") next.next = entry;
|
|
211
|
+
else if (entry.type === "open-question" && entry.questionId !== undefined) next.questions = setCapped(next.questions, entry.questionId, entry, next.evicted, "questions");
|
|
212
|
+
else if (entry.type === "question.resolved" && entry.questionId !== undefined) delete next.questions[entry.questionId];
|
|
213
|
+
}
|
|
214
|
+
return next;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* @param {JournalEntry[]} list
|
|
218
|
+
* @param {JournalEntry} entry
|
|
219
|
+
* @param {string} key
|
|
220
|
+
* @param {Record<string, number>} evicted
|
|
221
|
+
* @returns {JournalEntry[]}
|
|
222
|
+
*/
|
|
223
|
+
function pushCapped(list, entry, key, evicted) {
|
|
224
|
+
const next = [...list, entry];
|
|
225
|
+
if (next.length > PROJECTION_LIST_CAP) {
|
|
226
|
+
const dropped = next.length - PROJECTION_LIST_CAP;
|
|
227
|
+
evicted[key] = (evicted[key] ?? 0) + dropped;
|
|
228
|
+
return next.slice(dropped);
|
|
229
|
+
}
|
|
230
|
+
return next;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* @param {Record<string, JournalEntry>} map
|
|
234
|
+
* @param {string} key
|
|
235
|
+
* @param {JournalEntry} entry
|
|
236
|
+
* @param {Record<string, number>} evicted
|
|
237
|
+
* @param {string} evictionKey
|
|
238
|
+
* @returns {Record<string, JournalEntry>}
|
|
239
|
+
*/
|
|
240
|
+
function setCapped(map, key, entry, evicted, evictionKey) {
|
|
241
|
+
/** @type {Record<string, JournalEntry>} */
|
|
242
|
+
const next = { ...map, [key]: entry };
|
|
243
|
+
const keys = Object.keys(next);
|
|
244
|
+
if (keys.length > PROJECTION_ACTIVE_CAP) {
|
|
245
|
+
const oldest = keys.reduce((left, right) => (next[left].at <= next[right].at ? left : right));
|
|
246
|
+
delete next[oldest];
|
|
247
|
+
evicted[evictionKey] = (evicted[evictionKey] ?? 0) + 1;
|
|
248
|
+
}
|
|
249
|
+
return next;
|
|
250
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `campaign.json` record itself: read it, validate it, and answer what a
|
|
3
|
+
* campaign's id is.
|
|
4
|
+
*
|
|
5
|
+
* `campaignIdOf` falls back to the directory name when the record cannot be
|
|
6
|
+
* read, because a campaign whose file is corrupt still has to be nameable in an
|
|
7
|
+
* error message.
|
|
8
|
+
*/
|
|
9
|
+
import { CAMPAIGN_FILE, basenameSafe } from "./layout.mjs";
|
|
10
|
+
import { errorCode } from "../util.mjs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { assertObject, requireId, requirePacketHash, requireString, requireText, requireTimestamp } from "../contract/assert.mjs";
|
|
14
|
+
|
|
15
|
+
/** @typedef {import("./index.mjs").Campaign} Campaign */
|
|
16
|
+
/** @typedef {import("../notify/index.mjs").JsonObject} JsonObject */
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} path
|
|
20
|
+
* @returns {Campaign}
|
|
21
|
+
*/
|
|
22
|
+
export function readCampaign(path) {
|
|
23
|
+
let campaign;
|
|
24
|
+
try {
|
|
25
|
+
campaign = /** @type {unknown} */ (JSON.parse(readFileSync(join(path, CAMPAIGN_FILE), "utf8")));
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (errorCode(error) === "ENOENT") throw new Error(`campaign not found: ${path}`);
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
validateCampaign(campaign);
|
|
31
|
+
// A campaign written before the chain fields existed still reads: the
|
|
32
|
+
// manifest is empty and the landing branch takes the current default. A
|
|
33
|
+
// campaign written by `initializeCampaign` always carries all three.
|
|
34
|
+
const record = /** @type {JsonObject} */ (campaign);
|
|
35
|
+
record.contracts ??= [];
|
|
36
|
+
record.landBranch ??= `campaign/${String(record.id)}`;
|
|
37
|
+
record.promotions ??= [];
|
|
38
|
+
return /** @type {Campaign} */ (campaign);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} campaignPath
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function campaignIdOf(campaignPath) {
|
|
45
|
+
try {
|
|
46
|
+
return readCampaign(campaignPath).id;
|
|
47
|
+
} catch {
|
|
48
|
+
return basenameSafe(campaignPath);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* @param {unknown} campaign
|
|
53
|
+
*/
|
|
54
|
+
function validateCampaign(campaign) {
|
|
55
|
+
if (!campaign || typeof campaign !== "object" || Array.isArray(campaign)) {
|
|
56
|
+
throw new TypeError("campaign.json must be an object");
|
|
57
|
+
}
|
|
58
|
+
const record = /** @type {JsonObject} */ (campaign);
|
|
59
|
+
requireId(record.id, "campaign.id");
|
|
60
|
+
requireText(record.goal, "campaign.goal");
|
|
61
|
+
if (record.status !== "active" && record.status !== "closed") {
|
|
62
|
+
throw new TypeError("campaign.status must be active or closed");
|
|
63
|
+
}
|
|
64
|
+
if (!Array.isArray(record.linkedRunIds)) throw new TypeError("campaign.linkedRunIds must be an array");
|
|
65
|
+
for (const runId of record.linkedRunIds) requireId(runId, "campaign.linkedRunIds[]");
|
|
66
|
+
// The ordered contract manifest and the landing branch. Each entry carries
|
|
67
|
+
// the digest of the contract's authored bytes, enough to detect tampering
|
|
68
|
+
// between authoring and the launch that validates it. The fields are
|
|
69
|
+
// optional on read so a recorded historical campaign stays readable; a
|
|
70
|
+
// campaign written by `initializeCampaign` always carries them.
|
|
71
|
+
if (record.contracts !== undefined) {
|
|
72
|
+
if (!Array.isArray(record.contracts)) throw new TypeError("campaign.contracts must be an array");
|
|
73
|
+
for (const [index, entry] of record.contracts.entries()) {
|
|
74
|
+
assertObject(entry, `campaign.contracts[${index}]`);
|
|
75
|
+
requireString(/** @type {JsonObject} */ (entry).path, `campaign.contracts[${index}].path`);
|
|
76
|
+
requirePacketHash(/** @type {JsonObject} */ (entry).digest, `campaign.contracts[${index}].digest`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (record.landBranch !== undefined) requireText(record.landBranch, "campaign.landBranch");
|
|
80
|
+
// The chain's durable park: the campaign stays active but carries the reason
|
|
81
|
+
// it stopped, naming the contract, the node and the status when they exist.
|
|
82
|
+
if (record.attention !== undefined && record.attention !== null) {
|
|
83
|
+
assertObject(record.attention, "campaign.attention");
|
|
84
|
+
const attention = /** @type {JsonObject} */ (record.attention);
|
|
85
|
+
requireString(attention.code, "campaign.attention.code");
|
|
86
|
+
requireString(attention.message, "campaign.attention.message");
|
|
87
|
+
requireTimestamp(attention.at, "campaign.attention.at");
|
|
88
|
+
}
|
|
89
|
+
if (record.promotions !== undefined) {
|
|
90
|
+
if (!Array.isArray(record.promotions)) throw new TypeError("campaign.promotions must be an array");
|
|
91
|
+
for (const [index, entry] of record.promotions.entries()) {
|
|
92
|
+
assertObject(entry, `campaign.promotions[${index}]`);
|
|
93
|
+
const promotion = /** @type {JsonObject} */ (entry);
|
|
94
|
+
requireId(promotion.runId, `campaign.promotions[${index}].runId`);
|
|
95
|
+
requireString(promotion.branch, `campaign.promotions[${index}].branch`);
|
|
96
|
+
requireString(promotion.sha, `campaign.promotions[${index}].sha`);
|
|
97
|
+
if (promotion.previousSha !== null) requireString(promotion.previousSha, `campaign.promotions[${index}].previousSha`);
|
|
98
|
+
requireTimestamp(promotion.at, `campaign.promotions[${index}].at`);
|
|
99
|
+
if (promotion.contractPath !== undefined) requireString(promotion.contractPath, `campaign.promotions[${index}].contractPath`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The way back from a parked campaign.
|
|
3
|
+
*
|
|
4
|
+
* The chain parks by writing `campaign.attention`, and `parkCampaign` in
|
|
5
|
+
* `index.mjs` is its only writer. Clearing it needs both the campaign record
|
|
6
|
+
* (`record.mjs`) and the chain's run classification (`chain.mjs`), and
|
|
7
|
+
* `chain.mjs` already imports `index.mjs`. Putting the clearing in either would
|
|
8
|
+
* close a runtime import cycle, so it lives here, where both directions of that
|
|
9
|
+
* dependency are already acyclic.
|
|
10
|
+
*/
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { writeJsonAtomic } from "../run/store.mjs";
|
|
15
|
+
import { requireTimestamp } from "../contract/assert.mjs";
|
|
16
|
+
import { CAMPAIGN_FILE } from "./layout.mjs";
|
|
17
|
+
import { readCampaign } from "./record.mjs";
|
|
18
|
+
import { appendJournal } from "./journal.mjs";
|
|
19
|
+
import { classifyRunProgress } from "./chain.mjs";
|
|
20
|
+
import { runProgress } from "../engine/supervise.mjs";
|
|
21
|
+
|
|
22
|
+
/** @typedef {import("./index.mjs").Campaign} Campaign */
|
|
23
|
+
/** @typedef {import("./index.mjs").CampaignAttention} CampaignAttention */
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Clear a campaign's attention once the run it names is no longer parked, and
|
|
27
|
+
* append the `campaign.unparked` journal event. A closed campaign and a
|
|
28
|
+
* campaign without attention are refused before anything is written. A run
|
|
29
|
+
* that is still parked or canceled is refused unless `force` overrides it;
|
|
30
|
+
* a run directory that no longer exists has nothing left to resume, so the
|
|
31
|
+
* attention is cleared.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} campaignPath
|
|
34
|
+
* @param {{runsDir: string, force?: boolean, at?: string, eventId?: string}} options
|
|
35
|
+
* @returns {{campaign: Campaign, cleared: CampaignAttention}}
|
|
36
|
+
*/
|
|
37
|
+
export function unparkCampaign(campaignPath, { runsDir, force = false, at = new Date().toISOString(), eventId = randomUUID() }) {
|
|
38
|
+
requireTimestamp(at, "at");
|
|
39
|
+
const campaign = readCampaign(campaignPath);
|
|
40
|
+
if (campaign.status === "closed") throw new Error(`campaign is closed: ${campaign.id}`);
|
|
41
|
+
const attention = campaign.attention;
|
|
42
|
+
if (!attention) throw new Error(`campaign is not parked: ${campaign.id}`);
|
|
43
|
+
const runId = attention.runId;
|
|
44
|
+
const runDir = typeof runId === "string" && runId ? join(runsDir, runId) : null;
|
|
45
|
+
if (runDir !== null && existsSync(runDir)) {
|
|
46
|
+
const state = classifyRunProgress(runProgress(runDir, Date.parse(at)));
|
|
47
|
+
if (!force && (state === "parked" || state === "canceled")) {
|
|
48
|
+
throw new Error(`run ${runId} is still ${state}; resume it first or pass --force`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const unparked = /** @type {Campaign} */ ({ ...campaign, updatedAt: at });
|
|
52
|
+
delete unparked.attention;
|
|
53
|
+
writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), unparked);
|
|
54
|
+
appendJournal(campaignPath, { type: "campaign.unparked", at, eventId, code: attention.code, runId: runId ?? null });
|
|
55
|
+
return { campaign: unparked, cleared: attention };
|
|
56
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command line's visual identity: color capability, the semantic role
|
|
3
|
+
* palette, glyphs, check tokens, the hornero-nest banner and the usage text.
|
|
4
|
+
*
|
|
5
|
+
* It is separate from `cli.mjs` because `DESIGN.md` is the specification and a
|
|
6
|
+
* renderer that disagrees with it is a defect. Dispatch decides what to print;
|
|
7
|
+
* this module is the one place a palette value, a glyph or a banner line lives.
|
|
8
|
+
* `--json` and other machine output call with `level` 0 and receive the text
|
|
9
|
+
* untouched.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { compareVersions, faberunHome, readUpdateCheck } from "../host/home.mjs";
|
|
13
|
+
|
|
14
|
+
/** @typedef {"brand"|"ok"|"progress"|"warn"|"fail"|"muted"|"text"} Role */
|
|
15
|
+
/** @typedef {"terra"|"argila"|"folha"} ColorName */
|
|
16
|
+
/** @typedef {{color?: ColorName, bold?: boolean, dim?: boolean}} RoleSpec */
|
|
17
|
+
/** @typedef {{version: string, nodeVersion: string, harnessCount: number, level: number, env?: NodeJS.ProcessEnv}} BannerOptions */
|
|
18
|
+
|
|
19
|
+
/** The palette's terminal encodings, exactly as DESIGN.md's table gives them. */
|
|
20
|
+
/** @type {Record<ColorName, {rgb: string, index: number, ansi16: number}>} */
|
|
21
|
+
const COLORS = {
|
|
22
|
+
terra: { rgb: "38;2;181;82;42", index: 166, ansi16: 31 },
|
|
23
|
+
argila: { rgb: "38;2;217;123;79", index: 173, ansi16: 33 },
|
|
24
|
+
folha: { rgb: "38;2;85;107;63", index: 65, ansi16: 32 },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** One role per rendered state; a new state picks a row, never a color. */
|
|
28
|
+
/** @type {Record<Role, RoleSpec>} */
|
|
29
|
+
const ROLE_SPECS = {
|
|
30
|
+
brand: { color: "terra", bold: true },
|
|
31
|
+
ok: { color: "folha" },
|
|
32
|
+
progress: { color: "argila" },
|
|
33
|
+
warn: { color: "argila", bold: true },
|
|
34
|
+
fail: { color: "terra", bold: true },
|
|
35
|
+
muted: { dim: true },
|
|
36
|
+
text: {},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The Unicode glyph and ASCII fallback of each glyph-bearing role. */
|
|
40
|
+
/** @type {Record<string, {unicode: string, ascii: string}>} */
|
|
41
|
+
const GLYPHS = {
|
|
42
|
+
ok: { unicode: "✓", ascii: "+" },
|
|
43
|
+
progress: { unicode: "·", ascii: "." },
|
|
44
|
+
warn: { unicode: "!", ascii: "!" },
|
|
45
|
+
fail: { unicode: "✗", ascii: "x" },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve how much color this process may emit, once, in DESIGN.md's order:
|
|
50
|
+
* `NO_COLOR` wins, an explicit `FORCE_COLOR` overrides the terminal, and a
|
|
51
|
+
* non-TTY or `TERM=dumb` stays monochrome.
|
|
52
|
+
*
|
|
53
|
+
* @param {NodeJS.ProcessEnv} env
|
|
54
|
+
* @param {boolean} isTTY
|
|
55
|
+
* @returns {0|1|2|3}
|
|
56
|
+
*/
|
|
57
|
+
export function colorLevel(env, isTTY) {
|
|
58
|
+
if (env.NO_COLOR !== undefined) return 0;
|
|
59
|
+
if (env.FORCE_COLOR !== undefined) {
|
|
60
|
+
if (env.FORCE_COLOR === "") return 3;
|
|
61
|
+
if (env.FORCE_COLOR === "0") return 0;
|
|
62
|
+
if (env.FORCE_COLOR === "1") return 1;
|
|
63
|
+
if (env.FORCE_COLOR === "2") return 2;
|
|
64
|
+
return 3;
|
|
65
|
+
}
|
|
66
|
+
if (!isTTY || env.TERM === "dumb") return 0;
|
|
67
|
+
if (env.COLORTERM === "truecolor" || env.COLORTERM === "24bit") return 3;
|
|
68
|
+
return 2;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {ColorName} name
|
|
73
|
+
* @param {number} level
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
function colorCode(name, level) {
|
|
77
|
+
const color = COLORS[name];
|
|
78
|
+
if (level === 1) return `\u001b[${color.ansi16}m`;
|
|
79
|
+
if (level === 2) return `\u001b[38;5;${color.index}m`;
|
|
80
|
+
return `\u001b[${color.rgb}m`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} text
|
|
85
|
+
* @param {RoleSpec} spec
|
|
86
|
+
* @param {number} level
|
|
87
|
+
* @returns {string}
|
|
88
|
+
*/
|
|
89
|
+
function colorize(text, spec, level) {
|
|
90
|
+
if (level === 0) return text;
|
|
91
|
+
let open = "";
|
|
92
|
+
if (spec.bold) open += "\u001b[1m";
|
|
93
|
+
if (spec.dim) open += "\u001b[2m";
|
|
94
|
+
if (spec.color) open += colorCode(spec.color, level);
|
|
95
|
+
return open ? `${open}${text}\u001b[0m` : text;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Paint one short token in its semantic role. At level 0 the text is returned
|
|
100
|
+
* untouched, which is what every non-TTY test and all `--json` output see.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} text
|
|
103
|
+
* @param {Role} role
|
|
104
|
+
* @param {number} level
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
export function paint(text, role, level) {
|
|
108
|
+
return colorize(text, ROLE_SPECS[role], level);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether the locale advertises UTF-8 and the terminal is not dumb.
|
|
113
|
+
*
|
|
114
|
+
* @param {NodeJS.ProcessEnv} env
|
|
115
|
+
* @returns {boolean}
|
|
116
|
+
*/
|
|
117
|
+
export function useUnicodeGlyphs(env) {
|
|
118
|
+
if (env.TERM === "dumb") return false;
|
|
119
|
+
return [env.LANG, env.LC_ALL, env.LC_CTYPE].some((value) => typeof value === "string" && /utf-?8/iu.test(value));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The role's glyph, in Unicode or its ASCII fallback. Roles without a glyph
|
|
124
|
+
* (brand, muted, text) have none.
|
|
125
|
+
*
|
|
126
|
+
* @param {Role} role
|
|
127
|
+
* @param {NodeJS.ProcessEnv} env
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
export function glyph(role, env) {
|
|
131
|
+
const entry = GLYPHS[role];
|
|
132
|
+
if (!entry) return "";
|
|
133
|
+
return useUnicodeGlyphs(env) ? entry.unicode : entry.ascii;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The bracketed check token in its role color. The bracketed text is the
|
|
138
|
+
* contract with scripts and tests and never changes; only color is added.
|
|
139
|
+
*
|
|
140
|
+
* @param {"ok"|"warn"|"fail"} kind
|
|
141
|
+
* @param {number} level
|
|
142
|
+
* @returns {string}
|
|
143
|
+
*/
|
|
144
|
+
export function statusToken(kind, level) {
|
|
145
|
+
return paint(`[${kind}]`, kind, level);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The five-line hornero-nest banner. The dome is Terra without bold, the inner
|
|
150
|
+
* opening is muted, the wordmark is the brand role, the tagline is plain text
|
|
151
|
+
* and the last line is muted and filled from the running process. The last line
|
|
152
|
+
* gains ` · update available: <latest>` when the cached check names a newer
|
|
153
|
+
* release; the banner reads only the cache (`update-check.json`) and never
|
|
154
|
+
* fetches. ASCII apart from the middle-dot separator, so it survives every
|
|
155
|
+
* monospace font.
|
|
156
|
+
*
|
|
157
|
+
* @param {BannerOptions} options
|
|
158
|
+
* @returns {string}
|
|
159
|
+
*/
|
|
160
|
+
export function renderBanner({ version, nodeVersion, harnessCount, level, env = process.env }) {
|
|
161
|
+
const cached = readUpdateCheck(faberunHome(env));
|
|
162
|
+
const latest = cached && compareVersions(cached.latest, version) > 0 ? cached.latest : null;
|
|
163
|
+
const terra = /** @param {string} text @returns {string} */ (text) => colorize(text, { color: "terra" }, level);
|
|
164
|
+
const lines = [
|
|
165
|
+
terra(" .-~~~-."),
|
|
166
|
+
`${terra(" .-' ")}${paint(".-.", "muted", level)}${terra(" '-.")} ${paint("faberun", "brand", level)}`,
|
|
167
|
+
`${terra(" / ")}${paint("( )", "muted", level)}${terra(" \\")} from intent to running software`,
|
|
168
|
+
`${terra(" \\ ")}${paint("'-'", "muted", level)}${terra(" /")}`,
|
|
169
|
+
`${terra(" '-.._____..-'")} ${paint(`v${version} · node ${nodeVersion} · ${harnessCount} harnesses detected${latest ? ` · update available: ${latest}` : ""}`, "muted", level)}`,
|
|
170
|
+
];
|
|
171
|
+
return `${lines.join("\n")}\n`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* One `usage: faberun <verb> ...` line per verb group, keeping the verbs and
|
|
176
|
+
* flags the CLI dispatches. The same text serves the help path (stdout) and
|
|
177
|
+
* the usage error (stderr).
|
|
178
|
+
*
|
|
179
|
+
* @returns {string}
|
|
180
|
+
*/
|
|
181
|
+
export function renderUsage() {
|
|
182
|
+
const groups = [
|
|
183
|
+
"<run|validate> <contract.json> [--base-ref <ref>] [--detach]",
|
|
184
|
+
"preflight <contract.json> [--static] [--time-verification] [--json]",
|
|
185
|
+
"<resume|cancel> <run-dir> [--detach]",
|
|
186
|
+
"supervise <run-dir> [--detach] [--interval <sec>]",
|
|
187
|
+
"supervise campaign <campaign-id> [--cwd <dir>] [--allow-main]",
|
|
188
|
+
"<status|report> <run-dir> [--json]",
|
|
189
|
+
"findings <run-dir>",
|
|
190
|
+
"doctor [<contract.json>] [--cwd <dir>] [--discover] [--json]",
|
|
191
|
+
"setup [--yes] [--harnesses <a,b>] [--worker <id>] [--judge <id>] [--json]",
|
|
192
|
+
"init [--cwd <dir>] [--yes] [--no-skill] [--agentkit] [--greenfield|--stable] [--json]",
|
|
193
|
+
"update [--check] [--json]",
|
|
194
|
+
"models [--probe] [--json]",
|
|
195
|
+
"next [--cwd <dir>] [--json]",
|
|
196
|
+
"bulk-read --question <text> --paths <a,b,c> [--json]",
|
|
197
|
+
"contract validate <contract.json>",
|
|
198
|
+
"metrics <campaign-id> [--cwd <dir>] [--json]",
|
|
199
|
+
"campaign <init|watch|attach|note|resolve|close|supervise|show|list|sync|ack> ...",
|
|
200
|
+
"seat <start|attach|status|stop> [<campaign-id>] [--cwd <dir>] ...",
|
|
201
|
+
"skills list",
|
|
202
|
+
"skills install [<name>...] [--target <dir>] [--global] [--force]",
|
|
203
|
+
];
|
|
204
|
+
return `${groups.map((group) => `usage: faberun ${group}`).join("\n")}\n`;
|
|
205
|
+
}
|