@forwardimpact/libwiki 0.2.25 → 0.2.26
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 +108 -3
- 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/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
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
WEEKLY_LOG_NAME_RE,
|
|
8
8
|
WEEKLY_LOG_PART_NAME_RE,
|
|
9
9
|
} from "../constants.js";
|
|
10
|
+
import { listAdmissionPaths } from "./admission.js";
|
|
11
|
+
import { classifyPath, rootSummaryStem } from "./grammar.js";
|
|
10
12
|
|
|
11
13
|
// Capture the agent-title group so the same regex both classifies a file
|
|
12
14
|
// (`.test`) and yields the title for the agent-prefix audit (`.match[1]`).
|
|
@@ -33,6 +35,34 @@ function listMdFiles(wikiRoot, fs) {
|
|
|
33
35
|
.map((e) => path.join(wikiRoot, e));
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
// Recursively collect every *.csv under `<wikiRoot>/metrics/`. The real layout
|
|
39
|
+
// is `metrics/<skill>/<year>.csv` (two levels), so the walk recurses rather
|
|
40
|
+
// than assuming a fixed depth. Uses readdirSync + statSync (rather than
|
|
41
|
+
// `withFileTypes` Dirents) so it runs unchanged under the in-memory mock fs.
|
|
42
|
+
function listCsvFiles(wikiRoot, fs) {
|
|
43
|
+
const metricsRoot = path.join(wikiRoot, "metrics");
|
|
44
|
+
if (!fs.existsSync(metricsRoot)) return [];
|
|
45
|
+
const found = [];
|
|
46
|
+
const walk = (dir) => {
|
|
47
|
+
for (const name of fs.readdirSync(dir)) {
|
|
48
|
+
const full = path.join(dir, name);
|
|
49
|
+
if (fs.statSync(full).isDirectory()) walk(full);
|
|
50
|
+
else if (name.endsWith(".csv")) found.push(full);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
walk(metricsRoot);
|
|
54
|
+
return found;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Load a metrics CSV as an audit subject: `rows` is the array of line strings,
|
|
58
|
+
// so a rule indexes `rows[i]` (a string) and `i + 1` is its line number.
|
|
59
|
+
function loadCsv(filePath, fs) {
|
|
60
|
+
return {
|
|
61
|
+
path: filePath,
|
|
62
|
+
rows: fs.readFileSync(filePath, "utf-8").split("\n"),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
36
66
|
function loadFile(filePath, fs) {
|
|
37
67
|
const text = fs.readFileSync(filePath, "utf-8");
|
|
38
68
|
const fileLines = text.split("\n");
|
|
@@ -177,6 +207,7 @@ const SCOPE_RESOLVERS = {
|
|
|
177
207
|
summary: (ctx) => ctx.subjects.summary,
|
|
178
208
|
"weekly-log-main": (ctx) => ctx.subjects["weekly-log-main"],
|
|
179
209
|
"weekly-log-part": (ctx) => ctx.subjects["weekly-log-part"],
|
|
210
|
+
"metrics-csv": (ctx) => ctx.subjects["metrics-csv"],
|
|
180
211
|
memory: (ctx) => [ctx.memory],
|
|
181
212
|
"claims-row": (ctx) =>
|
|
182
213
|
parseClaims(ctx.memory.text).map((c) => ({ ...c, path: ctx.memory.path })),
|
|
@@ -191,8 +222,54 @@ const SCOPE_RESOLVERS = {
|
|
|
191
222
|
...r,
|
|
192
223
|
path: ctx.status.path,
|
|
193
224
|
})),
|
|
225
|
+
"conflict-scan": (ctx) => conflictScanSubjects(ctx),
|
|
226
|
+
admission: (ctx) =>
|
|
227
|
+
ctx.admission.paths
|
|
228
|
+
.filter((p) => classifyPath(p, ctx.admission) === "rejected")
|
|
229
|
+
.map((relPath) => ({
|
|
230
|
+
path: path.join(ctx.wikiRoot, relPath),
|
|
231
|
+
relPath,
|
|
232
|
+
})),
|
|
194
233
|
};
|
|
195
234
|
|
|
235
|
+
// Normalize every audited surface into a uniform `{ path, text, fenceExempt }`
|
|
236
|
+
// subject for the conflict-marker scan. The per-file subjects (summaries,
|
|
237
|
+
// weekly logs and sealed parts, storyboard) carry `fileLines`; MEMORY.md and
|
|
238
|
+
// STATUS.md carry `text` (readOptional shape). `fenceExempt` is true for prose
|
|
239
|
+
// surfaces, where a fence quotes content, and false for STATUS.md, whose fenced
|
|
240
|
+
// rows are data — a marker there is never legitimate (per-surface fence
|
|
241
|
+
// contract). Files absent from disk (missing MEMORY/STATUS/storyboard) yield
|
|
242
|
+
// empty text and produce no findings.
|
|
243
|
+
function conflictScanSubjects(ctx) {
|
|
244
|
+
const subjects = [];
|
|
245
|
+
const fileScopes = ["summary", "weekly-log-main", "weekly-log-part"];
|
|
246
|
+
for (const scope of fileScopes) {
|
|
247
|
+
for (const s of ctx.subjects[scope]) {
|
|
248
|
+
subjects.push({
|
|
249
|
+
path: s.path,
|
|
250
|
+
text: s.fileLines.join("\n"),
|
|
251
|
+
fenceExempt: true,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
subjects.push({
|
|
256
|
+
path: ctx.storyboard.path,
|
|
257
|
+
text: ctx.storyboard.text,
|
|
258
|
+
fenceExempt: true,
|
|
259
|
+
});
|
|
260
|
+
subjects.push({
|
|
261
|
+
path: ctx.memory.path,
|
|
262
|
+
text: ctx.memory.text,
|
|
263
|
+
fenceExempt: true,
|
|
264
|
+
});
|
|
265
|
+
subjects.push({
|
|
266
|
+
path: ctx.status.path,
|
|
267
|
+
text: ctx.status.text,
|
|
268
|
+
fenceExempt: false,
|
|
269
|
+
});
|
|
270
|
+
return subjects;
|
|
271
|
+
}
|
|
272
|
+
|
|
196
273
|
/** Resolve a scope key into the list of subjects the engine should iterate. */
|
|
197
274
|
export function resolveScope(scopeKey, ctx) {
|
|
198
275
|
const resolver = SCOPE_RESOLVERS[scopeKey];
|
|
@@ -200,21 +277,48 @@ export function resolveScope(scopeKey, ctx) {
|
|
|
200
277
|
return resolver(ctx);
|
|
201
278
|
}
|
|
202
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Build the admission slice: the tracked-file universe plus the
|
|
282
|
+
* `rootSummaryAgents` set that gates `<agent>/` sidecar directories. The agent
|
|
283
|
+
* set is derived first (a root-level summary-class file's stem) so the
|
|
284
|
+
* `admission` scope can classify sidecar directories against it.
|
|
285
|
+
*
|
|
286
|
+
* Returns the empty universe when `subprocess` is absent — callers that only
|
|
287
|
+
* read `.subjects` (the rotation pre-pass) skip the git read and the tree walk
|
|
288
|
+
* entirely, and produce no `admission` findings.
|
|
289
|
+
*/
|
|
290
|
+
function buildAdmission(wikiRoot, fs, subprocess) {
|
|
291
|
+
if (!subprocess) return { paths: [], rootSummaryAgents: new Set() };
|
|
292
|
+
const paths = listAdmissionPaths({ wikiRoot, fs, subprocess });
|
|
293
|
+
const rootSummaryAgents = new Set();
|
|
294
|
+
for (const p of paths) {
|
|
295
|
+
if (p.includes("/")) continue; // root-level files only
|
|
296
|
+
const stem = rootSummaryStem(p);
|
|
297
|
+
if (stem) rootSummaryAgents.add(stem);
|
|
298
|
+
}
|
|
299
|
+
return { paths, rootSummaryAgents };
|
|
300
|
+
}
|
|
301
|
+
|
|
203
302
|
/**
|
|
204
303
|
* 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`)
|
|
304
|
+
* @param {{wikiRoot: string, today: string, fs: object, subprocess: object}} options
|
|
305
|
+
* `fs` is the sync filesystem surface (`runtime.fsSync`); `subprocess` is
|
|
306
|
+
* `runtime.subprocess` (its `runSync` backs the admission scope's git read).
|
|
207
307
|
*/
|
|
208
|
-
export function buildContext({ wikiRoot, today, fs }) {
|
|
308
|
+
export function buildContext({ wikiRoot, today, fs, subprocess }) {
|
|
209
309
|
const subjects = {
|
|
210
310
|
summary: [],
|
|
211
311
|
"weekly-log-main": [],
|
|
212
312
|
"weekly-log-part": [],
|
|
313
|
+
"metrics-csv": [],
|
|
213
314
|
};
|
|
214
315
|
for (const file of listMdFiles(wikiRoot, fs)) {
|
|
215
316
|
const classified = classifyFile(file, fs);
|
|
216
317
|
if (classified) subjects[classified.kind].push(classified.subject);
|
|
217
318
|
}
|
|
319
|
+
for (const file of listCsvFiles(wikiRoot, fs)) {
|
|
320
|
+
subjects["metrics-csv"].push(loadCsv(file, fs));
|
|
321
|
+
}
|
|
218
322
|
return {
|
|
219
323
|
wikiRoot,
|
|
220
324
|
today,
|
|
@@ -222,5 +326,6 @@ export function buildContext({ wikiRoot, today, fs }) {
|
|
|
222
326
|
memory: readOptional(path.join(wikiRoot, "MEMORY.md"), fs),
|
|
223
327
|
status: readOptional(path.join(wikiRoot, "STATUS.md"), fs),
|
|
224
328
|
storyboard: loadStoryboard(wikiRoot, today, fs),
|
|
329
|
+
admission: buildAdmission(wikiRoot, fs, subprocess),
|
|
225
330
|
};
|
|
226
331
|
}
|
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);
|
package/src/commands/claim.js
CHANGED
|
@@ -8,7 +8,25 @@ import {
|
|
|
8
8
|
filterExpired,
|
|
9
9
|
} from "../active-claims.js";
|
|
10
10
|
import { currentDayIso } from "../util/clock.js";
|
|
11
|
+
import { requireAgentFlag } from "../util/agent-flag.js";
|
|
11
12
|
import { resolveWikiRoot } from "../util/wiki-dir.js";
|
|
13
|
+
import { refusalEnvelope } from "../secret-gate.js";
|
|
14
|
+
import { AncestryRefusal } from "../wiki-sync.js";
|
|
15
|
+
|
|
16
|
+
/** Non-zero envelope returned when the ancestry guard refused publication. */
|
|
17
|
+
const NOT_PUBLISHED = {
|
|
18
|
+
ok: false,
|
|
19
|
+
code: 1,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Build the not-published refusal message for the given guard refusal. */
|
|
23
|
+
function notPublishedMessage(err) {
|
|
24
|
+
return (
|
|
25
|
+
`${err.message}\n` +
|
|
26
|
+
"The row was written to MEMORY.md but is NOT published — it remains an " +
|
|
27
|
+
"uncommitted working-tree change.\n"
|
|
28
|
+
);
|
|
29
|
+
}
|
|
12
30
|
|
|
13
31
|
function readMemory(runtime, memPath) {
|
|
14
32
|
if (!runtime.fsSync.existsSync(memPath)) return "";
|
|
@@ -19,20 +37,72 @@ function memoryPath(runtime, options) {
|
|
|
19
37
|
return path.join(resolveWikiRoot(runtime, options), "MEMORY.md");
|
|
20
38
|
}
|
|
21
39
|
|
|
22
|
-
|
|
23
|
-
|
|
40
|
+
/**
|
|
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 }`).
|
|
46
|
+
*
|
|
47
|
+
* 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".
|
|
52
|
+
*
|
|
53
|
+
* @param {object} wikiSync - The WikiSync collaborator (may be absent in tests).
|
|
54
|
+
* @param {object} runtime - The runtime bag (for stdout/stderr).
|
|
55
|
+
* @param {string} message - The commit message.
|
|
56
|
+
* @param {(freshText: string) => string | null} [reapply] - Re-derives the row
|
|
57
|
+
* against the fresh tip when the landing contends.
|
|
58
|
+
* @returns {Promise<{ok: boolean, code?: number}>}
|
|
59
|
+
*/
|
|
60
|
+
async function pushWiki(wikiSync, runtime, message, reapply) {
|
|
61
|
+
if (!wikiSync) return { ok: true };
|
|
62
|
+
let result;
|
|
24
63
|
try {
|
|
25
64
|
await wikiSync.inheritIdentity();
|
|
26
65
|
// claim/release contract is a 1-line MEMORY.md change; the pathspec keeps
|
|
27
|
-
// foreign uncommitted files from parallel writers out of the commit.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
66
|
+
// foreign uncommitted files from parallel writers out of the commit. The
|
|
67
|
+
// `reapply` closure re-derives this row against the fresh tip if the landing
|
|
68
|
+
// contends, so a parallel writer's row is never erased.
|
|
69
|
+
result = await wikiSync.commitAndPush(message, ["MEMORY.md"], { reapply });
|
|
31
70
|
} 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.
|
|
75
|
+
if (err instanceof AncestryRefusal) throw err;
|
|
32
76
|
createLogger("wiki", runtime).warn(
|
|
33
77
|
"claim",
|
|
34
78
|
`push failed (saved locally): ${err.message}`,
|
|
35
79
|
);
|
|
80
|
+
return { ok: true };
|
|
81
|
+
}
|
|
82
|
+
const refusal = refusalEnvelope(runtime, result);
|
|
83
|
+
if (refusal) return refusal;
|
|
84
|
+
if (result.pushed) runtime.proc.stdout.write("push: committed and pushed\n");
|
|
85
|
+
return { ok: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 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.
|
|
94
|
+
*/
|
|
95
|
+
async function pushRowOrRefuse(wikiSync, runtime, message, reapply) {
|
|
96
|
+
try {
|
|
97
|
+
// Propagate pushWiki's envelope so a secret-gate refusal ({ ok: false })
|
|
98
|
+
// fails the command closed; a clean push returns { ok: true }.
|
|
99
|
+
return await pushWiki(wikiSync, runtime, message, reapply);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err instanceof AncestryRefusal) {
|
|
102
|
+
runtime.proc.stderr.write(notPublishedMessage(err));
|
|
103
|
+
return NOT_PUBLISHED;
|
|
104
|
+
}
|
|
105
|
+
throw err;
|
|
36
106
|
}
|
|
37
107
|
}
|
|
38
108
|
|
|
@@ -40,14 +110,13 @@ async function pushWiki(wikiSync, runtime, message) {
|
|
|
40
110
|
export async function runClaimCommand(ctx) {
|
|
41
111
|
const { runtime, wikiSync } = ctx.deps;
|
|
42
112
|
const options = ctx.options;
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
113
|
+
const resolved = requireAgentFlag(options, {
|
|
114
|
+
command: "claim",
|
|
115
|
+
example:
|
|
116
|
+
"fit-wiki claim --agent staff-engineer --target spec-NNNN --branch claude/...",
|
|
117
|
+
});
|
|
118
|
+
if (!resolved.ok) return resolved;
|
|
119
|
+
const agent = resolved.agent;
|
|
51
120
|
if (!options.target || !options.branch) {
|
|
52
121
|
return {
|
|
53
122
|
ok: false,
|
|
@@ -59,14 +128,15 @@ export async function runClaimCommand(ctx) {
|
|
|
59
128
|
const expires = options["expires-at"] || addDays(today, 7);
|
|
60
129
|
const memPath = memoryPath(runtime, options);
|
|
61
130
|
const text = readMemory(runtime, memPath);
|
|
62
|
-
const
|
|
131
|
+
const claim = {
|
|
63
132
|
agent,
|
|
64
133
|
target: options.target,
|
|
65
134
|
branch: options.branch,
|
|
66
135
|
pr: options.pr || null,
|
|
67
136
|
claimed_at: today,
|
|
68
137
|
expires_at: expires,
|
|
69
|
-
}
|
|
138
|
+
};
|
|
139
|
+
const result = appendClaim(text, claim);
|
|
70
140
|
if (!result.inserted) {
|
|
71
141
|
createLogger("wiki", runtime).warn(
|
|
72
142
|
"claim",
|
|
@@ -76,8 +146,17 @@ export async function runClaimCommand(ctx) {
|
|
|
76
146
|
}
|
|
77
147
|
runtime.fsSync.writeFileSync(memPath, result.text);
|
|
78
148
|
runtime.proc.stdout.write(`claimed ${options.target} (expires ${expires})\n`);
|
|
79
|
-
|
|
80
|
-
|
|
149
|
+
// Re-apply the same append against the fresh tip if the landing contends.
|
|
150
|
+
const reapply = (fresh) => {
|
|
151
|
+
const r = appendClaim(fresh, claim);
|
|
152
|
+
return r.inserted ? r.text : null;
|
|
153
|
+
};
|
|
154
|
+
return pushRowOrRefuse(
|
|
155
|
+
wikiSync,
|
|
156
|
+
runtime,
|
|
157
|
+
`wiki: claim ${options.target}`,
|
|
158
|
+
reapply,
|
|
159
|
+
);
|
|
81
160
|
}
|
|
82
161
|
|
|
83
162
|
/** Remove a claim row. `--expired` cleans every row past expires_at. */
|
|
@@ -102,18 +181,35 @@ export async function runReleaseCommand(ctx) {
|
|
|
102
181
|
}
|
|
103
182
|
runtime.fsSync.writeFileSync(memPath, current);
|
|
104
183
|
runtime.proc.stdout.write(`released ${count} expired claim(s)\n`);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
184
|
+
// Re-derive expiry against the fresh tip so a renewal landed since the stale
|
|
185
|
+
// read survives; only still-expired rows are removed.
|
|
186
|
+
const reapply = (fresh) => {
|
|
187
|
+
const freshExpired = filterExpired(parseClaims(fresh), today).expired;
|
|
188
|
+
let next = fresh;
|
|
189
|
+
let anyRemoved = false;
|
|
190
|
+
for (const c of freshExpired) {
|
|
191
|
+
const r = removeClaim(next, { agent: c.agent, target: c.target });
|
|
192
|
+
if (r.removed) {
|
|
193
|
+
next = r.text;
|
|
194
|
+
anyRemoved = true;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return anyRemoved ? next : null;
|
|
115
198
|
};
|
|
199
|
+
return pushRowOrRefuse(
|
|
200
|
+
wikiSync,
|
|
201
|
+
runtime,
|
|
202
|
+
"wiki: release expired claims",
|
|
203
|
+
reapply,
|
|
204
|
+
);
|
|
116
205
|
}
|
|
206
|
+
|
|
207
|
+
const resolved = requireAgentFlag(options, {
|
|
208
|
+
command: "release",
|
|
209
|
+
example: "fit-wiki release --agent staff-engineer --target spec-NNNN",
|
|
210
|
+
});
|
|
211
|
+
if (!resolved.ok) return resolved;
|
|
212
|
+
const agent = resolved.agent;
|
|
117
213
|
if (!options.target) {
|
|
118
214
|
return {
|
|
119
215
|
ok: false,
|
|
@@ -127,9 +223,19 @@ export async function runReleaseCommand(ctx) {
|
|
|
127
223
|
runtime.proc.stdout.write(
|
|
128
224
|
`no matching claim for ${agent}/${options.target}\n`,
|
|
129
225
|
);
|
|
130
|
-
|
|
131
|
-
runtime.proc.stdout.write(`released ${options.target}\n`);
|
|
132
|
-
await pushWiki(wikiSync, runtime, `wiki: release ${options.target}`);
|
|
226
|
+
return { ok: true };
|
|
133
227
|
}
|
|
134
|
-
|
|
228
|
+
runtime.proc.stdout.write(`released ${options.target}\n`);
|
|
229
|
+
// Re-apply the same removal against the fresh tip if the landing contends;
|
|
230
|
+
// re-removing an absent row is a no-op, so a re-release never resurrects it.
|
|
231
|
+
const reapply = (fresh) => {
|
|
232
|
+
const r = removeClaim(fresh, { agent, target: options.target });
|
|
233
|
+
return r.removed ? r.text : null;
|
|
234
|
+
};
|
|
235
|
+
return pushRowOrRefuse(
|
|
236
|
+
wikiSync,
|
|
237
|
+
runtime,
|
|
238
|
+
`wiki: release ${options.target}`,
|
|
239
|
+
reapply,
|
|
240
|
+
);
|
|
135
241
|
}
|
package/src/commands/fix.js
CHANGED
|
@@ -38,14 +38,18 @@ function classOf(finding) {
|
|
|
38
38
|
* Handing the agent the full contract for the files it edits — not just the
|
|
39
39
|
* failing rules — stops it fixing one finding by breaking another (dropping
|
|
40
40
|
* the `**Last run**:` line, appending a section after `## Open Blockers`, …).
|
|
41
|
+
*
|
|
42
|
+
* Only static-string hints are listed: a function hint is a per-finding
|
|
43
|
+
* resolved command (a `rotate` remediation the agent never performs), not a
|
|
44
|
+
* file invariant, so listing it would leak its source text into the prompt.
|
|
41
45
|
*/
|
|
42
46
|
function invariantContract(findings) {
|
|
43
47
|
const scopes = new Set(
|
|
44
48
|
findings.map((f) => RULES.find((r) => r.id === f.id)?.scope),
|
|
45
49
|
);
|
|
46
|
-
return RULES.filter(
|
|
47
|
-
(r) =>
|
|
48
|
-
);
|
|
50
|
+
return RULES.filter(
|
|
51
|
+
(r) => scopes.has(r.scope) && typeof r.hint === "string",
|
|
52
|
+
).map((r) => `- ${r.id} — ${r.hint}`);
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
/**
|
|
@@ -106,7 +110,7 @@ function sealMainLog(agent, { wikiRoot, today, projectRoot, fs, out, err }) {
|
|
|
106
110
|
wikiRoot,
|
|
107
111
|
agent,
|
|
108
112
|
today,
|
|
109
|
-
0,
|
|
113
|
+
{ lines: 0, words: 0 },
|
|
110
114
|
{ force: true },
|
|
111
115
|
fs,
|
|
112
116
|
);
|
|
@@ -282,7 +286,11 @@ export async function runFixCommand(ctx) {
|
|
|
282
286
|
|
|
283
287
|
// The agent's edits change the result, so re-read and re-audit each round.
|
|
284
288
|
const audit = () =>
|
|
285
|
-
runRules(
|
|
289
|
+
runRules(
|
|
290
|
+
RULES,
|
|
291
|
+
buildContext({ wikiRoot, today, fs, subprocess: runtime.subprocess }),
|
|
292
|
+
{ resolveScope },
|
|
293
|
+
);
|
|
286
294
|
// The agent only ever gets prose-judgment (`agent`-class) findings. A
|
|
287
295
|
// `rotate` finding that survived the pre-pass (e.g. a prior-week log) is
|
|
288
296
|
// unfixable by the agent — and trimming append-only history to satisfy a
|