@forwardimpact/libwiki 0.2.27 → 0.2.28

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libwiki",
3
- "version": "0.2.27",
3
+ "version": "0.2.28",
4
4
  "description": "Wiki lifecycle primitives — stable memory for agent teams so coordination persists across sessions.",
5
5
  "keywords": [
6
6
  "wiki",
@@ -3,6 +3,8 @@ import {
3
3
  ACTIVE_CLAIMS_HEADING,
4
4
  ACTIVE_CLAIMS_SEPARATOR_RE,
5
5
  ACTIVE_CLAIMS_TABLE_HEADER,
6
+ AGENT_EXPERIMENTS_CLOSE_RE,
7
+ AGENT_EXPERIMENTS_OPEN_RE,
6
8
  DECISION_HEADING,
7
9
  ISSUE_CLOSE_RE,
8
10
  ISSUE_OPEN_RE,
@@ -544,6 +546,20 @@ export const RULES = [
544
546
  `${r.reason} issue-list marker${r.label ? ` (${r.label})` : ""}`,
545
547
  hint: "every '<!-- obstacles:* -->' or '<!-- experiments:* -->' needs a matching close marker",
546
548
  },
549
+ {
550
+ id: "storyboard.markers-balanced.agent-experiments",
551
+ scope: "storyboard",
552
+ severity: "fail",
553
+ when: storyboardExists,
554
+ check: markersBalanced({
555
+ openRe: AGENT_EXPERIMENTS_OPEN_RE,
556
+ closeRe: AGENT_EXPERIMENTS_CLOSE_RE,
557
+ label: "agent-experiments",
558
+ }),
559
+ message: (_s, r) =>
560
+ `${r.reason} agent-experiments marker${r.label ? ` (${r.label})` : ""}`,
561
+ hint: "every '<!-- agent-experiments -->' needs a matching '<!-- /agent-experiments -->'",
562
+ },
547
563
 
548
564
  // -- Metrics CSVs (union merge keeps both sides on concurrent appends;
549
565
  // exact-duplicate rows are surfaced here, never silently removed) --
package/src/boot.js CHANGED
@@ -3,6 +3,9 @@ import { yearMonth } from "@forwardimpact/libutil";
3
3
  import { parseClaims, filterExpired } from "./active-claims.js";
4
4
  import { countLines, countWords } from "./budget.js";
5
5
  import {
6
+ AGENT_EXPERIMENTS_CLOSE_RE,
7
+ AGENT_EXPERIMENTS_OPEN_RE,
8
+ AGENT_EXPERIMENT_ITEM_RE,
6
9
  MEMO_INBOX_MARKER,
7
10
  PRIORITY_INDEX_HEADING,
8
11
  SUMMARY_LINE_BUDGET,
@@ -12,6 +15,8 @@ import {
12
15
  } from "./constants.js";
13
16
  import { weeklyLogPath } from "./weekly-log.js";
14
17
 
18
+ const STANDING_CARRIES_HEADING = "## Standing Carries";
19
+
15
20
  function readIfExists(fs, filePath) {
16
21
  if (!fs.existsSync(filePath)) return null;
17
22
  return fs.readFileSync(filePath, "utf-8");
@@ -96,35 +101,89 @@ function splitPriorities(rows, agent) {
96
101
  return { owned, cross };
97
102
  }
98
103
 
104
+ // Parse an attributed item line from the materialized block for `agent`.
105
+ // Returns the unified item shape or null (wrong agent / not an item line).
106
+ function parseBlockItem(line, agent) {
107
+ const m = line.match(AGENT_EXPERIMENT_ITEM_RE);
108
+ if (!m || m[2] !== agent) return null;
109
+ return {
110
+ dim: agent,
111
+ threshold: m[3],
112
+ status: "open",
113
+ link: null,
114
+ issue: Number(m[1]),
115
+ author: m[4],
116
+ source: "experiment",
117
+ };
118
+ }
119
+
120
+ function bulletItem(threshold, agent) {
121
+ return {
122
+ dim: agent,
123
+ threshold,
124
+ status: "open",
125
+ link: null,
126
+ issue: null,
127
+ author: null,
128
+ source: "bullet",
129
+ };
130
+ }
131
+
132
+ // Advance the agent-section scan for one storyboard line that is NOT inside the
133
+ // materialized block. Returns the next `inAgent` state and pushes an h3-bullet
134
+ // item for the booting agent when one is found. An h2 ends the agent-section
135
+ // scan (team-wide sections follow the last agent h3 — without this the scan
136
+ // would run past the agent sections and misattribute team-wide bullets).
137
+ function scanAgentLine(line, agent, inAgent, items) {
138
+ if (/^## /.test(line)) return false;
139
+ const h3Match = line.match(/^### (.+)$/);
140
+ if (h3Match) {
141
+ return h3Match[1].toLowerCase().startsWith(agent.toLowerCase());
142
+ }
143
+ const bullet = inAgent && line.match(/^[-*]\s+(.+)$/);
144
+ if (bullet) items.push(bulletItem(bullet[1], agent));
145
+ return inAgent;
146
+ }
147
+
99
148
  function parseStoryboardItems(text, agent) {
100
149
  if (!text) return [];
101
- const lines = text.split("\n");
102
150
  const items = [];
103
151
  let inAgent = false;
104
- for (const line of lines) {
105
- const h3Match = line.match(/^### (.+)$/);
106
- if (h3Match) {
107
- inAgent = h3Match[1].toLowerCase().startsWith(agent.toLowerCase());
108
- continue;
109
- }
110
- if (/^#{1,2} /.test(line)) {
152
+ let inBlock = false;
153
+ for (const line of text.split("\n")) {
154
+ // The materialized block carries `- #N [agent] …` bullets that the agent
155
+ // scan must never capture; track it so the bullet loop skips inside it.
156
+ // (Without it the legacy scan double-counted these as the last agent's bullets.)
157
+ if (AGENT_EXPERIMENTS_OPEN_RE.test(line)) {
158
+ inBlock = true;
111
159
  inAgent = false;
112
- continue;
113
- }
114
- if (!inAgent) continue;
115
- const bullet = line.match(/^[-*]\s+(.+)$/);
116
- if (bullet) {
117
- items.push({
118
- dim: agent,
119
- threshold: bullet[1],
120
- status: "open",
121
- link: null,
122
- });
160
+ } else if (AGENT_EXPERIMENTS_CLOSE_RE.test(line)) {
161
+ inBlock = false;
162
+ } else if (inBlock) {
163
+ const item = parseBlockItem(line, agent);
164
+ if (item) items.push(item);
165
+ } else {
166
+ inAgent = scanAgentLine(line, agent, inAgent, items);
123
167
  }
124
168
  }
125
169
  return items;
126
170
  }
127
171
 
172
+ function extractStandingCarries(text) {
173
+ if (!text) return [];
174
+ const lines = text.split("\n");
175
+ const start = lines.findIndex((l) => l.trim() === STANDING_CARRIES_HEADING);
176
+ if (start === -1) return [];
177
+ const carries = [];
178
+ for (let i = start + 1; i < lines.length; i++) {
179
+ const line = lines[i];
180
+ if (/^## /.test(line)) break;
181
+ const bullet = line.match(/^[-*] (.*)$/);
182
+ if (bullet) carries.push(bullet[1]);
183
+ }
184
+ return carries;
185
+ }
186
+
128
187
  function countInbox(text) {
129
188
  if (!text) return 0;
130
189
  const lines = text.split("\n");
@@ -203,6 +262,7 @@ export function buildDigest({ wikiRoot, agent, today, fs }) {
203
262
  cross_cutting: cross.map(mapPriority),
204
263
  claims: active.map(mapClaim),
205
264
  storyboard_items: parseStoryboardItems(storyboardText ?? "", agent),
265
+ standing_carries: extractStandingCarries(summaryText),
206
266
  inbox_count: countInbox(summaryText),
207
267
  summary_headroom: headroom(
208
268
  summaryText ?? "",
@@ -1,5 +1,6 @@
1
1
  import { runMemoCommand } from "./commands/memo.js";
2
2
  import { runRefreshCommand } from "./commands/refresh.js";
3
+ import { runProductMixCommand } from "./commands/product-mix.js";
3
4
  import { runInitCommand } from "./commands/init.js";
4
5
  import { runPushCommand, runPullCommand } from "./commands/sync.js";
5
6
  import { runBootCommand } from "./commands/boot.js";
@@ -218,6 +219,31 @@ export function createDefinition() {
218
219
  },
219
220
  },
220
221
  },
222
+ {
223
+ name: "product-mix",
224
+ description:
225
+ "Emit the product-vs-internal mix of merged PRs as a `product_share` metric row",
226
+ handler: runProductMixCommand,
227
+ options: {
228
+ until: {
229
+ type: "string",
230
+ description: "Window end ISO date (default: today)",
231
+ },
232
+ since: {
233
+ type: "string",
234
+ description: "Window start ISO date (default: until − 7 days)",
235
+ },
236
+ run: {
237
+ type: "string",
238
+ description: "Run id recorded on the metric row (default: gh-live)",
239
+ },
240
+ repo: {
241
+ type: "string",
242
+ description: "owner/repo slug (default: origin remote)",
243
+ },
244
+ ...wikiRootOpt,
245
+ },
246
+ },
221
247
  {
222
248
  name: "init",
223
249
  description: "Bootstrap a wiki working tree and scaffold Active Claims",
@@ -262,6 +288,7 @@ export function createDefinition() {
262
288
  "fit-wiki fix",
263
289
  'fit-wiki memo --from staff-engineer --to security-engineer --message "audit d642ff0c"',
264
290
  "fit-wiki refresh",
291
+ "fit-wiki product-mix",
265
292
  "fit-wiki init",
266
293
  "fit-wiki push",
267
294
  "fit-wiki pull",
@@ -11,7 +11,11 @@ import { currentDayIso } from "../util/clock.js";
11
11
  import { requireAgentFlag } from "../util/agent-flag.js";
12
12
  import { resolveWikiRoot } from "../util/wiki-dir.js";
13
13
  import { refusalEnvelope } from "../secret-gate.js";
14
- import { AncestryRefusal } from "../wiki-sync.js";
14
+ import {
15
+ AncestryRefusal,
16
+ PUSH_REASONS,
17
+ WikiPushFailure,
18
+ } from "../wiki-sync.js";
15
19
 
16
20
  /** Non-zero envelope returned when the ancestry guard refused publication. */
17
21
  const NOT_PUBLISHED = {
@@ -19,6 +23,16 @@ const NOT_PUBLISHED = {
19
23
  code: 1,
20
24
  };
21
25
 
26
+ // Failure reasons that, on the claim/release surfaces, are an unsafe-state
27
+ // refusal (D7/D9 family) rather than a saved-locally success (D1): the refusal
28
+ // fires before the local write is publishable, or leaves the tree unsafe for a
29
+ // later whole-tree sweep, so the surface must exit non-zero.
30
+ const UNSAFE_STATE_REASONS = new Set([
31
+ PUSH_REASONS.PRECONDITION,
32
+ PUSH_REASONS.RESIDUE_CONFLICT,
33
+ PUSH_REASONS.CONSERVATION,
34
+ ]);
35
+
22
36
  /** Build the not-published refusal message for the given guard refusal. */
23
37
  function notPublishedMessage(err) {
24
38
  return (
@@ -38,17 +52,23 @@ function memoryPath(runtime, options) {
38
52
  }
39
53
 
40
54
  /**
41
- * Push the claim/release MEMORY.md change through the secret-gated push path
42
- * and translate the result into a command envelope. A secret block fails the
43
- * command closed (`{ ok: false, code: 1 }`) and names the finding or the
44
- * scanner absence on stderr — distinct from a *network* push failure, which
45
- * still degrades to "saved locally" and succeeds (`{ ok: true }`).
55
+ * Push the claim/release MEMORY.md change and translate the honest outcome
56
+ * (the honest-CLI contract) into a command envelope, composed with the singleton merge
57
+ * discipline (the singleton merge discipline) and the secret/ancestry guards:
58
+ * - landed (grounded or re-applied) ⇒ `{ ok: true }`, success message printed;
59
+ * - `rejected`/`transport` ⇒ `{ ok: true }` with a saved-locally warning (the
60
+ * landed-locally row is complete; the session-end push is its retry);
61
+ * - `precondition`/`residue-conflict`/`conservation` ⇒ `{ ok: false, code: 1 }`
62
+ * (D7/D9 unsafe-state family — the row is not published and the tree may be
63
+ * left unsafe for a later whole-tree sweep);
64
+ * - a secret-gate refusal ⇒ `{ ok: false, code: 1 }` ({@link refusalEnvelope});
65
+ * - an {@link AncestryRefusal} is rethrown so `pushRowOrRefuse` maps it to the
66
+ * not-published non-zero envelope;
67
+ * - any other thrown error is a network/credential failure that degrades to
68
+ * "saved locally" (`{ ok: true }`).
46
69
  *
47
70
  * The `reapply` closure re-derives this row against the fresh tip if the
48
- * landing contends, so a parallel writer's row is never erased. An
49
- * ancestry-guard refusal ({@link AncestryRefusal}) is rethrown so the caller's
50
- * `pushRowOrRefuse` can map it to the not-published non-zero envelope; every
51
- * other throw is a network/credential failure that degrades to "saved locally".
71
+ * landing contends, so a parallel writer's row is never erased.
52
72
  *
53
73
  * @param {object} wikiSync - The WikiSync collaborator (may be absent in tests).
54
74
  * @param {object} runtime - The runtime bag (for stdout/stderr).
@@ -65,37 +85,54 @@ async function pushWiki(wikiSync, runtime, message, reapply) {
65
85
  // claim/release contract is a 1-line MEMORY.md change; the pathspec keeps
66
86
  // foreign uncommitted files from parallel writers out of the commit. The
67
87
  // `reapply` closure re-derives this row against the fresh tip if the landing
68
- // contends, so a parallel writer's row is never erased.
88
+ // contends (the singleton merge discipline), so a parallel writer's row is never erased.
69
89
  result = await wikiSync.commitAndPush(message, ["MEMORY.md"], { reapply });
70
90
  } catch (err) {
71
- // An ancestry-guard refusal pierces the saved-locally degradation: it must
72
- // reach a non-zero exit so the session stops rather than scroll past. Every
73
- // other failure is a network/credential failure: preserve fire-and-forget
74
- // "saved locally" — the change is on disk and the command still succeeds.
91
+ // An ancestry-guard refusal pierces the saved-locally degradation: rethrow
92
+ // so pushRowOrRefuse maps it to the not-published non-zero envelope.
75
93
  if (err instanceof AncestryRefusal) throw err;
94
+ if (err instanceof WikiPushFailure) {
95
+ // D7/D9 unsafe-state family: the row is not published and the tree may be
96
+ // left unsafe for a later sweep — fail the command closed (non-zero).
97
+ if (UNSAFE_STATE_REASONS.has(err.reason)) {
98
+ runtime.proc.stderr.write(`${err.message}\n`);
99
+ return { ok: false, code: 1 };
100
+ }
101
+ // rejected / transport: the local row landed; warn and keep zero exit.
102
+ runtime.proc.stderr.write(
103
+ `saved locally — not yet visible to parallel sessions (${err.reason}): ${err.message}\n`,
104
+ );
105
+ return { ok: true };
106
+ }
107
+ // Any other failure: preserve fire-and-forget "saved locally" — the change
108
+ // is on disk and the command still succeeds.
76
109
  createLogger("wiki", runtime).warn(
77
110
  "claim",
78
111
  `push failed (saved locally): ${err.message}`,
79
112
  );
80
113
  return { ok: true };
81
114
  }
115
+ // A secret-gate refusal fails the command closed; a grounded-landed or a
116
+ // re-applied push reports success.
82
117
  const refusal = refusalEnvelope(runtime, result);
83
118
  if (refusal) return refusal;
84
- if (result.pushed) runtime.proc.stdout.write("push: committed and pushed\n");
119
+ if (result.landed || result.pushed) {
120
+ runtime.proc.stdout.write("push: committed and pushed\n");
121
+ }
85
122
  return { ok: true };
86
123
  }
87
124
 
88
125
  /**
89
126
  * Push a written claim/release row, mapping an ancestry-guard refusal to the
90
- * not-published non-zero envelope and any other outcome to `{ ok: true }`. The
91
- * row is already written to MEMORY.md; on refusal it stays as an uncommitted
92
- * working-tree change. The `reapply` closure re-derives the same row against
93
- * the fresh tip when the landing contends.
127
+ * not-published non-zero envelope and any other outcome to `pushWiki`'s
128
+ * envelope. The row is already written to MEMORY.md; on refusal it stays as an
129
+ * uncommitted working-tree change. The `reapply` closure re-derives the same
130
+ * row against the fresh tip when the landing contends.
94
131
  */
95
132
  async function pushRowOrRefuse(wikiSync, runtime, message, reapply) {
96
133
  try {
97
- // Propagate pushWiki's envelope so a secret-gate refusal ({ ok: false })
98
- // fails the command closed; a clean push returns { ok: true }.
134
+ // Propagate pushWiki's envelope so a secret-gate or unsafe-state refusal
135
+ // ({ ok: false }) fails the command closed; a clean push returns { ok: true }.
99
136
  return await pushWiki(wikiSync, runtime, message, reapply);
100
137
  } catch (err) {
101
138
  if (err instanceof AncestryRefusal) {
@@ -0,0 +1,148 @@
1
+ import { addDays } from "@forwardimpact/libutil";
2
+ import { createLogger } from "@forwardimpact/libtelemetry";
3
+ import { createScriptConfig } from "@forwardimpact/libconfig";
4
+ import { parseRepoSlug } from "../issue-list-renderer.js";
5
+ import { currentDayIso } from "../util/clock.js";
6
+ import { resolveProjectRoot } from "../util/wiki-dir.js";
7
+
8
+ // Resolve the monorepo's `owner/repo` slug the way `refresh.js` does: an
9
+ // explicit `FIT_GH_REPO` env override (sandbox proxy URLs), else the origin
10
+ // remote parsed via the injected git client. Returns null when nothing
11
+ // parseable is found, in which case `gh` falls back to its own cwd resolution.
12
+ async function deriveRepo(gitClient, cwd, env) {
13
+ if (env.FIT_GH_REPO) return env.FIT_GH_REPO;
14
+ if (!gitClient) return null;
15
+ try {
16
+ const url = await gitClient.remoteGetUrl("origin", { cwd });
17
+ return parseRepoSlug(url);
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ // A missing token is non-fatal: `gh` may still resolve ambient auth, and a
24
+ // hard fetch failure downstream collapses to a logged warning and no row.
25
+ async function resolveToken() {
26
+ try {
27
+ return (await createScriptConfig("wiki")).ghToken();
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ // `gh pr list` returns at most this many PRs; a window that hits the cap is
34
+ // truncated, so the caller warns rather than silently undercounting.
35
+ const FETCH_LIMIT = 200;
36
+
37
+ // Fetch merged PRs in `[since, until]` and return their parsed JSON, or null on
38
+ // any failure (non-zero exit or unparseable stdout) so the caller emits no row.
39
+ async function fetchMergedPrs({ runtime, cwd, repo, since, until, token }) {
40
+ const args = ["pr", "list", "--base", "main"];
41
+ if (repo) args.push("--repo", repo);
42
+ args.push(
43
+ "--json",
44
+ "number,labels,mergedAt",
45
+ "--search",
46
+ `merged:${since}..${until}`,
47
+ "--limit",
48
+ String(FETCH_LIMIT),
49
+ );
50
+ const env = token
51
+ ? { ...runtime.proc.env, GH_TOKEN: token }
52
+ : runtime.proc.env;
53
+ const result = await runtime.subprocess.run("gh", args, { cwd, env });
54
+ if (result.exitCode !== 0) return null;
55
+ try {
56
+ return JSON.parse(result.stdout || "[]");
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ // Tally merged PRs by their classification label. A PR with neither label is
63
+ // unlabeled; `product` wins if both are somehow present.
64
+ function countByLabel(prs) {
65
+ const counts = { product: 0, internal: 0, unlabeled: 0 };
66
+ for (const pr of prs) {
67
+ const names = (pr.labels || []).map((l) => l.name);
68
+ if (names.includes("product")) counts.product++;
69
+ else if (names.includes("internal")) counts.internal++;
70
+ else counts.unlabeled++;
71
+ }
72
+ return counts;
73
+ }
74
+
75
+ /**
76
+ * Emit the product-vs-internal mix of merged PRs as a `product_share` metric
77
+ * row. Counts PRs merged in `[since, until]` by their `product` / `internal`
78
+ * label and appends `product_share = round(product / (product + internal) *
79
+ * 100)` to `wiki/metrics/product-mix/<YYYY>.csv` via the `fit-xmr record` write
80
+ * path. Deterministic — re-running over the same merged PRs yields the same
81
+ * value. A window with no labeled merged PRs emits no row (avoids a 0/0 ratio).
82
+ */
83
+ export async function runProductMixCommand(ctx) {
84
+ const { runtime, gitClient } = ctx.deps;
85
+ const options = ctx.options;
86
+ const logger = createLogger("wiki", runtime);
87
+ const cwd = resolveProjectRoot(runtime);
88
+
89
+ const until = options.until || currentDayIso(runtime);
90
+ const since = options.since || addDays(until, -7);
91
+ const run = options.run || "gh-live";
92
+ const repo =
93
+ options.repo || (await deriveRepo(gitClient, cwd, runtime.proc.env));
94
+ const token = await resolveToken();
95
+
96
+ const prs = await fetchMergedPrs({ runtime, cwd, repo, since, until, token });
97
+ if (prs === null) {
98
+ logger.warn("product-mix", `gh pr list failed for ${since}..${until}`);
99
+ return { ok: true };
100
+ }
101
+ if (prs.length >= FETCH_LIMIT) {
102
+ logger.warn(
103
+ "product-mix",
104
+ `window ${since}..${until} hit the ${FETCH_LIMIT}-PR fetch cap; product_share may undercount`,
105
+ );
106
+ }
107
+
108
+ const { product, internal, unlabeled } = countByLabel(prs);
109
+ const total = product + internal;
110
+ if (total === 0) {
111
+ logger.info(
112
+ "product-mix",
113
+ `no labeled merged PRs in ${since}..${until}; emitting no row`,
114
+ );
115
+ return { ok: true };
116
+ }
117
+
118
+ const share = Math.round((product / total) * 100);
119
+ const recordArgs = [
120
+ "fit-xmr",
121
+ "record",
122
+ "--skill",
123
+ "product-mix",
124
+ "--metric",
125
+ "product_share",
126
+ "--value",
127
+ String(share),
128
+ "--unit",
129
+ "pct",
130
+ "--date",
131
+ until,
132
+ "--run",
133
+ run,
134
+ "--note",
135
+ `product=${product} internal=${internal} unlabeled=${unlabeled} window=${since}..${until}`,
136
+ "--event-type",
137
+ "kata-shift",
138
+ ];
139
+ if (options["wiki-root"]) {
140
+ recordArgs.push("--wiki-root", options["wiki-root"]);
141
+ }
142
+
143
+ const recordResult = await runtime.subprocess.run("npx", recordArgs, { cwd });
144
+ if (recordResult.exitCode !== 0) {
145
+ logger.warn("product-mix", "fit-xmr record failed");
146
+ }
147
+ return { ok: true };
148
+ }
@@ -4,7 +4,12 @@ import { createLogger } from "@forwardimpact/libtelemetry";
4
4
  import { createScriptConfig } from "@forwardimpact/libconfig";
5
5
  import { scanMarkers } from "../marker-scanner.js";
6
6
  import { renderBlock, BlockRenderError } from "../block-renderer.js";
7
- import { renderIssueList, parseRepoSlug } from "../issue-list-renderer.js";
7
+ import {
8
+ renderIssueList,
9
+ renderAgentExperiments,
10
+ TrackerQueryError,
11
+ parseRepoSlug,
12
+ } from "../issue-list-renderer.js";
8
13
  import { currentDayIso } from "../util/clock.js";
9
14
  import { resolveProjectRoot } from "../util/wiki-dir.js";
10
15
 
@@ -22,7 +27,33 @@ async function deriveParentRepo(gitClient, parentDir, env) {
22
27
  }
23
28
  }
24
29
 
25
- async function renderForBlock(block, projectRoot, ghContext, runtime) {
30
+ // Compose the agent-experiments block body. On a successful tracker query the
31
+ // body is a fresh last-successful-sync stamp followed by freshly rendered,
32
+ // label-re-checked, sanitized item lines. On a tracker failure the previously
33
+ // materialized body (stamp + items) is preserved verbatim so boot keeps serving
34
+ // the last good routing surface instead of an empty one, and the timestamp is
35
+ // not advanced, so staleness stays auditable from the stamp.
36
+ async function renderAgentExperimentsBlock(block, lines, ghContext, runtime) {
37
+ const priorBody = lines.slice(block.openLine + 1, block.closeLine);
38
+ try {
39
+ const items = await renderAgentExperiments({
40
+ cwd: ghContext.cwd,
41
+ repo: ghContext.repo,
42
+ token: ghContext.token,
43
+ runtime,
44
+ });
45
+ const today = currentDayIso(runtime);
46
+ return [`<!-- last-successful-sync: ${today} -->`, ...items];
47
+ } catch (err) {
48
+ if (!(err instanceof TrackerQueryError)) throw err;
49
+ runtime.proc.stderr.write(
50
+ "refresh: gh issue list failed for agent-experiments; keeping previous materialized items\n",
51
+ );
52
+ return priorBody;
53
+ }
54
+ }
55
+
56
+ async function renderForBlock(block, lines, projectRoot, ghContext, runtime) {
26
57
  if (block.kind === "xmr") {
27
58
  return renderBlock({
28
59
  metric: block.metric,
@@ -44,6 +75,9 @@ async function renderForBlock(block, projectRoot, ghContext, runtime) {
44
75
  runtime,
45
76
  });
46
77
  }
78
+ if (block.kind === "agent-experiments") {
79
+ return renderAgentExperimentsBlock(block, lines, ghContext, runtime);
80
+ }
47
81
  return null;
48
82
  }
49
83
 
@@ -116,6 +150,7 @@ export async function runRefreshCommand(ctx) {
116
150
  try {
117
151
  const rendered = await renderForBlock(
118
152
  block,
153
+ lines,
119
154
  projectRoot,
120
155
  ghContext,
121
156
  runtime,
@@ -1,6 +1,10 @@
1
1
  import { createLogger } from "@forwardimpact/libtelemetry";
2
2
  import { refusalEnvelope } from "../secret-gate.js";
3
- import { AncestryRefusal, WikiPullConflict } from "../wiki-sync.js";
3
+ import {
4
+ AncestryRefusal,
5
+ WikiPullConflict,
6
+ WikiPushFailure,
7
+ } from "../wiki-sync.js";
4
8
  import { sweepTier2, renderDetections } from "../integrity.js";
5
9
  import { resolveWikiRoot } from "../util/wiki-dir.js";
6
10
 
@@ -19,7 +23,10 @@ export async function runPushCommand(ctx) {
19
23
  try {
20
24
  result = await wikiSync.commitAndPush("wiki: update from session");
21
25
  } catch (err) {
22
- if (err instanceof AncestryRefusal) {
26
+ // Honest CLI contract (the honest-CLI contract): non-zero on any non-land push
27
+ // failure, and on the ancestry guard's refusal (the ancestry guard). The Stop-hook
28
+ // recipe maps this to a stop-blocking exit; CI steps see a loud failure.
29
+ if (err instanceof WikiPushFailure || err instanceof AncestryRefusal) {
23
30
  runtime.proc.stderr.write(`${err.message}\n`);
24
31
  return { ok: false, code: 1 };
25
32
  }
@@ -29,7 +36,7 @@ export async function runPushCommand(ctx) {
29
36
  // unavailable); a clean push falls through to the normal reporting below.
30
37
  const refusal = refusalEnvelope(runtime, result);
31
38
  if (refusal) return refusal;
32
- if (result.pushed) {
39
+ if (result.landed) {
33
40
  runtime.proc.stdout.write("push: committed and pushed\n");
34
41
  } else {
35
42
  runtime.proc.stdout.write("push: nothing to push\n");
package/src/constants.js CHANGED
@@ -94,3 +94,21 @@ export const ISSUE_OPEN_RE =
94
94
  /^<!--\s*(obstacles|experiments):(open|closed)(?::(\d+d))?(?:\s+[^>]*?)?\s*-->\s*$/;
95
95
  export const ISSUE_CLOSE_RE =
96
96
  /^<!--\s*\/(obstacles|experiments)(?:\s+[^>]*?)?\s*-->\s*$/;
97
+
98
+ // Materialized per-agent experiments surface. A distinct marker
99
+ // kind from `experiments:open` — it carries attributed, sanitized items plus a
100
+ // last-successful-sync stamp, and is read offline by `fit-wiki boot`. One home
101
+ // so the scanner (marker-scanner.js), the refresh renderer (commands/refresh.js),
102
+ // the boot parser (boot.js), and the audit balance check (audit/rules.js) cannot
103
+ // drift on the syntax.
104
+ export const AGENT_EXPERIMENTS_OPEN_RE =
105
+ /^<!--\s*agent-experiments(?:\s+[^>]*?)?\s*-->\s*$/;
106
+ export const AGENT_EXPERIMENTS_CLOSE_RE =
107
+ /^<!--\s*\/agent-experiments(?:\s+[^>]*?)?\s*-->\s*$/;
108
+ export const LAST_SYNC_RE =
109
+ /^<!--\s*last-successful-sync:\s*(\d{4}-\d{2}-\d{2})\s*-->\s*$/;
110
+ // Attributed item line: `- #<n> [<agent>] <title> (by <author>)`. The author
111
+ // suffix is mandatory and anchored at end; the title group is greedy, which is
112
+ // unambiguous because sanitizeTitle defuses any embedded ` (by ` token.
113
+ export const AGENT_EXPERIMENT_ITEM_RE =
114
+ /^- #(\d+) \[([a-z][a-z-]*)\] (.*) \(by (.+)\)$/;