@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.
@@ -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 (.+)\)$/;
@@ -1,5 +1,22 @@
1
1
  import { addDays } from "@forwardimpact/libutil";
2
2
  import { createLogger } from "@forwardimpact/libtelemetry";
3
+ import { sanitizeCrossingField, sanitizeTitle } from "./sanitize.js";
4
+
5
+ /**
6
+ * Thrown when the tracker query for the agent-experiments materialization fails
7
+ * (non-zero exit or unparseable JSON). Distinct from returning `[]` so the
8
+ * refresh command can keep the previously materialized block instead of wiping
9
+ * the routing surface when the tracker is briefly unavailable.
10
+ */
11
+ export class TrackerQueryError extends Error {
12
+ /** @param {string} reason */
13
+ constructor(reason) {
14
+ super(reason);
15
+ this.name = "TrackerQueryError";
16
+ }
17
+ }
18
+
19
+ const AGENT_LABEL_RE = /^agent:([a-z][a-z-]*)$/;
3
20
 
4
21
  /** Parse `owner/repo` from a git origin URL. Tolerates http(s), ssh, and proxy-rewritten URLs (e.g. `http://host/git/owner/repo`) by taking the last two path segments after stripping `.git`. Returns null when nothing parseable is found. */
5
22
  export function parseRepoSlug(originUrl) {
@@ -88,3 +105,57 @@ export async function renderIssueList({
88
105
  }
89
106
  return lines;
90
107
  }
108
+
109
+ /**
110
+ * Render the attributed per-agent experiments surface. Fetches open
111
+ * issues labeled `experiment`, keeps only those also carrying an
112
+ * `agent:{name}` label, and emits one sanitized, body-free line per issue:
113
+ * `- #<number> [<agent>] <title> (by <author>)`. Issue bodies are never read.
114
+ * Throws {@link TrackerQueryError} on tracker failure so the caller can keep the
115
+ * previously materialized block; never returns `[]` on failure.
116
+ *
117
+ * @param {object} options
118
+ * @param {string} options.cwd
119
+ * @param {string} [options.repo]
120
+ * @param {string} [options.token]
121
+ * @param {import('@forwardimpact/libutil/runtime').Runtime} options.runtime
122
+ * @returns {Promise<string[]>}
123
+ */
124
+ export async function renderAgentExperiments({ cwd, repo, token, runtime }) {
125
+ const args = ["issue", "list"];
126
+ if (repo) args.push("--repo", repo);
127
+ args.push(
128
+ "--label",
129
+ "experiment",
130
+ "--state",
131
+ "open",
132
+ "--json",
133
+ "number,title,labels,author",
134
+ "--limit",
135
+ "100",
136
+ );
137
+ const env = token ? { ...runtime.proc.env, GH_TOKEN: token } : undefined;
138
+ const result = await runtime.subprocess.run("gh", args, { cwd, env });
139
+ if (result.exitCode !== 0) {
140
+ throw new TrackerQueryError("gh issue list failed for agent-experiments");
141
+ }
142
+ let issues;
143
+ try {
144
+ issues = JSON.parse(result.stdout || "[]");
145
+ } catch {
146
+ throw new TrackerQueryError("gh issue list JSON parse failed");
147
+ }
148
+
149
+ const lines = [];
150
+ for (const issue of issues) {
151
+ const agentLabel = (issue.labels || [])
152
+ .map((l) => (typeof l === "string" ? l : l.name))
153
+ .map((name) => name?.match(AGENT_LABEL_RE)?.[1])
154
+ .find(Boolean);
155
+ if (!agentLabel) continue;
156
+ const title = sanitizeTitle(issue.title);
157
+ const author = sanitizeCrossingField(issue.author?.login?.toLowerCase());
158
+ lines.push(`- #${issue.number} [${agentLabel}] ${title} (by ${author})`);
159
+ }
160
+ return lines;
161
+ }
@@ -1,4 +1,6 @@
1
1
  import {
2
+ AGENT_EXPERIMENTS_CLOSE_RE,
3
+ AGENT_EXPERIMENTS_OPEN_RE,
2
4
  ISSUE_CLOSE_RE,
3
5
  ISSUE_OPEN_RE,
4
6
  XMR_CLOSE_RE,
@@ -6,7 +8,9 @@ import {
6
8
  } from "./constants.js";
7
9
 
8
10
  function openLabel(open) {
9
- return open.kind === "xmr" ? open.metric : open.topic;
11
+ if (open.kind === "xmr") return open.metric;
12
+ if (open.kind === "agent-experiments") return "agent-experiments";
13
+ return open.topic;
10
14
  }
11
15
 
12
16
  function warnDangling(open, warn) {
@@ -34,6 +38,9 @@ function tryOpen(line, i) {
34
38
  openLine: i,
35
39
  };
36
40
  }
41
+ if (AGENT_EXPERIMENTS_OPEN_RE.test(line)) {
42
+ return { kind: "agent-experiments", openLine: i };
43
+ }
37
44
  return null;
38
45
  }
39
46
 
@@ -48,6 +55,13 @@ function closePair(open, i) {
48
55
  closeLine: i,
49
56
  };
50
57
  }
