@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 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.29",
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/libeval": "^0.1.49",
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",
@@ -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 --
@@ -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: readOptional(path.join(wikiRoot, "MEMORY.md"), fs),
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
+ }
@@ -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+7d)",
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 and obstacle/experiment marker blocks in a storyboard",
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: { ...wikiRootOpt },
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" },
@@ -162,7 +162,9 @@ export async function runClaimCommand(ctx) {
162
162
  };
163
163
  }
164
164
  const today = options.today || currentDayIso(runtime);
165
- const expires = options["expires-at"] || addDays(today, 7);
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 = {
@@ -5,7 +5,7 @@ import {
5
5
  createAgentRunner,
6
6
  composeProfilePrompt,
7
7
  createRedactor,
8
- } from "@forwardimpact/libeval";
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
+ }