@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.
- package/dist/cli/commands/cortex.js +4 -0
- package/dist/modules/cortex/harness.js +5 -2
- package/dist/modules/cortex/register.js +6 -3
- package/dist/modules/cortex/status.js +197 -0
- package/dist/modules/lawbook/assets/commands/cortex.md +2 -0
- package/dist/modules/lawbook/assets/skills/cortex/steps/01-load-or-start.md +27 -0
- package/dist/modules/lawbook/assets/skills/cortex/steps/02-dispatch-loop.md +15 -0
- package/dist/modules/lawbook/assets/skills/cortex/steps/03-complete.md +3 -1
- package/dist/modules/lawbook/change-tool.js +4 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
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)}'`);
|