@forwardimpact/libwiki 0.2.25 → 0.2.27
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/LICENSE +21 -201
- package/README.md +5 -3
- package/bin/fit-wiki.js +1 -1
- package/package.json +1 -1
- package/src/audit/admission.js +53 -0
- package/src/audit/conflict-markers-rule.js +24 -0
- package/src/audit/grammar.js +97 -0
- package/src/audit/rules.js +96 -5
- package/src/audit/scopes.js +121 -5
- package/src/audit/status-row.js +91 -6
- package/src/block-renderer.js +19 -5
- package/src/boot.js +39 -1
- package/src/cli-definition.js +17 -16
- package/src/commands/audit.js +6 -1
- package/src/commands/boot.js +7 -2
- package/src/commands/claim.js +138 -32
- package/src/commands/fix.js +13 -5
- package/src/commands/inbox.js +7 -8
- package/src/commands/init.js +6 -0
- package/src/commands/log.js +47 -25
- package/src/commands/memo.js +10 -9
- package/src/commands/refresh.js +1 -0
- package/src/commands/rotate.js +40 -19
- package/src/commands/sync.js +54 -6
- package/src/conflict-markers.js +78 -0
- package/src/constants.js +31 -1
- package/src/gitattributes.js +40 -0
- package/src/index.js +2 -1
- package/src/integrity.js +288 -0
- package/src/lane-files.js +62 -0
- package/src/marker-scanner.js +2 -0
- package/src/secret-gate.js +177 -0
- package/src/status.js +39 -12
- package/src/util/agent-flag.js +31 -0
- package/src/weekly-log.js +146 -37
- package/src/wiki-sync.js +528 -11
package/src/audit/scopes.js
CHANGED
|
@@ -2,11 +2,14 @@ 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,
|
|
8
9
|
WEEKLY_LOG_PART_NAME_RE,
|
|
9
10
|
} from "../constants.js";
|
|
11
|
+
import { listAdmissionPaths } from "./admission.js";
|
|
12
|
+
import { classifyPath, rootSummaryStem } from "./grammar.js";
|
|
10
13
|
|
|
11
14
|
// Capture the agent-title group so the same regex both classifies a file
|
|
12
15
|
// (`.test`) and yields the title for the agent-prefix audit (`.match[1]`).
|
|
@@ -33,6 +36,34 @@ function listMdFiles(wikiRoot, fs) {
|
|
|
33
36
|
.map((e) => path.join(wikiRoot, e));
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
// Recursively collect every *.csv under `<wikiRoot>/metrics/`. The real layout
|
|
40
|
+
// is `metrics/<skill>/<year>.csv` (two levels), so the walk recurses rather
|
|
41
|
+
// than assuming a fixed depth. Uses readdirSync + statSync (rather than
|
|
42
|
+
// `withFileTypes` Dirents) so it runs unchanged under the in-memory mock fs.
|
|
43
|
+
function listCsvFiles(wikiRoot, fs) {
|
|
44
|
+
const metricsRoot = path.join(wikiRoot, "metrics");
|
|
45
|
+
if (!fs.existsSync(metricsRoot)) return [];
|
|
46
|
+
const found = [];
|
|
47
|
+
const walk = (dir) => {
|
|
48
|
+
for (const name of fs.readdirSync(dir)) {
|
|
49
|
+
const full = path.join(dir, name);
|
|
50
|
+
if (fs.statSync(full).isDirectory()) walk(full);
|
|
51
|
+
else if (name.endsWith(".csv")) found.push(full);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
walk(metricsRoot);
|
|
55
|
+
return found;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Load a metrics CSV as an audit subject: `rows` is the array of line strings,
|
|
59
|
+
// so a rule indexes `rows[i]` (a string) and `i + 1` is its line number.
|
|
60
|
+
function loadCsv(filePath, fs) {
|
|
61
|
+
return {
|
|
62
|
+
path: filePath,
|
|
63
|
+
rows: fs.readFileSync(filePath, "utf-8").split("\n"),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
36
67
|
function loadFile(filePath, fs) {
|
|
37
68
|
const text = fs.readFileSync(filePath, "utf-8");
|
|
38
69
|
const fileLines = text.split("\n");
|
|
@@ -90,9 +121,12 @@ function readOptional(filePath, fs) {
|
|
|
90
121
|
|
|
91
122
|
/**
|
|
92
123
|
* Parse the rows inside STATUS.md's fenced block into audit subjects. Lines
|
|
93
|
-
* 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`.
|
|
94
128
|
* @param {string} statusText - The full STATUS.md contents.
|
|
95
|
-
* @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}>}
|
|
96
130
|
*/
|
|
97
131
|
function parseStatusRows(statusText) {
|
|
98
132
|
const lines = statusText.split("\n");
|
|
@@ -106,6 +140,12 @@ function parseStatusRows(statusText) {
|
|
|
106
140
|
}
|
|
107
141
|
if (!inFence || line.trim() === "") continue;
|
|
108
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);
|
|
109
149
|
rows.push({
|
|
110
150
|
lineNo: i + 1,
|
|
111
151
|
text: line,
|
|
@@ -113,6 +153,7 @@ function parseStatusRows(statusText) {
|
|
|
113
153
|
id: cells[0],
|
|
114
154
|
phase: cells[1],
|
|
115
155
|
status: cells[2],
|
|
156
|
+
kind: isExp ? "experiment" : parsed ? parsed.kind : null,
|
|
116
157
|
});
|
|
117
158
|
}
|
|
118
159
|
return rows;
|
|
@@ -177,6 +218,7 @@ const SCOPE_RESOLVERS = {
|
|
|
177
218
|
summary: (ctx) => ctx.subjects.summary,
|
|
178
219
|
"weekly-log-main": (ctx) => ctx.subjects["weekly-log-main"],
|
|
179
220
|
"weekly-log-part": (ctx) => ctx.subjects["weekly-log-part"],
|
|
221
|
+
"metrics-csv": (ctx) => ctx.subjects["metrics-csv"],
|
|
180
222
|
memory: (ctx) => [ctx.memory],
|
|
181
223
|
"claims-row": (ctx) =>
|
|
182
224
|
parseClaims(ctx.memory.text).map((c) => ({ ...c, path: ctx.memory.path })),
|
|
@@ -191,8 +233,54 @@ const SCOPE_RESOLVERS = {
|
|
|
191
233
|
...r,
|
|
192
234
|
path: ctx.status.path,
|
|
193
235
|
})),
|
|
236
|
+
"conflict-scan": (ctx) => conflictScanSubjects(ctx),
|
|
237
|
+
admission: (ctx) =>
|
|
238
|
+
ctx.admission.paths
|
|
239
|
+
.filter((p) => classifyPath(p, ctx.admission) === "rejected")
|
|
240
|
+
.map((relPath) => ({
|
|
241
|
+
path: path.join(ctx.wikiRoot, relPath),
|
|
242
|
+
relPath,
|
|
243
|
+
})),
|
|
194
244
|
};
|
|
195
245
|
|
|
246
|
+
// Normalize every audited surface into a uniform `{ path, text, fenceExempt }`
|
|
247
|
+
// subject for the conflict-marker scan. The per-file subjects (summaries,
|
|
248
|
+
// weekly logs and sealed parts, storyboard) carry `fileLines`; MEMORY.md and
|
|
249
|
+
// STATUS.md carry `text` (readOptional shape). `fenceExempt` is true for prose
|
|
250
|
+
// surfaces, where a fence quotes content, and false for STATUS.md, whose fenced
|
|
251
|
+
// rows are data — a marker there is never legitimate (per-surface fence
|
|
252
|
+
// contract). Files absent from disk (missing MEMORY/STATUS/storyboard) yield
|
|
253
|
+
// empty text and produce no findings.
|
|
254
|
+
function conflictScanSubjects(ctx) {
|
|
255
|
+
const subjects = [];
|
|
256
|
+
const fileScopes = ["summary", "weekly-log-main", "weekly-log-part"];
|
|
257
|
+
for (const scope of fileScopes) {
|
|
258
|
+
for (const s of ctx.subjects[scope]) {
|
|
259
|
+
subjects.push({
|
|
260
|
+
path: s.path,
|
|
261
|
+
text: s.fileLines.join("\n"),
|
|
262
|
+
fenceExempt: true,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
subjects.push({
|
|
267
|
+
path: ctx.storyboard.path,
|
|
268
|
+
text: ctx.storyboard.text,
|
|
269
|
+
fenceExempt: true,
|
|
270
|
+
});
|
|
271
|
+
subjects.push({
|
|
272
|
+
path: ctx.memory.path,
|
|
273
|
+
text: ctx.memory.text,
|
|
274
|
+
fenceExempt: true,
|
|
275
|
+
});
|
|
276
|
+
subjects.push({
|
|
277
|
+
path: ctx.status.path,
|
|
278
|
+
text: ctx.status.text,
|
|
279
|
+
fenceExempt: false,
|
|
280
|
+
});
|
|
281
|
+
return subjects;
|
|
282
|
+
}
|
|
283
|
+
|
|
196
284
|
/** Resolve a scope key into the list of subjects the engine should iterate. */
|
|
197
285
|
export function resolveScope(scopeKey, ctx) {
|
|
198
286
|
const resolver = SCOPE_RESOLVERS[scopeKey];
|
|
@@ -200,21 +288,48 @@ export function resolveScope(scopeKey, ctx) {
|
|
|
200
288
|
return resolver(ctx);
|
|
201
289
|
}
|
|
202
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Build the admission slice: the tracked-file universe plus the
|
|
293
|
+
* `rootSummaryAgents` set that gates `<agent>/` sidecar directories. The agent
|
|
294
|
+
* set is derived first (a root-level summary-class file's stem) so the
|
|
295
|
+
* `admission` scope can classify sidecar directories against it.
|
|
296
|
+
*
|
|
297
|
+
* Returns the empty universe when `subprocess` is absent — callers that only
|
|
298
|
+
* read `.subjects` (the rotation pre-pass) skip the git read and the tree walk
|
|
299
|
+
* entirely, and produce no `admission` findings.
|
|
300
|
+
*/
|
|
301
|
+
function buildAdmission(wikiRoot, fs, subprocess) {
|
|
302
|
+
if (!subprocess) return { paths: [], rootSummaryAgents: new Set() };
|
|
303
|
+
const paths = listAdmissionPaths({ wikiRoot, fs, subprocess });
|
|
304
|
+
const rootSummaryAgents = new Set();
|
|
305
|
+
for (const p of paths) {
|
|
306
|
+
if (p.includes("/")) continue; // root-level files only
|
|
307
|
+
const stem = rootSummaryStem(p);
|
|
308
|
+
if (stem) rootSummaryAgents.add(stem);
|
|
309
|
+
}
|
|
310
|
+
return { paths, rootSummaryAgents };
|
|
311
|
+
}
|
|
312
|
+
|
|
203
313
|
/**
|
|
204
314
|
* Build the audit context: classifies and loads every wiki file once.
|
|
205
|
-
* @param {{wikiRoot: string, today: string, fs: object}} options
|
|
206
|
-
* `fs` is the sync filesystem surface (`runtime.fsSync`)
|
|
315
|
+
* @param {{wikiRoot: string, today: string, fs: object, subprocess: object}} options
|
|
316
|
+
* `fs` is the sync filesystem surface (`runtime.fsSync`); `subprocess` is
|
|
317
|
+
* `runtime.subprocess` (its `runSync` backs the admission scope's git read).
|
|
207
318
|
*/
|
|
208
|
-
export function buildContext({ wikiRoot, today, fs }) {
|
|
319
|
+
export function buildContext({ wikiRoot, today, fs, subprocess }) {
|
|
209
320
|
const subjects = {
|
|
210
321
|
summary: [],
|
|
211
322
|
"weekly-log-main": [],
|
|
212
323
|
"weekly-log-part": [],
|
|
324
|
+
"metrics-csv": [],
|
|
213
325
|
};
|
|
214
326
|
for (const file of listMdFiles(wikiRoot, fs)) {
|
|
215
327
|
const classified = classifyFile(file, fs);
|
|
216
328
|
if (classified) subjects[classified.kind].push(classified.subject);
|
|
217
329
|
}
|
|
330
|
+
for (const file of listCsvFiles(wikiRoot, fs)) {
|
|
331
|
+
subjects["metrics-csv"].push(loadCsv(file, fs));
|
|
332
|
+
}
|
|
218
333
|
return {
|
|
219
334
|
wikiRoot,
|
|
220
335
|
today,
|
|
@@ -222,5 +337,6 @@ export function buildContext({ wikiRoot, today, fs }) {
|
|
|
222
337
|
memory: readOptional(path.join(wikiRoot, "MEMORY.md"), fs),
|
|
223
338
|
status: readOptional(path.join(wikiRoot, "STATUS.md"), fs),
|
|
224
339
|
storyboard: loadStoryboard(wikiRoot, today, fs),
|
|
340
|
+
admission: buildAdmission(wikiRoot, fs, subprocess),
|
|
225
341
|
};
|
|
226
342
|
}
|
package/src/audit/status-row.js
CHANGED
|
@@ -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
|
|
4
|
-
// `
|
|
5
|
-
//
|
|
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
|
|
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
|
-
|
|
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/block-renderer.js
CHANGED
|
@@ -13,10 +13,17 @@ export class BlockRenderError extends Error {
|
|
|
13
13
|
/**
|
|
14
14
|
* Render an XmR chart block for a metric by reading its CSV and producing
|
|
15
15
|
* markdown lines.
|
|
16
|
-
* @param {{metric: string, csvPath: string, projectRoot: string, fs: object}} options
|
|
17
|
-
* `fs` is the sync filesystem surface (`runtime.fsSync`).
|
|
16
|
+
* @param {{metric: string, csvPath: string, projectRoot: string, fs: object, priorReadAnchor?: string|null}} options
|
|
17
|
+
* `fs` is the sync filesystem surface (`runtime.fsSync`). `priorReadAnchor`,
|
|
18
|
+
* when supplied, stamps per-signal provenance surfaced in the Signals line.
|
|
18
19
|
*/
|
|
19
|
-
export function renderBlock({
|
|
20
|
+
export function renderBlock({
|
|
21
|
+
metric,
|
|
22
|
+
csvPath,
|
|
23
|
+
projectRoot,
|
|
24
|
+
fs,
|
|
25
|
+
priorReadAnchor,
|
|
26
|
+
}) {
|
|
20
27
|
const fullPath = path.resolve(projectRoot, csvPath);
|
|
21
28
|
let csvText;
|
|
22
29
|
try {
|
|
@@ -24,7 +31,7 @@ export function renderBlock({ metric, csvPath, projectRoot, fs }) {
|
|
|
24
31
|
} catch {
|
|
25
32
|
throw new BlockRenderError(`csv-not-found: ${csvPath}`);
|
|
26
33
|
}
|
|
27
|
-
const report = analyze(csvText);
|
|
34
|
+
const report = analyze(csvText, { priorReadAnchor });
|
|
28
35
|
|
|
29
36
|
const m = report.metrics.find((entry) => entry.metric === metric);
|
|
30
37
|
if (!m) {
|
|
@@ -50,11 +57,18 @@ export function renderBlock({ metric, csvPath, projectRoot, fs }) {
|
|
|
50
57
|
];
|
|
51
58
|
}
|
|
52
59
|
|
|
60
|
+
// Annotate each fired rule with its records' provenance when present, so a
|
|
61
|
+
// storyboard reader distinguishes recomputation-revealed signals from
|
|
62
|
+
// new-point signals at the cell. Without a prior-read anchor, provenance is
|
|
63
|
+
// absent and the line renders bare rule names exactly as before.
|
|
53
64
|
function formatSignals(signals) {
|
|
54
65
|
if (!signals) return "—";
|
|
55
66
|
const fired = [];
|
|
56
67
|
for (const rule of ["xRule1", "xRule2", "xRule3", "mrRule1"]) {
|
|
57
|
-
|
|
68
|
+
const recs = signals[rule];
|
|
69
|
+
if (!recs?.length) continue;
|
|
70
|
+
const tags = [...new Set(recs.map((r) => r.provenance).filter(Boolean))];
|
|
71
|
+
fired.push(tags.length ? `${rule} (${tags.join(", ")})` : rule);
|
|
58
72
|
}
|
|
59
73
|
return fired.length > 0 ? fired.join(", ") : "—";
|
|
60
74
|
}
|
package/src/boot.js
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { yearMonth } from "@forwardimpact/libutil";
|
|
3
3
|
import { parseClaims, filterExpired } from "./active-claims.js";
|
|
4
|
-
import {
|
|
4
|
+
import { countLines, countWords } from "./budget.js";
|
|
5
|
+
import {
|
|
6
|
+
MEMO_INBOX_MARKER,
|
|
7
|
+
PRIORITY_INDEX_HEADING,
|
|
8
|
+
SUMMARY_LINE_BUDGET,
|
|
9
|
+
SUMMARY_WORD_BUDGET,
|
|
10
|
+
WEEKLY_LOG_LINE_BUDGET,
|
|
11
|
+
WEEKLY_LOG_WORD_BUDGET,
|
|
12
|
+
} from "./constants.js";
|
|
13
|
+
import { weeklyLogPath } from "./weekly-log.js";
|
|
5
14
|
|
|
6
15
|
function readIfExists(fs, filePath) {
|
|
7
16
|
if (!fs.existsSync(filePath)) return null;
|
|
@@ -133,6 +142,24 @@ function countInbox(text) {
|
|
|
133
142
|
return n;
|
|
134
143
|
}
|
|
135
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Remaining budget for a budgeted surface: current value, cap, and headroom for
|
|
147
|
+
* both the line and word budget. An absent file (empty text) reports zero usage
|
|
148
|
+
* and full headroom so a writer sees the ceiling before composing.
|
|
149
|
+
*/
|
|
150
|
+
function headroom(text, lineCap, wordCap) {
|
|
151
|
+
const lines = countLines(text);
|
|
152
|
+
const words = countWords(text);
|
|
153
|
+
return {
|
|
154
|
+
words,
|
|
155
|
+
lines,
|
|
156
|
+
word_cap: wordCap,
|
|
157
|
+
line_cap: lineCap,
|
|
158
|
+
words_remaining: wordCap - words,
|
|
159
|
+
lines_remaining: lineCap - lines,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
136
163
|
function mapPriority(r) {
|
|
137
164
|
return { item: r.item, status: r.status, added: r.added, link: r.link };
|
|
138
165
|
}
|
|
@@ -162,6 +189,7 @@ export function buildDigest({ wikiRoot, agent, today, fs }) {
|
|
|
162
189
|
const summaryText = readIfExists(fs, summaryPath);
|
|
163
190
|
const memoryText = readIfExists(fs, memoryPath);
|
|
164
191
|
const storyboardText = readIfExists(fs, storyboardPath);
|
|
192
|
+
const weeklyLogText = readIfExists(fs, weeklyLogPath(wikiRoot, agent, today));
|
|
165
193
|
|
|
166
194
|
const { active } = filterExpired(parseClaims(memoryText ?? ""), today);
|
|
167
195
|
const { owned, cross } = splitPriorities(
|
|
@@ -176,6 +204,16 @@ export function buildDigest({ wikiRoot, agent, today, fs }) {
|
|
|
176
204
|
claims: active.map(mapClaim),
|
|
177
205
|
storyboard_items: parseStoryboardItems(storyboardText ?? "", agent),
|
|
178
206
|
inbox_count: countInbox(summaryText),
|
|
207
|
+
summary_headroom: headroom(
|
|
208
|
+
summaryText ?? "",
|
|
209
|
+
SUMMARY_LINE_BUDGET,
|
|
210
|
+
SUMMARY_WORD_BUDGET,
|
|
211
|
+
),
|
|
212
|
+
weekly_log_headroom: headroom(
|
|
213
|
+
weeklyLogText ?? "",
|
|
214
|
+
WEEKLY_LOG_LINE_BUDGET,
|
|
215
|
+
WEEKLY_LOG_WORD_BUDGET,
|
|
216
|
+
),
|
|
179
217
|
storyboard_path: fs.existsSync(storyboardPath)
|
|
180
218
|
? path.relative(path.dirname(wikiRoot) || ".", storyboardPath)
|
|
181
219
|
: "",
|
package/src/cli-definition.js
CHANGED
|
@@ -11,18 +11,17 @@ import { runAuditCommand } from "./commands/audit.js";
|
|
|
11
11
|
import { runFixCommand } from "./commands/fix.js";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* Build the `fit-wiki` libcli definition.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* resolved by libcli's `createCli` from
|
|
18
|
-
* subcommand carries a `handler` and (for
|
|
19
|
-
* `args`/`argsUsage` so `cli.dispatch` can route to
|
|
20
|
-
* with a frozen `ctx`.
|
|
14
|
+
* Build the `fit-wiki` libcli definition. Agent identity is never resolved from
|
|
15
|
+
* the environment: agent-scoped subcommands require an explicit `--agent`
|
|
16
|
+
* (`--from` for `memo`) and fail closed without it, so this module carries no
|
|
17
|
+
* ambient agent identity. The version is resolved by libcli's `createCli` from
|
|
18
|
+
* the bin's `packageJsonUrl`. Each subcommand carries a `handler` and (for
|
|
19
|
+
* subcommand-bearing commands) `args`/`argsUsage` so `cli.dispatch` can route to
|
|
20
|
+
* the per-command handler with a frozen `ctx`.
|
|
21
21
|
*
|
|
22
|
-
* @param {Record<string, string>} env - The process env (`runtime.proc.env`).
|
|
23
22
|
* @returns {object} The libcli definition.
|
|
24
23
|
*/
|
|
25
|
-
export function createDefinition(
|
|
24
|
+
export function createDefinition() {
|
|
26
25
|
const wikiRootOpt = {
|
|
27
26
|
"wiki-root": {
|
|
28
27
|
type: "string",
|
|
@@ -33,9 +32,7 @@ export function createDefinition(env) {
|
|
|
33
32
|
const agentOpt = {
|
|
34
33
|
agent: {
|
|
35
34
|
type: "string",
|
|
36
|
-
description:
|
|
37
|
-
"Agent name (falls back to LIBEVAL_AGENT_PROFILE, then staff-engineer)",
|
|
38
|
-
default: env.LIBEVAL_AGENT_PROFILE || "staff-engineer",
|
|
35
|
+
description: "Agent name (required; no environment fallback)",
|
|
39
36
|
},
|
|
40
37
|
};
|
|
41
38
|
|
|
@@ -148,12 +145,17 @@ export function createDefinition(env) {
|
|
|
148
145
|
},
|
|
149
146
|
{
|
|
150
147
|
name: "rotate",
|
|
151
|
-
description: "
|
|
148
|
+
description: "Rotate the current weekly log to a sealed part",
|
|
152
149
|
handler: runRotateCommand,
|
|
153
150
|
options: {
|
|
154
151
|
...agentOpt,
|
|
155
152
|
...wikiRootOpt,
|
|
156
153
|
...todayOpt,
|
|
154
|
+
force: {
|
|
155
|
+
type: "boolean",
|
|
156
|
+
description:
|
|
157
|
+
"Seal even an under-budget log (the header-only floor still holds)",
|
|
158
|
+
},
|
|
157
159
|
},
|
|
158
160
|
},
|
|
159
161
|
{
|
|
@@ -188,8 +190,7 @@ export function createDefinition(env) {
|
|
|
188
190
|
from: {
|
|
189
191
|
type: "string",
|
|
190
192
|
description:
|
|
191
|
-
"Sender agent name (
|
|
192
|
-
default: env.LIBEVAL_AGENT_PROFILE,
|
|
193
|
+
"Sender agent name (required; no environment fallback)",
|
|
193
194
|
},
|
|
194
195
|
to: {
|
|
195
196
|
type: "string",
|
|
@@ -239,7 +240,7 @@ export function createDefinition(env) {
|
|
|
239
240
|
name: "pull",
|
|
240
241
|
description: "Pull remote wiki changes into the local working tree",
|
|
241
242
|
handler: runPullCommand,
|
|
242
|
-
options: { ...wikiRootOpt },
|
|
243
|
+
options: { ...agentOpt, ...wikiRootOpt, ...todayOpt },
|
|
243
244
|
},
|
|
244
245
|
],
|
|
245
246
|
globalOptions: {
|
package/src/commands/audit.js
CHANGED
|
@@ -17,7 +17,12 @@ export function runAuditCommand(ctx) {
|
|
|
17
17
|
const wikiRoot = options["wiki-root"] || path.join(projectRoot, "wiki");
|
|
18
18
|
const today = options.today || currentDayIso(runtime);
|
|
19
19
|
|
|
20
|
-
const auditCtx = buildContext({
|
|
20
|
+
const auditCtx = buildContext({
|
|
21
|
+
wikiRoot,
|
|
22
|
+
today,
|
|
23
|
+
fs: runtime.fsSync,
|
|
24
|
+
subprocess: runtime.subprocess,
|
|
25
|
+
});
|
|
21
26
|
const findings = runRules(RULES, auditCtx, { resolveScope });
|
|
22
27
|
|
|
23
28
|
runtime.proc.stdout.write(
|
package/src/commands/boot.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { buildDigest } from "../boot.js";
|
|
2
2
|
import { currentDayIso } from "../util/clock.js";
|
|
3
|
+
import { requireAgentFlag } from "../util/agent-flag.js";
|
|
3
4
|
import { resolveWikiRoot } from "../util/wiki-dir.js";
|
|
4
5
|
|
|
5
6
|
function renderMarkdown(digest) {
|
|
@@ -43,8 +44,12 @@ function renderMarkdown(digest) {
|
|
|
43
44
|
export function runBootCommand(ctx) {
|
|
44
45
|
const { runtime } = ctx.deps;
|
|
45
46
|
const options = ctx.options;
|
|
46
|
-
const
|
|
47
|
-
|
|
47
|
+
const resolved = requireAgentFlag(options, {
|
|
48
|
+
command: "boot",
|
|
49
|
+
example: "fit-wiki boot --agent staff-engineer",
|
|
50
|
+
});
|
|
51
|
+
if (!resolved.ok) return resolved;
|
|
52
|
+
const agent = resolved.agent;
|
|
48
53
|
|
|
49
54
|
const wikiRoot = resolveWikiRoot(runtime, options);
|
|
50
55
|
const today = options.today || currentDayIso(runtime);
|