@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 +1 -1
- package/src/audit/rules.js +16 -0
- package/src/audit/scopes.js +13 -2
- package/src/audit/status-row.js +91 -6
- package/src/boot.js +79 -19
- package/src/cli-definition.js +27 -0
- package/src/commands/claim.js +59 -22
- package/src/commands/product-mix.js +148 -0
- package/src/commands/refresh.js +37 -2
- package/src/commands/sync.js +10 -3
- package/src/constants.js +18 -0
- package/src/issue-list-renderer.js +71 -0
- package/src/marker-scanner.js +18 -1
- package/src/sanitize.js +53 -0
- package/src/status.js +39 -12
- package/src/wiki-sync.js +573 -56
package/src/commands/refresh.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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,
|
package/src/commands/sync.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { createLogger } from "@forwardimpact/libtelemetry";
|
|
2
2
|
import { refusalEnvelope } from "../secret-gate.js";
|
|
3
|
-
import {
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/marker-scanner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/sanitize.js
ADDED
|
@@ -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
|
|
2
|
-
// per-migration-unit sub-row of a master
|
|
3
|
-
// `NNNN` row advances only when every
|
|
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
|
|
6
|
-
export const STATUS_ID_REGEX =
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
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
|
}
|