@glrs-dev/cli 2.2.0 → 2.3.0
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/CHANGELOG.md +2 -0
- package/dist/{chunk-SB3MLROC.js → chunk-MIWZLETC.js} +7 -2
- package/dist/cli.js +1 -1
- package/dist/lib/auto-update.js +1 -1
- package/dist/vendor/harness-opencode/dist/agents/prompts/build.md +16 -0
- package/dist/vendor/harness-opencode/dist/agents/prompts/code-reviewer-thorough.md +6 -7
- package/dist/vendor/harness-opencode/dist/agents/prompts/debriefer.md +55 -0
- package/dist/vendor/harness-opencode/dist/agents/prompts/plan-reviewer.md +2 -1
- package/dist/vendor/harness-opencode/dist/agents/prompts/plan.md +97 -7
- package/dist/vendor/harness-opencode/dist/agents/prompts/prime.md +4 -2
- package/dist/vendor/harness-opencode/dist/agents/prompts/scoper.md +129 -0
- package/dist/vendor/harness-opencode/dist/agents/prompts/spec-reviewer.md +0 -1
- package/dist/vendor/harness-opencode/dist/agents/prompts/spec-reviewer.open.md +0 -1
- package/dist/vendor/harness-opencode/dist/autopilot/prompt-template.md +69 -45
- package/dist/vendor/harness-opencode/dist/chunk-GCWHRUOK.js +259 -0
- package/dist/vendor/harness-opencode/dist/chunk-MJSMBY2Y.js +87 -0
- package/dist/vendor/harness-opencode/dist/chunk-NIFAVPNN.js +544 -0
- package/dist/vendor/harness-opencode/dist/cli.js +448 -503
- package/dist/vendor/harness-opencode/dist/index.js +90 -14
- package/dist/vendor/harness-opencode/dist/loop-session-J35NILUZ.js +30 -0
- package/dist/vendor/harness-opencode/dist/opencode-server-KPCDFYAX.js +22 -0
- package/dist/vendor/harness-opencode/dist/plan-parser-TMHEKT22.js +6 -0
- package/dist/vendor/harness-opencode/dist/plan-session-7VS32P52.js +117 -0
- package/dist/vendor/harness-opencode/dist/scoper-S77SOK7X.js +326 -0
- package/dist/vendor/harness-opencode/dist/skills/spear-protocol/SKILL.md +2 -1
- package/dist/vendor/harness-opencode/package.json +1 -1
- package/package.json +3 -1
- package/dist/vendor/harness-opencode/dist/bin/plan-check.sh +0 -255
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createSession,
|
|
3
|
+
getLastAssistantMessage,
|
|
4
|
+
sendAndWait,
|
|
5
|
+
startServer
|
|
6
|
+
} from "./chunk-GCWHRUOK.js";
|
|
7
|
+
|
|
8
|
+
// src/autopilot/scoper.ts
|
|
9
|
+
import * as fs from "fs";
|
|
10
|
+
import * as path from "path";
|
|
11
|
+
var ANSI_RESET = "\x1B[0m\x1B[2K\r";
|
|
12
|
+
var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
13
|
+
function createSpinner() {
|
|
14
|
+
let timer = null;
|
|
15
|
+
let frame = 0;
|
|
16
|
+
return {
|
|
17
|
+
start(label = "Thinking") {
|
|
18
|
+
if (timer) return;
|
|
19
|
+
frame = 0;
|
|
20
|
+
const isTTY = process.stderr.isTTY ?? false;
|
|
21
|
+
if (!isTTY) return;
|
|
22
|
+
timer = setInterval(() => {
|
|
23
|
+
const f = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
|
|
24
|
+
process.stderr.write(`\x1B[2K\r\x1B[36m${f}\x1B[0m ${label}...`);
|
|
25
|
+
frame++;
|
|
26
|
+
}, 80);
|
|
27
|
+
},
|
|
28
|
+
stop() {
|
|
29
|
+
if (!timer) return;
|
|
30
|
+
clearInterval(timer);
|
|
31
|
+
timer = null;
|
|
32
|
+
process.stderr.write("\x1B[2K\r");
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function parseQuestion(response) {
|
|
37
|
+
const match = response.match(/^([^\n]{1,199}\?)\s*$/);
|
|
38
|
+
return match ? match[1] ?? null : null;
|
|
39
|
+
}
|
|
40
|
+
function parseScopeSummary(response) {
|
|
41
|
+
const match = response.match(/^SCOPE_SUMMARY:\s*\n?([\s\S]+)$/);
|
|
42
|
+
return match ? match[1]?.trim() ?? null : null;
|
|
43
|
+
}
|
|
44
|
+
function extractScopeCompletePath(output) {
|
|
45
|
+
const lines = output.split("\n");
|
|
46
|
+
let lastMatch = null;
|
|
47
|
+
for (const line of lines) {
|
|
48
|
+
const trimmed = line.trim();
|
|
49
|
+
if (trimmed.startsWith("SCOPE_COMPLETE:")) {
|
|
50
|
+
const rest = trimmed.slice("SCOPE_COMPLETE:".length).trim();
|
|
51
|
+
if (rest.length > 0) {
|
|
52
|
+
lastMatch = rest;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return lastMatch;
|
|
57
|
+
}
|
|
58
|
+
var DEFAULT_SCOPER_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
59
|
+
var MAX_QUESTIONS = 8;
|
|
60
|
+
var FORCED_FINALIZE_MESSAGE = "You have asked enough questions. Present a SCOPE_SUMMARY for user approval, then write scope.md and emit SCOPE_COMPLETE.";
|
|
61
|
+
var PARSE_RETRY_REMINDER = "Your last response did not follow the strict contract. Respond with EXACTLY one of: (a) a single question (\u2264200 chars, ending with '?'), (b) a scope summary starting with 'SCOPE_SUMMARY:', or (c) the sentinel 'SCOPE_COMPLETE: <absolute-path>'. Nothing else.";
|
|
62
|
+
async function runScoperSession(opts) {
|
|
63
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_SCOPER_TIMEOUT_MS;
|
|
64
|
+
const _startServer = opts._deps?.startServer ?? startServer;
|
|
65
|
+
const _createSession = opts._deps?.createSession ?? createSession;
|
|
66
|
+
const _sendAndWait = opts._deps?.sendAndWait ?? sendAndWait;
|
|
67
|
+
const _getLastAssistantMessage = opts._deps?.getLastAssistantMessage ?? getLastAssistantMessage;
|
|
68
|
+
const _existsSync = opts._deps?.existsSync ?? fs.existsSync;
|
|
69
|
+
const _promptUser = opts._deps?.promptUser ?? (async (question) => {
|
|
70
|
+
const { input } = await import("@inquirer/prompts");
|
|
71
|
+
return input({ message: question });
|
|
72
|
+
});
|
|
73
|
+
const server = await _startServer({ cwd: opts.planDir });
|
|
74
|
+
const spinner = createSpinner();
|
|
75
|
+
try {
|
|
76
|
+
const sessionId = await _createSession(server.client, {
|
|
77
|
+
cwd: opts.planDir,
|
|
78
|
+
agentName: "scoper"
|
|
79
|
+
});
|
|
80
|
+
const initialPrompt = [
|
|
81
|
+
"You are running in an inquirer-driven wizard. Follow the strict response contract:",
|
|
82
|
+
"- Every response must be EXACTLY one of:",
|
|
83
|
+
" (a) A single question (\u2264200 chars, ending with '?')",
|
|
84
|
+
" (b) A scope summary starting with 'SCOPE_SUMMARY:' for user approval",
|
|
85
|
+
" (c) The sentinel 'SCOPE_COMPLETE: <absolute-path>'",
|
|
86
|
+
"- Do NOT call the question tool. Emit questions as plain assistant text.",
|
|
87
|
+
"- Start with first-principles questions (WHAT and WHY), not implementation details.",
|
|
88
|
+
"",
|
|
89
|
+
`The user wants to build: ${opts.initialGoal}`,
|
|
90
|
+
"",
|
|
91
|
+
"Begin by asking your first clarifying question about the problem being solved."
|
|
92
|
+
].join("\n");
|
|
93
|
+
spinner.start("Scoper is thinking");
|
|
94
|
+
const firstResult = await _sendAndWait(server.client, {
|
|
95
|
+
sessionId,
|
|
96
|
+
message: initialPrompt,
|
|
97
|
+
agentName: "scoper",
|
|
98
|
+
stallMs: timeoutMs,
|
|
99
|
+
autoRejectPermissions: true
|
|
100
|
+
});
|
|
101
|
+
if (firstResult.kind === "abort") {
|
|
102
|
+
throw new Error(`Scoper session aborted (timeout after ${timeoutMs}ms).`);
|
|
103
|
+
}
|
|
104
|
+
if (firstResult.kind === "stall") {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Scoper session stalled for ${firstResult.stallMs}ms with no idle signal.`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (firstResult.kind === "error") {
|
|
110
|
+
throw new Error(`Scoper session error: ${firstResult.message}`);
|
|
111
|
+
}
|
|
112
|
+
let questionsAsked = 0;
|
|
113
|
+
let parseRetryPending = false;
|
|
114
|
+
while (true) {
|
|
115
|
+
const lastMessage = await _getLastAssistantMessage(
|
|
116
|
+
server.client,
|
|
117
|
+
sessionId
|
|
118
|
+
);
|
|
119
|
+
const scopePath = extractScopeCompletePath(lastMessage);
|
|
120
|
+
if (scopePath) {
|
|
121
|
+
spinner.stop();
|
|
122
|
+
if (!_existsSync(scopePath)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Scoper emitted SCOPE_COMPLETE but scope.md does not exist at: ${scopePath}`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return { scopePath };
|
|
128
|
+
}
|
|
129
|
+
if (questionsAsked >= MAX_QUESTIONS) {
|
|
130
|
+
spinner.start("Finalizing scope");
|
|
131
|
+
const finalResult = await _sendAndWait(server.client, {
|
|
132
|
+
sessionId,
|
|
133
|
+
message: FORCED_FINALIZE_MESSAGE,
|
|
134
|
+
stallMs: timeoutMs,
|
|
135
|
+
autoRejectPermissions: true
|
|
136
|
+
});
|
|
137
|
+
if (finalResult.kind !== "idle") {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Scoper session failed during forced finalize: ${finalResult.kind}`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const finalMessage = await _getLastAssistantMessage(
|
|
143
|
+
server.client,
|
|
144
|
+
sessionId
|
|
145
|
+
);
|
|
146
|
+
const finalScopePath = extractScopeCompletePath(finalMessage);
|
|
147
|
+
if (finalScopePath) {
|
|
148
|
+
spinner.stop();
|
|
149
|
+
if (!_existsSync(finalScopePath)) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Scoper emitted SCOPE_COMPLETE after forced finalize but scope.md does not exist at: ${finalScopePath}`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return { scopePath: finalScopePath };
|
|
155
|
+
}
|
|
156
|
+
const expectedScopePath = path.join(opts.planDir, opts.slug, "scope.md");
|
|
157
|
+
if (_existsSync(expectedScopePath)) {
|
|
158
|
+
spinner.stop();
|
|
159
|
+
process.stderr.write(
|
|
160
|
+
`
|
|
161
|
+
\u26A0 Scoper didn't emit sentinel, but scope.md exists at ${expectedScopePath}. Using it.
|
|
162
|
+
|
|
163
|
+
`
|
|
164
|
+
);
|
|
165
|
+
return { scopePath: expectedScopePath };
|
|
166
|
+
}
|
|
167
|
+
spinner.stop();
|
|
168
|
+
process.stderr.write(
|
|
169
|
+
`
|
|
170
|
+
\u26A0 Scoper didn't write scope.md. Constructing from conversation.
|
|
171
|
+
|
|
172
|
+
`
|
|
173
|
+
);
|
|
174
|
+
const scopeDir = path.join(opts.planDir, opts.slug);
|
|
175
|
+
if (!_existsSync(scopeDir)) {
|
|
176
|
+
fs.mkdirSync(scopeDir, { recursive: true });
|
|
177
|
+
}
|
|
178
|
+
const constructedScope = [
|
|
179
|
+
`# ${opts.initialGoal}`,
|
|
180
|
+
"",
|
|
181
|
+
"## Goal",
|
|
182
|
+
"",
|
|
183
|
+
opts.initialGoal,
|
|
184
|
+
"",
|
|
185
|
+
"## Scoper conversation summary",
|
|
186
|
+
"",
|
|
187
|
+
"The scoper agent asked 8 questions and the user provided answers,",
|
|
188
|
+
"but the agent did not produce a formal scope.md. The last agent",
|
|
189
|
+
"response is included below for the plan agent to work from.",
|
|
190
|
+
"",
|
|
191
|
+
"### Last agent response",
|
|
192
|
+
"",
|
|
193
|
+
finalMessage || "(no response captured)",
|
|
194
|
+
"",
|
|
195
|
+
"## Acceptance criteria",
|
|
196
|
+
"",
|
|
197
|
+
"- To be determined by the plan agent based on the conversation above.",
|
|
198
|
+
"",
|
|
199
|
+
"## Constraints",
|
|
200
|
+
"",
|
|
201
|
+
"- To be determined by the plan agent.",
|
|
202
|
+
"",
|
|
203
|
+
"## Out of scope",
|
|
204
|
+
"",
|
|
205
|
+
"- To be determined by the plan agent.",
|
|
206
|
+
"",
|
|
207
|
+
"## Open questions for the plan agent",
|
|
208
|
+
"",
|
|
209
|
+
"- The scoper did not complete formally. Review the conversation summary above and fill in the missing sections."
|
|
210
|
+
].join("\n");
|
|
211
|
+
const constructedPath = path.join(scopeDir, "scope.md");
|
|
212
|
+
fs.writeFileSync(constructedPath, constructedScope);
|
|
213
|
+
return { scopePath: constructedPath };
|
|
214
|
+
}
|
|
215
|
+
const summary = parseScopeSummary(lastMessage);
|
|
216
|
+
if (summary) {
|
|
217
|
+
spinner.stop();
|
|
218
|
+
parseRetryPending = false;
|
|
219
|
+
process.stderr.write(ANSI_RESET);
|
|
220
|
+
process.stderr.write(`
|
|
221
|
+
\x1B[1m\u{1F4CB} Scope summary:\x1B[0m
|
|
222
|
+
|
|
223
|
+
${summary}
|
|
224
|
+
|
|
225
|
+
`);
|
|
226
|
+
const approval = await _promptUser("Approve this scope? (yes / or describe what to change)");
|
|
227
|
+
if (approval.toLowerCase().startsWith("yes") || approval.toLowerCase() === "y" || approval.toLowerCase() === "approve") {
|
|
228
|
+
spinner.start("Writing scope.md");
|
|
229
|
+
const writeResult = await _sendAndWait(server.client, {
|
|
230
|
+
sessionId,
|
|
231
|
+
message: "The user approved the scope. Write scope.md now and emit SCOPE_COMPLETE.",
|
|
232
|
+
stallMs: timeoutMs,
|
|
233
|
+
autoRejectPermissions: true
|
|
234
|
+
});
|
|
235
|
+
if (writeResult.kind !== "idle") {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`Scoper session failed after scope approval: ${writeResult.kind}`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
continue;
|
|
241
|
+
} else {
|
|
242
|
+
spinner.start("Scoper is revising");
|
|
243
|
+
const reviseResult = await _sendAndWait(server.client, {
|
|
244
|
+
sessionId,
|
|
245
|
+
message: approval,
|
|
246
|
+
stallMs: timeoutMs,
|
|
247
|
+
autoRejectPermissions: true
|
|
248
|
+
});
|
|
249
|
+
if (reviseResult.kind !== "idle") {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`Scoper session failed during revision: ${reviseResult.kind}`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const question = parseQuestion(lastMessage);
|
|
258
|
+
if (question) {
|
|
259
|
+
parseRetryPending = false;
|
|
260
|
+
spinner.stop();
|
|
261
|
+
process.stderr.write(ANSI_RESET);
|
|
262
|
+
questionsAsked++;
|
|
263
|
+
const userAnswer = await _promptUser(question);
|
|
264
|
+
spinner.start("Scoper is thinking");
|
|
265
|
+
const nextResult = await _sendAndWait(server.client, {
|
|
266
|
+
sessionId,
|
|
267
|
+
message: userAnswer,
|
|
268
|
+
stallMs: timeoutMs,
|
|
269
|
+
autoRejectPermissions: true
|
|
270
|
+
});
|
|
271
|
+
if (nextResult.kind === "abort") {
|
|
272
|
+
throw new Error(
|
|
273
|
+
`Scoper session aborted (timeout after ${timeoutMs}ms).`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
if (nextResult.kind === "stall") {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`Scoper session stalled for ${nextResult.stallMs}ms with no idle signal.`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (nextResult.kind === "error") {
|
|
282
|
+
throw new Error(`Scoper session error: ${nextResult.message}`);
|
|
283
|
+
}
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (parseRetryPending) {
|
|
287
|
+
spinner.stop();
|
|
288
|
+
throw new Error(
|
|
289
|
+
`Scoper response did not follow the strict contract after retry. Last response: ${lastMessage.slice(0, 200)}`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
parseRetryPending = true;
|
|
293
|
+
spinner.start("Retrying");
|
|
294
|
+
const retryResult = await _sendAndWait(server.client, {
|
|
295
|
+
sessionId,
|
|
296
|
+
message: PARSE_RETRY_REMINDER,
|
|
297
|
+
stallMs: timeoutMs,
|
|
298
|
+
autoRejectPermissions: true
|
|
299
|
+
});
|
|
300
|
+
if (retryResult.kind === "abort") {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`Scoper session aborted during parse retry (timeout after ${timeoutMs}ms).`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
if (retryResult.kind === "stall") {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`Scoper session stalled during parse retry for ${retryResult.stallMs}ms.`
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
if (retryResult.kind === "error") {
|
|
311
|
+
throw new Error(
|
|
312
|
+
`Scoper session error during parse retry: ${retryResult.message}`
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} finally {
|
|
317
|
+
spinner.stop();
|
|
318
|
+
await server.shutdown();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
export {
|
|
322
|
+
extractScopeCompletePath,
|
|
323
|
+
parseQuestion,
|
|
324
|
+
parseScopeSummary,
|
|
325
|
+
runScoperSession
|
|
326
|
+
};
|
|
@@ -14,7 +14,8 @@ Before Scope, run this probe inline (no subagent) — sessions typically start i
|
|
|
14
14
|
1. `pwd` — confirm working directory.
|
|
15
15
|
2. `git status --short` — see uncommitted work.
|
|
16
16
|
3. `git log --oneline -5` — recent history.
|
|
17
|
-
4.
|
|
17
|
+
4. Resolve the plan dir and list recent plans:
|
|
18
|
+
`PLAN_BASE="${GLORIOUS_PLAN_DIR:-$HOME/.glorious/opencode}" && GIT_COMMON="$(git rev-parse --git-common-dir 2>/dev/null)" && [ -n "$GIT_COMMON" ] && [[ "$GIT_COMMON" != /* ]] && GIT_COMMON="$PWD/$GIT_COMMON"; REPO_FOLDER="$(basename "$(dirname "$GIT_COMMON")" 2>/dev/null)" && [ -n "$REPO_FOLDER" ] && [ "$REPO_FOLDER" != "." ] && ls "$PLAN_BASE/$REPO_FOLDER/plans" 2>/dev/null | tail -5` — plans for this repo.
|
|
18
19
|
|
|
19
20
|
For each plan found, read it and count unchecked acceptance items. Classify as **stale** (ignore) only if `git merge-base --is-ancestor HEAD origin/main` (fallback `origin/master`) exits 0. If classification fails, treat as active.
|
|
20
21
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glrs-dev/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Unified CLI for the @glrs-dev ecosystem — OpenCode agent harness dispatch + worktree management.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -47,6 +47,8 @@
|
|
|
47
47
|
"@opencode-ai/sdk": "^1.14",
|
|
48
48
|
"cmd-ts": "^0.15.0",
|
|
49
49
|
"picomatch": "^4.0.4",
|
|
50
|
+
"pino": "^10.3.1",
|
|
51
|
+
"pino-pretty": "^13.1.3",
|
|
50
52
|
"ulid": "^3.0.2",
|
|
51
53
|
"yaml": "^2.8.3",
|
|
52
54
|
"zod": "4.1.8"
|
|
@@ -1,255 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# plan-check.sh — parse a plan file's plan-state fence and report on it.
|
|
3
|
-
#
|
|
4
|
-
# Modes:
|
|
5
|
-
# plan-check.sh <path> Prints a summary line then one line per item:
|
|
6
|
-
# `total=N done=M pending=K invalid=I`
|
|
7
|
-
# `STATUS ID VERIFY` (one per item)
|
|
8
|
-
#
|
|
9
|
-
# plan-check.sh --run <path> Prints the verify command of each PENDING
|
|
10
|
-
# item on stdout, one per line, raw. The
|
|
11
|
-
# caller is responsible for executing them.
|
|
12
|
-
# This script NEVER executes verify commands
|
|
13
|
-
# itself — that would bypass the caller's
|
|
14
|
-
# bash-permission scope.
|
|
15
|
-
#
|
|
16
|
-
# plan-check.sh --check <path>
|
|
17
|
-
# Structural validation only. Exits 1 if any
|
|
18
|
-
# fence item is missing a required field.
|
|
19
|
-
#
|
|
20
|
-
# Fence format, inside `## Acceptance criteria`:
|
|
21
|
-
#
|
|
22
|
-
# ```plan-state
|
|
23
|
-
# - [ ] id: a1
|
|
24
|
-
# intent: Prose description of business intent (one line).
|
|
25
|
-
# tests:
|
|
26
|
-
# - path/to/test.sh::"some test name"
|
|
27
|
-
# - path/to/other.ts::"another test"
|
|
28
|
-
# verify: bash path/to/test.sh
|
|
29
|
-
#
|
|
30
|
-
# - [x] id: a2
|
|
31
|
-
# ...
|
|
32
|
-
# ```
|
|
33
|
-
#
|
|
34
|
-
# Backward compat: a plan without a ```plan-state fence emits the line
|
|
35
|
-
# `legacy` and exits 0 — callers treat it as "old format, fall back".
|
|
36
|
-
#
|
|
37
|
-
# Portability: POSIX bash + awk + grep only. No sed -i.
|
|
38
|
-
|
|
39
|
-
set -eu
|
|
40
|
-
|
|
41
|
-
MODE=""
|
|
42
|
-
PLAN_PATH=""
|
|
43
|
-
|
|
44
|
-
case "${1:-}" in
|
|
45
|
-
--run) MODE=run; PLAN_PATH="${2:-}" ;;
|
|
46
|
-
--check) MODE=check; PLAN_PATH="${2:-}" ;;
|
|
47
|
-
-h|--help|"")
|
|
48
|
-
sed -n '2,34p' "$0"
|
|
49
|
-
exit 0
|
|
50
|
-
;;
|
|
51
|
-
*) MODE=summary; PLAN_PATH="${1:-}" ;;
|
|
52
|
-
esac
|
|
53
|
-
|
|
54
|
-
if [[ -z "${PLAN_PATH:-}" ]]; then
|
|
55
|
-
echo "plan-check.sh: missing plan path" >&2
|
|
56
|
-
exit 2
|
|
57
|
-
fi
|
|
58
|
-
|
|
59
|
-
if [[ ! -f "$PLAN_PATH" ]]; then
|
|
60
|
-
echo "plan-check.sh: file not found: $PLAN_PATH" >&2
|
|
61
|
-
exit 2
|
|
62
|
-
fi
|
|
63
|
-
|
|
64
|
-
# Extract the plan-state fence body into a temp file. awk state machine:
|
|
65
|
-
# enter `## Acceptance criteria`, enter ``` plan-state, exit on next ```.
|
|
66
|
-
FENCE_BODY="$(awk '
|
|
67
|
-
/^## Acceptance criteria/ { in_ac = 1; next }
|
|
68
|
-
/^## / && in_ac && !in_fence { in_ac = 0 }
|
|
69
|
-
in_ac && /^```plan-state[[:space:]]*$/ { in_fence = 1; next }
|
|
70
|
-
in_fence && /^```[[:space:]]*$/ { in_fence = 0; next }
|
|
71
|
-
in_fence { print }
|
|
72
|
-
' "$PLAN_PATH")"
|
|
73
|
-
|
|
74
|
-
if [[ -z "$FENCE_BODY" ]]; then
|
|
75
|
-
# No fence found — legacy plan. Report and exit cleanly.
|
|
76
|
-
if [[ "$MODE" == "summary" ]]; then
|
|
77
|
-
echo "legacy (no plan-state fence)"
|
|
78
|
-
fi
|
|
79
|
-
# --run on a legacy plan emits nothing (no commands to run).
|
|
80
|
-
# --check on a legacy plan succeeds (we're accepting legacy plans).
|
|
81
|
-
exit 0
|
|
82
|
-
fi
|
|
83
|
-
|
|
84
|
-
# Parse items. awk state machine:
|
|
85
|
-
# - A line `- [ ] id: ID` or `- [x] id: ID` starts a new item.
|
|
86
|
-
# - While inside an item, indented keys `intent:`, `tests:`, `verify:` set
|
|
87
|
-
# fields. Under `tests:`, subsequent ` - ...` lines extend the list
|
|
88
|
-
# until the next key or the next item.
|
|
89
|
-
# - Items are separated by one or more blank lines OR by the next `- [`.
|
|
90
|
-
#
|
|
91
|
-
# We emit a tab-delimited record per item:
|
|
92
|
-
# STATUS<TAB>ID<TAB>INTENT<TAB>TESTS<TAB>VERIFY
|
|
93
|
-
# TESTS is a `|`-delimited list. Missing fields are the empty string.
|
|
94
|
-
PARSED="$(echo "$FENCE_BODY" | awk '
|
|
95
|
-
function flush() {
|
|
96
|
-
if (cur_id != "") {
|
|
97
|
-
# Trim trailing/leading whitespace on each field
|
|
98
|
-
gsub(/^[[:space:]]+|[[:space:]]+$/, "", cur_intent)
|
|
99
|
-
gsub(/^[[:space:]]+|[[:space:]]+$/, "", cur_verify)
|
|
100
|
-
gsub(/^\||\|$/, "", cur_tests)
|
|
101
|
-
printf "%s\t%s\t%s\t%s\t%s\n", cur_status, cur_id, cur_intent, cur_tests, cur_verify
|
|
102
|
-
}
|
|
103
|
-
cur_status = ""; cur_id = ""; cur_intent = ""; cur_tests = ""; cur_verify = ""
|
|
104
|
-
in_tests = 0
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/^-[[:space:]]+\[[[:space:]xX[:space:]]\][[:space:]]+id:/ {
|
|
108
|
-
flush()
|
|
109
|
-
status = $0
|
|
110
|
-
sub(/^-[[:space:]]+\[[[:space:]]*/, "", status)
|
|
111
|
-
sub(/\].*$/, "", status)
|
|
112
|
-
# status is either empty/space (" ") -> pending, or "x"/"X" -> done
|
|
113
|
-
if (status ~ /[xX]/) cur_status = "done"; else cur_status = "pending"
|
|
114
|
-
# Capture id
|
|
115
|
-
id_part = $0
|
|
116
|
-
sub(/^.*id:[[:space:]]*/, "", id_part)
|
|
117
|
-
cur_id = id_part
|
|
118
|
-
next
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/^[[:space:]]*intent:/ {
|
|
122
|
-
field = $0
|
|
123
|
-
sub(/^[[:space:]]*intent:[[:space:]]*/, "", field)
|
|
124
|
-
cur_intent = field
|
|
125
|
-
in_tests = 0
|
|
126
|
-
next
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/^[[:space:]]*intent\b/ {
|
|
130
|
-
# already handled
|
|
131
|
-
next
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/^[[:space:]]*tests:/ {
|
|
135
|
-
in_tests = 1
|
|
136
|
-
next
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/^[[:space:]]*verify:/ {
|
|
140
|
-
field = $0
|
|
141
|
-
sub(/^[[:space:]]*verify:[[:space:]]*/, "", field)
|
|
142
|
-
cur_verify = field
|
|
143
|
-
in_tests = 0
|
|
144
|
-
next
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
# Continuation lines inside tests: list
|
|
148
|
-
in_tests && /^[[:space:]]+-[[:space:]]/ {
|
|
149
|
-
line = $0
|
|
150
|
-
sub(/^[[:space:]]+-[[:space:]]+/, "", line)
|
|
151
|
-
if (cur_tests == "") cur_tests = line
|
|
152
|
-
else cur_tests = cur_tests "|" line
|
|
153
|
-
next
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
# Continuation line for intent (indented without `-`, after intent is set
|
|
157
|
-
# and before another key). Append with a space separator.
|
|
158
|
-
!in_tests && /^[[:space:]]{4,}[^-[:space:]]/ && cur_id != "" && cur_intent != "" && cur_verify == "" {
|
|
159
|
-
line = $0
|
|
160
|
-
sub(/^[[:space:]]+/, "", line)
|
|
161
|
-
cur_intent = cur_intent " " line
|
|
162
|
-
next
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
END { flush() }
|
|
166
|
-
' 2>&1)"
|
|
167
|
-
|
|
168
|
-
# If PARSED contains awk errors, surface them as invalid.
|
|
169
|
-
if echo "$PARSED" | grep -q '^awk:'; then
|
|
170
|
-
echo "plan-check.sh: parser error" >&2
|
|
171
|
-
echo "$PARSED" >&2
|
|
172
|
-
exit 3
|
|
173
|
-
fi
|
|
174
|
-
|
|
175
|
-
# Count totals.
|
|
176
|
-
total=0
|
|
177
|
-
done_count=0
|
|
178
|
-
pending_count=0
|
|
179
|
-
invalid_count=0
|
|
180
|
-
invalid_reasons=()
|
|
181
|
-
|
|
182
|
-
while IFS=$'\t' read -r status id intent tests verify; do
|
|
183
|
-
[[ -z "$status" ]] && continue
|
|
184
|
-
total=$((total + 1))
|
|
185
|
-
if [[ -z "$id" ]]; then
|
|
186
|
-
invalid_count=$((invalid_count + 1))
|
|
187
|
-
invalid_reasons+=("missing id")
|
|
188
|
-
continue
|
|
189
|
-
fi
|
|
190
|
-
if [[ -z "$intent" ]]; then
|
|
191
|
-
invalid_count=$((invalid_count + 1))
|
|
192
|
-
invalid_reasons+=("$id: missing intent")
|
|
193
|
-
continue
|
|
194
|
-
fi
|
|
195
|
-
if [[ -z "$tests" ]]; then
|
|
196
|
-
invalid_count=$((invalid_count + 1))
|
|
197
|
-
invalid_reasons+=("$id: missing tests")
|
|
198
|
-
continue
|
|
199
|
-
fi
|
|
200
|
-
if [[ -z "$verify" ]]; then
|
|
201
|
-
invalid_count=$((invalid_count + 1))
|
|
202
|
-
invalid_reasons+=("$id: missing verify")
|
|
203
|
-
continue
|
|
204
|
-
fi
|
|
205
|
-
if [[ "$status" == "done" ]]; then
|
|
206
|
-
done_count=$((done_count + 1))
|
|
207
|
-
else
|
|
208
|
-
pending_count=$((pending_count + 1))
|
|
209
|
-
fi
|
|
210
|
-
done <<< "$PARSED"
|
|
211
|
-
|
|
212
|
-
case "$MODE" in
|
|
213
|
-
summary)
|
|
214
|
-
printf 'total=%d done=%d pending=%d invalid=%d\n' \
|
|
215
|
-
"$total" "$done_count" "$pending_count" "$invalid_count"
|
|
216
|
-
while IFS=$'\t' read -r status id intent tests verify; do
|
|
217
|
-
[[ -z "$status" ]] && continue
|
|
218
|
-
# For the summary-per-item line, prefer displaying the verify
|
|
219
|
-
# command (truncated) so the reader sees what gates each item.
|
|
220
|
-
v="${verify:0:60}"
|
|
221
|
-
if [[ -n "$verify" && ${#verify} -gt 60 ]]; then v="${v}…"; fi
|
|
222
|
-
printf '%s %s %s\n' "$status" "$id" "$v"
|
|
223
|
-
done <<< "$PARSED"
|
|
224
|
-
if [[ "$invalid_count" -gt 0 ]]; then
|
|
225
|
-
echo "invalid:"
|
|
226
|
-
for r in "${invalid_reasons[@]}"; do
|
|
227
|
-
echo " $r"
|
|
228
|
-
done
|
|
229
|
-
fi
|
|
230
|
-
;;
|
|
231
|
-
|
|
232
|
-
run)
|
|
233
|
-
# Emit verify command per PENDING item, one per line. Skip done items,
|
|
234
|
-
# skip invalid items. Caller executes via their own bash permission.
|
|
235
|
-
while IFS=$'\t' read -r status id intent tests verify; do
|
|
236
|
-
[[ -z "$status" ]] && continue
|
|
237
|
-
[[ "$status" == "done" ]] && continue
|
|
238
|
-
[[ -z "$verify" ]] && continue
|
|
239
|
-
[[ -z "$intent" || -z "$tests" ]] && continue
|
|
240
|
-
echo "$verify"
|
|
241
|
-
done <<< "$PARSED"
|
|
242
|
-
;;
|
|
243
|
-
|
|
244
|
-
check)
|
|
245
|
-
# Structural validation. Exit 1 if anything invalid.
|
|
246
|
-
if [[ "$invalid_count" -gt 0 ]]; then
|
|
247
|
-
echo "plan-check: $invalid_count invalid item(s):" >&2
|
|
248
|
-
for r in "${invalid_reasons[@]}"; do
|
|
249
|
-
echo " $r" >&2
|
|
250
|
-
done
|
|
251
|
-
exit 1
|
|
252
|
-
fi
|
|
253
|
-
printf 'ok: %d item(s) pass structural validation\n' "$total"
|
|
254
|
-
;;
|
|
255
|
-
esac
|