@esneiderbravo/speclaw 2.0.7 → 2.0.8

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.
@@ -41,6 +41,10 @@ export async function runCortex(flags) {
41
41
  note: typeof flags.note === "string" ? flags.note : undefined,
42
42
  });
43
43
  printHarnessWarnings(result);
44
+ // The human line goes to stderr so stdout stays one JSON document.
45
+ if (op === "status" && !flags.json && "summary" in result && result.summary) {
46
+ process.stderr.write(` ${result.summary.line}\n`);
47
+ }
44
48
  console.log(JSON.stringify(result, null, 2));
45
49
  }
46
50
  catch (err) {
@@ -11,6 +11,7 @@
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";
14
15
  // Harness types live in the leaf `types.ts` so `compass-gate.ts` can use them
15
16
  // without importing this file back (no file-level import cycle).
16
17
  export { HARNESS_STAGES, } from "./types.js";
@@ -112,7 +113,8 @@ function allowedAdvance(from, level, verdict) {
112
113
  }
113
114
  }
114
115
  /**
115
- * Dispatch a harness op for a change.
116
+ * Dispatch a harness op for a change. `status` also returns the status
117
+ * summary (`req~cortex-status-summary~1`); other ops return the state.
116
118
  *
117
119
  * // Covers: req~harness-state~1
118
120
  */
