@forwardimpact/libwiki 0.2.25 → 0.2.27

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.
@@ -8,7 +8,25 @@ import {
8
8
  filterExpired,
9
9
  } from "../active-claims.js";
10
10
  import { currentDayIso } from "../util/clock.js";
11
+ import { requireAgentFlag } from "../util/agent-flag.js";
11
12
  import { resolveWikiRoot } from "../util/wiki-dir.js";
13
+ import { refusalEnvelope } from "../secret-gate.js";
14
+ import { AncestryRefusal } from "../wiki-sync.js";
15
+
16
+ /** Non-zero envelope returned when the ancestry guard refused publication. */
17
+ const NOT_PUBLISHED = {
18
+ ok: false,
19
+ code: 1,
20
+ };
21
+
22
+ /** Build the not-published refusal message for the given guard refusal. */
23
+ function notPublishedMessage(err) {
24
+ return (
25
+ `${err.message}\n` +
26
+ "The row was written to MEMORY.md but is NOT published — it remains an " +
27
+ "uncommitted working-tree change.\n"
28
+ );
29
+ }
12
30
 
13
31
  function readMemory(runtime, memPath) {
14
32
  if (!runtime.fsSync.existsSync(memPath)) return "";
@@ -19,20 +37,72 @@ function memoryPath(runtime, options) {
19
37
  return path.join(resolveWikiRoot(runtime, options), "MEMORY.md");
20
38
  }
21
39
 
22
- async function pushWiki(wikiSync, runtime, message) {
23
- if (!wikiSync) return;
40
+ /**
41
+ * Push the claim/release MEMORY.md change through the secret-gated push path
42
+ * and translate the result into a command envelope. A secret block fails the
43
+ * command closed (`{ ok: false, code: 1 }`) and names the finding or the
44
+ * scanner absence on stderr — distinct from a *network* push failure, which
45
+ * still degrades to "saved locally" and succeeds (`{ ok: true }`).
46
+ *
47
+ * The `reapply` closure re-derives this row against the fresh tip if the
48
+ * landing contends, so a parallel writer's row is never erased. An
49
+ * ancestry-guard refusal ({@link AncestryRefusal}) is rethrown so the caller's
50
+ * `pushRowOrRefuse` can map it to the not-published non-zero envelope; every
51
+ * other throw is a network/credential failure that degrades to "saved locally".
52
+ *
53
+ * @param {object} wikiSync - The WikiSync collaborator (may be absent in tests).
54
+ * @param {object} runtime - The runtime bag (for stdout/stderr).
55
+ * @param {string} message - The commit message.
56
+ * @param {(freshText: string) => string | null} [reapply] - Re-derives the row
57
+ * against the fresh tip when the landing contends.
58
+ * @returns {Promise<{ok: boolean, code?: number}>}
59
+ */
60
+ async function pushWiki(wikiSync, runtime, message, reapply) {
61
+ if (!wikiSync) return { ok: true };
62
+ let result;
24
63
  try {
25
64
  await wikiSync.inheritIdentity();
26
65
  // claim/release contract is a 1-line MEMORY.md change; the pathspec keeps
27
- // foreign uncommitted files from parallel writers out of the commit.
28
- const result = await wikiSync.commitAndPush(message, ["MEMORY.md"]);
29
- if (result.pushed)
30
- runtime.proc.stdout.write("push: committed and pushed\n");
66
+ // foreign uncommitted files from parallel writers out of the commit. The
67
+ // `reapply` closure re-derives this row against the fresh tip if the landing
68
+ // contends, so a parallel writer's row is never erased.
69
+ result = await wikiSync.commitAndPush(message, ["MEMORY.md"], { reapply });
31
70
  } catch (err) {
71
+ // An ancestry-guard refusal pierces the saved-locally degradation: it must
72
+ // reach a non-zero exit so the session stops rather than scroll past. Every
73
+ // other failure is a network/credential failure: preserve fire-and-forget
74
+ // "saved locally" — the change is on disk and the command still succeeds.
75
+ if (err instanceof AncestryRefusal) throw err;
32
76
  createLogger("wiki", runtime).warn(
33
77
  "claim",
34
78
  `push failed (saved locally): ${err.message}`,
35
79
  );
80
+ return { ok: true };
81
+ }
82
+ const refusal = refusalEnvelope(runtime, result);
83
+ if (refusal) return refusal;
84
+ if (result.pushed) runtime.proc.stdout.write("push: committed and pushed\n");
85
+ return { ok: true };
86
+ }
87
+
88
+ /**
89
+ * Push a written claim/release row, mapping an ancestry-guard refusal to the
90
+ * not-published non-zero envelope and any other outcome to `{ ok: true }`. The
91
+ * row is already written to MEMORY.md; on refusal it stays as an uncommitted
92
+ * working-tree change. The `reapply` closure re-derives the same row against
93
+ * the fresh tip when the landing contends.
94
+ */
95
+ async function pushRowOrRefuse(wikiSync, runtime, message, reapply) {
96
+ try {
97
+ // Propagate pushWiki's envelope so a secret-gate refusal ({ ok: false })
98
+ // fails the command closed; a clean push returns { ok: true }.
99
+ return await pushWiki(wikiSync, runtime, message, reapply);
100
+ } catch (err) {
101
+ if (err instanceof AncestryRefusal) {
102
+ runtime.proc.stderr.write(notPublishedMessage(err));
103
+ return NOT_PUBLISHED;
104
+ }
105
+ throw err;
36
106
  }
37
107
  }
38
108
 
@@ -40,14 +110,13 @@ async function pushWiki(wikiSync, runtime, message) {
40
110
  export async function runClaimCommand(ctx) {
41
111
  const { runtime, wikiSync } = ctx.deps;
42
112
  const options = ctx.options;
43
- const agent = options.agent || runtime.proc.env.LIBEVAL_AGENT_PROFILE;
44
- if (!agent) {
45
- return {
46
- ok: false,
47
- code: 2,
48
- error: "claim requires --agent or LIBEVAL_AGENT_PROFILE",
49
- };
50
- }
113
+ const resolved = requireAgentFlag(options, {
114
+ command: "claim",
115
+ example:
116
+ "fit-wiki claim --agent staff-engineer --target spec-NNNN --branch claude/...",
117
+ });
118
+ if (!resolved.ok) return resolved;
119
+ const agent = resolved.agent;
51
120
  if (!options.target || !options.branch) {
52
121
  return {
53
122
  ok: false,
@@ -59,14 +128,15 @@ export async function runClaimCommand(ctx) {
59
128
  const expires = options["expires-at"] || addDays(today, 7);
60
129
  const memPath = memoryPath(runtime, options);
61
130
  const text = readMemory(runtime, memPath);
62
- const result = appendClaim(text, {
131
+ const claim = {
63
132
  agent,
64
133
  target: options.target,
65
134
  branch: options.branch,
66
135
  pr: options.pr || null,
67
136
  claimed_at: today,
68
137
  expires_at: expires,
69
- });
138
+ };
139
+ const result = appendClaim(text, claim);
70
140
  if (!result.inserted) {
71
141
  createLogger("wiki", runtime).warn(
72
142
  "claim",
@@ -76,8 +146,17 @@ export async function runClaimCommand(ctx) {
76
146
  }
77
147
  runtime.fsSync.writeFileSync(memPath, result.text);
78
148
  runtime.proc.stdout.write(`claimed ${options.target} (expires ${expires})\n`);
79
- await pushWiki(wikiSync, runtime, `wiki: claim ${options.target}`);
80
- return { ok: true };
149
+ // Re-apply the same append against the fresh tip if the landing contends.
150
+ const reapply = (fresh) => {
151
+ const r = appendClaim(fresh, claim);
152
+ return r.inserted ? r.text : null;
153
+ };
154
+ return pushRowOrRefuse(
155
+ wikiSync,
156
+ runtime,
157
+ `wiki: claim ${options.target}`,
158
+ reapply,
159
+ );
81
160
  }
82
161
 
83
162
  /** Remove a claim row. `--expired` cleans every row past expires_at. */
@@ -102,18 +181,35 @@ export async function runReleaseCommand(ctx) {
102
181
  }
103
182
  runtime.fsSync.writeFileSync(memPath, current);
104
183
  runtime.proc.stdout.write(`released ${count} expired claim(s)\n`);
105
- await pushWiki(wikiSync, runtime, "wiki: release expired claims");
106
- return { ok: true };
107
- }
108
-
109
- const agent = options.agent || runtime.proc.env.LIBEVAL_AGENT_PROFILE;
110
- if (!agent) {
111
- return {
112
- ok: false,
113
- code: 2,
114
- error: "release requires --agent or --expired",
184
+ // Re-derive expiry against the fresh tip so a renewal landed since the stale
185
+ // read survives; only still-expired rows are removed.
186
+ const reapply = (fresh) => {
187
+ const freshExpired = filterExpired(parseClaims(fresh), today).expired;
188
+ let next = fresh;
189
+ let anyRemoved = false;
190
+ for (const c of freshExpired) {
191
+ const r = removeClaim(next, { agent: c.agent, target: c.target });
192
+ if (r.removed) {
193
+ next = r.text;
194
+ anyRemoved = true;
195
+ }
196
+ }
197
+ return anyRemoved ? next : null;
115
198
  };
199
+ return pushRowOrRefuse(
200
+ wikiSync,
201
+ runtime,
202
+ "wiki: release expired claims",
203
+ reapply,
204
+ );
116
205
  }
206
+
207
+ const resolved = requireAgentFlag(options, {
208
+ command: "release",
209
+ example: "fit-wiki release --agent staff-engineer --target spec-NNNN",
210
+ });
211
+ if (!resolved.ok) return resolved;
212
+ const agent = resolved.agent;
117
213
  if (!options.target) {
118
214
  return {
119
215
  ok: false,
@@ -127,9 +223,19 @@ export async function runReleaseCommand(ctx) {
127
223
  runtime.proc.stdout.write(
128
224
  `no matching claim for ${agent}/${options.target}\n`,
129
225
  );
130
- } else {
131
- runtime.proc.stdout.write(`released ${options.target}\n`);
132
- await pushWiki(wikiSync, runtime, `wiki: release ${options.target}`);
226
+ return { ok: true };
133
227
  }
134
- return { ok: true };
228
+ runtime.proc.stdout.write(`released ${options.target}\n`);
229
+ // Re-apply the same removal against the fresh tip if the landing contends;
230
+ // re-removing an absent row is a no-op, so a re-release never resurrects it.
231
+ const reapply = (fresh) => {
232
+ const r = removeClaim(fresh, { agent, target: options.target });
233
+ return r.removed ? r.text : null;
234
+ };
235
+ return pushRowOrRefuse(
236
+ wikiSync,
237
+ runtime,
238
+ `wiki: release ${options.target}`,
239
+ reapply,
240
+ );
135
241
  }
@@ -38,14 +38,18 @@ function classOf(finding) {
38
38
  * Handing the agent the full contract for the files it edits — not just the
39
39
  * failing rules — stops it fixing one finding by breaking another (dropping
40
40
  * the `**Last run**:` line, appending a section after `## Open Blockers`, …).
41
+ *
42
+ * Only static-string hints are listed: a function hint is a per-finding
43
+ * resolved command (a `rotate` remediation the agent never performs), not a
44
+ * file invariant, so listing it would leak its source text into the prompt.
41
45
  */
42
46
  function invariantContract(findings) {
43
47
  const scopes = new Set(
44
48
  findings.map((f) => RULES.find((r) => r.id === f.id)?.scope),
45
49
  );
46
- return RULES.filter((r) => scopes.has(r.scope) && r.hint).map(
47
- (r) => `- ${r.id} — ${r.hint}`,
48
- );
50
+ return RULES.filter(
51
+ (r) => scopes.has(r.scope) && typeof r.hint === "string",
52
+ ).map((r) => `- ${r.id} — ${r.hint}`);
49
53
  }
50
54
 
51
55
  /**
@@ -106,7 +110,7 @@ function sealMainLog(agent, { wikiRoot, today, projectRoot, fs, out, err }) {
106
110
  wikiRoot,
107
111
  agent,
108
112
  today,
109
- 0,
113
+ { lines: 0, words: 0 },
110
114
  { force: true },
111
115
  fs,
112
116
  );
@@ -282,7 +286,11 @@ export async function runFixCommand(ctx) {
282
286
 
283
287
  // The agent's edits change the result, so re-read and re-audit each round.
284
288
  const audit = () =>
285
- runRules(RULES, buildContext({ wikiRoot, today, fs }), { resolveScope });
289
+ runRules(
290
+ RULES,
291
+ buildContext({ wikiRoot, today, fs, subprocess: runtime.subprocess }),
292
+ { resolveScope },
293
+ );
286
294
  // The agent only ever gets prose-judgment (`agent`-class) findings. A
287
295
  // `rotate` finding that survived the pre-pass (e.g. a prior-week log) is
288
296
  // unfixable by the agent — and trimming append-only history to satisfy a
@@ -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 };