@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.
Files changed (28) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/dist/{chunk-SB3MLROC.js → chunk-MIWZLETC.js} +7 -2
  3. package/dist/cli.js +1 -1
  4. package/dist/lib/auto-update.js +1 -1
  5. package/dist/vendor/harness-opencode/dist/agents/prompts/build.md +16 -0
  6. package/dist/vendor/harness-opencode/dist/agents/prompts/code-reviewer-thorough.md +6 -7
  7. package/dist/vendor/harness-opencode/dist/agents/prompts/debriefer.md +55 -0
  8. package/dist/vendor/harness-opencode/dist/agents/prompts/plan-reviewer.md +2 -1
  9. package/dist/vendor/harness-opencode/dist/agents/prompts/plan.md +97 -7
  10. package/dist/vendor/harness-opencode/dist/agents/prompts/prime.md +4 -2
  11. package/dist/vendor/harness-opencode/dist/agents/prompts/scoper.md +129 -0
  12. package/dist/vendor/harness-opencode/dist/agents/prompts/spec-reviewer.md +0 -1
  13. package/dist/vendor/harness-opencode/dist/agents/prompts/spec-reviewer.open.md +0 -1
  14. package/dist/vendor/harness-opencode/dist/autopilot/prompt-template.md +69 -45
  15. package/dist/vendor/harness-opencode/dist/chunk-GCWHRUOK.js +259 -0
  16. package/dist/vendor/harness-opencode/dist/chunk-MJSMBY2Y.js +87 -0
  17. package/dist/vendor/harness-opencode/dist/chunk-NIFAVPNN.js +544 -0
  18. package/dist/vendor/harness-opencode/dist/cli.js +448 -503
  19. package/dist/vendor/harness-opencode/dist/index.js +90 -14
  20. package/dist/vendor/harness-opencode/dist/loop-session-J35NILUZ.js +30 -0
  21. package/dist/vendor/harness-opencode/dist/opencode-server-KPCDFYAX.js +22 -0
  22. package/dist/vendor/harness-opencode/dist/plan-parser-TMHEKT22.js +6 -0
  23. package/dist/vendor/harness-opencode/dist/plan-session-7VS32P52.js +117 -0
  24. package/dist/vendor/harness-opencode/dist/scoper-S77SOK7X.js +326 -0
  25. package/dist/vendor/harness-opencode/dist/skills/spear-protocol/SKILL.md +2 -1
  26. package/dist/vendor/harness-opencode/package.json +1 -1
  27. package/package.json +3 -1
  28. 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. `PLAN_DIR="$(bunx @glrs-dev/harness-plugin-opencode plan-dir 2>/dev/null)" && ls "$PLAN_DIR" 2>/dev/null | tail -5` — plans for this repo.
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
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glrs-dev/harness-plugin-opencode",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glrs-dev/cli",
3
- "version": "2.2.0",
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