@@ -120,7 +122,8 @@ export function handleHarness(args) {
120
122
  const { projectPath, change, harnessOp } = args;
121
123
  requireChangeDir(projectPath, change);
122
124
  if (harnessOp === "status") {
123
- return { state: readHarness(projectPath, change) };
125
+ const state = readHarness(projectPath, change);
126
+ return { summary: state ? buildStatusSummary(projectPath, state) : null, state };
124
127
  }
125
128
  if (harnessOp === "start") {
126
129
  const existing = readHarness(projectPath, change);
@@ -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,197 @@
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
+ /** Interval used when the key is missing or invalid. */
16
+ export const DEFAULT_STATUS_INTERVAL_MINUTES = 5;
17
+ /**
18
+ * Largest interval reported. A session timer is a cron expression, and
19
+ * an every-N-minutes step only exists for N ≤ 59; 60 is the hourly ping.
20
+ */
21
+ export const MAX_STATUS_INTERVAL_MINUTES = 60;
22
+ const CHECKBOX = /^\s*[-*]\s+\[( |x|X)\]/;
23
+ /**
24
+ * Count Markdown task-list items (`- [ ]`, `- [x]`, `* [X]`). Brackets outside
25
+ * a list item are ignored. This is a line scan, not a Markdown parser: items
26
+ * inside fenced code blocks are counted, and `+` bullets and numbered
27
+ * (`1. [ ]`) items are not.
28
+ *
29
+ * @param text - Markdown source.
30
+ * @returns Checked and total item counts.
31
+ */
32
+ export function countTaskCheckboxes(text) {
33
+ let done = 0;
34
+ let total = 0;
35
+ for (const lineText of text.split(/\r?\n/)) {
36
+ const m = CHECKBOX.exec(lineText);
37
+ if (!m)
38
+ continue;
39
+ total++;
40
+ if (m[1] !== " ")
41
+ done++;
42
+ }
43
+ return { done, total };
44
+ }
45
+ /**
46
+ * Read `statusIntervalMinutes` from the top-level `cortex:` block of
47
+ * `lawbook/config.yaml`. Quotes and a trailing comment are allowed. A missing
48
+ * file, block, or key, or any value that is not a whole number ≥ 0, yields 5.
49
+ * Values above 60 are clamped to 60. Never throws.
50
+ *
51
+ * This is a line scan, not a YAML parser: the key is accepted at any
52
+ * indentation under `cortex:` (nested maps included), and flow style
53
+ * (`cortex: {statusIntervalMinutes: 3}`) is not read, so it yields 5.
54
+ *
55
+ * @param projectPath - Project root.
56
+ *
57
+ * // Covers: req~cortex-status-interval~1
58
+ */
59
+ export function readStatusIntervalMinutes(projectPath) {
60
+ let raw;
61
+ try {
62
+ raw = fs.readFileSync(path.join(projectPath, "lawbook", "config.yaml"), "utf8");
63
+ }
64
+ catch {
65
+ return DEFAULT_STATUS_INTERVAL_MINUTES;
66
+ }
67
+ const lines = raw.split(/\r?\n/);
68
+ const start = lines.findIndex((l) => /^cortex:[ \t]*(?:#.*)?$/.test(l));
69
+ if (start < 0)
70
+ return DEFAULT_STATUS_INTERVAL_MINUTES;
71
+ for (let i = start + 1; i < lines.length; i++) {
72
+ const l = lines[i];
73
+ // The block ends at the next column-0 key; blank and comment lines are skipped.
74
+ if (/^\S/.test(l) && !l.startsWith("#"))
75
+ break;
76
+ const m = /^\s+statusIntervalMinutes:\s*(\S+)/.exec(l);
77
+ if (!m)
78
+ continue;
79
+ const value = m[1].replace(/#.*$/, "").replace(/^(["'])(.*)\1$/, "$2");
80
+ if (!/^\d+$/.test(value))
81
+ return DEFAULT_STATUS_INTERVAL_MINUTES;
82
+ return Math.min(Number(value), MAX_STATUS_INTERVAL_MINUTES);
83
+ }
84
+ return DEFAULT_STATUS_INTERVAL_MINUTES;
85
+ }
86
+ function readTaskCounts(projectPath, change) {
87
+ const dir = path.join(projectPath, "lawbook", "changes", change);
88
+ for (const file of ["tasks.md", "record.md"]) {
89
+ let text;
90
+ try {
91
+ text = fs.readFileSync(path.join(dir, file), "utf8");
92
+ }
93
+ catch {
94
+ continue;
95
+ }
96
+ return countTaskCheckboxes(text);
97
+ }
98
+ return null;
99
+ }
100
+ function elapsedSince(startedAt, now) {
101
+ if (!startedAt)
102
+ return null;
103
+ const t = Date.parse(startedAt);
104
+ if (Number.isNaN(t))
105
+ return null;
106
+ return Math.max(0, Math.floor((now.getTime() - t) / 60000));
107
+ }
108
+ /**
109
+ * Build the status summary of a running change from its harness state, its
110
+ * task list, and the configured status interval.
111
+ *
112
+ * @param projectPath - Project root.
113
+ * @param state - Harness state of the change.
114
+ * @param now - Clock for `elapsedMinutes`; injectable for tests.
115
+ * @returns The structured summary and its one-line English rendering.
116
+ *
117
+ * // Covers: req~cortex-status-summary~1
118
+ */
119
+ export function buildStatusSummary(projectPath, state, now = new Date()) {
120
+ const role = briefForStage(state.stage).role;
121
+ const startedAt = stageStartedAt(state);
122
+ const elapsedMinutes = elapsedSince(startedAt, now);
123
+ const tasks = readTaskCounts(projectPath, state.change);
124
+ const pendingVerdicts = [];
125
+ if (state.level >= 1 && state.verdicts.review !== "PASS")
126
+ pendingVerdicts.push("review");
127
+ if (state.verdicts.test !== "PASS")
128
+ pendingVerdicts.push("test");
129
+ const openQuestions = state.openQuestions.length;
130
+ const parts = [
131
+ state.change,
132
+ role ? `${state.stage} (${role})` : state.stage,
133
+ elapsedMinutes === null ? "elapsed n/a" : `${elapsedMinutes}m in stage`,
134
+ tasks ? `tasks ${tasks.done}/${tasks.total}` : "tasks n/a",
135
+ `rework ${state.iteration}/${state.maxRework}`,
136
+ `pending: ${pendingVerdicts.length ? pendingVerdicts.join(", ") : "none"}`,
137
+ ];
138
+ if (state.stage === "questions") {
139
+ parts.push(`waiting on human: ${openQuestions} question(s)`);
140
+ }
141
+ return {
142
+ change: state.change,
143
+ stage: state.stage,
144
+ role,
145
+ stageStartedAt: startedAt,
146
+ elapsedMinutes,
147
+ tasks,
148
+ iteration: state.iteration,
149
+ maxRework: state.maxRework,
150
+ pendingVerdicts,
151
+ openQuestions,
152
+ statusIntervalMinutes: readStatusIntervalMinutes(projectPath),
153
+ line: parts.join(" · "),
154
+ };
155
+ }
156
+ /**
157
+ * Fit a `status` result into the brief MCP output budget without ever cutting
158
+ * `summary`. `text()` truncates a payload from the end, which would yield
159
+ * invalid JSON and lose whatever comes last; this keeps the payload under the
160
+ * budget instead, by dropping the oldest `state.history` entries (newest
161
+ * kept) and recording how many were dropped in `historyOmitted`. If an empty
162
+ * history still does not fit, `state` becomes `null` with `stateOmitted`.
163
+ * Only the MCP rendering is trimmed: `harness.json` and the CLI keep the full
164
+ * history.
165
+ *
166
+ * @param result - The `handleHarness` `status` result.
167
+ * @param maxTokens - Token ceiling; defaults to the brief output budget.
168
+ * @returns A result whose pretty-printed JSON fits `maxTokens`.
169
+ *
170
+ * // Covers: req~cortex-status-summary~1
171
+ */
172
+ export function fitStatusResult(result, maxTokens = OUTPUT_BUDGET.brief) {
173
+ const fits = (value) => estimateTokens(JSON.stringify(value, null, 2)) <= maxTokens;
174
+ const { summary, state } = result;
175
+ const full = { summary, state };
176
+ if (!state || fits(full))
177
+ return full;
178
+ const history = state.history;
179
+ const keepLast = (k) => ({
180
+ summary,
181
+ historyOmitted: history.length - k,
182
+ state: { ...state, history: k > 0 ? history.slice(-k) : [] },
183
+ });
184
+ // Fitting is monotone in the number of kept entries: binary-search the most.
185
+ let lo = 0;
186
+ let hi = history.length - 1;
187
+ if (!fits(keepLast(0)))
188
+ return { summary, stateOmitted: true, state: null };
189
+ while (lo < hi) {
190
+ const mid = Math.ceil((lo + hi) / 2);
191
+ if (fits(keepLast(mid)))
192
+ lo = mid;
193
+ else
194
+ hi = mid - 1;
195
+ }
196
+ return keepLast(lo);
197
+ }
@@ -9,3 +9,5 @@ explorer → planner → implementer → reviewer → tester → archiver. Drive
9
9
  with the `cortex` MCP tool (`status`/`start`/`advance`/`rework`/`brief`) or
10
10
  `speclaw cortex …`. Surface planner questions to the human. Do not implement
11
11
  product code yourself.
12
+ The run posts status updates every `cortex.statusIntervalMinutes` minutes
13
+ (`lawbook/config.yaml`, default 5, `0` disables, at most 60).
@@ -22,4 +22,31 @@ Stage → role:
22
22
  | archiving | archiver (`sync` + `archive`) |
23
23
  | done | stop — report success |
24
24
 
25
+ ## Status updates
26
+
27
+ - Read `summary.statusIntervalMinutes` from the `status` result (after
28
+ `start`, run `status` once). `0` disables unsolicited updates; never parse
29
+ `lawbook/config.yaml` yourself.
30
+ - Create the timer only when the summary is non-null and the stage is not
31
+ `done`, the interval is greater than 0, and the host has a session timer
32
+ (Claude Code `CronCreate`, session-only, at least 1 minute). Then create
33
+ **one** recurring timer every `statusIntervalMinutes` minutes:
34
+ - schedule: for 1–59 minutes use `*/N * * * *`; for 60 (the engine caps the
35
+ interval at 60) use an hourly expression such as `7 * * * *`;
36
+ - prompt: the timer firing is a fresh prompt, so it must carry its context.
37
+ Name the change and the rules: run `cortex` action `status` for that
38
+ change and post the update in the session's language; skip the ping while
39
+ the stage is `questions`; when the stage is `done` or the run was stopped,
40
+ delete this timer (`CronDelete`) and post nothing.
41
+ - Remember the id that `CronCreate` returns; it is the run's status timer.
42
+ Before creating one, check that no status timer exists for this change
43
+ (your remembered id, or `CronList`), so there is exactly one per run.
44
+ `CronDelete` always targets that id.
45
+ - Create the timer only inside a Cortex run, never a second one per run, and
46
+ none when the interval is 0.
47
+ - Prefer background role dispatch: the timer fires only while the session is
48
+ idle, not during a blocking dispatch.
49
+ - On a host without a session timer, the after-op and before-dispatch updates
50
+ of the dispatch loop stand in for it.
51
+
25
52
  Next: read `steps/02-dispatch-loop.md` and do only what it says.
@@ -34,5 +34,20 @@ For the current Cortex stage (`cortex` action `brief` or `status`):
34
34
  4. Max rework is 3; if the engine rejects further rework, **ask the human**.
35
35
  5. Coordinator never edits `src/`, never archives, never pushes, never runs
36
36
  `laws accept`.
37
+ 6. **Status updates.** Unless `summary.statusIntervalMinutes` is 0, post a
38
+ compact update after every Cortex op and just before every blocking role
39
+ dispatch, built from the `status` summary fields:
40
+ - the change, the stage and active role, the elapsed time in the stage;
41
+ - tasks done/total, rework iteration/max, and the pending verdicts;
42
+ - in `questions`, that the human owes answers.
43
+
44
+ Write it in the session's language (the language of the human's most
45
+ recent messages); keep stage, role, and tool names, file paths, and change
46
+ names in English. `summary.line` is English-only; use it as is only in an
47
+ English session. Skip timer pings while the stage is `questions` (the
48
+ question round already speaks to the human). When the human asks to stop
49
+ the updates, delete the timer (`CronDelete` with the id `CronCreate`
50
+ returned) and post no further unsolicited update for this run. An explicit
51
+ request for status is always answered.
37
52
 
38
53
  Next: read `steps/03-complete.md` and do only what it says.
@@ -1,6 +1,8 @@
1
1
  # Complete
2
2
 
3
- When Cortex stage is `done` (or the human stops the run), summarize:
3
+ When Cortex stage is `done` (or the human stops the run), first delete the
4
+ status timer if one exists (`CronDelete` with the id `CronCreate` returned,
5
+ or the id `CronList` shows for this change), then summarize:
4
6
 
5
7
  - Change name and final stage
6
8
  - Review/test verdicts and iteration count
@@ -6,6 +6,7 @@ import { scaffoldFeature } from "./scaffold-change.js";
6
6
  import { buildCoverageReport, loadCoverageConfig, renderCoverageAgent } from "./coverage.js";
7
7
  import { buildDriftReport, renderDriftAgent } from "./drift.js";
8
8
  import { handleHarness, harnessOps } from "../cortex/harness.js";
9
+ import { fitStatusResult } from "../cortex/status.js";
9
10
  export const lawbookChangeActions = [
10
11
  "init",
11
12
  "list",
@@ -111,7 +112,7 @@ export function handleLawbookChange(args) {
111
112
  if (!args.harnessOp) {
112
113
  throw new Error(`lawbook_change: action 'harness' requires 'harnessOp'`);
113
114
  }
114
- return handleHarness({
115
+ const result = handleHarness({
115
116
  projectPath: args.projectPath,
116
117
  change: requireField(args, "change"),
117
118
  harnessOp: args.harnessOp,
@@ -120,6 +121,8 @@ export function handleLawbookChange(args) {
120
121
  pauseForQuestions: args.pauseForQuestions,
121
122
  note: args.note ?? args.reason,
122
123
  });
124
+ // MCP-only path: fit `status` to the output budget like the `cortex` tool.
125
+ return args.harnessOp === "status" && "summary" in result ? fitStatusResult(result) : result;
123
126
  }
124
127
  default:
125
128
  throw new Error(`lawbook_change: unknown action '${String(args.action)}'`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "2.0.7",
3
+ "version": "2.0.8",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },