@forwardimpact/libwiki 0.2.29 → 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.
@@ -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
- /** Re-render XmR chart blocks and issue-list blocks in a storyboard file. */
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),
@@ -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
package/src/constants.js CHANGED
@@ -48,17 +48,19 @@ export const PRIORITY_INDEX_TABLE_HEADER =
48
48
  "| Item | Agents | Owner | Status | Added |";
49
49
  export const DECISION_HEADING = "### Decision";
50
50
 
51
- // Unified budgets for the three audited surfaces (summary, weekly-log,
52
- // storyboard). They share the same numeric limits today so the
53
- // context-tax floor is symmetric across surfaces; each surface keeps
54
- // its own audit rule pair so the limits can diverge later if the
55
- // context-tax model says one surface should be looser or tighter.
51
+ // Unified budgets for the audited surfaces (summary, weekly-log, storyboard,
52
+ // memory). They keep per-surface rule pairs so the limits can diverge as the
53
+ // context-tax model says one surface should be looser or tighter. MEMORY.md is
54
+ // the tightest: it is read on every boot and holds settled cross-cutting state,
55
+ // not history, so its budget is sized to keep the on-boot read cheap.
56
56
  export const SUMMARY_LINE_BUDGET = 496;
57
57
  export const SUMMARY_WORD_BUDGET = 2048;
58
58
  export const WEEKLY_LOG_LINE_BUDGET = 496;
59
59
  export const WEEKLY_LOG_WORD_BUDGET = 6400;
60
60
  export const STORYBOARD_LINE_BUDGET = 496;
61
61
  export const STORYBOARD_WORD_BUDGET = 6400;
62
+ export const MEMORY_LINE_BUDGET = 128;
63
+ export const MEMORY_WORD_BUDGET = 2048;
62
64
 
63
65
  // Weekly-log filename convention: `<agent>-YYYY-Www.md` for the live main log
