@forwardimpact/libwiki 0.2.25 → 0.2.26

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.
@@ -6,18 +6,17 @@ import {
6
6
  PRIORITY_INDEX_TABLE_HEADER,
7
7
  } from "../constants.js";
8
8
  import { currentDayIso } from "../util/clock.js";
9
+ import { requireAgentFlag } from "../util/agent-flag.js";
9
10
  import { resolveWikiRoot } from "../util/wiki-dir.js";
10
11
 
11
12
  function paths(runtime, options) {
12
13
  const wikiRoot = resolveWikiRoot(runtime, options);
13
- const agent = options.agent || runtime.proc.env.LIBEVAL_AGENT_PROFILE;
14
- if (!agent) {
15
- createLogger("wiki", runtime).warn(
16
- "inbox",
17
- "inbox requires --agent or LIBEVAL_AGENT_PROFILE",
18
- );
19
- return { error: { ok: false, code: 2 } };
20
- }
14
+ const resolved = requireAgentFlag(options, {
15
+ command: "inbox",
16
+ example: "fit-wiki inbox list --agent staff-engineer",
17
+ });
18
+ if (!resolved.ok) return { error: resolved };
19
+ const agent = resolved.agent;
21
20
  return {
22
21
  summaryPath: path.join(wikiRoot, `${agent}.md`),
23
22
  memoryPath: path.join(wikiRoot, "MEMORY.md"),
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { createLogger } from "@forwardimpact/libtelemetry";
3
+ import { ensureMetricsCsvMergeAttribute } from "../gitattributes.js";
3
4
  import { listSkills } from "../skill-roster.js";
4
5
  import { resolveProjectRoot, resolveWikiRoot } from "../util/wiki-dir.js";
5
6
  import {
@@ -100,6 +101,11 @@ export async function runInitCommand(ctx) {
100
101
  `init: scaffolded ${ACTIVE_CLAIMS_HEADING} in ${memoryPath}\n`,
101
102
  );
102
103
  }
104
+ if (ensureMetricsCsvMergeAttribute(wikiDir, runtime.fsSync).changed) {
105
+ runtime.proc.stdout.write(
106
+ "init: declared metrics-CSV union merge in .gitattributes\n",
107
+ );
108
+ }
103
109
  }
104
110
 
105
111
  runtime.proc.stdout.write(`init: wiki ready at ${wikiDir}\n`);
@@ -4,28 +4,33 @@ import {
4
4
  rotateIfOverBudget,
5
5
  appendEntry,
6
6
  } from "../weekly-log.js";
7
- import { DECISION_HEADING } from "../constants.js";
7
+ import {
8
+ DECISION_HEADING,
9
+ WEEKLY_LOG_LINE_BUDGET,
10
+ WEEKLY_LOG_SEAM_RE,
11
+ WEEKLY_LOG_WORD_BUDGET,
12
+ } from "../constants.js";
13
+ import { countLines, countWords } from "../budget.js";
8
14
  import { currentDayIso } from "../util/clock.js";
15
+ import { requireAgentFlag } from "../util/agent-flag.js";
9
16
  import { resolveWikiRoot } from "../util/wiki-dir.js";
10
17
 
11
18
  function commonContext(runtime, options) {
12
- const agent = options.agent || runtime.proc.env.LIBEVAL_AGENT_PROFILE;
13
- if (!agent) {
14
- createLogger("wiki", runtime).warn(
15
- "log",
16
- "log requires --agent <name> or LIBEVAL_AGENT_PROFILE env var",
17
- );
18
- return { error: { ok: false, code: 2 } };
19
- }
19
+ const resolved = requireAgentFlag(options, {
20
+ command: "log",
21
+ example:
22
+ 'fit-wiki log decision --agent staff-engineer --chosen "..." --rationale "..."',
23
+ });
24
+ if (!resolved.ok) return { error: resolved };
20
25
  const wikiRoot = resolveWikiRoot(runtime, options);
21
26
  const today = options.today || currentDayIso(runtime);
22
- return { agent, wikiRoot, today };
27
+ return { agent: resolved.agent, wikiRoot, today };
23
28
  }
24
29
 
25
30
  function lastDateHeading(text) {
26
31
  // Match `## YYYY-MM-DD` at the start of a line, optionally followed by
27
32
  // suffix text (e.g. `## 2026-05-19 (third activation)`).
28
- const re = /^## (\d{4}-\d{2}-\d{2})/gm;
33
+ const re = new RegExp(WEEKLY_LOG_SEAM_RE.source, "gm");
29
34
  let last = null;
30
35
  let match;
31
36
  while ((match = re.exec(text)) !== null) last = match[1];
@@ -40,13 +45,14 @@ function lastDateHeading(text) {
40
45
  * thrown fs error is reported and swallowed: the writer rolled back, so the
41
46
  * (intact) current file still receives the new entry.
42
47
  */
43
- function rotateBeforeAppend(wikiRoot, agent, today, appendLines, runtime) {
48
+ function rotateBeforeAppend(wikiRoot, agent, today, body, runtime) {
44
49
  try {
50
+ const delta = { lines: countLines(body), words: countWords(body) };
45
51
  const res = rotateIfOverBudget(
46
52
  wikiRoot,
47
53
  agent,
48
54
  today,
49
- appendLines,
55
+ delta,
50
56
  {},
51
57
  runtime.fsSync,
52
58
  );
@@ -63,6 +69,26 @@ function rotateBeforeAppend(wikiRoot, agent, today, appendLines, runtime) {
63
69
  }
64
70
  }
65
71
 
72
+ /**
73
+ * Report the just-written weekly log's budget state — value, cap, and remaining
74
+ * headroom for both the line and word budget — so a writer sees the ceiling
75
+ * before composing the next entry rather than discovering it via a red gate.
76
+ * Runs after every successful append, on the rotated and non-rotated path alike.
77
+ */
78
+ function reportBudget(target, runtime) {
79
+ const text = runtime.fsSync.existsSync(target)
80
+ ? runtime.fsSync.readFileSync(target, "utf-8")
81
+ : "";
82
+ const lines = countLines(text);
83
+ const words = countWords(text);
84
+ runtime.proc.stdout.write(
85
+ `weekly log budget: ${lines}/${WEEKLY_LOG_LINE_BUDGET} lines ` +
86
+ `(${WEEKLY_LOG_LINE_BUDGET - lines} remaining), ` +
87
+ `${words}/${WEEKLY_LOG_WORD_BUDGET} words ` +
88
+ `(${WEEKLY_LOG_WORD_BUDGET - words} remaining)\n`,
89
+ );
90
+ }
91
+
66
92
  function runDecision(runtime, options) {
67
93
  const ctx = commonContext(runtime, options);
68
94
  if (ctx.error) return ctx.error;
@@ -85,11 +111,11 @@ function runDecision(runtime, options) {
85
111
  `**Rationale:** ${rationale}`,
86
112
  "",
87
113
  ].join("\n");
88
- const lineCount = body.split("\n").length;
89
- rotateBeforeAppend(wikiRoot, agent, today, lineCount, runtime);
114
+ rotateBeforeAppend(wikiRoot, agent, today, body, runtime);
90
115
  const target = weeklyLogPath(wikiRoot, agent, today);
91
116
  appendEntry(target, body, agent, today, runtime.fsSync);
92
117
  runtime.proc.stdout.write(`logged decision to ${target}\n`);
118
+ reportBudget(target, runtime);
93
119
  return { ok: true };
94
120
  }
95
121
 
@@ -105,15 +131,10 @@ function runNote(runtime, options) {
105
131
  return { ok: false, code: 2 };
106
132
  }
107
133
  const fieldBlock = `### ${options.field}\n\n${options.body}\n`;
108
- // Conservative line budget: assume we'll prepend a date heading.
134
+ // Conservative budget: assume we'll prepend a date heading. Rotate on the
135
+ // larger `withHeading` body so the word/line projection never under-counts.
109
136
  const withHeading = `## ${today}\n\n${fieldBlock}`;
110
- rotateBeforeAppend(
111
- wikiRoot,
112
- agent,
113
- today,
114
- withHeading.split("\n").length,
115
- runtime,
116
- );
137
+ rotateBeforeAppend(wikiRoot, agent, today, withHeading, runtime);
117
138
  const target = weeklyLogPath(wikiRoot, agent, today);
118
139
  // Append under the open entry if the file's last `## YYYY-MM-DD` is today;
119
140
  // otherwise open a new entry by prepending a date heading.
@@ -123,6 +144,7 @@ function runNote(runtime, options) {
123
144
  const body = lastDateHeading(existing) === today ? fieldBlock : withHeading;
124
145
  appendEntry(target, body, agent, today, runtime.fsSync);
125
146
  runtime.proc.stdout.write(`logged note to ${target}\n`);
147
+ reportBudget(target, runtime);
126
148
  return { ok: true };
127
149
  }
128
150
 
@@ -131,11 +153,11 @@ function runDone(runtime, options) {
131
153
  if (ctx.error) return ctx.error;
132
154
  const { agent, wikiRoot, today } = ctx;
133
155
  const body = `### Closed\n\nRun closed ${today}.\n`;
134
- const lineCount = body.split("\n").length;
135
- rotateBeforeAppend(wikiRoot, agent, today, lineCount, runtime);
156
+ rotateBeforeAppend(wikiRoot, agent, today, body, runtime);
136
157
  const target = weeklyLogPath(wikiRoot, agent, today);
137
158
  appendEntry(target, body, agent, today, runtime.fsSync);
138
159
  runtime.proc.stdout.write(`closed entry in ${target}\n`);
160
+ reportBudget(target, runtime);
139
161
  return { ok: true };
140
162
  }
141
163
 
@@ -4,6 +4,7 @@ import { writeMemo } from "../memo-writer.js";
4
4
  import { listAgents } from "../agent-roster.js";
5
5
  import { BROADCAST_TARGET } from "../constants.js";
6
6
  import { currentDayIso } from "../util/clock.js";
7
+ import { requireAgentFlag } from "../util/agent-flag.js";
7
8
  import { resolveProjectRoot } from "../util/wiki-dir.js";
8
9
 
9
10
  function writeAndCheck(runtime, summaryPath, sender, message, today) {
@@ -63,19 +64,19 @@ function writeBroadcast(
63
64
  return { ok: true };
64
65
  }
65
66
 
66
- /** Write a memo to a target agent's summary file (or broadcast to all except the sender); sender is --from or LIBEVAL_AGENT_PROFILE env var. */
67
+ /** Write a memo to a target agent's summary file (or broadcast to all except the sender); the sender is the required --from flag. */
67
68
  export function runMemoCommand(ctx) {
68
69
  const { runtime } = ctx.deps;
69
70
  const options = ctx.options;
70
- const sender = options.from || runtime.proc.env.LIBEVAL_AGENT_PROFILE;
71
+ const resolved = requireAgentFlag(options, {
72
+ command: "memo",
73
+ flag: "--from",
74
+ example:
75
+ 'fit-wiki memo --from staff-engineer --to security-engineer --message "..."',
76
+ });
77
+ if (!resolved.ok) return resolved;
78
+ const sender = resolved.agent;
71
79
 
72
- if (!sender) {
73
- return {
74
- ok: false,
75
- code: 2,
76
- error: "memo requires --from <sender> or LIBEVAL_AGENT_PROFILE env var",
77
- };
78
- }
79
80
  if (!options.to) {
80
81
  return { ok: false, code: 2, error: "memo requires --to <target|all>" };
81
82
  }
@@ -27,6 +27,7 @@ async function renderForBlock(block, projectRoot, ghContext, runtime) {
27
27
  return renderBlock({
28
28
  metric: block.metric,
29
29
  csvPath: block.csvPath,
30
+ priorReadAnchor: block.priorReadAnchor,
30
31
  projectRoot,
31
32
  fs: runtime.fsSync,
32
33
  });
@@ -1,26 +1,28 @@
1
1
  import { createLogger } from "@forwardimpact/libtelemetry";
2
2
  import { rotateIfOverBudget, weeklyLogPath } from "../weekly-log.js";
3
3
  import { currentDayIso } from "../util/clock.js";
4
+ import { requireAgentFlag } from "../util/agent-flag.js";
4
5
  import { resolveWikiRoot } from "../util/wiki-dir.js";
5
6
 
6
- /** Force-rotate the current weekly log to a sealed part file. */
7
+ /**
8
+ * Rotate the current weekly log to a sealed part file. Refuses an under-budget
9
+ * target (exit 2) unless `--force`; the header-only floor stays a zero-exit
10
+ * no-op even under `--force`; a missing target exits 2.
11
+ */
7
12
  export function runRotateCommand(ctx) {
8
13
  const { runtime } = ctx.deps;
9
14
  const logger = createLogger("wiki", runtime);
10
15
  const options = ctx.options;
11
- const agent = options.agent || runtime.proc.env.LIBEVAL_AGENT_PROFILE;
12
- if (!agent) {
13
- return {
14
- ok: false,
15
- code: 2,
16
- error: "rotate requires --agent or LIBEVAL_AGENT_PROFILE",
17
- };
18
- }
16
+ const resolved = requireAgentFlag(options, {
17
+ command: "rotate",
18
+ example: "fit-wiki rotate --agent staff-engineer",
19
+ });
20
+ if (!resolved.ok) return resolved;
21
+ const agent = resolved.agent;
19
22
  const wikiRoot = resolveWikiRoot(runtime, options);
20
23
  const today = options.today || currentDayIso(runtime);
21
24
  // Name the resolved target before any seal: the file follows from agent +
22
- // current week, not from any audit finding, so an env-default agent can
23
- // silently select a different file than the one the operator has in mind.
25
+ // current week, not from any audit finding.
24
26
  runtime.proc.stdout.write(
25
27
  `target → ${weeklyLogPath(wikiRoot, agent, today)}\n`,
26
28
  );
@@ -31,8 +33,8 @@ export function runRotateCommand(ctx) {
31
33
  wikiRoot,
32
34
  agent,
33
35
  today,
34
- 0,
35
- { force: true },
36
+ { lines: 0, words: 0 },
37
+ { force: Boolean(options.force) },
36
38
  runtime.fsSync,
37
39
  );
38
40
  } catch (e) {
@@ -41,8 +43,27 @@ export function runRotateCommand(ctx) {
41
43
  }
42
44
  switch (result.status) {
43
45
  case "noop":
44
- runtime.proc.stdout.write(`no rotation needed for ${agent}\n`);
45
- return { ok: true };
46
+ // The header-only floor is a benign no-op; an under-budget or missing
47
+ // target fails closed so a stale/typo'd invocation cannot pass silently.
48
+ if (result.reason === "floor") {
49
+ runtime.proc.stdout.write(`no rotation needed for ${agent}\n`);
50
+ return { ok: true };
51
+ }
52
+ if (result.reason === "missing") {
53
+ return {
54
+ ok: false,
55
+ code: 2,
56
+ error: `no weekly log for ${agent} at ${result.fromPath}`,
57
+ };
58
+ }
59
+ return {
60
+ ok: false,
61
+ code: 2,
62
+ error:
63
+ `${result.fromPath} is under budget ` +
64
+ `(${result.lines} lines, ${result.words} words); ` +
65
+ `pass --force to seal it early`,
66
+ };
46
67
  case "sealed":
47
68
  for (const part of result.parts) {
48
69
  runtime.proc.stdout.write(`sealed → ${part}\n`);
@@ -55,10 +76,10 @@ export function runRotateCommand(ctx) {
55
76
  const { section, lines, words, path: residuePath } = result.residue;
56
77
  logger.error(
57
78
  "rotate",
58
- `day-section ${section} alone exceeds the budget ` +
59
- `(${lines} lines, ${words} words) and cannot be split at a day ` +
60
- `seam: ${residuePath}\n` +
61
- `recover it by hand — bisect the section at a finer seam ` +
79
+ `section ${section} alone exceeds the budget ` +
80
+ `(${lines} lines, ${words} words) and has no finer seam to split ` +
81
+ `at: ${residuePath}\n` +
82
+ `recover it by hand — shorten the section ` +
62
83
  `(see the memory protocol's manual-recovery convention)`,
63
84
  );
64
85
  return { ok: false, code: 1 };
@@ -1,29 +1,53 @@
1
1
  import { createLogger } from "@forwardimpact/libtelemetry";
2
- import { WikiPullConflict } from "../wiki-sync.js";
2
+ import { refusalEnvelope } from "../secret-gate.js";
3
+ import { AncestryRefusal, WikiPullConflict } from "../wiki-sync.js";
4
+ import { sweepTier2, renderDetections } from "../integrity.js";
5
+ import { resolveWikiRoot } from "../util/wiki-dir.js";
3
6
 
4
- /** Commit all wiki changes and push them to the remote wiki repository. */
7
+ /**
8
+ * Commit all wiki changes and push them through the secret-gated push path.
9
+ * A detected secret or unavailable scanner fails the command closed and names
10
+ * the cause on stderr without attempting the push; a clean write pushes or
11
+ * reports nothing to push. The post-push tier-1 integrity detections surface in
12
+ * the output; they never gate the push.
13
+ */
5
14
  export async function runPushCommand(ctx) {
6
15
  const { runtime, wikiSync } = ctx.deps;
7
16
  await wikiSync.inheritIdentity();
8
17
 
9
- const result = await wikiSync.commitAndPush("wiki: update from session");
18
+ let result;
19
+ try {
20
+ result = await wikiSync.commitAndPush("wiki: update from session");
21
+ } catch (err) {
22
+ if (err instanceof AncestryRefusal) {
23
+ runtime.proc.stderr.write(`${err.message}\n`);
24
+ return { ok: false, code: 1 };
25
+ }
26
+ throw err;
27
+ }
28
+ // Fail the command closed on a secret-gate refusal (secret detected or scanner
29
+ // unavailable); a clean push falls through to the normal reporting below.
30
+ const refusal = refusalEnvelope(runtime, result);
31
+ if (refusal) return refusal;
10
32
  if (result.pushed) {
11
33
  runtime.proc.stdout.write("push: committed and pushed\n");
12
34
  } else {
13
35
  runtime.proc.stdout.write("push: nothing to push\n");
14
36
  }
37
+ if (result.detections?.length) {
38
+ runtime.proc.stdout.write(renderDetections(result.detections));
39
+ }
15
40
  return { ok: true };
16
41
  }
17
42
 
18
- /** Fetch and rebase the local wiki on origin/master; on rebase conflict, return a non-zero envelope with a message to resolve manually or push first. */
43
+ /** Fetch and rebase the local wiki on origin/master; on rebase conflict, return a non-zero envelope with a message to resolve manually or push first. After a clean pull, the tier-2 lane-record sweep surfaces any previous-session content absent at the fetched tip; it never gates the boot. */
19
44
  export async function runPullCommand(ctx) {
20
- const { runtime, wikiSync } = ctx.deps;
45
+ const { runtime, wikiSync, gitClient } = ctx.deps;
21
46
  await wikiSync.inheritIdentity();
22
47
 
23
48
  try {
24
49
  await wikiSync.pull();
25
50
  runtime.proc.stdout.write("pull: up to date\n");
26
- return { ok: true };
27
51
  } catch (err) {
28
52
  if (err instanceof WikiPullConflict) {
29
53
  createLogger("wiki", runtime).error(
@@ -34,4 +58,28 @@ export async function runPullCommand(ctx) {
34
58
  }
35
59
  throw err;
36
60
  }
61
+
62
+ // Tier-2 sweep on the just-rebased tree. Detection-only: any failure degrades
63
+ // to no detections, never throws into the flow, never changes the exit code.
64
+ try {
65
+ const wikiDir = resolveWikiRoot(runtime, ctx.options);
66
+ // `--today` (ISO date) overrides the wall clock for deterministic tests;
67
+ // a malformed value falls back to the runtime clock rather than NaN.
68
+ const today = ctx.options.today
69
+ ? Date.parse(ctx.options.today)
70
+ : Number.NaN;
71
+ const now = Number.isNaN(today) ? runtime.clock.now() : today;
72
+ const detections = await sweepTier2({
73
+ runtime,
74
+ gitClient,
75
+ wikiDir,
76
+ agent: ctx.options.agent,
77
+ now,
78
+ });
79
+ if (detections.length)
80
+ runtime.proc.stdout.write(renderDetections(detections));
81
+ } catch {
82
+ // Detection-only instrument: a sweep error never gates the boot flow.
83
+ }
84
+ return { ok: true };
37
85
  }
@@ -0,0 +1,78 @@
1
+ // Structural detector for unresolved git conflict markers, shared by the wiki
2
+ // audit's `conflict.markers` rule (audit/conflict-markers-rule.js) and the
3
+ // WikiSync pre-push guard (wiki-sync.js). One home so the two layers cannot
4
+ // drift on what counts as a marker.
5
+ //
6
+ // Detection is line-anchored and structural, not a naive grep, so it does not
7
+ // fire on markers legitimately quoted in prose:
8
+ //
9
+ // - Open (`<<<<<<<`) and close (`>>>>>>>`) marker lines fire UNCONDITIONALLY,
10
+ // per file. A seal rotation can sever one conflict block across two sealed
11
+ // files (the open in one, the separator + close in the other); a
12
+ // complete-in-file-block matcher would miss both, so each marker form stands
13
+ // alone. The single-space-or-end-of-line guard after the 7-char run admits
14
+ // the stash-pop label forms (`<<<<<<< Updated upstream`,
15
+ // `>>>>>>> Stashed changes`) and the branch/sha label forms while rejecting
16
+ // longer `<`/`>` runs.
17
+ // - The separator (`=======`) fires ONLY while a conflict block is open in the
18
+ // same file (block-conditioned). A lone separator with no open above it is
19
+ // indistinguishable from a setext-heading underline and is a deliberate
20
+ // accepted non-detection.
21
+ // - In a `fenceExempt` (prose) surface, occurrences inside a fenced code block
22
+ // are suppressed — a fence quotes content. Markers quoted in a backtick code
23
+ // SPAN are handled by the column-1 anchor itself: a span sits mid-line, so
24
+ // `^` never matches. STATUS.md and non-markdown push targets pass
25
+ // `fenceExempt:false`: their fenced rows are data, where a marker is never
26
+ // legitimate, so fence state never suppresses.
27
+
28
+ const OPEN_RE = /^<{7}( |$)/;
29
+ const CLOSE_RE = /^>{7}( |$)/;
30
+ const SEPARATOR_RE = /^={7}\s*$/;
31
+ // A fenced-code delimiter: three or more backticks or tildes, up to three
32
+ // leading spaces of indentation (CommonMark). The info string after the run is
33
+ // ignored — a delimiter line is never itself a marker.
34
+ const FENCE_RE = /^\s{0,3}(`{3,}|~{3,})/;
35
+
36
+ /**
37
+ * Scan `text` for unresolved git conflict markers.
38
+ *
39
+ * @param {string} text - The file (or diff-added) content to scan.
40
+ * @param {object} [options]
41
+ * @param {boolean} [options.fenceExempt=true] - When true, suppress markers
42
+ * inside fenced code blocks (prose surfaces). When false, fence state never
43
+ * suppresses (STATUS.md, non-markdown targets).
44
+ * @returns {Array<{lineNo: number, kind: "open"|"separator"|"close"}>}
45
+ * One entry per detected marker line, in document order.
46
+ */
47
+ export function scanConflictMarkers(text, { fenceExempt = true } = {}) {
48
+ const lines = text.split("\n");
49
+ const hits = [];
50
+ let insideFence = false;
51
+ let openDepth = 0;
52
+ for (let i = 0; i < lines.length; i++) {
53
+ // Strip a trailing CR so a CRLF checkout is matched identically to LF — a
54
+ // bare marker (`<<<<<<<\r`) would otherwise escape the `( |$)` anchor and
55
+ // a CRLF corruption block would publish undetected.
56
+ const line = lines[i].replace(/\r$/, "");
57
+ if (FENCE_RE.test(line)) {
58
+ insideFence = !insideFence;
59
+ continue;
60
+ }
61
+ if (fenceExempt && insideFence) continue;
62
+ const kind = classify(line, openDepth);
63
+ if (!kind) continue;
64
+ hits.push({ lineNo: i + 1, kind });
65
+ if (kind === "open") openDepth++;
66
+ else if (kind === "close" && openDepth > 0) openDepth--;
67
+ }
68
+ return hits;
69
+ }
70
+
71
+ // Classify a single line as a marker kind, or null. The separator is
72
+ // block-conditioned: it only counts while a conflict block is open.
73
+ function classify(line, openDepth) {
74
+ if (OPEN_RE.test(line)) return "open";
75
+ if (CLOSE_RE.test(line)) return "close";
76
+ if (openDepth > 0 && SEPARATOR_RE.test(line)) return "separator";
77
+ return null;
78
+ }
package/src/constants.js CHANGED
@@ -3,6 +3,21 @@ export const INBOX_HEADING = "## Message Inbox";
3
3
  export const BROADCAST_TARGET = "all";
4
4
 
5
5
  export const MEMORY_FILE = "MEMORY.md";
6
+
7
+ // Row-structured singleton surfaces under the sync-merge discipline: when a
8
+ // landing on one of these is contended, the resolution re-runs the row
9
+ // operation against the fresh remote tip (rebase the operation, not the
10
+ // lines), never a textual merge. Founding member: MEMORY.md (the Active Claims
11
+ // table). STATUS.md phase rows join as their own re-apply operations land.
12
+ // Metrics CSV appends take the complementary union-merge path below.
13
+ export const SINGLETON_PATHS = new Set([MEMORY_FILE]);
14
+
15
+ // The tracked `.gitattributes` declaration that makes concurrent appends to
16
+ // metrics CSVs union-merge (keep both sides' rows) on every publish path,
17
+ // instead of conflicting or side-picking. Carried by the wiki repo itself so
18
+ // it governs every clone.
19
+ export const GITATTRIBUTES_FILE = ".gitattributes";
20
+ export const METRICS_CSV_MERGE_ATTRIBUTE = "metrics/**/*.csv merge=union";
6
21
  export const ACTIVE_CLAIMS_HEADING = "## Active Claims";
7
22
  export const ACTIVE_CLAIMS_TABLE_HEADER =
8
23
  "| agent | target | branch | pr | claimed_at | expires_at |";
@@ -53,12 +68,27 @@ export const WEEKLY_LOG_NAME_RE = /^([a-z][a-z-]*)-(\d{4})-W(\d{2})\.md$/;
53
68
  export const WEEKLY_LOG_PART_NAME_RE =
54
69
  /^([a-z][a-z-]*)-(\d{4})-W(\d{2})-part\d+\.md$/;
55
70
 
71
+ // Day-section seam: `## YYYY-MM-DD` at line start, a trailing suffix tolerated
72
+ // (e.g. `## 2026-05-19 (third activation)`). One home so the rotation
73
+ // seam-finder (weekly-log.js), the `log` command's last-entry probe
74
+ // (commands/log.js), and the audit's heading-grammar-drift rule (audit/rules.js)
75
+ // cannot disagree on what a conforming entry heading is. Source has no flags;
76
+ // call sites add `g`/`m` as needed via `new RegExp(WEEKLY_LOG_SEAM_RE.source, …)`.
77
+ export const WEEKLY_LOG_SEAM_RE = /^## (\d{4}-\d{2}-\d{2})/;
78
+
79
+ // Tier-2 integrity sweep idle-gap: lane-authored commits separated by more
80
+ // than this delimit sessions in the wiki history. 30 minutes.
81
+ export const SESSION_GAP_MS = 30 * 60 * 1000;
82
+
56
83
  // Storyboard marker syntax. An open or close marker tolerates optional trailing
57
84
  // text after the tag (typically an inline "Do not edit. Generated from fit-wiki
58
85
  // refresh." notice). One home so the marker scanner (marker-scanner.js) and the
59
86
  // audit's balance check (audit/rules.js) cannot drift on the syntax.
87
+ // Capture groups: 1 metric, 2 csvPath, 3 optional prior-read anchor date. The
88
+ // `prior=YYYY-MM-DD` token sits before the trailing-text group so the "Do not
89
+ // edit" notice is still tolerated and does not swallow the anchor.
60
90
  export const XMR_OPEN_RE =
61
- /^<!--\s*xmr:([^:\s]+):(\S+)(?:\s+[^>]*?)?\s*-->\s*$/;
91
+ /^<!--\s*xmr:([^:\s]+):(\S+)(?:\s+prior=(\d{4}-\d{2}-\d{2}))?(?:\s+[^>]*?)?\s*-->\s*$/;
62
92
  export const XMR_CLOSE_RE = /^<!--\s*\/xmr(?:\s+[^>]*?)?\s*-->\s*$/;
63
93
  export const ISSUE_OPEN_RE =
64
94
  /^<!--\s*(obstacles|experiments):(open|closed)(?::(\d+d))?(?:\s+[^>]*?)?\s*-->\s*$/;
@@ -0,0 +1,40 @@
1
+ import path from "node:path";
2
+ import {
3
+ GITATTRIBUTES_FILE,
4
+ METRICS_CSV_MERGE_ATTRIBUTE,
5
+ } from "./constants.js";
6
+
7
+ /**
8
+ * Ensure the wiki's tracked `.gitattributes` declares the metrics-CSV union
9
+ * merge attribute (`metrics/**\/*.csv merge=union`), idempotently.
10
+ *
11
+ * Present-and-correct means no write: if the exact attribute line already
12
+ * appears in the file, the file is left byte-unchanged and `{ changed: false }`
13
+ * is returned. Otherwise the line is appended (preserving any existing
14
+ * `.gitattributes` content) or the file is created with just that line, and
15
+ * `{ changed: true }` is returned.
16
+ *
17
+ * The single union declaration governs every clone because it is a tracked
18
+ * worktree file; per-clone config (`core.attributesFile`, `.git/info/attributes`)
19
+ * would not propagate to the sibling sessions that cause the loss.
20
+ *
21
+ * @param {string} wikiDir - The wiki clone directory.
22
+ * @param {import('node:fs')} fsSync - Synchronous filesystem surface (`runtime.fsSync`).
23
+ * @returns {{ changed: boolean }}
24
+ */
25
+ export function ensureMetricsCsvMergeAttribute(wikiDir, fsSync) {
26
+ const filePath = path.join(wikiDir, GITATTRIBUTES_FILE);
27
+ if (fsSync.existsSync(filePath)) {
28
+ const text = fsSync.readFileSync(filePath, "utf-8");
29
+ const present = text
30
+ .split("\n")
31
+ .some((line) => line.trim() === METRICS_CSV_MERGE_ATTRIBUTE);
32
+ if (present) return { changed: false };
33
+ // Append the line, keeping existing content and ending with a newline.
34
+ const base = text.endsWith("\n") || text === "" ? text : `${text}\n`;
35
+ fsSync.writeFileSync(filePath, `${base}${METRICS_CSV_MERGE_ATTRIBUTE}\n`);
36
+ return { changed: true };
37
+ }
38
+ fsSync.writeFileSync(filePath, `${METRICS_CSV_MERGE_ATTRIBUTE}\n`);
39
+ return { changed: true };
40
+ }
package/src/index.js CHANGED
@@ -18,9 +18,10 @@ export {
18
18
  SUMMARY_WORD_BUDGET,
19
19
  } from "./constants.js";
20
20
  export { scanMarkers } from "./marker-scanner.js";
21
+ export { scanConflictMarkers } from "./conflict-markers.js";
21
22
  export { renderBlock } from "./block-renderer.js";
22
23
  export { renderIssueList } from "./issue-list-renderer.js";
23
- export { WikiSync, WikiPullConflict } from "./wiki-sync.js";
24
+ export { WikiSync, WikiPullConflict, WikiSyncRefusal } from "./wiki-sync.js";
24
25
  export { listSkills } from "./skill-roster.js";
25
26
  export {
26
27
  parseClaims,