@forwardimpact/libwiki 0.2.28 → 0.2.30
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 +1 -1
- package/src/audit/rule-builders.js +247 -0
- package/src/audit/rules.js +89 -196
- package/src/audit/scopes.js +34 -2
- package/src/budget-gate.js +193 -0
- package/src/cli-definition.js +53 -3
- package/src/commands/claim.js +3 -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 +18 -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 +167 -32
package/src/audit/scopes.js
CHANGED
|
@@ -4,6 +4,8 @@ import { parseClaims } from "../active-claims.js";
|
|
|
4
4
|
import { countLines, countWords } from "../budget.js";
|
|
5
5
|
import { parseStatusRowId } from "../status.js";
|
|
6
6
|
import {
|
|
7
|
+
CARRY_SURFACE_H1_RE,
|
|
8
|
+
CARRY_SURFACE_NAME_RE,
|
|
7
9
|
PRIORITY_INDEX_HEADING,
|
|
8
10
|
WEEKLY_LOG_NAME_RE,
|
|
9
11
|
WEEKLY_LOG_PART_NAME_RE,
|
|
@@ -75,6 +77,11 @@ function loadFile(filePath, fs) {
|
|
|
75
77
|
const base = path.basename(filePath);
|
|
76
78
|
const weekMatch =
|
|
77
79
|
base.match(WEEKLY_LOG_NAME_RE) || base.match(WEEKLY_LOG_PART_NAME_RE);
|
|
80
|
+
const carryMatch = base.match(CARRY_SURFACE_NAME_RE);
|
|
81
|
+
let agentPrefix;
|
|
82
|
+
if (weekMatch) agentPrefix = weekMatch[1];
|
|
83
|
+
else if (carryMatch) agentPrefix = carryMatch[1];
|
|
84
|
+
else agentPrefix = base.replace(/\.md$/, "");
|
|
78
85
|
return {
|
|
79
86
|
path: filePath,
|
|
80
87
|
text,
|
|
@@ -83,7 +90,7 @@ function loadFile(filePath, fs) {
|
|
|
83
90
|
h2s,
|
|
84
91
|
lines: countLines(text),
|
|
85
92
|
words: countWords(text),
|
|
86
|
-
agentPrefix
|
|
93
|
+
agentPrefix,
|
|
87
94
|
};
|
|
88
95
|
}
|
|
89
96
|
|
|
@@ -101,6 +108,17 @@ function classifyFile(filePath, fs) {
|
|
|
101
108
|
return { kind: "weekly-log-part", subject: loadFile(filePath, fs) };
|
|
102
109
|
}
|
|
103
110
|
const subject = loadFile(filePath, fs);
|
|
111
|
+
// Carry surface: a `<agent>-carries.md` whose H1 matches the Carry H1 RE.
|
|
112
|
+
// Both axes must match (filename prefix and H1), mirroring the summary
|
|
113
|
+
// classifier. The two H1 REs end in distinct literals (`— Carries` vs
|
|
114
|
+
// `— Summary`) so the branches cannot cross-capture regardless of order;
|
|
115
|
+
// a name-match + H1-miss is left unclassified, like a malformed summary.
|
|
116
|
+
if (CARRY_SURFACE_NAME_RE.test(base)) {
|
|
117
|
+
if (CARRY_SURFACE_H1_RE.test(subject.firstLine)) {
|
|
118
|
+
return { kind: "carry-surface", subject };
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
104
122
|
// Files that do not match a summary or weekly-log shape are left
|
|
105
123
|
// unclassified: stray files are not audited.
|
|
106
124
|
if (!SUMMARY_H1_RE.test(subject.firstLine)) return null;
|
|
@@ -119,6 +137,18 @@ function readOptional(filePath, fs) {
|
|
|
119
137
|
};
|
|
120
138
|
}
|
|
121
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
|
+
|
|
122
152
|
/**
|
|
123
153
|
* Parse the rows inside STATUS.md's fenced block into audit subjects. Lines
|
|
124
154
|
* outside the ``` fence (header prose) and blank lines are skipped. Each row
|
|
@@ -219,6 +249,7 @@ const SCOPE_RESOLVERS = {
|
|
|
219
249
|
"weekly-log-main": (ctx) => ctx.subjects["weekly-log-main"],
|
|
220
250
|
"weekly-log-part": (ctx) => ctx.subjects["weekly-log-part"],
|
|
221
251
|
"metrics-csv": (ctx) => ctx.subjects["metrics-csv"],
|
|
252
|
+
"carry-surface": (ctx) => ctx.subjects["carry-surface"],
|
|
222
253
|
memory: (ctx) => [ctx.memory],
|
|
223
254
|
"claims-row": (ctx) =>
|
|
224
255
|
parseClaims(ctx.memory.text).map((c) => ({ ...c, path: ctx.memory.path })),
|
|
@@ -322,6 +353,7 @@ export function buildContext({ wikiRoot, today, fs, subprocess }) {
|
|
|
322
353
|
"weekly-log-main": [],
|
|
323
354
|
"weekly-log-part": [],
|
|
324
355
|
"metrics-csv": [],
|
|
356
|
+
"carry-surface": [],
|
|
325
357
|
};
|
|
326
358
|
for (const file of listMdFiles(wikiRoot, fs)) {
|
|
327
359
|
const classified = classifyFile(file, fs);
|
|
@@ -334,7 +366,7 @@ export function buildContext({ wikiRoot, today, fs, subprocess }) {
|
|
|
334
366
|
wikiRoot,
|
|
335
367
|
today,
|
|
336
368
|
subjects,
|
|
337
|
-
memory:
|
|
369
|
+
memory: loadMemory(path.join(wikiRoot, "MEMORY.md"), fs),
|
|
338
370
|
status: readOptional(path.join(wikiRoot, "STATUS.md"), fs),
|
|
339
371
|
storyboard: loadStoryboard(wikiRoot, today, fs),
|
|
340
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 = {
|
|
@@ -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
|
+
}
|
package/src/commands/refresh.js
CHANGED
|
@@ -10,8 +10,9 @@ import {
|
|
|
10
10
|
TrackerQueryError,
|
|
11
11
|
parseRepoSlug,
|
|
12
12
|
} from "../issue-list-renderer.js";
|
|
13
|
+
import { parseClaims, filterExpired, removeClaim } from "../active-claims.js";
|
|
13
14
|
import { currentDayIso } from "../util/clock.js";
|
|
14
|
-
import { resolveProjectRoot } from "../util/wiki-dir.js";
|
|
15
|
+
import { resolveProjectRoot, resolveWikiRoot } from "../util/wiki-dir.js";
|
|
15
16
|
|
|
16
17
|
function currentStoryboardRelPath(runtime) {
|
|
17
18
|
return `wiki/storyboard-${yearMonth(currentDayIso(runtime))}.md`;
|
|
@@ -101,13 +102,42 @@ function readStoryboardOrNull(runtime, storyboardPath) {
|
|
|
101
102
|
}
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
|
|
105
|
+
// Drop every MEMORY.md `## Active Claims` row past its `expires_at`, writing the
|
|
106
|
+
// trimmed table back in place. Refresh is the deterministic "freshen the wiki"
|
|
107
|
+
// step, so clearing lapsed claims belongs here alongside the storyboard render;
|
|
108
|
+
// it runs whether or not the storyboard has marker blocks to regenerate. The
|
|
109
|
+
// write is local, mirroring the storyboard splice — the caller's push publishes
|
|
110
|
+
// it. A missing wiki or claims table is a clean no-op.
|
|
111
|
+
function clearExpiredClaims(runtime, options, today, logger) {
|
|
112
|
+
const memPath = path.join(resolveWikiRoot(runtime, options), "MEMORY.md");
|
|
113
|
+
if (!runtime.fsSync.existsSync(memPath)) return;
|
|
114
|
+
const text = runtime.fsSync.readFileSync(memPath, "utf-8");
|
|
115
|
+
const { expired } = filterExpired(parseClaims(text), today);
|
|
116
|
+
if (expired.length === 0) return;
|
|
117
|
+
let current = text;
|
|
118
|
+
for (const c of expired) {
|
|
119
|
+
const result = removeClaim(current, { agent: c.agent, target: c.target });
|
|
120
|
+
if (result.removed) current = result.text;
|
|
121
|
+
}
|
|
122
|
+
if (current !== text) {
|
|
123
|
+
runtime.fsSync.writeFileSync(memPath, current);
|
|
124
|
+
logger.info("refresh", `cleared ${expired.length} expired claim(s)`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Re-render storyboard XmR/issue-list blocks and clear expired MEMORY.md claims.
|
|
130
|
+
*/
|
|
105
131
|
export async function runRefreshCommand(ctx) {
|
|
106
132
|
const { runtime, gitClient } = ctx.deps;
|
|
107
133
|
const options = ctx.options;
|
|
108
134
|
const logger = createLogger("wiki", runtime);
|
|
109
135
|
const projectRoot = resolveProjectRoot(runtime);
|
|
110
136
|
|
|
137
|
+
// Independent of the storyboard render below (and its early returns), so a
|
|
138
|
+
// wiki with no storyboard or no marker blocks still gets its claims swept.
|
|
139
|
+
clearExpiredClaims(runtime, options, currentDayIso(runtime), logger);
|
|
140
|
+
|
|
111
141
|
const storyboardPath = path.resolve(
|
|
112
142
|
projectRoot,
|
|
113
143
|
ctx.args["storyboard-path"] || currentStoryboardRelPath(runtime),
|
package/src/commands/sync.js
CHANGED
|
@@ -19,9 +19,14 @@ export async function runPushCommand(ctx) {
|
|
|
19
19
|
const { runtime, wikiSync } = ctx.deps;
|
|
20
20
|
await wikiSync.inheritIdentity();
|
|
21
21
|
|
|
22
|
+
// A caller that knows its narrower write-set passes `--paths` (repeatable);
|
|
23
|
+
// the bare session-close invocation passes none and lands the session's own
|
|
24
|
+
// dirty set under per-session checkout isolation.
|
|
25
|
+
const paths = ctx.options?.paths?.length ? ctx.options.paths : undefined;
|
|
26
|
+
|
|
22
27
|
let result;
|
|
23
28
|
try {
|
|
24
|
-
result = await wikiSync.commitAndPush("wiki: update from session");
|
|
29
|
+
result = await wikiSync.commitAndPush("wiki: update from session", paths);
|
|
25
30
|
} catch (err) {
|
|
26
31
|
// Honest CLI contract (the honest-CLI contract): non-zero on any non-land push
|
|
27
32
|
// failure, and on the ancestry guard's refusal (the ancestry guard). The Stop-hook
|