64
66
  // and `<agent>-YYYY-Www-partN.md` for a sealed part. Capture groups are
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Allocation-anchor body format for the parallel-collision ledger. An anchor is
3
+ * an append-only comment on the obstacle issue, so its identity is durable
4
+ * independent of the merge-contested ledger page. Each anchor carries one
5
+ * fenced block:
6
+ *
7
+ * ```text
8
+ * ```yaml alloc
9
+ * kind: occ
10
+ * ids: ["#97", "#98"]
11
+ * event: 7d0f8bca
12
+ * note: dual-execution episode
13
+ * ```
14
+ * ```
15
+ *
16
+ * The block is parsed by structure, not by a general YAML engine, so libwiki
17
+ * adds no parser dependency. `kind` is one of `occ`, `nm`, `fold`, `meta`;
18
+ * `ids` is a list of display labels; `event` is the durable key (a SHA or a
19
+ * prior anchor id); `note` is free text. The durable key is `event`; labels are
20
+ * display only, so relabeling is lossless.
21
+ */
22
+
23
+ const FENCE_OPEN = "```yaml alloc";
24
+ const FENCE_CLOSE = "```";
25
+ const KINDS = new Set(["occ", "nm", "fold", "meta"]);
26
+
27
+ /**
28
+ * Parse the allocation anchor out of a comment body, or `null` when the body
29
+ * carries no `yaml alloc` fenced block.
30
+ *
31
+ * @param {string} body - The full comment body.
32
+ * @returns {{kind: string, ids: string[], event: string, note: string} | null}
33
+ */
34
+ export function parseAnchor(body) {
35
+ if (typeof body !== "string") return null;
36
+ const lines = body.split("\n");
37
+ const open = lines.findIndex((l) => l.trim() === FENCE_OPEN);
38
+ if (open === -1) return null;
39
+ const rest = lines.slice(open + 1);
40
+ const close = rest.findIndex((l) => l.trim() === FENCE_CLOSE);
41
+ if (close === -1) return null;
42
+ const block = rest.slice(0, close);
43
+
44
+ const fields = {};
45
+ for (const line of block) {
46
+ const idx = line.indexOf(":");
47
+ if (idx === -1) continue;
48
+ const key = line.slice(0, idx).trim();
49
+ const value = line.slice(idx + 1).trim();
50
+ fields[key] = value;
51
+ }
52
+
53
+ const kind = fields.kind;
54
+ if (!KINDS.has(kind)) return null;
55
+ const ids = parseIdList(fields.ids);
56
+ const event = fields.event ?? "";
57
+ if (event === "") return null;
58
+ return { kind, ids, event, note: fields.note ?? "" };
59
+ }
60
+
61
+ /**
62
+ * Render the canonical anchor body for posting.
63
+ *
64
+ * @param {{kind: string, ids: string[], event: string, note?: string}} anchor
65
+ * @returns {string}
66
+ */
67
+ export function renderAnchorBody({ kind, ids, event, note = "" }) {
68
+ if (!KINDS.has(kind)) {
69
+ throw new Error(`renderAnchorBody: unknown kind "${kind}"`);
70
+ }
71
+ const lines = [
72
+ FENCE_OPEN,
73
+ `kind: ${kind}`,
74
+ `ids: ${renderIdList(ids)}`,
75
+ `event: ${event}`,
76
+ ];
77
+ if (note) lines.push(`note: ${note}`);
78
+ lines.push(FENCE_CLOSE);
79
+ return lines.join("\n");
80
+ }
81
+
82
+ /** The set of valid kinds. */
83
+ export const ANCHOR_KINDS = KINDS;
84
+
85
+ function parseIdList(raw) {
86
+ if (!raw) return [];
87
+ const inner = raw.replace(/^\[/, "").replace(/\]$/, "");
88
+ return inner
89
+ .split(",")
90
+ .map((s) => s.trim().replace(/^["']/, "").replace(/["']$/, ""))
91
+ .filter(Boolean);
92
+ }
93
+
94
+ function renderIdList(ids) {
95
+ return `[${(ids ?? []).map((id) => `"${id}"`).join(", ")}]`;
96
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Fold the ordered allocation-anchor sequence into id assignments and render
3
+ * the two derived projections — the ledger page body and the MEMORY
4
+ * cross-cutting row. The anchor record is authoritative; these projections hold
5
+ * no sole-copy state and are rebuildable from it, so erasure of a projection is
6
+ * a cache miss repaired by rebuild, not a loss event.
7
+ *
8
+ * Identity is the `event` key; labels are display output, so a double-allocation
9
+ * resolves first-published-wins and the loser is re-labeled without losing any
10
+ * record. The labeling policy is a `labelMode` parameter: `renumber` (the
11
+ * default, matching the team's established convention) keeps the labels dense
12
+ * and re-mints the loser at the next free index; `gapped` leaves a gap so a
13
+ * label never moves. Both are supported; neither is forced.
14
+ */
15
+
16
+ /**
17
+ * @typedef {object} AnchorRecord
18
+ * @property {number} id - The comment id (the serialization key).
19
+ * @property {string} createdAt
20
+ * @property {{kind: string, ids: string[], event: string, note: string}} anchor
21
+ */
22
+
23
+ /**
24
+ * Fold anchors into assignments and conflicts. First-published (lowest comment
25
+ * `id`) wins each contested label.
26
+ *
27
+ * @param {AnchorRecord[]} anchors - Anchors in ascending `id` order.
28
+ * @returns {{assignments: Map<string, AnchorRecord>, conflicts: Array<{label: string, winner: AnchorRecord, losers: AnchorRecord[]}>}}
29
+ */
30
+ export function foldAnchors(anchors) {
31
+ const assignments = new Map();
32
+ const contested = new Map();
33
+ for (const record of anchors) {
34
+ for (const label of record.anchor.ids) {
35
+ const existing = assignments.get(label);
36
+ if (!existing) {
37
+ assignments.set(label, record);
38
+ continue;
39
+ }
40
+ // existing was published earlier (anchors are id-ordered): it wins.
41
+ if (!contested.has(label)) contested.set(label, []);
42
+ contested.get(label).push(record);
43
+ }
44
+ }
45
+ const conflicts = [...contested.entries()].map(([label, losers]) => ({
46
+ label,
47
+ winner: assignments.get(label),
48
+ losers,
49
+ }));
50
+ return { assignments, conflicts };
51
+ }
52
+
53
+ /**
54
+ * Render the ledger page body from a fold. Entries are grouped by kind and
55
+ * ordered by their winning anchor's id. Authored prose carried by
56
+ * `<!-- anchor:ID -->`-cited blocks is re-emitted in anchor-id order; a cited
57
+ * anchor that does not exist is reported in the returned `missingProse` list,
58
+ * never silently dropped. `labelMode` selects the loser re-mint guidance for a
59
+ * double-allocation: `renumber` (default) re-mints at the next free index,
60
+ * `gapped` leaves the loser's index as a gap.
61
+ *
62
+ * @param {{assignments: Map, conflicts: Array}} fold
63
+ * @param {Array<{anchorId: number, text: string}>} [prose] - Anchor-cited prose blocks.
64
+ * @param {{labelMode?: "renumber" | "gapped"}} [opts]
65
+ * @returns {{body: string, missingProse: number[]}}
66
+ */
67
+ export function renderLedgerPage(
68
+ fold,
69
+ prose = [],
70
+ { labelMode = "renumber" } = {},
71
+ ) {
72
+ const lines = [
73
+ "# Parallel-Collision Ledger",
74
+ "",
75
+ "Derived projection of the allocation-anchor record. Rebuilt by `fit-wiki ledger rebuild`; do not hand-edit identifiers here — allocate at an anchor.",
76
+ "",
77
+ ...renderKindSections(fold),
78
+ ...renderConflicts(fold, labelMode),
79
+ ];
80
+ const missingProse = appendProse(lines, fold, prose);
81
+ return { body: `${lines.join("\n").trimEnd()}\n`, missingProse };
82
+ }
83
+
84
+ function renderKindSections(fold) {
85
+ const byKind = { occ: [], nm: [], fold: [], meta: [] };
86
+ for (const [label, record] of fold.assignments) {
87
+ byKind[record.anchor.kind]?.push({ label, record });
88
+ }
89
+ const lines = [];
90
+ for (const [kind, heading] of KIND_HEADINGS) {
91
+ byKind[kind].sort((a, b) => a.record.id - b.record.id);
92
+ lines.push(`## ${heading}`, "");
93
+ for (const { label, record } of byKind[kind]) {
94
+ const note = record.anchor.note ? ` — ${record.anchor.note}` : "";
95
+ lines.push(`- ${label} (event ${record.anchor.event})${note}`);
96
+ }
97
+ lines.push("");
98
+ }
99
+ return lines;
100
+ }
101
+
102
+ function renderConflicts(fold, labelMode) {
103
+ if (fold.conflicts.length === 0) return [];
104
+ const guidance =
105
+ labelMode === "gapped"
106
+ ? "leave the contested index as a gap"
107
+ : "re-mint the loser at the next free index";
108
+ const lines = [
109
+ `## Double-allocations (first-published wins; ${guidance})`,
110
+ "",
111
+ ];
112
+ for (const c of fold.conflicts) {
113
+ const losers = c.losers
114
+ .map((l) => `${l.anchor.event} (id ${l.id})`)
115
+ .join(", ");
116
+ lines.push(
117
+ `- ${c.label}: winner ${c.winner.anchor.event} (id ${c.winner.id}); re-mint required for ${losers}`,
118
+ );
119
+ }
120
+ lines.push("");
121
+ return lines;
122
+ }
123
+
124
+ /**
125
+ * Extract `<!-- anchor:ID -->`-cited prose blocks from an existing ledger-page
126
+ * body so a rebuild re-emits them rather than dropping them. Each block runs
127
+ * from its citation marker to the next marker or end of input.
128
+ *
129
+ * @param {string} pageBody - The current ledger-page text.
130
+ * @returns {Array<{anchorId: number, text: string}>}
131
+ */
132
+ export function extractProse(pageBody) {
133
+ if (!pageBody) return [];
134
+ const marker = /<!--\s*anchor:(\d+)\s*-->\n?/g;
135
+ const blocks = [];
136
+ let match = marker.exec(pageBody);
137
+ while (match) {
138
+ const anchorId = Number.parseInt(match[1], 10);
139
+ const start = match.index + match[0].length;
140
+ const next = marker.exec(pageBody);
141
+ const end = next ? next.index : pageBody.length;
142
+ const text = pageBody.slice(start, end).trim();
143
+ if (text) blocks.push({ anchorId, text });
144
+ match = next;
145
+ }
146
+ return blocks;
147
+ }
148
+
149
+ function appendProse(lines, fold, prose) {
150
+ const missingProse = [];
151
+ const knownIds = new Set([...fold.assignments.values()].map((r) => r.id));
152
+ const ordered = [...prose].sort((a, b) => a.anchorId - b.anchorId);
153
+ if (ordered.length === 0) return missingProse;
154
+ lines.push("## Conventions and floors (binding)", "");
155
+ for (const block of ordered) {
156
+ if (!knownIds.has(block.anchorId)) missingProse.push(block.anchorId);
157
+ lines.push(`<!-- anchor:${block.anchorId} -->`, block.text, "");
158
+ }
159
+ return missingProse;
160
+ }
161
+
162
+ /**
163
+ * Render the MEMORY cross-cutting row counters from a fold: next-free index per
164
+ * kind, plus the total assigned count.
165
+ *
166
+ * @param {{assignments: Map}} fold
167
+ * @returns {string}
168
+ */
169
+ export function renderMemoryRow(fold) {
170
+ const next = {
171
+ occ: nextFree(fold, "occ", "#"),
172
+ nm: nextFree(fold, "nm", "NM"),
173
+ fold: nextFree(fold, "fold", "n="),
174
+ meta: nextFree(fold, "meta", "M"),
175
+ };
176
+ return (
177
+ `Parallel-collision allocation (derived from the anchor record): ` +
178
+ `${fold.assignments.size} ids assigned; next free #${next.occ}, NM${next.nm}, ` +
179
+ `n=${next.fold}, M${next.meta}. Allocate at an anchor, never by editing this row.`
180
+ );
181
+ }
182
+
183
+ const MEMORY_REGION_OPEN = "<!-- ledger:memory-row -->";
184
+ const MEMORY_REGION_CLOSE = "<!-- /ledger:memory-row -->";
185
+ const MEMORY_REGION_RE = new RegExp(
186
+ `${MEMORY_REGION_OPEN}\\n[\\s\\S]*?\\n${MEMORY_REGION_CLOSE}`,
187
+ );
188
+
189
+ /**
190
+ * Write the derived MEMORY-row counters into a delimited region of a MEMORY.md
191
+ * body, so the row is a rebuildable projection of the anchor record
192
+ * without overwriting the surrounding authored narrative. The region is the
193
+ * only sole-copy-free surface: its interior is fully regenerated, everything
194
+ * outside it is preserved byte-for-byte. If the region is absent it is appended
195
+ * under a heading; if present, only its interior is replaced.
196
+ *
197
+ * @param {string} memoryBody - Current `MEMORY.md` text.
198
+ * @param {{assignments: Map}} fold
199
+ * @returns {string} The updated body.
200
+ */
201
+ export function writeMemoryRowRegion(memoryBody, fold) {
202
+ const region = `${MEMORY_REGION_OPEN}\n${renderMemoryRow(fold)}\n${MEMORY_REGION_CLOSE}`;
203
+ const body = memoryBody ?? "";
204
+ if (MEMORY_REGION_RE.test(body)) {
205
+ return body.replace(MEMORY_REGION_RE, region);
206
+ }
207
+ const trimmed = body.trimEnd();
208
+ return `${trimmed}${trimmed ? "\n\n" : ""}${region}\n`;
209
+ }
210
+
211
+ /**
212
+ * Extract the derived MEMORY-row region interior from a MEMORY.md body, or
213
+ * `null` if the region is absent. Used by `verify` to diff the projection
214
+ * surface alone, never the surrounding narrative.
215
+ *
216
+ * @param {string} memoryBody
217
+ * @returns {string|null}
218
+ */
219
+ export function readMemoryRowRegion(memoryBody) {
220
+ const m = (memoryBody ?? "").match(MEMORY_REGION_RE);
221
+ if (!m) return null;
222
+ return m[0]
223
+ .replace(`${MEMORY_REGION_OPEN}\n`, "")
224
+ .replace(`\n${MEMORY_REGION_CLOSE}`, "");
225
+ }
226
+
227
+ const KIND_HEADINGS = [
228
+ ["occ", "Occurrences"],
229
+ ["nm", "Near-misses"],
230
+ ["fold", "Folds"],
231
+ ["meta", "Meta-instances"],
232
+ ];
233
+
234
+ function nextFree(fold, kind, prefix) {
235
+ let max = 0;
236
+ for (const [label, record] of fold.assignments) {
237
+ if (record.anchor.kind !== kind) continue;
238
+ const n = Number.parseInt(label.replace(prefix, ""), 10);
239
+ if (Number.isFinite(n) && n > max) max = n;
240
+ }
241
+ return max + 1;
242
+ }
@@ -0,0 +1,45 @@
1
+ import { parseAnchor } from "./anchor.js";
2
+
3
+ /**
4
+ * The obstacle issue whose comment thread is the allocation-anchor surface.
5
+ * GitHub serializes comment creation and assigns a monotonic `id`, so that `id`
6
+ * order is the allocation serialization no merge can erase.
7
+ */
8
+ export const DEFAULT_ANCHOR_ISSUE = 1564;
9
+
10
+ /**
11
+ * Read every allocation anchor from the obstacle issue's comment thread, in
12
+ * server `id` order ascending. The lowest comment `id` claiming a given label
13
+ * is its winner (first published wins). Comments carrying no anchor block are
14
+ * skipped.
15
+ *
16
+ * @param {object} ghClient - A GhClient (or mock) exposing `apiGetPaginated`.
17
+ * @param {object} opts
18
+ * @param {string} opts.owner - Repository owner.
19
+ * @param {string} opts.repo - Repository name.
20
+ * @param {number} [opts.issue] - Issue number (defaults to the obstacle issue).
21
+ * @param {string} [opts.cwd] - Working directory for the gh invocation.
22
+ * @returns {Promise<Array<{id: number, createdAt: string, anchor: object}>>}
23
+ */
24
+ export async function readAnchors(
25
+ ghClient,
26
+ { owner, repo, issue = DEFAULT_ANCHOR_ISSUE, cwd } = {},
27
+ ) {
28
+ if (!owner || !repo) {
29
+ throw new Error("readAnchors: owner and repo are required");
30
+ }
31
+ const path = `repos/${owner}/${repo}/issues/${issue}/comments`;
32
+ const comments = await ghClient.apiGetPaginated(path, { cwd });
33
+ const anchors = [];
34
+ for (const comment of comments ?? []) {
35
+ const anchor = parseAnchor(comment.body ?? "");
36
+ if (!anchor) continue;
37
+ anchors.push({
38
+ id: comment.id,
39
+ createdAt: comment.created_at,
40
+ anchor,
41
+ });
42
+ }
43
+ anchors.sort((a, b) => a.id - b.id);
44
+ return anchors;
45
+ }