58
+ if (open.kind === "agent-experiments") {
59
+ return {
60
+ kind: "agent-experiments",
61
+ openLine: open.openLine,
62
+ closeLine: i,
63
+ };
64
+ }
51
65
  return {
52
66
  kind: "issue-list",
53
67
  topic: open.topic,
@@ -61,6 +75,9 @@ function closePair(open, i) {
61
75
  function matchClose(line, open) {
62
76
  if (!open) return false;
63
77
  if (open.kind === "xmr") return XMR_CLOSE_RE.test(line);
78
+ if (open.kind === "agent-experiments") {
79
+ return AGENT_EXPERIMENTS_CLOSE_RE.test(line);
80
+ }
64
81
  const m = line.match(ISSUE_CLOSE_RE);
65
82
  return Boolean(m && open.kind === "issue-list" && open.topic === m[1]);
66
83
  }
@@ -0,0 +1,53 @@
1
+ const FIELD_CAP = 200;
2
+ const ELLIPSIS = "…";
3
+ const ZERO_WIDTH_SPACE = "\u200b";
4
+
5
+ // Replace every newline, control character, or whitespace code point with a
6
+ // single space, then collapse runs. Done by code-point inspection rather than a
7
+ // character-class range so no literal hyphen is ever folded into a range and
8
+ // hyphenated identifiers ("staff-engineer", "dick-olsson") survive intact.
9
+ function flattenWhitespace(input) {
10
+ let out = "";
11
+ for (const ch of input) {
12
+ const code = ch.codePointAt(0);
13
+ const isControl = code <= 0x1f || code === 0x7f;
14
+ const isSpace = /\s/.test(ch);
15
+ out += isControl || isSpace ? " " : ch;
16
+ }
17
+ return out.replace(/ {2,}/g, " ");
18
+ }
19
+
20
+ /**
21
+ * Neutralize an anyone-editable issue-tracker field before it crosses into a
22
+ * boot-readable wiki surface. Flattens newlines / control characters /
23
+ * whitespace to single spaces (a multi-line value is what would let a field
24
+ * inject a heading or block marker and move section boundaries), escapes a
25
+ * leading protocol sigil ("[" or "<") so "[ask#N]" / "<tag>" / HTML-comment
26
+ * lookalikes render inert, and length-caps the result.
27
+ * @param {string|null|undefined} value
28
+ * @param {number} [maxLen]
29
+ * @returns {string}
30
+ */
31
+ export function sanitizeCrossingField(value, maxLen = FIELD_CAP) {
32
+ if (value == null) return "";
33
+ let s = flattenWhitespace(String(value)).trim();
34
+ s = s.replace(/^\[/, "\\[").replace(/^</, "\\<");
35
+ if (s.length > maxLen) s = s.slice(0, maxLen - 1) + ELLIPSIS;
36
+ return s;
37
+ }
38
+
39
+ /**
40
+ * Sanitize a materialized item title. Beyond {@link sanitizeCrossingField}, it
41
+ * defuses the literal author-suffix token " (by " by inserting a zero-width
42
+ * space after "(by", so a title can never be mistaken for the trailing
43
+ * "(by <author>)" provenance suffix when the line is parsed back at boot.
44
+ * @param {string|null|undefined} value
45
+ * @param {number} [maxLen]
46
+ * @returns {string}
47
+ */
48
+ export function sanitizeTitle(value, maxLen = FIELD_CAP) {
49
+ return sanitizeCrossingField(value, maxLen).replace(
50
+ / \(by /g,
51
+ " (by" + ZERO_WIDTH_SPACE + " ",
52
+ );
53
+ }
package/src/status.js CHANGED
@@ -1,19 +1,46 @@
1
- // STATUS.md row ids may carry a `/<unit>` suffix denoting a
2
- // per-migration-unit sub-row of a master spec (`1370/libutil`, …). The master
3
- // `NNNN` row advances only when every sub-row reads `plan implemented`.
1
+ // STATUS.md rows come in two kinds. A spec row's id is four digits with an
2
+ // optional `/<unit>` suffix denoting a per-migration-unit sub-row of a master
3
+ // spec (`1370/libutil`, …); the master `NNNN` row advances only when every
4
+ // sub-row reads `plan implemented`. An experiment row's id is `exp:<issue>`
5
+ // and the row carries four tab cells — `exp:<issue><TAB><state><TAB><pin>
6
+ // <TAB><plan-ref>` — keying the merge-gate approval path for a spec-less
7
+ // experiment PR. The `exp:` namespace cannot match the spec id's `^\d{4}`
8
+ // anchor, so the two kinds never collide for any issue-number width.
4
9
 
5
- /** Matches a status-row id: four digits, optionally a `/<unit>` suffix. */
6
- export const STATUS_ID_REGEX = /^\d{4}(\/[a-z0-9-]+)?$/;
10
+ /** Matches a status-row id: a four-digit spec id (optional `/<unit>`) or `exp:<issue>`. */
11
+ export const STATUS_ID_REGEX = /^(\d{4}(\/[a-z0-9-]+)?|exp:\d+)$/;
7
12
 
8
13
  /**
9
- * Parse a status-row id into its master spec id and optional unit suffix.
10
- * @param {string} id - The id field of a STATUS.md row.
11
- * @returns {{ specId: string, unit: string|null }|null} Parsed parts, or null
12
- * when the id does not match {@link STATUS_ID_REGEX}.
14
+ * Classify a status-row id into its kind and parts. Experiment rows are
15
+ * identified by an `exp:` id together with a four-cell row; the optional
16
+ * `cells` array supplies that count (a bare `exp:` id without four cells is
17
+ * not a valid row and yields null).
18
+ * @param {string} id - The id field (cell 0) of a STATUS.md row.
19
+ * @param {string[]} [cells] - The full tab-separated cells of the row, when
20
+ * available. Required to classify an experiment row.
21
+ * @returns {(
22
+ * {kind: "spec", specId: string, unit: string|null} |
23
+ * {kind: "experiment", issue: string, state: string, pin: string, planRef: string} |
24
+ * null
25
+ * )} Parsed parts, or null when the id/row does not match a known kind.
13
26
  */
14
- export function parseStatusRowId(id) {
27
+ export function parseStatusRowId(id, cells) {
15
28
  if (typeof id !== "string" || !STATUS_ID_REGEX.test(id)) return null;
29
+ if (id.startsWith("exp:")) {
30
+ if (!Array.isArray(cells) || cells.length !== 4) return null;
31
+ return {
32
+ kind: "experiment",
33
+ issue: id.slice("exp:".length),
34
+ state: cells[1],
35
+ pin: cells[2],
36
+ planRef: cells[3],
37
+ };
38
+ }
16
39
  const slash = id.indexOf("/");
17
- if (slash === -1) return { specId: id, unit: null };
18
- return { specId: id.slice(0, slash), unit: id.slice(slash + 1) };
40
+ if (slash === -1) return { kind: "spec", specId: id, unit: null };
41
+ return {
42
+ kind: "spec",
43
+ specId: id.slice(0, slash),
44
+ unit: id.slice(slash + 1),
45
+ };
19
46
  }