@forwardimpact/libwiki 0.2.26 → 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.26",
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) --
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { yearMonth } from "@forwardimpact/libutil";
3
3
  import { parseClaims } from "../active-claims.js";
4
4
  import { countLines, countWords } from "../budget.js";
5
+ import { parseStatusRowId } from "../status.js";
5
6
  import {
6
7
  PRIORITY_INDEX_HEADING,
7
8
  WEEKLY_LOG_NAME_RE,
@@ -120,9 +121,12 @@ function readOptional(filePath, fs) {
120
121
 
121
122
  /**
122
123
  * Parse the rows inside STATUS.md's fenced block into audit subjects. Lines
123
- * outside the ``` fence (header prose) and blank lines are skipped.
124
+ * outside the ``` fence (header prose) and blank lines are skipped. Each row
125
+ * carries a `kind` from {@link parseStatusRowId} (`"spec"`, `"experiment"`, or
126
+ * `null` for an unrecognized id); spec-shaped rules read the positional
127
+ * `id`/`phase`/`status` fields, experiment rules read `cells`.
124
128
  * @param {string} statusText - The full STATUS.md contents.
125
- * @returns {Array<{lineNo: number, text: string, cells: string[], id: string, phase: string, status: string}>}
129
+ * @returns {Array<{lineNo: number, text: string, cells: string[], id: string, phase: string, status: string, kind: string|null}>}
126
130
  */
127
131
  function parseStatusRows(statusText) {
128
132
  const lines = statusText.split("\n");
@@ -136,6 +140,12 @@ function parseStatusRows(statusText) {
136
140
  }
137
141
  if (!inFence || line.trim() === "") continue;
138
142
  const cells = line.split("\t");
143
+ // Classify by id prefix so a malformed `exp:` row (e.g. wrong cell count)
144
+ // is still routed to the experiment rules, which flag it — rather than
145
+ // slipping through the spec-shaped rules. parseStatusRowId returns the
146
+ // structured fields only for a well-formed row; the rules read `cells`.
147
+ const isExp = typeof cells[0] === "string" && cells[0].startsWith("exp:");
148
+ const parsed = parseStatusRowId(cells[0], cells);
139
149
  rows.push({
140
150
  lineNo: i + 1,
141
151
  text: line,
@@ -143,6 +153,7 @@ function parseStatusRows(statusText) {
143
153
  id: cells[0],
144
154
  phase: cells[1],
145
155
  status: cells[2],
156
+ kind: isExp ? "experiment" : parsed ? parsed.kind : null,
146
157
  });
147
158
  }
148
159
  return rows;
@@ -1,24 +1,37 @@
1
1
  import { STATUS_ID_REGEX } from "../status.js";
2
2
 
3
- // Validate every row inside wiki/STATUS.md's code fence against the
4
- // `{id}<TAB>{phase}<TAB>{status}` shape. Rows are resolved by the `status-row`
5
- // scope in scopes.js; each subject carries `{ cells, id, phase, status, text }`.
3
+ // Validate every row inside wiki/STATUS.md's code fence. Rows are resolved by
4
+ // the `status-row` scope in scopes.js; each subject carries
5
+ // `{ cells, id, phase, status, kind, text }`. Two row kinds share the fence:
6
+ //
7
+ // spec `{id}<TAB>{phase}<TAB>{status}` — three cells
8
+ // experiment `exp:{issue}<TAB>{state}<TAB>{pin}<TAB>{plan-ref}` — four cells
9
+ //
10
+ // Spec-shaped rules run for every non-experiment row (`kind !== "experiment"`,
11
+ // which includes an unrecognized id so a malformed id still flags). Experiment
12
+ // rules run only for `kind === "experiment"`.
6
13
 
7
14
  const PHASES = new Set(["spec", "design", "plan"]);
8
15
  const STATUSES = new Set(["draft", "approved", "implemented", "cancelled"]);
16
+ const EXP_STATES = new Set(["registered", "approved", "cancelled"]);
17
+ const PIN_RE = /^[0-9a-f]{40}$/;
9
18
 
10
- const hasThreeCells = (s) => s.cells.length === 3;
19
+ const isSpecShaped = (s) => s.kind !== "experiment";
20
+ const isExperiment = (s) => s.kind === "experiment";
21
+ const hasThreeCells = (s) => isSpecShaped(s) && s.cells.length === 3;
22
+ const hasFourCells = (s) => isExperiment(s) && s.cells.length === 4;
11
23
 
12
24
  export const STATUS_ROW_RULES = [
13
25
  {
14
26
  id: "status-row.shape",
15
27
  scope: "status-row",
16
28
  severity: "fail",
29
+ when: isSpecShaped,
17
30
  check: (s) =>
18
- hasThreeCells(s) ? null : { actual: s.cells.length, text: s.text },
31
+ s.cells.length === 3 ? null : { actual: s.cells.length, text: s.text },
19
32
  message: (_s, r) =>
20
33
  `${r.actual} tab-separated field(s), expected 3: "${r.text}"`,
21
- hint: "each STATUS row is `{id}<TAB>{phase}<TAB>{status}`",
34
+ hint: "each spec STATUS row is `{id}<TAB>{phase}<TAB>{status}`",
22
35
  },
23
36
  {
24
37
  id: "status-row.id-format",
@@ -48,4 +61,76 @@ export const STATUS_ROW_RULES = [
48
61
  `Bad status '${r.status}' (expected draft|approved|implemented|cancelled)`,
49
62
  hint: "status is one of draft, approved, implemented, cancelled",
50
63
  },
64
+ {
65
+ id: "status-row.exp-shape",
66
+ scope: "status-row",
67
+ severity: "fail",
68
+ when: isExperiment,
69
+ check: (s) =>
70
+ s.cells.length === 4 ? null : { actual: s.cells.length, text: s.text },
71
+ message: (_s, r) =>
72
+ `${r.actual} tab-separated field(s), expected 4: "${r.text}"`,
73
+ hint: "each experiment row is `exp:{issue}<TAB>{state}<TAB>{pin}<TAB>{plan-ref}`",
74
+ },
75
+ {
76
+ // An experiment-kind row is classified by its `exp:` id prefix
77
+ // (scopes.js), so the spec `id-format` rule is skipped for it; this rule
78
+ // enforces the `exp:\d+` id so a non-numeric issue (e.g. `exp:abc`) flags
79
+ // rather than auditing clean — keeping the audit aligned with
80
+ // STATUS_ID_REGEX / parseStatusRowId.
81
+ id: "status-row.exp-id-format",
82
+ scope: "status-row",
83
+ severity: "fail",
84
+ when: isExperiment,
85
+ check: (s) => (/^exp:\d+$/.test(s.id) ? null : { id: s.id }),
86
+ message: (_s, r) => `Bad experiment id '${r.id}' (expected exp:NNN)`,
87
+ hint: "an experiment id is `exp:` followed by the issue number",
88
+ },
89
+ {
90
+ id: "status-row.exp-state",
91
+ scope: "status-row",
92
+ severity: "fail",
93
+ when: hasFourCells,
94
+ check: (s) => (EXP_STATES.has(s.cells[1]) ? null : { state: s.cells[1] }),
95
+ message: (_s, r) =>
96
+ `Bad experiment state '${r.state}' (expected registered|approved|cancelled)`,
97
+ hint: "experiment state is one of registered, approved, cancelled",
98
+ },
99
+ {
100
+ id: "status-row.exp-pin",
101
+ scope: "status-row",
102
+ severity: "fail",
103
+ when: hasFourCells,
104
+ // The pin is decidable per state, with no "ever approved" inference: a
105
+ // `registered` row has no pin (`-`); an `approved` row pins the 40-hex
106
+ // head; a `cancelled` row may carry the retained pin or `-` (it may or may
107
+ // not have been approved before cancellation), so both are accepted.
108
+ check: (s) => {
109
+ const [, state, pin] = s.cells;
110
+ if (state === "registered") {
111
+ return pin === "-" ? null : { state, pin, want: "-" };
112
+ }
113
+ if (state === "approved") {
114
+ return PIN_RE.test(pin) ? null : { state, pin, want: "a 40-hex SHA" };
115
+ }
116
+ if (state === "cancelled") {
117
+ return pin === "-" || PIN_RE.test(pin)
118
+ ? null
119
+ : { state, pin, want: "`-` or a 40-hex SHA" };
120
+ }
121
+ return null; // bad state already flagged by exp-state
122
+ },
123
+ message: (_s, r) =>
124
+ `Bad pin '${r.pin}' for state '${r.state}' (expected ${r.want})`,
125
+ hint: "registered pins `-`; approved pins a 40-hex SHA; cancelled pins either",
126
+ },
127
+ {
128
+ id: "status-row.exp-planref",
129
+ scope: "status-row",
130
+ severity: "fail",
131
+ when: hasFourCells,
132
+ check: (s) => (/^#\d+$/.test(s.cells[3]) ? null : { planRef: s.cells[3] }),
133
+ message: (_s, r) => `Bad plan-ref '${r.planRef}' (expected #NNN)`,
134
+ hint: "the plan-ref names the issue carrying the execution plan, e.g. #NNN",
135
+ },
51
136
  ];
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
+ }