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.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. 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
+ }