@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/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
|
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
|
|
52
|
-
//
|
|
53
|
-
// context-tax
|
|
54
|
-
//
|
|
55
|
-
//
|
|
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
|
+
}
|