@forwardimpact/libwiki 0.2.29 → 0.2.31
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/README.md +5 -2
- package/bin/fit-wiki.js +11 -1
- package/package.json +2 -2
- package/src/audit/rules.js +34 -1
- package/src/audit/scopes.js +13 -1
- package/src/budget-gate.js +193 -0
- package/src/cli-definition.js +53 -3
- package/src/commands/claim.js +3 -1
- package/src/commands/fix.js +1 -1
- package/src/commands/ledger.js +208 -0
- package/src/commands/refresh.js +32 -2
- package/src/commands/sync.js +6 -1
- package/src/constants.js +7 -5
- package/src/ledger/anchor.js +96 -0
- package/src/ledger/projection.js +242 -0
- package/src/ledger/reader.js +45 -0
- package/src/wiki-sync.js +208 -34
package/README.md
CHANGED
|
@@ -61,7 +61,8 @@ npx fit-wiki release --expired
|
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
Maintains the `## Active Claims` table in `MEMORY.md`. Duplicates refused;
|
|
64
|
-
row absent means settled.
|
|
64
|
+
row absent means settled. `expires_at` defaults to `claimed_at + 1 day` — a
|
|
65
|
+
claim is a short-lived "shipping this now" assertion, not a lease.
|
|
65
66
|
|
|
66
67
|
### `inbox` — triage memos
|
|
67
68
|
|
|
@@ -119,7 +120,9 @@ npx fit-wiki refresh [storyboard-path]
|
|
|
119
120
|
|
|
120
121
|
Re-renders `<!-- xmr:metric:csv-path -->` and `<!-- obstacles:open[:Nd] -->`
|
|
121
122
|
marker blocks inside a storyboard from their backing CSV / GitHub state.
|
|
122
|
-
Default path: `wiki/storyboard-YYYY-MMM.md` for the current month.
|
|
123
|
+
Default path: `wiki/storyboard-YYYY-MMM.md` for the current month. Also sweeps
|
|
124
|
+
every expired row from `MEMORY.md ## Active Claims` as part of the same
|
|
125
|
+
deterministic refresh.
|
|
123
126
|
|
|
124
127
|
### `init` / `push` / `pull` — wiki working tree
|
|
125
128
|
|
package/bin/fit-wiki.js
CHANGED
|
@@ -4,6 +4,7 @@ import "@forwardimpact/libpreflight/node22";
|
|
|
4
4
|
|
|
5
5
|
import { createDefaultRuntime } from "@forwardimpact/libutil/runtime";
|
|
6
6
|
import { GitClient } from "@forwardimpact/libutil/git-client";
|
|
7
|
+
import { GhClient } from "@forwardimpact/libutil/gh-client";
|
|
7
8
|
import { createScriptConfig } from "@forwardimpact/libconfig";
|
|
8
9
|
import { createCli } from "@forwardimpact/libcli";
|
|
9
10
|
import { createLogger } from "@forwardimpact/libtelemetry";
|
|
@@ -19,6 +20,10 @@ import { createDefinition } from "../src/cli-definition.js";
|
|
|
19
20
|
// (and its config-backed token resolver); the rest run against the local tree.
|
|
20
21
|
const NEEDS_WIKI_SYNC = new Set(["claim", "release", "push", "pull", "init"]);
|
|
21
22
|
|
|
23
|
+
// The ledger command reads and writes the allocation-anchor surface (the
|
|
24
|
+
// obstacle issue's comments) over the GitHub API, so it needs a GhClient.
|
|
25
|
+
const NEEDS_GH_CLIENT = new Set(["ledger"]);
|
|
26
|
+
|
|
22
27
|
async function main() {
|
|
23
28
|
const runtime = createDefaultRuntime();
|
|
24
29
|
const definition = createDefinition();
|
|
@@ -72,8 +77,13 @@ async function main() {
|
|
|
72
77
|
});
|
|
73
78
|
}
|
|
74
79
|
|
|
80
|
+
let ghClient;
|
|
81
|
+
if (NEEDS_GH_CLIENT.has(command)) {
|
|
82
|
+
ghClient = new GhClient({ runtime });
|
|
83
|
+
}
|
|
84
|
+
|
|
75
85
|
const result = await cli.dispatch(parsed, {
|
|
76
|
-
deps: { runtime, wikiSync, gitClient },
|
|
86
|
+
deps: { runtime, wikiSync, gitClient, ghClient },
|
|
77
87
|
});
|
|
78
88
|
|
|
79
89
|
const envelope = result ?? { ok: true };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/libwiki",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.31",
|
|
4
4
|
"description": "Wiki lifecycle primitives — stable memory for agent teams so coordination persists across sessions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"wiki",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@forwardimpact/libcli": "^0.1.0",
|
|
49
49
|
"@forwardimpact/libconfig": "^0.1.77",
|
|
50
|
-
"@forwardimpact/
|
|
50
|
+
"@forwardimpact/libharness": "^1.0.0",
|
|
51
51
|
"@forwardimpact/libpreflight": "^0.1.0",
|
|
52
52
|
"@forwardimpact/libtelemetry": "^0.1.47",
|
|
53
53
|
"@forwardimpact/libutil": "^0.1.0",
|
package/src/audit/rules.js
CHANGED
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
ISSUE_CLOSE_RE,
|
|
9
9
|
ISSUE_OPEN_RE,
|
|
10
10
|
MEMO_INBOX_MARKER,
|
|
11
|
+
MEMORY_LINE_BUDGET,
|
|
12
|
+
MEMORY_WORD_BUDGET,
|
|
11
13
|
PRIORITY_INDEX_HEADING,
|
|
12
14
|
STORYBOARD_LINE_BUDGET,
|
|
13
15
|
STORYBOARD_WORD_BUDGET,
|
|
@@ -53,6 +55,19 @@ import {
|
|
|
53
55
|
} from "./rule-builders.js";
|
|
54
56
|
import { STATUS_ROW_RULES } from "./status-row.js";
|
|
55
57
|
|
|
58
|
+
// The budget predicates the post-landing pre-push gate re-runs over the
|
|
59
|
+
// outgoing tree. Naming the ids here keeps the gate selecting rules by
|
|
60
|
+
// membership of this set, so a future predicate change to any of these
|
|
61
|
+
// rules flows through the gate with no gate-code change.
|
|
62
|
+
export const BUDGET_RULE_IDS = new Set([
|
|
63
|
+
"summary.line-budget",
|
|
64
|
+
"summary.word-budget",
|
|
65
|
+
"weekly-log.line-budget",
|
|
66
|
+
"weekly-log.word-budget",
|
|
67
|
+
"weekly-log-part.line-budget",
|
|
68
|
+
"weekly-log-part.word-budget",
|
|
69
|
+
]);
|
|
70
|
+
|
|
56
71
|
export const RULES = [
|
|
57
72
|
// -- Summary files --
|
|
58
73
|
|
|
@@ -260,6 +275,24 @@ export const RULES = [
|
|
|
260
275
|
message: () => "MEMORY.md not found",
|
|
261
276
|
hint: "run `bunx fit-wiki init` to scaffold the canonical sections",
|
|
262
277
|
},
|
|
278
|
+
{
|
|
279
|
+
id: "memory.line-budget",
|
|
280
|
+
scope: "memory",
|
|
281
|
+
severity: "fail",
|
|
282
|
+
when: memoryExists,
|
|
283
|
+
check: lineBudget(MEMORY_LINE_BUDGET),
|
|
284
|
+
message: (_s, r) => `${r.value} lines (limit ${MEMORY_LINE_BUDGET})`,
|
|
285
|
+
hint: "MEMORY.md holds settled cross-cutting state, not history; release settled claims, prune stale priority rows, and move event-by-event detail to the relevant ledger page or weekly log",
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
id: "memory.word-budget",
|
|
289
|
+
scope: "memory",
|
|
290
|
+
severity: "fail",
|
|
291
|
+
when: memoryExists,
|
|
292
|
+
check: wordBudget(MEMORY_WORD_BUDGET),
|
|
293
|
+
message: (_s, r) => `${r.value} words (limit ${MEMORY_WORD_BUDGET})`,
|
|
294
|
+
hint: "MEMORY.md holds settled cross-cutting state, not history; release settled claims, prune stale priority rows, and move event-by-event detail to the relevant ledger page or weekly log",
|
|
295
|
+
},
|
|
263
296
|
{
|
|
264
297
|
id: "memory.priority-heading",
|
|
265
298
|
scope: "memory",
|
|
@@ -338,7 +371,7 @@ export const RULES = [
|
|
|
338
371
|
severity: "warn",
|
|
339
372
|
check: expired,
|
|
340
373
|
message: (s) => `${s.agent}/${s.target} expired ${s.expires_at}`,
|
|
341
|
-
hint: "run `bunx fit-wiki release --expired` to clear expired claims",
|
|
374
|
+
hint: "run `bunx fit-wiki refresh` (or `release --expired`) to clear expired claims",
|
|
342
375
|
},
|
|
343
376
|
|
|
344
377
|
// -- Storyboards --
|
package/src/audit/scopes.js
CHANGED
|
@@ -137,6 +137,18 @@ function readOptional(filePath, fs) {
|
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
// MEMORY.md carries the same line/word budget rules as the prose surfaces, so
|
|
141
|
+
// its subject needs the `lines`/`words` counters those check builders read.
|
|
142
|
+
// Counted off the canonical budget.js pair, like every other budgeted surface.
|
|
143
|
+
function loadMemory(filePath, fs) {
|
|
144
|
+
const base = readOptional(filePath, fs);
|
|
145
|
+
return {
|
|
146
|
+
...base,
|
|
147
|
+
lines: countLines(base.text),
|
|
148
|
+
words: countWords(base.text),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
140
152
|
/**
|
|
141
153
|
* Parse the rows inside STATUS.md's fenced block into audit subjects. Lines
|
|
142
154
|
* outside the ``` fence (header prose) and blank lines are skipped. Each row
|
|
@@ -354,7 +366,7 @@ export function buildContext({ wikiRoot, today, fs, subprocess }) {
|
|
|
354
366
|
wikiRoot,
|
|
355
367
|
today,
|
|
356
368
|
subjects,
|
|
357
|
-
memory:
|
|
369
|
+
memory: loadMemory(path.join(wikiRoot, "MEMORY.md"), fs),
|
|
358
370
|
status: readOptional(path.join(wikiRoot, "STATUS.md"), fs),
|
|
359
371
|
storyboard: loadStoryboard(wikiRoot, today, fs),
|
|
360
372
|
admission: buildAdmission(wikiRoot, fs, subprocess),
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Post-landing, pre-push budget re-validation on the size (word/line) axis.
|
|
2
|
+
//
|
|
3
|
+
// The wiki landing flow re-runs the audit's budget predicates over the
|
|
4
|
+
// outgoing tree between landing and push, and refuses a push that introduces
|
|
5
|
+
// or deepens a per-file budget breach this writer's push would publish. The
|
|
6
|
+
// gate reuses the audit's budget rules by reference: it resolves the rule
|
|
7
|
+
// objects named by `BUDGET_RULE_IDS` and calls each rule's own `check` (the
|
|
8
|
+
// over-cap predicate) plus the same `countWords` / `countLines` the audit
|
|
9
|
+
// builds its subjects from. It never re-defines a budget, never routes through
|
|
10
|
+
// the `runRules` engine (which drops the numeric value and emits nothing under
|
|
11
|
+
// cap), and never edits — it refuses, keeping commits local.
|
|
12
|
+
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { BUDGET_RULE_IDS, RULES } from "./audit/rules.js";
|
|
15
|
+
import { buildContext, resolveScope } from "./audit/scopes.js";
|
|
16
|
+
import { countLines, countWords } from "./budget.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve `BUDGET_RULE_IDS` to their rule objects in `RULES`, tagging each with
|
|
20
|
+
* the count axis its id implies. Throws if a named id is missing from `RULES`,
|
|
21
|
+
* so a rule rename surfaces here rather than silently dropping a predicate.
|
|
22
|
+
* @returns {Array<{id: string, scope: string, axis: 'words'|'lines', check: Function}>}
|
|
23
|
+
*/
|
|
24
|
+
export function budgetRules() {
|
|
25
|
+
const byId = new Map(RULES.map((r) => [r.id, r]));
|
|
26
|
+
return [...BUDGET_RULE_IDS].map((id) => {
|
|
27
|
+
const rule = byId.get(id);
|
|
28
|
+
if (!rule) throw new Error(`budget-gate: unknown budget rule id '${id}'`);
|
|
29
|
+
return {
|
|
30
|
+
id,
|
|
31
|
+
scope: rule.scope,
|
|
32
|
+
axis: id.endsWith("word-budget") ? "words" : "lines",
|
|
33
|
+
check: rule.check,
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Enumerate which wiki files are budgeted, by reusing the audit's
|
|
40
|
+
* classification. Subjects carry an absolute `path`, so each is reduced to the
|
|
41
|
+
* `<file>` half of `git show <ref>:<file>` relative to `wikiRoot`. No count is
|
|
42
|
+
* read off the working-dir subject — only the file identity and its scope.
|
|
43
|
+
* @param {object} ctx - An audit context from `buildContext`.
|
|
44
|
+
* @param {string} wikiRoot - The wiki clone directory the paths are relative to.
|
|
45
|
+
* @returns {Array<{relPath: string, scope: string}>}
|
|
46
|
+
*/
|
|
47
|
+
export function budgetedFiles(ctx, wikiRoot) {
|
|
48
|
+
const scopes = new Set(budgetRules().map((r) => r.scope));
|
|
49
|
+
const files = [];
|
|
50
|
+
for (const scope of scopes) {
|
|
51
|
+
for (const subject of resolveScope(scope, ctx)) {
|
|
52
|
+
files.push({ relPath: path.relative(wikiRoot, subject.path), scope });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return files;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Measure the budget predicates for the tree at `ref`. Reads each budgeted
|
|
60
|
+
* file's blob via the cwd-bound `showFile`, counts it once with the audit's
|
|
61
|
+
* counters, then for every budget rule on that file's scope records the axis
|
|
62
|
+
* value and whether the rule's own `check` flags it over cap. An absent path
|
|
63
|
+
* at the ref counts as 0 (matching the audit's "missing counts as empty"
|
|
64
|
+
* posture); an unreadable ref makes `showFile` throw, which propagates.
|
|
65
|
+
* @param {(ref: string, file: string) => Promise<string|null>} showFile
|
|
66
|
+
* @param {string} ref - The tree-ish to measure (e.g. "HEAD", a SHA).
|
|
67
|
+
* @param {Array<{relPath: string, scope: string}>} budgeted
|
|
68
|
+
* @returns {Promise<Map<string, Map<string, {value: number, overCap: boolean}>>>}
|
|
69
|
+
* relPath → ruleId → { value, overCap }.
|
|
70
|
+
*/
|
|
71
|
+
export async function measureRef(showFile, ref, budgeted) {
|
|
72
|
+
const rules = budgetRules();
|
|
73
|
+
const result = new Map();
|
|
74
|
+
for (const { relPath, scope } of budgeted) {
|
|
75
|
+
const text = (await showFile(ref, relPath)) ?? "";
|
|
76
|
+
const counts = { words: countWords(text), lines: countLines(text) };
|
|
77
|
+
const perRule = new Map();
|
|
78
|
+
for (const rule of rules) {
|
|
79
|
+
if (rule.scope !== scope) continue;
|
|
80
|
+
perRule.set(rule.id, {
|
|
81
|
+
value: counts[rule.axis],
|
|
82
|
+
overCap: rule.check(counts) != null,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
result.set(relPath, perRule);
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Compare the outgoing tree against the two push-input baselines and return the
|
|
92
|
+
* per-file/per-predicate refusal delta. For each (file, rule) the baseline is
|
|
93
|
+
* the worse (higher) of the session-base and origin-tip values, treating an
|
|
94
|
+
* absent measurement as 0. A predicate refuses iff the outgoing value is over
|
|
95
|
+
* cap AND strictly exceeds that baseline — so equal-or-better states pass, and
|
|
96
|
+
* a foreign breach the writer did not worsen passes. A `summary.*` breach on a
|
|
97
|
+
* file listed in `exemptSummaryFiles` is surfaced instead of refused — the
|
|
98
|
+
* memo-delivery seam, where blocking a delivery into deficient headroom would
|
|
99
|
+
* enforce a contradiction the memo-headroom measures exist to resolve.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} args
|
|
102
|
+
* @param {Map<string, Map<string, {value: number, overCap: boolean}>>} args.outgoing
|
|
103
|
+
* @param {Map<string, Map<string, {value: number}>>|null} args.sessionBase - null when unborn.
|
|
104
|
+
* @param {Map<string, Map<string, {value: number}>>|null} args.originTip
|
|
105
|
+
* @param {string[]} [args.exemptSummaryFiles]
|
|
106
|
+
* @returns {{refusals: Array<object>, surfaced: Array<object>}}
|
|
107
|
+
* Each entry: { file, ruleId, baseline, value }.
|
|
108
|
+
*/
|
|
109
|
+
export function revalidateBudgets({
|
|
110
|
+
outgoing,
|
|
111
|
+
sessionBase,
|
|
112
|
+
originTip,
|
|
113
|
+
exemptSummaryFiles = [],
|
|
114
|
+
}) {
|
|
115
|
+
const exempt = new Set(exemptSummaryFiles);
|
|
116
|
+
const refusals = [];
|
|
117
|
+
const surfaced = [];
|
|
118
|
+
const baselineValue = (ref, relPath, ruleId) =>
|
|
119
|
+
ref?.get(relPath)?.get(ruleId)?.value ?? 0;
|
|
120
|
+
for (const [relPath, perRule] of outgoing) {
|
|
121
|
+
for (const [ruleId, { value, overCap }] of perRule) {
|
|
122
|
+
if (!overCap) continue;
|
|
123
|
+
const baseline = Math.max(
|
|
124
|
+
baselineValue(sessionBase, relPath, ruleId),
|
|
125
|
+
baselineValue(originTip, relPath, ruleId),
|
|
126
|
+
);
|
|
127
|
+
if (value <= baseline) continue;
|
|
128
|
+
const entry = { file: relPath, ruleId, baseline, value };
|
|
129
|
+
if (ruleId.startsWith("summary.") && exempt.has(relPath)) {
|
|
130
|
+
surfaced.push(entry);
|
|
131
|
+
} else {
|
|
132
|
+
refusals.push(entry);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { refusals, surfaced };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Run the gate end to end over the outgoing tree. Builds the audit context,
|
|
141
|
+
* enumerates the budgeted files, measures the committed `HEAD` (what publishes)
|
|
142
|
+
* and the two push-input baselines through the one `measureRef` path, then
|
|
143
|
+
* computes the per-file/per-predicate delta. An unreadable baseline ref makes
|
|
144
|
+
* `showFile` throw, which aborts the gate WITHOUT refusing — the gate only
|
|
145
|
+
* refuses a regression it can prove, so a read failure surfaces (the push
|
|
146
|
+
* proceeds) rather than fabricating a value-0 baseline that would wrongly block
|
|
147
|
+
* a foreign pre-existing breach.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} args
|
|
150
|
+
* @param {(ref: string, file: string) => Promise<string|null>} args.showFile
|
|
151
|
+
* @param {string} args.wikiRoot - The wiki clone directory.
|
|
152
|
+
* @param {string} args.today - ISO day for the audit context (weekly-log scope).
|
|
153
|
+
* @param {object} args.fs - Sync fs the audit context reads with.
|
|
154
|
+
* @param {string} args.headRef - The outgoing tree-ish (e.g. "HEAD").
|
|
155
|
+
* @param {string} args.originRef - The landed origin tip ref.
|
|
156
|
+
* @param {string} [args.sessionBaseSha] - Pre-fetch session base, or "" when unborn.
|
|
157
|
+
* @param {string[]} [args.exemptSummaryFiles] - Memo-delivery seam set.
|
|
158
|
+
* @returns {Promise<{refusals: Array<object>, surfaced: Array<object>}>}
|
|
159
|
+
*/
|
|
160
|
+
export async function runBudgetGate({
|
|
161
|
+
showFile,
|
|
162
|
+
wikiRoot,
|
|
163
|
+
today,
|
|
164
|
+
fs,
|
|
165
|
+
headRef,
|
|
166
|
+
originRef,
|
|
167
|
+
sessionBaseSha,
|
|
168
|
+
exemptSummaryFiles = [],
|
|
169
|
+
}) {
|
|
170
|
+
const budgeted = budgetedFiles(
|
|
171
|
+
buildContext({ wikiRoot, today, fs }),
|
|
172
|
+
wikiRoot,
|
|
173
|
+
);
|
|
174
|
+
let outgoing;
|
|
175
|
+
let sessionBase = null;
|
|
176
|
+
let originTip = null;
|
|
177
|
+
try {
|
|
178
|
+
outgoing = await measureRef(showFile, headRef, budgeted);
|
|
179
|
+
if (sessionBaseSha) {
|
|
180
|
+
sessionBase = await measureRef(showFile, sessionBaseSha, budgeted);
|
|
181
|
+
}
|
|
182
|
+
originTip = await measureRef(showFile, originRef, budgeted);
|
|
183
|
+
} catch {
|
|
184
|
+
// Cannot prove a regression (unreadable ref) ⇒ do not refuse; fail-visible.
|
|
185
|
+
return { refusals: [], surfaced: [] };
|
|
186
|
+
}
|
|
187
|
+
return revalidateBudgets({
|
|
188
|
+
outgoing,
|
|
189
|
+
sessionBase,
|
|
190
|
+
originTip,
|
|
191
|
+
exemptSummaryFiles,
|
|
192
|
+
});
|
|
193
|
+
}
|
package/src/cli-definition.js
CHANGED
|
@@ -10,6 +10,7 @@ import { runInboxCommand } from "./commands/inbox.js";
|
|
|
10
10
|
import { runRotateCommand } from "./commands/rotate.js";
|
|
11
11
|
import { runAuditCommand } from "./commands/audit.js";
|
|
12
12
|
import { runFixCommand } from "./commands/fix.js";
|
|
13
|
+
import { runLedgerCommand } from "./commands/ledger.js";
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Build the `fit-wiki` libcli definition. Agent identity is never resolved from
|
|
@@ -105,7 +106,7 @@ export function createDefinition() {
|
|
|
105
106
|
pr: { type: "string", description: "Optional PR id" },
|
|
106
107
|
"expires-at": {
|
|
107
108
|
type: "string",
|
|
108
|
-
description: "Override expiry ISO date (default claim+
|
|
109
|
+
description: "Override expiry ISO date (default claim+1d)",
|
|
109
110
|
},
|
|
110
111
|
},
|
|
111
112
|
},
|
|
@@ -208,7 +209,7 @@ export function createDefinition() {
|
|
|
208
209
|
{
|
|
209
210
|
name: "refresh",
|
|
210
211
|
description:
|
|
211
|
-
"Regenerate XmR
|
|
212
|
+
"Regenerate storyboard XmR/marker blocks and clear expired MEMORY.md claims",
|
|
212
213
|
args: ["storyboard-path"],
|
|
213
214
|
argsUsage: "[storyboard-path]",
|
|
214
215
|
handler: runRefreshCommand,
|
|
@@ -260,7 +261,15 @@ export function createDefinition() {
|
|
|
260
261
|
name: "push",
|
|
261
262
|
description: "Commit and push local wiki changes to the remote",
|
|
262
263
|
handler: runPushCommand,
|
|
263
|
-
options: {
|
|
264
|
+
options: {
|
|
265
|
+
...wikiRootOpt,
|
|
266
|
+
paths: {
|
|
267
|
+
type: "string",
|
|
268
|
+
multiple: true,
|
|
269
|
+
description:
|
|
270
|
+
"Pathspec(s) limiting the write-set; omit to land the session's dirty set",
|
|
271
|
+
},
|
|
272
|
+
},
|
|
264
273
|
},
|
|
265
274
|
{
|
|
266
275
|
name: "pull",
|
|
@@ -268,6 +277,47 @@ export function createDefinition() {
|
|
|
268
277
|
handler: runPullCommand,
|
|
269
278
|
options: { ...agentOpt, ...wikiRootOpt, ...todayOpt },
|
|
270
279
|
},
|
|
280
|
+
{
|
|
281
|
+
name: "ledger",
|
|
282
|
+
description:
|
|
283
|
+
"Allocate collision-ledger ids at anchors and rebuild projections",
|
|
284
|
+
args: ["subcommand"],
|
|
285
|
+
argsUsage: "<allocate|rebuild|verify>",
|
|
286
|
+
handler: runLedgerCommand,
|
|
287
|
+
options: {
|
|
288
|
+
...wikiRootOpt,
|
|
289
|
+
kind: {
|
|
290
|
+
type: "string",
|
|
291
|
+
description: "Allocation kind: occ | nm | fold | meta",
|
|
292
|
+
},
|
|
293
|
+
count: {
|
|
294
|
+
type: "string",
|
|
295
|
+
description: "How many ids to allocate (default 1)",
|
|
296
|
+
},
|
|
297
|
+
ids: {
|
|
298
|
+
type: "string",
|
|
299
|
+
description:
|
|
300
|
+
"Comma-separated ids to backfill an anchor for (instead of --count)",
|
|
301
|
+
},
|
|
302
|
+
event: {
|
|
303
|
+
type: "string",
|
|
304
|
+
description: "Durable key for the allocation (SHA or anchor id)",
|
|
305
|
+
},
|
|
306
|
+
note: {
|
|
307
|
+
type: "string",
|
|
308
|
+
description: "Free-text note for the anchor",
|
|
309
|
+
},
|
|
310
|
+
gapped: {
|
|
311
|
+
type: "boolean",
|
|
312
|
+
description:
|
|
313
|
+
"Render double-allocation losers as a gap, not a renumber",
|
|
314
|
+
},
|
|
315
|
+
issue: {
|
|
316
|
+
type: "string",
|
|
317
|
+
description: "Anchor issue number (default obstacle issue)",
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
},
|
|
271
321
|
],
|
|
272
322
|
globalOptions: {
|
|
273
323
|
help: { type: "boolean", short: "h", description: "Show this help" },
|
package/src/commands/claim.js
CHANGED
|
@@ -162,7 +162,9 @@ export async function runClaimCommand(ctx) {
|
|
|
162
162
|
};
|
|
163
163
|
}
|
|
164
164
|
const today = options.today || currentDayIso(runtime);
|
|
165
|
-
|
|
165
|
+
// Default expiry is claim+1 day: a claim is a short-lived "actively shipping
|
|
166
|
+
// this now" assertion, not a long lease. A run that outlives one day re-claims.
|
|
167
|
+
const expires = options["expires-at"] || addDays(today, 1);
|
|
166
168
|
const memPath = memoryPath(runtime, options);
|
|
167
169
|
const text = readMemory(runtime, memPath);
|
|
168
170
|
const claim = {
|
package/src/commands/fix.js
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
createAgentRunner,
|
|
6
6
|
composeProfilePrompt,
|
|
7
7
|
createRedactor,
|
|
8
|
-
} from "@forwardimpact/
|
|
8
|
+
} from "@forwardimpact/libharness";
|
|
9
9
|
import { RULES } from "../audit/rules.js";
|
|
10
10
|
import { buildContext, resolveScope } from "../audit/scopes.js";
|
|
11
11
|
import {
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { resolveWikiRoot } from "../util/wiki-dir.js";
|
|
3
|
+
import { renderAnchorBody, ANCHOR_KINDS } from "../ledger/anchor.js";
|
|
4
|
+
import { readAnchors, DEFAULT_ANCHOR_ISSUE } from "../ledger/reader.js";
|
|
5
|
+
import {
|
|
6
|
+
foldAnchors,
|
|
7
|
+
renderLedgerPage,
|
|
8
|
+
renderMemoryRow,
|
|
9
|
+
writeMemoryRowRegion,
|
|
10
|
+
readMemoryRowRegion,
|
|
11
|
+
extractProse,
|
|
12
|
+
} from "../ledger/projection.js";
|
|
13
|
+
|
|
14
|
+
const KIND_PREFIX = { occ: "#", nm: "NM", fold: "n=", meta: "M" };
|
|
15
|
+
const LEDGER_FILE = "parallel-collision-ledger.md";
|
|
16
|
+
const MEMORY_FILE = "MEMORY.md";
|
|
17
|
+
|
|
18
|
+
/** Parse `owner/repo` from a remote URL (https or ssh form). */
|
|
19
|
+
export function parseOwnerRepo(url) {
|
|
20
|
+
const m = url
|
|
21
|
+
.trim()
|
|
22
|
+
.replace(/\.wiki$/, "")
|
|
23
|
+
.match(/[/:]([^/:]+)\/([^/]+?)(?:\.wiki)?(?:\.git)?\/?$/);
|
|
24
|
+
if (!m) throw new Error(`ledger: cannot parse owner/repo from "${url}"`);
|
|
25
|
+
return { owner: m[1], repo: m[2].replace(/\.wiki$/, "") };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function nextFreeIds(fold, kind, count) {
|
|
29
|
+
const prefix = KIND_PREFIX[kind];
|
|
30
|
+
let max = 0;
|
|
31
|
+
for (const [label, record] of fold.assignments) {
|
|
32
|
+
if (record.anchor.kind !== kind) continue;
|
|
33
|
+
const n = Number.parseInt(label.replace(prefix, ""), 10);
|
|
34
|
+
if (Number.isFinite(n) && n > max) max = n;
|
|
35
|
+
}
|
|
36
|
+
const ids = [];
|
|
37
|
+
for (let i = 1; i <= count; i++) ids.push(`${prefix}${max + i}`);
|
|
38
|
+
return ids;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Read the ordered anchor sequence and fold it, resolving the repo slug. */
|
|
42
|
+
async function loadFold({ gitClient, ghClient, wikiDir, issue }) {
|
|
43
|
+
const url = await gitClient.remoteGetUrl("origin", { cwd: wikiDir });
|
|
44
|
+
const { owner, repo } = parseOwnerRepo(url);
|
|
45
|
+
const anchors = await readAnchors(ghClient, {
|
|
46
|
+
owner,
|
|
47
|
+
repo,
|
|
48
|
+
issue,
|
|
49
|
+
cwd: wikiDir,
|
|
50
|
+
});
|
|
51
|
+
return { fold: foldAnchors(anchors), owner, repo };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function allocate(env, options) {
|
|
55
|
+
const { runtime, ghClient } = env;
|
|
56
|
+
const kind = options.kind;
|
|
57
|
+
if (!ANCHOR_KINDS.has(kind)) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
code: 2,
|
|
61
|
+
error: `ledger allocate: --kind must be one of ${[...ANCHOR_KINDS].join(", ")}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const event = options.event;
|
|
65
|
+
if (!event) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
code: 2,
|
|
69
|
+
error: "ledger allocate: --event (SHA or anchor id) is required",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const { fold, owner, repo } = await loadFold(env);
|
|
73
|
+
// Backfill registers an anchor for ids that predate the anchor surface, named
|
|
74
|
+
// explicitly via --ids; their event keys already exist in history. A plain
|
|
75
|
+
// allocate mints the next free ids of the kind. The conflict detector at
|
|
76
|
+
// rebuild guards against double-registering an id that already has an anchor.
|
|
77
|
+
let ids;
|
|
78
|
+
if (options.ids) {
|
|
79
|
+
ids = options.ids
|
|
80
|
+
.split(",")
|
|
81
|
+
.map((s) => s.trim())
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
const already = ids.filter((id) => fold.assignments.has(id));
|
|
84
|
+
if (already.length > 0) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
code: 1,
|
|
88
|
+
error: `ledger allocate --backfill: already anchored: ${already.join(", ")}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
const count = options.count ? Number.parseInt(options.count, 10) : 1;
|
|
93
|
+
ids = nextFreeIds(fold, kind, count);
|
|
94
|
+
}
|
|
95
|
+
const body = renderAnchorBody({ kind, ids, event, note: options.note ?? "" });
|
|
96
|
+
// The anchor publication is the allocation; no projection is written here.
|
|
97
|
+
// The printed ids are provisional — a rebuild over the published sequence is
|
|
98
|
+
// authoritative and resolves any concurrent interleave first-published-wins.
|
|
99
|
+
await ghClient.apiPost(
|
|
100
|
+
`repos/${owner}/${repo}/issues/${env.issue}/comments`,
|
|
101
|
+
{ body },
|
|
102
|
+
{ cwd: env.wikiDir },
|
|
103
|
+
);
|
|
104
|
+
runtime.proc.stdout.write(`${ids.join(" ")}\n`);
|
|
105
|
+
return { ok: true };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readFileOrEmpty(runtime, filePath) {
|
|
109
|
+
return runtime.fsSync.existsSync(filePath)
|
|
110
|
+
? runtime.fsSync.readFileSync(filePath, "utf-8")
|
|
111
|
+
: "";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readLedgerPage(runtime, wikiDir) {
|
|
115
|
+
const ledgerPath = path.join(wikiDir, LEDGER_FILE);
|
|
116
|
+
return runtime.fsSync.existsSync(ledgerPath)
|
|
117
|
+
? runtime.fsSync.readFileSync(ledgerPath, "utf-8")
|
|
118
|
+
: "";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Project the anchor record onto the ledger-page body, preserving cited prose. */
|
|
122
|
+
async function project(env, options) {
|
|
123
|
+
const { runtime, wikiDir } = env;
|
|
124
|
+
const labelMode = options.gapped ? "gapped" : "renumber";
|
|
125
|
+
const { fold } = await loadFold(env);
|
|
126
|
+
const existing = readLedgerPage(runtime, wikiDir);
|
|
127
|
+
const prose = extractProse(existing);
|
|
128
|
+
const { body, missingProse } = renderLedgerPage(fold, prose, { labelMode });
|
|
129
|
+
return { fold, body, missingProse, existing };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function rebuild(env, options) {
|
|
133
|
+
const { runtime, wikiDir } = env;
|
|
134
|
+
const { fold, body, missingProse } = await project(env, options);
|
|
135
|
+
runtime.fsSync.writeFileSync(path.join(wikiDir, LEDGER_FILE), body);
|
|
136
|
+
const memoryPath = path.join(wikiDir, MEMORY_FILE);
|
|
137
|
+
const memoryBody = readFileOrEmpty(runtime, memoryPath);
|
|
138
|
+
runtime.fsSync.writeFileSync(
|
|
139
|
+
memoryPath,
|
|
140
|
+
writeMemoryRowRegion(memoryBody, fold),
|
|
141
|
+
);
|
|
142
|
+
runtime.proc.stdout.write(
|
|
143
|
+
`rebuilt: ${fold.assignments.size} ids, ${fold.conflicts.length} double-allocation(s)\n`,
|
|
144
|
+
);
|
|
145
|
+
if (missingProse.length > 0) {
|
|
146
|
+
runtime.proc.stderr.write(
|
|
147
|
+
`warning: prose cites missing anchors: ${missingProse.join(", ")}\n`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return { ok: true };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function verify(env, options) {
|
|
154
|
+
const { runtime, wikiDir } = env;
|
|
155
|
+
const { fold, body, missingProse, existing } = await project(env, options);
|
|
156
|
+
const problems = [];
|
|
157
|
+
if (fold.conflicts.length > 0) {
|
|
158
|
+
problems.push(`${fold.conflicts.length} double-allocation(s)`);
|
|
159
|
+
}
|
|
160
|
+
if (missingProse.length > 0) {
|
|
161
|
+
problems.push(`prose citing missing anchors: ${missingProse.join(", ")}`);
|
|
162
|
+
}
|
|
163
|
+
if (existing.trim() !== body.trim()) {
|
|
164
|
+
problems.push("ledger page diverges from the anchor record");
|
|
165
|
+
}
|
|
166
|
+
const memoryBody = readFileOrEmpty(runtime, path.join(wikiDir, MEMORY_FILE));
|
|
167
|
+
const memoryRegion = readMemoryRowRegion(memoryBody);
|
|
168
|
+
if (memoryRegion === null) {
|
|
169
|
+
problems.push("MEMORY row region absent (run rebuild)");
|
|
170
|
+
} else if (memoryRegion.trim() !== renderMemoryRow(fold).trim()) {
|
|
171
|
+
problems.push("MEMORY row diverges from the anchor record");
|
|
172
|
+
}
|
|
173
|
+
if (problems.length === 0) {
|
|
174
|
+
runtime.proc.stdout.write("verify: clean\n");
|
|
175
|
+
return { ok: true };
|
|
176
|
+
}
|
|
177
|
+
runtime.proc.stderr.write(`verify: ${problems.join("; ")}\n`);
|
|
178
|
+
return { ok: false, code: 1 };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const SUBS = { allocate, rebuild, verify };
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `fit-wiki ledger <allocate|rebuild|verify>` — the allocation procedure that
|
|
185
|
+
* keeps identity off the merge-contested page. Allocation publishes an anchor
|
|
186
|
+
* comment to the obstacle issue with no projection write at allocation time;
|
|
187
|
+
* rebuild and verify project the anchor record onto the ledger page and MEMORY
|
|
188
|
+
* row, preserving anchor-cited prose.
|
|
189
|
+
*/
|
|
190
|
+
export async function runLedgerCommand(ctx) {
|
|
191
|
+
const { runtime, gitClient, ghClient } = ctx.deps;
|
|
192
|
+
const options = ctx.options ?? {};
|
|
193
|
+
const sub = ctx.args?.subcommand;
|
|
194
|
+
const handler = SUBS[sub];
|
|
195
|
+
if (!handler) {
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
code: 2,
|
|
199
|
+
error: "ledger requires subcommand: allocate | rebuild | verify",
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const wikiDir = resolveWikiRoot(runtime, options);
|
|
203
|
+
const issue = options.issue
|
|
204
|
+
? Number.parseInt(options.issue, 10)
|
|
205
|
+
: DEFAULT_ANCHOR_ISSUE;
|
|
206
|
+
const env = { runtime, gitClient, ghClient, wikiDir, issue };
|
|
207
|
+
return handler(env, options);
|
|
208
|
+
}
|