@esneiderbravo/speclaw 2.0.7 → 2.0.9

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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Self-update for `speclaw update`: re-run the command as
3
+ * `npx -y <pkg>@<latest> update …` so the latest release applies its own
4
+ * migrations instead of a stale binary applying old ones. This is the only CLI
5
+ * module that imports `child_process`; `update.ts` receives it through
6
+ * `UpdateHooks` so tests never spawn anything.
7
+ */
8
+ // Covers: req~update-self-update~1
9
+ import { spawn } from "node:child_process";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ /**
13
+ * Flag tokens safe to forward to the child: `-x`, `--flag`, or `--flag=value`
14
+ * with a conservative value charset. The charset also keeps the Windows shell
15
+ * spawn free of injection.
16
+ */
17
+ const SAFE_TOKEN = /^--?[A-Za-z][A-Za-z0-9-]*(=[A-Za-z0-9._/:@-]*)?$/;
18
+ /**
19
+ * A registry version that is safe to put on a command line: strict semver with
20
+ * an optional prerelease tag. Anything else (for example `"9.9.9 & calc"`) is
21
+ * never spawned — it would reach the Windows shell verbatim.
22
+ */
23
+ const SAFE_VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
24
+ /**
25
+ * Whether `version` is a strict semver string safe to spawn with.
26
+ *
27
+ * @param version - The `latest` version reported by the registry.
28
+ * @returns True only for `MAJOR.MINOR.PATCH` with an optional `-prerelease`.
29
+ */
30
+ export function isSafeVersion(version) {
31
+ return SAFE_VERSION.test(version);
32
+ }
33
+ /**
34
+ * Whether `command` exists as a file in a directory on the env's PATH (the key
35
+ * is matched case-insensitively, since Windows spells it `Path`).
36
+ *
37
+ * @param command - The executable file name, e.g. `npx.cmd`.
38
+ * @param env - The environment whose PATH is searched.
39
+ * @param delimiter - The PATH separator (`;` on Windows).
40
+ * @returns True when some PATH entry holds `command`.
41
+ */
42
+ export function onPath(command, env, delimiter = path.delimiter) {
43
+ const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH");
44
+ const dirs = (key ? (env[key] ?? "") : "").split(delimiter).filter(Boolean);
45
+ return dirs.some((dir) => fs.existsSync(path.join(dir, command)));
46
+ }
47
+ /**
48
+ * Split the original `update` arguments into the ones forwarded to the child
49
+ * and the ones dropped. `update` takes no positional arguments, so dropping
50
+ * anything that is not a safe flag loses nothing in practice.
51
+ *
52
+ * @param argv - The arguments after `speclaw update`.
53
+ * @returns The forwarded tokens (in order) and the dropped ones.
54
+ */
55
+ export function safeForwardArgs(argv) {
56
+ const forward = [];
57
+ const dropped = [];
58
+ for (const token of argv)
59
+ (SAFE_TOKEN.test(token) ? forward : dropped).push(token);
60
+ return { forward, dropped };
61
+ }
62
+ /**
63
+ * Run `npx -y <pkg>@<version> update <args>` with inherited stdio and the loop
64
+ * guard `SPECLAW_SELF_UPDATED=<version>` in its environment. On Windows the
65
+ * `npx.cmd` shim is spawned through the shell (Node refuses `.cmd` files
66
+ * otherwise); `args` must already be filtered by {@link safeForwardArgs}. A
67
+ * version that is not strict semver is never spawned, and on Windows a missing
68
+ * `npx.cmd` is detected up front (the shell would turn it into exit code 1
69
+ * rather than an ENOENT), so both report `unavailable` and the caller migrates
70
+ * in process.
71
+ *
72
+ * @param opts - Package name, target version, forwarded args, the base env,
73
+ * and the platform (defaults to `process.platform`; tests pass `win32`).
74
+ * @returns `ran` with the child's exit code, or `unavailable` when it could not start.
75
+ */
76
+ export function selfUpdate(opts) {
77
+ if (!isSafeVersion(opts.version)) {
78
+ return Promise.resolve({
79
+ kind: "unavailable",
80
+ reason: `invalid version ${JSON.stringify(opts.version)}`,
81
+ });
82
+ }
83
+ const windows = (opts.platform ?? process.platform) === "win32";
84
+ const command = windows ? "npx.cmd" : "npx";
85
+ const args = ["-y", `${opts.pkg}@${opts.version}`, "update", ...opts.args];
86
+ const env = { ...(opts.env ?? process.env), SPECLAW_SELF_UPDATED: opts.version };
87
+ if (windows && !onPath(command, env, ";")) {
88
+ return Promise.resolve({ kind: "unavailable", reason: `${command} not found on PATH` });
89
+ }
90
+ return new Promise((resolve) => {
91
+ let settled = false;
92
+ const done = (outcome) => {
93
+ if (settled)
94
+ return;
95
+ settled = true;
96
+ resolve(outcome);
97
+ };
98
+ try {
99
+ const child = spawn(command, args, { stdio: "inherit", env, shell: windows });
100
+ child.on("error", (err) => done({ kind: "unavailable", reason: err.message }));
101
+ child.on("exit", (code) => done({ kind: "ran", code: code ?? 1 }));
102
+ }
103
+ catch (err) {
104
+ done({ kind: "unavailable", reason: err.message });
105
+ }
106
+ });
107
+ }
@@ -77,23 +77,28 @@ export function isNewer(latest, current) {
77
77
  * cache unless `force` is set. Falls back to stale cache when offline.
78
78
  *
79
79
  * @param opts - `force` bypasses the cache and always queries the registry.
80
- * @returns The current/latest versions and whether an upgrade is available.
80
+ * @returns The current/latest versions, whether an upgrade is available, and
81
+ * `fresh`: true only when `latest` came from the registry during this call
82
+ * (a cached or offline value is not fresh — `npx` could not resolve it either).
81
83
  */
82
84
  export async function checkForUpdates(opts = {}) {
83
85
  const current = pkgVersion();
84
86
  const cache = readCache();
85
87
  let latest;
88
+ let fresh = false;
86
89
  if (!opts.force && cache && Date.now() - cache.checkedAt < TTL_MS) {
87
90
  latest = cache.latest;
88
91
  }
89
92
  else {
90
93
  latest = await fetchLatest(pkgName());
91
- if (latest)
94
+ if (latest) {
95
+ fresh = true;
92
96
  writeCache(latest);
97
+ }
93
98
  else if (cache)
94
99
  latest = cache.latest; // offline: use whatever we last knew
95
100
  }
96
- return { current, latest, updateAvailable: !!latest && isNewer(latest, current) };
101
+ return { current, latest, updateAvailable: !!latest && isNewer(latest, current), fresh };
97
102
  }
98
103
  /** The public npm page for a package, where an upgrade can be reviewed. */
99
104
  export function npmPackageUrl(name) {
@@ -102,8 +107,8 @@ export function npmPackageUrl(name) {
102
107
  /**
103
108
  * Build the two-line "update available" notice. The latest version is rendered
104
109
  * as a clickable link to the package's npm page, so a capable terminal lets the
105
- * user open the release with a single click. Project migrations stay
106
- * `speclaw update`; the binary is upgraded separately (prefer npx).
110
+ * user open the release with a single click. `speclaw update` upgrades itself
111
+ * (it re-runs at the latest version through npx) and migrates the project.
107
112
  *
108
113
  * @param current - The installed version.
109
114
  * @param latest - The newest published version.
@@ -119,16 +124,18 @@ export function upgradeNotice(current, latest) {
119
124
  c.muted(" available") +
120
125
  "\n" +
121
126
  " " +
122
- c.muted("prefer ") +
123
- c.cyan(`npx ${name}@latest update`) +
124
- c.muted(" — or ") +
127
+ c.muted("run ") +
125
128
  c.cyan("speclaw update") +
126
- c.muted(" to migrate this project"));
129
+ c.muted(" (or ") +
130
+ c.cyan(`npx ${name}@latest update`) +
131
+ c.muted(") — it upgrades itself through npx and migrates this project"));
127
132
  }
128
133
  /**
129
134
  * Print the "update available" notice to stderr when a newer version exists.
130
135
  * No-op for the `mcp`/`update`/`help`/`version` commands, on non-TTY stderr, or
131
136
  * when NO_UPDATE_NOTIFIER / SPECLAW_NO_UPDATE_NOTIFIER is set. Never throws.
137
+ * `SPECLAW_UPDATE_NOTIFIER=force` lifts only the TTY check, so a test with
138
+ * piped stderr can prove a command path does (or does not) reach the notifier.
132
139
  *
133
140
  * @param cmd - The command that just ran (used to skip noisy contexts).
134
141
  */
@@ -136,7 +143,7 @@ export async function maybeNotifyUpdate(cmd) {
136
143
  try {
137
144
  if (process.env.NO_UPDATE_NOTIFIER || process.env.SPECLAW_NO_UPDATE_NOTIFIER)
138
145
  return;
139
- if (!process.stderr.isTTY)
146
+ if (!process.stderr.isTTY && process.env.SPECLAW_UPDATE_NOTIFIER !== "force")
140
147
  return;
141
148
  // `init` shows its own prominent up-front warning and ends on the clean
142
149
  // copy-paste prompt — don't append a second notice after it.
@@ -57,7 +57,9 @@ export function briefForStage(stage) {
57
57
  role: "archiver",
58
58
  agentPath: "ai-specs/agents/archiver.md",
59
59
  skillHints: ["sync", "archive"],
60
- nextOps: ["advance"],
60
+ // A successful `lawbook_change` archive completes the harness itself;
61
+ // no Cortex op follows (confirm `done` with `status`).
62
+ nextOps: [],
61
63
  };
62
64
  case "done":
63
65
  case null:
@@ -11,6 +11,9 @@
11
11
  import fs from "node:fs";
12
12
  import path from "node:path";
13
13
  import { GATED_STAGES, compassGateError, compassGateWarning, evaluateCompassGate, readCompassGateMode, } from "./compass-gate.js";
14
+ import { buildStatusSummary } from "./status.js";
15
+ import { resolveChangeDir } from "./paths.js";
16
+ export { resolveChangeDir } from "./paths.js";
14
17
  // Harness types live in the leaf `types.ts` so `compass-gate.ts` can use them
15
18
  // without importing this file back (no file-level import cycle).
16
19
  export { HARNESS_STAGES, } from "./types.js";
@@ -20,7 +23,8 @@ function specRoot(projectPath) {
20
23
  return path.join(projectPath, "lawbook");
21
24
  }
22
25
  function changeDir(projectPath, change) {
23
- return path.join(specRoot(projectPath), "changes", change);
26
+ return (resolveChangeDir(projectPath, change)?.dir ??
27
+ path.join(specRoot(projectPath), "changes", change));
24
28
  }
25
29
  function harnessPath(projectPath, change) {
26
30
  return path.join(changeDir(projectPath, change), "harness.json");
@@ -29,11 +33,11 @@ function nowIso() {
29
33
  return new Date().toISOString();
30
34
  }
31
35
  function requireChangeDir(projectPath, change) {
32
- const dir = changeDir(projectPath, change);
33
- if (!fs.existsSync(dir)) {
36
+ const resolved = resolveChangeDir(projectPath, change);
37
+ if (!resolved) {
34
38
  throw new Error(`change "${change}" not found under lawbook/changes/`);
35
39
  }
36
- return dir;
40
+ return resolved;
37
41
  }
38
42
  /**
39
43
  * Read confirmed ceremony level from change.json without importing lawbook.
@@ -112,15 +116,23 @@ function allowedAdvance(from, level, verdict) {
112
116
  }
113
117
  }
114
118
  /**
115
- * Dispatch a harness op for a change.
119
+ * Dispatch a harness op for a change. `status` also returns the status
120
+ * summary (`req~cortex-status-summary~1`); other ops return the state.
116
121
  *
117
122
  * // Covers: req~harness-state~1
118
123
  */
119
124
  export function handleHarness(args) {
120
125
  const { projectPath, change, harnessOp } = args;
121
- requireChangeDir(projectPath, change);
126
+ const resolved = requireChangeDir(projectPath, change);
122
127
  if (harnessOp === "status") {
123
- return { state: readHarness(projectPath, change) };
128
+ const state = readHarness(projectPath, change);
129
+ return { summary: state ? buildStatusSummary(projectPath, state) : null, state };
130
+ }
131
+ // An archived change is read-only: reject mutating ops before any write.
132
+ // Covers: req~harness-archive-completes~1
133
+ if (resolved.archived) {
134
+ throw new Error(`change ${change} is archived (${path.relative(projectPath, resolved.dir)}); ` +
135
+ `Cortex ops are read-only`);
124
136
  }
125
137
  if (harnessOp === "start") {
126
138
  const existing = readHarness(projectPath, change);
@@ -255,6 +267,43 @@ export function handleHarness(args) {
255
267
  }
256
268
  throw new Error(`unknown harnessOp '${String(harnessOp)}'`);
257
269
  }
270
+ /**
271
+ * Complete a change's harness as part of archiving: move stage `archiving` to
272
+ * `done` with one history entry. Called by the lawbook archive **before** the
273
+ * change directory moves, so a crash cannot leave the harness stuck; the
274
+ * returned `restore` puts the old bytes back if the move fails. A missing
275
+ * harness or one already `done` is left untouched.
276
+ *
277
+ * @param projectPath - Absolute path to the project root.
278
+ * @param change - Change name (an active change).
279
+ * @param note - History note naming the archive.
280
+ * @returns Whether the harness was completed and how to undo it.
281
+ * @throws If the harness is in any other stage (the archive gate prevents this).
282
+ */
283
+ // Covers: req~harness-archive-completes~1
284
+ export function completeHarnessOnArchive(projectPath, change, note) {
285
+ const noop = { completed: false, restore: () => { } };
286
+ const p = harnessPath(projectPath, change);
287
+ if (!fs.existsSync(p))
288
+ return noop;
289
+ const original = fs.readFileSync(p, "utf8");
290
+ const state = JSON.parse(original);
291
+ if (state.stage === "done")
292
+ return noop;
293
+ if (state.stage !== "archiving") {
294
+ throw new Error(`harness stage is ${state.stage} — advance to archiving before archive (${change})`);
295
+ }
296
+ const next = {
297
+ ...state,
298
+ stage: "done",
299
+ history: [
300
+ ...state.history,
301
+ { at: nowIso(), from: "archiving", to: "done", op: "advance", note },
302
+ ],
303
+ };
304
+ fs.writeFileSync(p, JSON.stringify(next, null, 2) + "\n");
305
+ return { completed: true, restore: () => fs.writeFileSync(p, original) };
306
+ }
258
307
  /**
259
308
  * Archive blockers derived from harness verdicts.
260
309
  *
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Change-directory resolution shared by the Cortex harness and its status
3
+ * summary. A leaf module (no Cortex imports) so both can use it without an
4
+ * import cycle; Cortex never imports the lawbook module.
5
+ */
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ function escapeRegExp(s) {
9
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
10
+ }
11
+ /**
12
+ * Resolve a change name to its directory: the active `lawbook/changes/<name>/`
13
+ * when it exists, otherwise the newest `lawbook/changes/archive/<YYYY-MM-DD>-<name>/`
14
+ * (matched exactly, so `<date>-other-<name>` never matches `<name>`).
15
+ *
16
+ * @param projectPath - Absolute path to the project root.
17
+ * @param change - Change name.
18
+ * @returns The directory and whether it is archived, or null when neither exists.
19
+ */
20
+ // Covers: req~harness-archive-completes~1
21
+ export function resolveChangeDir(projectPath, change) {
22
+ const active = path.join(projectPath, "lawbook", "changes", change);
23
+ if (fs.existsSync(active))
24
+ return { dir: active, archived: false };
25
+ const archiveRoot = path.join(projectPath, "lawbook", "changes", "archive");
26
+ let entries;
27
+ try {
28
+ entries = fs.readdirSync(archiveRoot);
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ const re = new RegExp(`^\\d{4}-\\d{2}-\\d{2}-${escapeRegExp(change)}$`);
34
+ const newest = entries
35
+ .filter((e) => re.test(e))
36
+ .sort()
37
+ .at(-1);
38
+ return newest ? { dir: path.join(archiveRoot, newest), archived: true } : null;
39
+ }
@@ -8,6 +8,7 @@ import { defineTool, text } from "../../shared/mcp.js";
8
8
  import { shouldExpose } from "../../shared/exposure.js";
9
9
  import { handleHarness, harnessOps, readHarness, } from "./harness.js";
10
10
  import { briefForStage } from "./brief.js";
11
+ import { fitStatusResult } from "./status.js";
11
12
  export const cortexActions = [...harnessOps, "brief"];
12
13
  /**
13
14
  * Register the canonical `cortex` MCP tool.
@@ -22,7 +23,7 @@ export function registerCortex(server, opts = {}) {
22
23
  return;
23
24
  defineTool(server, { name, description, inputSchema, handler });
24
25
  };
25
- add("cortex", "Cortex multi-agent loop: status, start, advance, rework, brief for a change.", {
26
+ add("cortex", "Cortex multi-agent loop: status with summary, start, advance, rework, brief for a change.", {
26
27
  projectPath: z.string(),
27
28
  change: z.string(),
28
29
  action: z.enum(cortexActions),
@@ -36,7 +37,7 @@ export function registerCortex(server, opts = {}) {
36
37
  const brief = briefForStage(state?.stage ?? null);
37
38
  return text(JSON.stringify({ state, ...brief }, null, 2));
38
39
  }
39
- return text(handleHarness({
40
+ const result = handleHarness({
40
41
  projectPath,
41
42
  change,
42
43
  harnessOp: action,
@@ -44,6 +45,8 @@ export function registerCortex(server, opts = {}) {
44
45
  verdict: verdict ?? null,
45
46
  openQuestions,
46
47
  pauseForQuestions,
47
- }));
48
+ });
49
+ // `text()` cuts from the end; fit `status` so `summary` and valid JSON survive.
50
+ return text(action === "status" && "summary" in result ? fitStatusResult(result) : result);
48
51
  });
49
52
  }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Per-change status summary for Cortex `status`: stage, active role, time in
3
+ * stage, task progress, rework, pending verdicts, and the configured interval
4
+ * at which the coordinator posts status updates.
5
+ *
6
+ * Cortex must not import from the lawbook module, so task checkboxes and the
7
+ * `cortex.statusIntervalMinutes` key are read with local text scans, like
8
+ * `readCompassGateMode`.
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { briefForStage } from "./brief.js";
13
+ import { stageStartedAt } from "./compass-gate.js";
14
+ import { OUTPUT_BUDGET, estimateTokens } from "../../shared/output-budget.js";
15
+ import { resolveChangeDir } from "./paths.js";
16
+ /** Interval used when the key is missing or invalid. */
17
+ export const DEFAULT_STATUS_INTERVAL_MINUTES = 5;
18
+ /**
19
+ * Largest interval reported. A session timer is a cron expression, and
20
+ * an every-N-minutes step only exists for N ≤ 59; 60 is the hourly ping.
21
+ */
22
+ export const MAX_STATUS_INTERVAL_MINUTES = 60;
23
+ const CHECKBOX = /^\s*[-*]\s+\[( |x|X)\]/;
24
+ /**
25
+ * Count Markdown task-list items (`- [ ]`, `- [x]`, `* [X]`). Brackets outside
26
+ * a list item are ignored. This is a line scan, not a Markdown parser: items
27
+ * inside fenced code blocks are counted, and `+` bullets and numbered
28
+ * (`1. [ ]`) items are not.
29
+ *
30
+ * @param text - Markdown source.
31
+ * @returns Checked and total item counts.
32
+ */
33
+ export function countTaskCheckboxes(text) {
34
+ let done = 0;
35
+ let total = 0;
36
+ for (const lineText of text.split(/\r?\n/)) {
37
+ const m = CHECKBOX.exec(lineText);
38
+ if (!m)
39
+ continue;
40
+ total++;
41
+ if (m[1] !== " ")
42
+ done++;
43
+ }
44
+ return { done, total };
45
+ }
46
+ /**
47
+ * Read `statusIntervalMinutes` from the top-level `cortex:` block of
48
+ * `lawbook/config.yaml`. Quotes and a trailing comment are allowed. A missing
49
+ * file, block, or key, or any value that is not a whole number ≥ 0, yields 5.
50
+ * Values above 60 are clamped to 60. Never throws.
51
+ *
52
+ * This is a line scan, not a YAML parser: the key is accepted at any
53
+ * indentation under `cortex:` (nested maps included), and flow style
54
+ * (`cortex: {statusIntervalMinutes: 3}`) is not read, so it yields 5.
55
+ *
56
+ * @param projectPath - Project root.
57
+ *
58
+ * // Covers: req~cortex-status-interval~1
59
+ */
60
+ export function readStatusIntervalMinutes(projectPath) {
61
+ let raw;
62
+ try {
63
+ raw = fs.readFileSync(path.join(projectPath, "lawbook", "config.yaml"), "utf8");
64
+ }
65
+ catch {
66
+ return DEFAULT_STATUS_INTERVAL_MINUTES;
67
+ }
68
+ const lines = raw.split(/\r?\n/);
69
+ const start = lines.findIndex((l) => /^cortex:[ \t]*(?:#.*)?$/.test(l));
70
+ if (start < 0)
71
+ return DEFAULT_STATUS_INTERVAL_MINUTES;
72
+ for (let i = start + 1; i < lines.length; i++) {
73
+ const l = lines[i];
74
+ // The block ends at the next column-0 key; blank and comment lines are skipped.
75
+ if (/^\S/.test(l) && !l.startsWith("#"))
76
+ break;
77
+ const m = /^\s+statusIntervalMinutes:\s*(\S+)/.exec(l);
78
+ if (!m)
79
+ continue;
80
+ const value = m[1].replace(/#.*$/, "").replace(/^(["'])(.*)\1$/, "$2");
81
+ if (!/^\d+$/.test(value))
82
+ return DEFAULT_STATUS_INTERVAL_MINUTES;
83
+ return Math.min(Number(value), MAX_STATUS_INTERVAL_MINUTES);
84
+ }
85
+ return DEFAULT_STATUS_INTERVAL_MINUTES;
86
+ }
87
+ function readTaskCounts(projectPath, change) {
88
+ // An archived change still reports its tasks from the archive directory.
89
+ const dir = resolveChangeDir(projectPath, change)?.dir ??
90
+ path.join(projectPath, "lawbook", "changes", change);
91
+ for (const file of ["tasks.md", "record.md"]) {
92
+ let text;
93
+ try {
94
+ text = fs.readFileSync(path.join(dir, file), "utf8");
95
+ }
96
+ catch {
97
+ continue;
98
+ }
99
+ return countTaskCheckboxes(text);
100
+ }
101
+ return null;
102
+ }
103
+ function elapsedSince(startedAt, now) {
104
+ if (!startedAt)
105
+ return null;
106
+ const t = Date.parse(startedAt);
107
+ if (Number.isNaN(t))
108
+ return null;
109
+ return Math.max(0, Math.floor((now.getTime() - t) / 60000));
110
+ }
111
+ /**
112
+ * Build the status summary of a running change from its harness state, its
113
+ * task list, and the configured status interval.
114
+ *
115
+ * @param projectPath - Project root.
116
+ * @param state - Harness state of the change.
117
+ * @param now - Clock for `elapsedMinutes`; injectable for tests.
118
+ * @returns The structured summary and its one-line English rendering.
119
+ *
120
+ * // Covers: req~cortex-status-summary~1
121
+ */
122
+ export function buildStatusSummary(projectPath, state, now = new Date()) {
123
+ const role = briefForStage(state.stage).role;
124
+ const startedAt = stageStartedAt(state);
125
+ const elapsedMinutes = elapsedSince(startedAt, now);
126
+ const tasks = readTaskCounts(projectPath, state.change);
127
+ const pendingVerdicts = [];
128
+ if (state.level >= 1 && state.verdicts.review !== "PASS")
129
+ pendingVerdicts.push("review");
130
+ if (state.verdicts.test !== "PASS")
131
+ pendingVerdicts.push("test");
132
+ const openQuestions = state.openQuestions.length;
133
+ const parts = [
134
+ state.change,
135
+ role ? `${state.stage} (${role})` : state.stage,
136
+ elapsedMinutes === null ? "elapsed n/a" : `${elapsedMinutes}m in stage`,
137
+ tasks ? `tasks ${tasks.done}/${tasks.total}` : "tasks n/a",
138
+ `rework ${state.iteration}/${state.maxRework}`,
139
+ `pending: ${pendingVerdicts.length ? pendingVerdicts.join(", ") : "none"}`,
140
+ ];
141
+ if (state.stage === "questions") {
142
+ parts.push(`waiting on human: ${openQuestions} question(s)`);
143
+ }
144
+ return {
145
+ change: state.change,
146
+ stage: state.stage,
147
+ role,
148
+ stageStartedAt: startedAt,
149
+ elapsedMinutes,
150
+ tasks,
151
+ iteration: state.iteration,
152
+ maxRework: state.maxRework,
153
+ pendingVerdicts,
154
+ openQuestions,
155
+ statusIntervalMinutes: readStatusIntervalMinutes(projectPath),
156
+ line: parts.join(" · "),
157
+ };
158
+ }
159
+ /**
160
+ * Fit a `status` result into the brief MCP output budget without ever cutting
161
+ * `summary`. `text()` truncates a payload from the end, which would yield
162
+ * invalid JSON and lose whatever comes last; this keeps the payload under the
163
+ * budget instead, by dropping the oldest `state.history` entries (newest
164
+ * kept) and recording how many were dropped in `historyOmitted`. If an empty
165
+ * history still does not fit, `state` becomes `null` with `stateOmitted`.
166
+ * Only the MCP rendering is trimmed: `harness.json` and the CLI keep the full
167
+ * history.
168
+ *
169
+ * @param result - The `handleHarness` `status` result.
170
+ * @param maxTokens - Token ceiling; defaults to the brief output budget.
171
+ * @returns A result whose pretty-printed JSON fits `maxTokens`.
172
+ *
173
+ * // Covers: req~cortex-status-summary~1
174
+ */
175
+ export function fitStatusResult(result, maxTokens = OUTPUT_BUDGET.brief) {
176
+ const fits = (value) => estimateTokens(JSON.stringify(value, null, 2)) <= maxTokens;
177
+ const { summary, state } = result;
178
+ const full = { summary, state };
179
+ if (!state || fits(full))
180
+ return full;
181
+ const history = state.history;
182
+ const keepLast = (k) => ({
183
+ summary,
184
+ historyOmitted: history.length - k,
185
+ state: { ...state, history: k > 0 ? history.slice(-k) : [] },
186
+ });
187
+ // Fitting is monotone in the number of kept entries: binary-search the most.
188
+ let lo = 0;
189
+ let hi = history.length - 1;
190
+ if (!fits(keepLast(0)))
191
+ return { summary, stateOmitted: true, state: null };
192
+ while (lo < hi) {
193
+ const mid = Math.ceil((lo + hi) / 2);
194
+ if (fits(keepLast(mid)))
195
+ lo = mid;
196
+ else
197
+ hi = mid - 1;
198
+ }
199
+ return keepLast(lo);
200
+ }
@@ -4,7 +4,7 @@ import { globError, isActiveLaw, readLawManifest, seedManifestFor, mergeSeedLaws
4
4
  import { parseLawsFromStandards } from "./laws-parse.js";
5
5
  import { agentsmdDialect, claudeRulesDialect, coderabbitDialect, copilotDialect, cursorMdcDialect, patchDelimited, } from "./dialects/index.js";
6
6
  import { detectConfiguredAgents } from "../../shared/agents.js";
7
- import { digestText, provenanceBlock, refreshLockfile } from "./lock.js";
7
+ import { digestText, driftedStrictPaths, provenanceBlock, refreshLockfile } from "./lock.js";
8
8
  const DIALECTS = [
9
9
  claudeRulesDialect,
10
10
  cursorMdcDialect,
@@ -159,7 +159,21 @@ export function compileLaws(opts) {
159
159
  failed: [],
160
160
  lawCount: 0,
161
161
  draftCount: 0,
162
+ lockPreserved: [],
162
163
  };
164
+ // Snapshot drift before any rule artifact is written: compilation rewrites
165
+ // the speclaw regions of CLAUDE.md / AGENTS.md, which must not launder edits.
166
+ // An existing lock that cannot be read is reported and left untouched, never
167
+ // rebuilt from disk (that would launder drift); compilation itself proceeds.
168
+ let drifted = opts.drifted ?? [];
169
+ if (opts.drifted === undefined && opts.refreshLock !== false) {
170
+ try {
171
+ drifted = driftedStrictPaths(projectPath);
172
+ }
173
+ catch (err) {
174
+ report.lockError = err.message;
175
+ }
176
+ }
163
177
  const laws = mergeLawSources(projectPath);
164
178
  validateScopes(laws);
165
179
  report.lawCount = laws.filter(isActiveLaw).length;
@@ -185,11 +199,14 @@ export function compileLaws(opts) {
185
199
  if (agents.includes("claude"))
186
200
  ensureClaudeRulesSymlink(projectPath, report);
187
201
  // Covers: req~lock-refresh-update~1
188
- try {
189
- refreshLockfile(projectPath);
190
- }
191
- catch {
192
- // Lock refresh must not fail compile; `speclaw laws lock` surfaces errors.
202
+ if (opts.refreshLock !== false && report.lockError === undefined) {
203
+ try {
204
+ report.lockPreserved = refreshLockfile(projectPath, { drifted }).preserved;
205
+ }
206
+ catch (err) {
207
+ // refreshLockfile throws before writing; the caller surfaces lockError.
208
+ report.lockError = err.message;
209
+ }
193
210
  }
194
211
  return report;
195
212
  }
@@ -557,6 +557,15 @@ function specsOrphansCheck(projectPath) {
557
557
  remedy: `speclaw lawbook archive ${active[0]}`,
558
558
  };
559
559
  }
560
+ /**
561
+ * Fix hint for an unreadable or invalid `speclaw.lock`. `laws lock` refuses
562
+ * such a lock, and deleting it re-baselines every pinned file, silently
563
+ * accepting any pending drift — so that is offered only as a last resort.
564
+ */
565
+ export const UNREADABLE_LOCK_REMEDY = "resolve the merge conflict in speclaw.lock or restore it from git " +
566
+ "(git checkout -- speclaw.lock); upgrade speclaw if the lockfileVersion is newer. " +
567
+ "Last resort: delete it and run speclaw laws lock — this re-baselines every " +
568
+ "pinned file and accepts any pending drift";
560
569
  function integrityChecks(projectPath) {
561
570
  // Covers: req~doctor-integrity~1
562
571
  const out = [];
@@ -591,7 +600,8 @@ function integrityChecks(projectPath) {
591
600
  title: "rule lockfile",
592
601
  status: "error",
593
602
  detail: err.message,
594
- remedy: "speclaw laws lock",
603
+ // `laws lock` refuses an unreadable lock, so point at repairing it.
604
+ remedy: UNREADABLE_LOCK_REMEDY,
595
605
  });
596
606
  }
597
607
  }