@nanobpm/nano-workforce 0.198.1 → 0.199.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 CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.199.0](https://github.com/nanobpm/nano-workforce/compare/v0.198.1...v0.199.0) (2026-10-02)
2
+
3
+ ### Features
4
+
5
+ * **review-round:** fix the bug class + self-review before push; heredoc result examples ([#843](https://github.com/nanobpm/nano-workforce/issues/843)) ([0b2f26f](https://github.com/nanobpm/nano-workforce/commit/0b2f26f817f15191ef530075d34f6131c99e43cb)), closes [#842](https://github.com/nanobpm/nano-workforce/issues/842)
6
+
1
7
  ## [0.198.1](https://github.com/nanobpm/nano-workforce/compare/v0.198.0...v0.198.1) (2026-10-02)
2
8
 
3
9
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.198.1",
3
+ "version": "0.199.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -120,14 +120,22 @@ machine-readable result one of two ways:
120
120
  harness sets for you). The object's keys become process variables. Examples:
121
121
 
122
122
  ```sh
123
- # pushed a fix you believe turns the failing checks green:
124
- printf '%s' '{"status":"fixed","pushed":true,"summary":"Fixed the flaky timeout in auth.test.ts and pushed"}' > "$AGENT_RESULT_FILE"
125
- # nothing to fix — the failing checks are stale/transient (CANCELLED superseded on the same head SHA, head already green); just re-attempt the merge:
126
- printf '%s' '{"status":"reattempt","pushed":false,"summary":"Failing checks are CANCELLED runs superseded by a green run on the same head SHA — nothing to fix, re-queue the merge"}' > "$AGENT_RESULT_FILE"
127
- # ordering constraint — must wait for another PR to land first:
128
- printf '%s' '{"status":"waiting-on-pr","pushed":false,"summary":"Blocked by the linked-issue gate","dependsOn":"owner/repo#123"}' > "$AGENT_RESULT_FILE"
129
- # genuinely stuck — a human must decide:
130
- printf '%s' '{"status":"blocked","pushed":false,"summary":"CI needs an NPM_TOKEN secret I cannot set","question":"Add the NPM_TOKEN repo secret, then answer to rerun."}' > "$AGENT_RESULT_FILE"
123
+ # pushed a fix you believe turns the failing checks green:
124
+ cat > "$AGENT_RESULT_FILE" <<'EOF'
125
+ {"status":"fixed","pushed":true,"summary":"Fixed the flaky timeout in auth.test.ts and pushed"}
126
+ EOF
127
+ # nothing to fix — the failing checks are stale/transient (CANCELLED superseded on the same head SHA, head already green); just re-attempt the merge:
128
+ cat > "$AGENT_RESULT_FILE" <<'EOF'
129
+ {"status":"reattempt","pushed":false,"summary":"Failing checks are CANCELLED runs superseded by a green run on the same head SHA — nothing to fix, re-queue the merge"}
130
+ EOF
131
+ # ordering constraint — must wait for another PR to land first:
132
+ cat > "$AGENT_RESULT_FILE" <<'EOF'
133
+ {"status":"waiting-on-pr","pushed":false,"summary":"Blocked by the linked-issue gate","dependsOn":"owner/repo#123"}
134
+ EOF
135
+ # genuinely stuck — a human must decide:
136
+ cat > "$AGENT_RESULT_FILE" <<'EOF'
137
+ {"status":"blocked","pushed":false,"summary":"CI needs an NPM_TOKEN secret I cannot set","question":"Add the NPM_TOKEN repo secret, then answer to rerun."}
138
+ EOF
131
139
  ```
132
140
 
133
141
  Write this file **once**, at the very end, with your final result. Keep it a flat
@@ -113,12 +113,18 @@ machine-readable result one of two ways:
113
113
  harness sets for you). The object's keys become process variables. Examples:
114
114
 
115
115
  ```sh
116
- # branch tip now contains the latest base (you pushed a resolved rebase, or it was already up to date):
117
- printf '%s' '{"status":"rebased","summary":"Rebased onto main, resolved 2 conflicts in router.ts, pushed"}' > "$AGENT_RESULT_FILE"
118
- # ordering constraint — must wait for another PR to land first:
119
- printf '%s' '{"status":"waiting-on-pr","summary":"Stacked on the base PR that has not merged","dependsOn":"owner/repo#123"}' > "$AGENT_RESULT_FILE"
120
- # genuine semantic conflict — a human must decide which behaviour wins:
121
- printf '%s' '{"status":"blocked","summary":"main and this branch both rewrote retry() incompatibly","question":"Should retries stay capped at 3 (main) or become unbounded (this PR)?"}' > "$AGENT_RESULT_FILE"
116
+ # branch tip now contains the latest base (you pushed a resolved rebase, or it was already up to date):
117
+ cat > "$AGENT_RESULT_FILE" <<'EOF'
118
+ {"status":"rebased","summary":"Rebased onto main, resolved 2 conflicts in router.ts, pushed"}
119
+ EOF
120
+ # ordering constraint — must wait for another PR to land first:
121
+ cat > "$AGENT_RESULT_FILE" <<'EOF'
122
+ {"status":"waiting-on-pr","summary":"Stacked on the base PR that has not merged","dependsOn":"owner/repo#123"}
123
+ EOF
124
+ # genuine semantic conflict — a human must decide which behaviour wins:
125
+ cat > "$AGENT_RESULT_FILE" <<'EOF'
126
+ {"status":"blocked","summary":"main and this branch both rewrote retry() incompatibly","question":"Should retries stay capped at 3 (main) or become unbounded (this PR)?"}
127
+ EOF
122
128
  ```
123
129
 
124
130
  Write this file **once**, at the very end, with your final result. Keep it a flat
@@ -90,13 +90,60 @@ Because several agents may run on the same host at once:
90
90
  *nitpick*); otherwise **decline it explicitly with a one-line rationale in your
91
91
  `summary`** (e.g. "declined suppressed advisory X — input already validated
92
92
  upstream at Y"). Never silently drop one.
93
- 3. **Act.** Make the code changes for all fixes + nitpicks in your workspace (`cwd`)
94
- in one coherent, signed-off commit (`git commit -s`). Run the repo's
95
- build/test/lint locally before pushing. Push to the PR's head branch (the branch
96
- you are already on) — do not open a new branch or PR. If the branch has drifted
93
+ Treat a **"Previously missed"** advisory as evidence that a whole *category* of issue
94
+ (e.g. unvalidated input, terminal-width overflow, an unhandled error path) went
95
+ unchecked in that area. Sweep **every file you touch** for that category this round,
96
+ rather than fixing only the cited line and waiting for the next review to cite the
97
+ neighbour.
98
+ 3. **Act — fix the class, not the instance.** Each review round costs a full GitHub
99
+ re-review cycle, so a fix that closes only the cited occurrence just buys another
100
+ round when the reviewer finds its sibling. For every *fix*:
101
+ - **Name the bug class** the finding is an instance of (e.g. "fail-open detector:
102
+ an unrecognised shape is allowed through", "unsanitised text reaches the
103
+ terminal", "unbounded growth").
104
+ - **Sweep for the class.** Search the changed code and its siblings (same file,
105
+ same pattern, same helper's other call sites) for every other occurrence, and fix
106
+ them in this same commit. Prefer a structural fix that removes the whole class
107
+ (e.g. fail closed by default, one validating constructor) over patching the cited
108
+ case.
109
+ - **Test the class.** Add a red-first regression test per variant you found, not
110
+ only the one cited.
111
+
112
+ Make the code changes for all fixes + nitpicks in your workspace (`cwd`)
113
+ in one coherent, signed-off commit (`git commit -s`). If the branch has drifted
97
114
  behind its base and you need to **rebase / resolve a merge conflict** to keep it
98
- mergeable, that is allowed: do it in place on this branch and **force-push**
99
- (`--force-with-lease`). Any push this round — including a rebase/force-push with
115
+ mergeable, do that rebase **now, before validating** — never after the self-review
116
+ pass below. Then run the repo's build/test/lint locally.
117
+
118
+ **Before you push, do one adversarial self-review pass over your own diff.**
119
+ Diff against the PR's **current base**, not `origin/<head-branch>`: after a
120
+ permitted rebase, `origin/<head-branch>` still names the pre-rebase remote
121
+ history, so the three-dot merge base can fall back to the old base and pull
122
+ unrelated upstream changes into the pass. Resolve and fetch the base fresh,
123
+ then diff it against `HEAD` — correct before and after any rebase:
124
+
125
+ ```sh
126
+ BASE=$(gh pr view --json baseRefName --jq .baseRefName) # the PR's current base branch
127
+ git fetch origin "$BASE"
128
+ git diff FETCH_HEAD...HEAD # the diff the reviewer actually sees
129
+ ```
130
+
131
+ Read it as the reviewer will: what input,
132
+ edge case, error path, or bypass would a hostile reviewer cite next? Fix what you
133
+ find in the same commit. **If this pass edits the diff, re-run the repo's
134
+ build/test/lint before pushing** — otherwise the self-review fix ships unvalidated,
135
+ since the checks above ran against the pre-self-review code. **The same rule covers
136
+ any change after the pass:** if you rebase, resolve a conflict, or otherwise alter
137
+ the diff after this point, re-run build/test/lint *and* redo this self-review before
138
+ pushing — a late rebase must never ship unvalidated. Do only **one** self-review
139
+ pass per state of the diff: it exists to catch the obvious next finding, not to
140
+ block the round.
141
+
142
+ Push to the PR's head branch (the branch
143
+ you are already on) — do not open a new branch or PR. A rebase/force-push
144
+ (`--force-with-lease`) is allowed when it is needed to keep the branch mergeable,
145
+ but only **before** the validation + self-review above (or followed by re-running
146
+ both). Any push this round — including a rebase/force-push with
100
147
  no reviewer comments to act on — is an **`addressed`** round (see the return table).
101
148
  4. **Reply in-thread** to each comment you addressed or pushed back on, one reply
102
149
  per comment, so the trail lives on the PR.
@@ -142,24 +189,24 @@ Because several agents may run on the same host at once:
142
189
  leading row summary before it (e.g. `nano-ack: a.json :: Adds X; A is wrong`). Example:
143
190
 
144
191
  ```sh
145
- # Post the ack thread (pick any changed line in the diff for path/line). Use the PR's real HEAD
146
- # SHA as commit_id — `git rev-parse HEAD` can drift from the PR head; ask GitHub:
147
- CID=$(gh api repos/OWNER/REPO/pulls/PR --jq .head.sha)
148
- # Build the body via a QUOTED heredoc so the verbatim advisory prose is never re-interpreted by
149
- # the shell — a single-quoted `-f body='...'` breaks the moment the prose contains a `'` (e.g.
150
- # "doesn't handle ..."), and a double-quoted one breaks on `$`/backticks. `<<'EOF'` (quoted
151
- # delimiter) disables ALL expansion, so any advisory text is safe:
152
- BODY=$(cat <<'EOF'
153
- Applied. nano-ack: <path> :: <verbatim advisory text>
154
- EOF
155
- ) # to DECLINE instead, build the body the same quoted-heredoc way (never a single-quoted
156
- # `-f body='...'`, which breaks the moment the reason or advisory prose contains a `'`):
157
- # BODY=$(cat <<'EOF'
158
- # Declined, false positive — <reason>. nano-ack: <path> :: <verbatim advisory text>
159
- # EOF
160
- # )
161
- gh api repos/OWNER/REPO/pulls/PR/comments -f commit_id="$CID" -f path="PATH" -F line=LINE -f side=RIGHT -f body="$BODY"
162
- # Then resolve it exactly like any other thread (map its databaseId -> thread node id -> resolveReviewThread).
192
+ # Post the ack thread (pick any changed line in the diff for path/line). Use the PR's real HEAD
193
+ # SHA as commit_id — `git rev-parse HEAD` can drift from the PR head; ask GitHub:
194
+ CID=$(gh api repos/OWNER/REPO/pulls/PR --jq .head.sha)
195
+ # Build the body via a QUOTED heredoc so the verbatim advisory prose is never re-interpreted by
196
+ # the shell — a single-quoted `-f body='...'` breaks the moment the prose contains a `'` (e.g.
197
+ # "doesn't handle ..."), and a double-quoted one breaks on `$`/backticks. `<<'EOF'` (quoted
198
+ # delimiter) disables ALL expansion, so any advisory text is safe:
199
+ BODY=$(cat <<'EOF'
200
+ Applied. nano-ack: <path> :: <verbatim advisory text>
201
+ EOF
202
+ ) # to DECLINE instead, build the body the same quoted-heredoc way (never a single-quoted
203
+ # `-f body='...'`, which breaks the moment the reason or advisory prose contains a `'`):
204
+ # BODY=$(cat <<'EOF'
205
+ # Declined, false positive — <reason>. nano-ack: <path> :: <verbatim advisory text>
206
+ # EOF
207
+ # )
208
+ gh api repos/OWNER/REPO/pulls/PR/comments -f commit_id="$CID" -f path="PATH" -F line=LINE -f side=RIGHT -f body="$BODY"
209
+ # Then resolve it exactly like any other thread (map its databaseId -> thread node id -> resolveReviewThread).
163
210
  ```
164
211
  Only the `nano-ack: <path> :: <text>` (prose-keyed) form is honoured. A bare
165
212
  `nano-ack: <path>:<line>` marker is **not** an acknowledgement: keyed only on
@@ -258,9 +305,15 @@ default, and you waste a round. So emit a machine-readable result one of two way
258
305
  round that needs a human decision:
259
306
 
260
307
  ```sh
261
- printf '%s' '{"status":"needs_input","summary":"Resolved 3 nits; blocked on API shape","question":"Should getUser() throw or return null when the user is absent?"}' > "$AGENT_RESULT_FILE"
308
+ cat > "$AGENT_RESULT_FILE" <<'EOF'
309
+ {"status":"needs_input","summary":"Resolved 3 nits; blocked on API shape","question":"Should getUser() throw or return null when the user is absent?"}
310
+ EOF
262
311
  ```
263
312
 
313
+ Use the **quoted heredoc** shown above (`<<'EOF'`), never a single-quoted
314
+ `printf '…'` string: your `summary` is free text, and its first apostrophe
315
+ ("review 123's finding") ends a single-quoted string and breaks the command.
316
+
264
317
  Write this file **once**, at the very end, with your final result. Keep it a flat
265
318
  JSON object of exactly the variables in the table above.
266
319
 
@@ -115,13 +115,17 @@ your output is **not** parsed. Emit a machine-readable result one of two ways:
115
115
  once, at the very end. Example (not blocked):
116
116
 
117
117
  ```sh
118
- printf '%s' '{"scopeBlocked":false,"scopeBlockReason":""}' > "$AGENT_RESULT_FILE"
118
+ cat > "$AGENT_RESULT_FILE" <<'EOF'
119
+ {"scopeBlocked":false,"scopeBlockReason":""}
120
+ EOF
119
121
  ```
120
122
 
121
123
  Blocked example:
122
124
 
123
125
  ```sh
124
- printf '%s' '{"scopeBlocked":true,"scopeBlockReason":"#412 requires both the read AND write projection (acceptance criteria 2 + 3); this PR ships only the read side and defers the write projection with no filed tracker. File a follow-up issue for the write projection, link it (Follow-up: #N), and downgrade Closes #412 -> Part of #412 (close #412 by hand only when the write side lands)."}' > "$AGENT_RESULT_FILE"
126
+ cat > "$AGENT_RESULT_FILE" <<'EOF'
127
+ {"scopeBlocked":true,"scopeBlockReason":"#412 requires both the read AND write projection (acceptance criteria 2 + 3); this PR ships only the read side and defers the write projection with no filed tracker. File a follow-up issue for the write projection, link it (Follow-up: #N), and downgrade Closes #412 -> Part of #412 (close #412 by hand only when the write side lands)."}
128
+ EOF
125
129
  ```
126
130
 
127
131
  2. **Fallback** (only if you cannot write the file): print a single last line to
@@ -0,0 +1,421 @@
1
+ // Guard for issue #842: agent-prompt result examples must not put free-text JSON in a
2
+ // single-quoted shell string.
3
+ //
4
+ // Agents copy the prompt's `$AGENT_RESULT_FILE` example verbatim. With the old
5
+ // `printf '%s' '{"summary":"…"}' > "$AGENT_RESULT_FILE"` shape, the first apostrophe in the
6
+ // agent's own free-text summary (e.g. "review 123's finding") terminates the single-quoted
7
+ // string. The command then breaks: the nano-coder sandbox rejected it as an "unterminated
8
+ // quote" and the agent had to retry, in a live kimi-k3 review round. A quoted heredoc
9
+ // (`<<'EOF'`) disables all expansion and quoting, so any prose is safe. This guards the
10
+ // whole class across every deployed prompt, not just the one that bit.
11
+ import { test } from "node:test";
12
+ import { assert } from "#test-assert";
13
+ import { readdirSync, readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ // Urban deploys everything under resources/ RECURSIVELY (AGENTS.md "Deploy by convention"), so a
17
+ // nested prompt (resources/**/foo.md) ships just like a top-level one. Scan the whole deployed
18
+ // surface, not just the immediate children of resources/prompts, or a nested prompt could
19
+ // reintroduce the unsafe example past this guard.
20
+ const RESOURCES_DIR = join(import.meta.dirname, "..", "resources");
21
+
22
+ function deployedMarkdownFiles(dir: string): string[] {
23
+ const out: string[] = [];
24
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
25
+ const full = join(dir, entry.name);
26
+ if (entry.isDirectory()) out.push(...deployedMarkdownFiles(full));
27
+ else if (entry.isFile() && entry.name.endsWith(".md")) out.push(full);
28
+ }
29
+ return out;
30
+ }
31
+
32
+ // The unsafe class is a single-quoted shell string that opens a JSON object (`'{"`) reaching the
33
+ // result file — in ANY redirection form. The retired `printf '…' > "$FILE"` shape is only one
34
+ // instance; `cat > "${AGENT_RESULT_FILE}" <<< '{"…"}'` (herestring, braced var) and
35
+ // `printf '…' | tee "$AGENT_RESULT_FILE"` break identically the moment an apostrophe appears in the
36
+ // free-text JSON. So match the two signals independently (order-free), not the printf layout:
37
+ // (a) a single-quoted JSON opener, and (b) a reference to AGENT_RESULT_FILE (with or without braces).
38
+ // The opener's whitespace class is `\s`, not `[ \t]`: a multiline single-quoted value (`JSON='` on
39
+ // one line, `{"summary":"x"}` on the next) is one shell string, and `unsafeResultBlocks` joins those
40
+ // lines — a horizontal-only class can't cross the inserted newline, so the same apostrophe failure
41
+ // would slip past the guard (review 5387000199). `\s` matches the joined newline too. But `\s` also
42
+ // lets the CLOSING quote of a quoted-heredoc delimiter (`<<'EOF'`) pair with a JSON body line on the
43
+ // next line — the safe shape — so the lookbehind `(?<![A-Za-z0-9_])` requires the `'` to be an
44
+ // OPENING quote (not preceded by a word char), excluding a delimiter's closing quote. JSON also
45
+ // allows whitespace AFTER the opening brace (`'{ "summary":…}'`), which breaks on an apostrophe
46
+ // identically — so the brace is followed by `\s*` too, not `"` directly (review 5387091667).
47
+ const SINGLE_QUOTED_JSON_OPENER = /(?<![A-Za-z0-9_])'\s*\{\s*"/;
48
+ const RESULT_FILE_REF = /\$\{?AGENT_RESULT_FILE\}?/;
49
+ const isUnsafeResultLine = (line: string): boolean =>
50
+ SINGLE_QUOTED_JSON_OPENER.test(line) && RESULT_FILE_REF.test(line);
51
+
52
+ // A single unsafe command need not fit on one physical line: a shell command split with `\`
53
+ // continuations, or a single-quoted JSON assignment on one line whose value is written to the
54
+ // result file on another, has the two signals on different lines and slips past a per-line check.
55
+ // And the assignment and write can be ARBITRARILY separated inside one shell example — three
56
+ // comments between them push the write past any fixed line-distance window, yet the apostrophe
57
+ // still breaks the assignment (review 5387175386). So scope the scan to the unit an agent actually
58
+ // copies and runs — the whole fenced code block — not a fixed window: join continuation lines,
59
+ // then test each fenced block as one unit so a split command / assignment-then-write is caught no
60
+ // matter how far apart the two signals sit inside the block.
61
+ function logicalLines(lines: string[]): string[] {
62
+ const joined: string[] = [];
63
+ let buf = "";
64
+ for (const raw of lines) {
65
+ const line = raw.replace(/\s+$/, "");
66
+ buf = buf ? `${buf}\n${line}` : line;
67
+ if (!line.endsWith("\\")) {
68
+ joined.push(buf);
69
+ buf = "";
70
+ }
71
+ }
72
+ if (buf) joined.push(buf);
73
+ return joined;
74
+ }
75
+ // Extract the fenced ``` code blocks from a markdown example. Only a fenced block is a runnable
76
+ // shell example an agent copies verbatim; prose between blocks never mixes into a command, so a
77
+ // single-quoted JSON opener and a result-file write only form the unsafe shape when they share a
78
+ // block. Scoping to the block (not the whole file) is what keeps a benign single-quoted JSON in
79
+ // one block from pairing with an unrelated `$AGENT_RESULT_FILE` prose mention in another.
80
+ function fencedBlocks(lines: string[]): string[][] {
81
+ const blocks: string[][] = [];
82
+ let cur: string[] | null = null;
83
+ for (const line of lines) {
84
+ if (/^```/.test(line.trim())) {
85
+ if (cur) {
86
+ blocks.push(cur);
87
+ cur = null;
88
+ } else {
89
+ cur = [];
90
+ }
91
+ } else if (cur) {
92
+ cur.push(line);
93
+ }
94
+ }
95
+ if (cur) blocks.push(cur);
96
+ return blocks;
97
+ }
98
+ function unsafeResultBlocks(lines: string[]): number[] {
99
+ const bad: number[] = [];
100
+ for (const block of fencedBlocks(lines)) {
101
+ const text = logicalLines(block).join("\n");
102
+ if (SINGLE_QUOTED_JSON_OPENER.test(text) && RESULT_FILE_REF.test(text)) bad.push(0);
103
+ }
104
+ return bad;
105
+ }
106
+
107
+ // A heredoc is only executable if its terminator is a bare delimiter at column 1. The defect fixed
108
+ // in #843 was an INDENTED `EOF` (list-indented markdown) that the shell never recognises, so the
109
+ // heredoc runs to EOF-of-file and the result file is garbage. Validate each deployed heredoc as a
110
+ // complete block: every `<<'TAG'` opener must be closed by a line that is exactly `TAG` at column 1.
111
+ // An opener is a real shell redirect, so ignore backtick-quoted inline-code mentions (`<<'EOF'`) and
112
+ // prose references to the syntax — only a `<<'TAG'` that is NOT inside backticks opens a heredoc.
113
+ // Capture whether the opener used `<<-` (strip-leading-tabs): shell `<<-` deliberately permits a
114
+ // TAB-indented terminator, so the dash flag must be preserved per pending heredoc and the
115
+ // terminator check strips leading tabs only for a `<<-` opener (review 5387476399, previously-missed).
116
+ // The `<<` must NOT be preceded by another `<`: a here-STRING `cat <<<'EOF'` is not a heredoc at
117
+ // all (it feeds the literal text `EOF` to the command's stdin), so without the `(?<!<)` lookbehind
118
+ // the regex matches the here-string's final two `<` and reports an unterminated heredoc where none
119
+ // exists (review 5387687539).
120
+ const HEREDOC_OPENER = /(?<!<)<<(-?)\s*'([A-Za-z_][A-Za-z0-9_]*)'/;
121
+ function heredocOpeners(line: string): { tag: string; dash: boolean }[] {
122
+ // Strip inline-code spans (`...`) so a prose mention of `<<'EOF'` is not mistaken for an opener.
123
+ const code = line.replace(/`[^`]*`/g, "");
124
+ if (code.trimStart().startsWith("#")) return []; // a commented-out example is not a real opener
125
+ const tags: { tag: string; dash: boolean }[] = [];
126
+ for (const m of code.matchAll(new RegExp(HEREDOC_OPENER.source, "g")))
127
+ tags.push({ tag: m[2], dash: m[1] === "-" });
128
+ return tags;
129
+ }
130
+ // Model the pending heredocs as an ordered QUEUE of expected terminators. One command line can
131
+ // stack several openers (`cat <<'A' <<'B'`), and the shell reads each body in turn, so EVERY opener
132
+ // needs its own later column-1 terminator. A single terminator line therefore closes only the FRONT
133
+ // pending heredoc — it must NOT be credited to every same-line opener that happens to share the
134
+ // delimiter: `cat <<'EOF' <<'EOF'` closed by a single `EOF` still leaves a second heredoc open, so
135
+ // it is malformed (review 5387296036).
136
+ //
137
+ // While a heredoc is pending its body is LITERAL shell input, so a body line is never a new opener:
138
+ // once `cat <<'OUTER'` opens, a body line containing the text `<<'INNER'` is data the command reads,
139
+ // not a redirection, and must not enqueue an `INNER` terminator (review 5387415767). So when the
140
+ // queue is non-empty we only test whether the line is the FRONT terminator; if it is, that heredoc
141
+ // closes, and either way we skip opener discovery for the line. Only a line scanned while NO heredoc
142
+ // is pending can contribute new openers. Every opener still pending at end-of-input is an
143
+ // unterminated heredoc.
144
+ //
145
+ // Two refinements:
146
+ // - A `<<-` opener's terminator may be TAB-indented (shell strips leading tabs), so the terminator
147
+ // test strips leading tabs only when that pending opener used `<<-`; a plain `<<` still requires
148
+ // the bare delimiter at column 1.
149
+ // - A heredoc body does not begin until a backslash-CONTINUED command is complete: `cat <<'A' \`
150
+ // followed by `<<'B'` is one command stacking two heredocs, so the second line is more command,
151
+ // not body, and its opener must still be discovered (review 5387476399). Continuation state must
152
+ // be tracked ONLY across the command portion — as an explicit flag carried forward, NOT recomputed
153
+ // from the previous physical line each iteration (review 5387541694). Recomputing it per line had
154
+ // two symmetric defects: (a) a terminator was tested BEFORE continuation, so `cat <<'A' \` then a
155
+ // bare `A` credited that `A` as the terminator even though it is a command argument (real bash:
156
+ // `cat: A: No such file or directory`) and the heredoc is unterminated; and (b) a BODY line ending
157
+ // in a backslash wrongly re-entered command mode, letting the next literal body line (e.g. text
158
+ // `<<'INNER'`) be parsed as a new opener. With the flag, `continued` is updated only while in
159
+ // command mode: once the command completes (a command line not ending in `\`), the body is literal
160
+ // and a trailing backslash inside it can never reopen command mode.
161
+ function malformedHeredocs(lines: string[]): string[] {
162
+ const pending: { tag: string; dash: boolean; line: number }[] = [];
163
+ const isTerminator = (line: string, open: { tag: string; dash: boolean }) =>
164
+ open.dash ? line.replace(/^\t+/, "") === open.tag : line === open.tag;
165
+ // `continued` === we are still inside the (possibly multi-line, backslash-continued) COMMAND that
166
+ // opened the pending heredocs — its body has not begun yet. While command mode is active we discover
167
+ // openers and never test terminators; once it ends, pending bodies are literal until their terminators.
168
+ let continued = false;
169
+ for (let i = 0; i < lines.length; i++) {
170
+ const inBody = pending.length > 0 && !continued;
171
+ if (inBody) {
172
+ // A pending body is literal input: the only thing that matters is whether this line closes the
173
+ // FRONT heredoc. It never opens a new one and never re-enters command mode.
174
+ if (isTerminator(lines[i], pending[0])) pending.shift();
175
+ continue;
176
+ }
177
+ for (const open of heredocOpeners(lines[i])) pending.push({ ...open, line: i });
178
+ continued = lines[i].replace(/\s+$/, "").endsWith("\\");
179
+ }
180
+ return pending.map(
181
+ (open) => `line ${open.line + 1}: heredoc <<'${open.tag}' has no column-1 '${open.tag}' terminator`,
182
+ );
183
+ }
184
+ // Validate a whole Markdown file one fenced block at a time. A runnable heredoc example lives inside
185
+ // a single ``` fence; scanning the whole file at once lets an unterminated opener borrow a bare
186
+ // delimiter from later prose or another fence (review 5387415767, previously-missed). `malformedHeredocs`
187
+ // itself validates ONE runnable unit (its `lines` are the block), so the cross-fence isolation lives
188
+ // here in the caller: feed each fenced block separately so a terminator can never cross a fence.
189
+ function malformedHeredocsInFile(lines: string[]): string[] {
190
+ const bad: string[] = [];
191
+ for (const block of fencedBlocks(lines)) bad.push(...malformedHeredocs(block));
192
+ return bad;
193
+ }
194
+
195
+ test("no agent prompt writes $AGENT_RESULT_FILE from a single-quoted JSON string (#842)", () => {
196
+ const offenders: string[] = [];
197
+ for (const file of deployedMarkdownFiles(RESOURCES_DIR)) {
198
+ const rel = file.slice(RESOURCES_DIR.length + 1);
199
+ const lines = readFileSync(file, "utf8").split("\n");
200
+ for (const i of unsafeResultBlocks(lines)) offenders.push(`${rel}:${i + 1}`);
201
+ }
202
+ assert(
203
+ offenders.length === 0,
204
+ `use a quoted heredoc (cat > "$AGENT_RESULT_FILE" <<'EOF') instead of a single-quoted JSON string: ${offenders.join(", ")}`,
205
+ );
206
+ });
207
+
208
+ test("every deployed heredoc is a complete, executable block with a column-1 terminator (#843)", () => {
209
+ const problems: string[] = [];
210
+ for (const file of deployedMarkdownFiles(RESOURCES_DIR)) {
211
+ const rel = file.slice(RESOURCES_DIR.length + 1);
212
+ const lines = readFileSync(file, "utf8").split("\n");
213
+ for (const p of malformedHeredocsInFile(lines)) problems.push(`${rel}: ${p}`);
214
+ }
215
+ assert(
216
+ problems.length === 0,
217
+ `every heredoc terminator must be the bare delimiter at column 1 so the example runs verbatim:\n${problems.join("\n")}`,
218
+ );
219
+ });
220
+
221
+ test("the guard flags every unsafe result-file shape and accepts the heredoc shape", () => {
222
+ // The printf redirection that bit, plus the equivalents a `> "$AGENT_RESULT_FILE"`-only check missed:
223
+ assert(
224
+ isUnsafeResultLine(`printf '%s' '{"status":"addressed","summary":"x"}' > "$AGENT_RESULT_FILE"`),
225
+ "retired printf shape must be flagged",
226
+ );
227
+ assert(
228
+ isUnsafeResultLine(`printf '%s' ' {"summary":"x"}' > "$AGENT_RESULT_FILE"`),
229
+ "single-quoted JSON with leading whitespace before { must still be flagged",
230
+ );
231
+ assert(
232
+ isUnsafeResultLine(`printf '%s' '{ "summary":"review 123's finding"}' > "$AGENT_RESULT_FILE"`),
233
+ "single-quoted JSON with whitespace after { must still be flagged (review 5387091667)",
234
+ );
235
+ assert(
236
+ isUnsafeResultLine(`cat > "\${AGENT_RESULT_FILE}" <<< '{"summary":"x"}'`),
237
+ "herestring into a braced result var must be flagged",
238
+ );
239
+ assert(
240
+ isUnsafeResultLine(`printf '%s' '{"summary":"x"}' | tee "$AGENT_RESULT_FILE"`),
241
+ "tee pipe of single-quoted JSON must be flagged",
242
+ );
243
+ // The safe heredoc shape, and its dedented/body lines, must all pass:
244
+ assert(!isUnsafeResultLine(`cat > "$AGENT_RESULT_FILE" <<'EOF'`), "heredoc opener must pass");
245
+ assert(!isUnsafeResultLine(`{"status":"addressed","summary":"x"}`), "heredoc body line must pass");
246
+ assert(!isUnsafeResultLine(`EOF`), "heredoc terminator must pass");
247
+ });
248
+
249
+ test("the guard flags unsafe shapes split across lines (continuations and assignment-then-write)", () => {
250
+ // A single-quoted JSON and the result-file write on DIFFERENT physical lines must still be caught.
251
+ // Each fixture is a fenced ``` block — the unit an agent copies and runs.
252
+ const continuation = ["```sh", `printf '%s' \\`, `'{"summary":"x"}' \\`, `> "$AGENT_RESULT_FILE"`, "```"];
253
+ assert(unsafeResultBlocks(continuation).length > 0, "backslash-continued unsafe command must be flagged");
254
+ const assignThenWrite = ["```sh", `JSON='{"summary":"x"}'`, `printf '%s' "$JSON" > "$AGENT_RESULT_FILE"`, "```"];
255
+ assert(unsafeResultBlocks(assignThenWrite).length > 0, "single-quoted JSON assigned then written must be flagged");
256
+ // A MULTILINE single-quoted value — `JSON='` opens on one line and the JSON object starts on the
257
+ // next — is one shell string. The joined block inserts a newline between `'` and `{"`, which a
258
+ // horizontal-only whitespace class cannot cross (review 5387000199): this shape must be flagged.
259
+ const multilineSingleQuoted = ["```sh", `JSON='`, `{"summary":"x"}'`, `printf '%s' "$JSON" > "$AGENT_RESULT_FILE"`, "```"];
260
+ assert(
261
+ unsafeResultBlocks(multilineSingleQuoted).length > 0,
262
+ "multiline single-quoted JSON value reaching the result file must be flagged",
263
+ );
264
+ // The safe heredoc shape spans lines but has no single-quoted JSON opener, so it must pass.
265
+ const safeHeredoc = ["```sh", `cat > "$AGENT_RESULT_FILE" <<'EOF'`, `{"summary":"x"}`, `EOF`, "```"];
266
+ assert(unsafeResultBlocks(safeHeredoc).length === 0, "safe heredoc block must pass");
267
+ // A benign single-quoted JSON in one fenced block must NOT pair with a `$AGENT_RESULT_FILE` prose
268
+ // mention in a DIFFERENT block — scoping to the block (not the whole file) prevents that false
269
+ // positive.
270
+ const separateBlocks = [
271
+ "```sh",
272
+ `echo '{"status":"ok"}'`,
273
+ "```",
274
+ "Write the result to `$AGENT_RESULT_FILE` as shown above.",
275
+ ];
276
+ assert(
277
+ unsafeResultBlocks(separateBlocks).length === 0,
278
+ "a benign single-quoted JSON in one block and a result-file mention in another must not be flagged",
279
+ );
280
+ // An assignment and its write can be arbitrarily separated inside one shell example. Three
281
+ // comments between them push the write past a fixed four-line window, yet the apostrophe still
282
+ // breaks the assignment — so the scan is scoped to the whole fenced code block, not a fixed
283
+ // line distance (review 5387175386).
284
+ const beyondWindow = [
285
+ "```sh",
286
+ `JSON='{"summary":"review 123's finding"}'`,
287
+ `# comment one`,
288
+ `# comment two`,
289
+ `# comment three`,
290
+ `printf '%s' "$JSON" > "$AGENT_RESULT_FILE"`,
291
+ "```",
292
+ ];
293
+ assert(
294
+ unsafeResultBlocks(beyondWindow).length > 0,
295
+ "single-quoted JSON assigned then written beyond a fixed four-line window must be flagged",
296
+ );
297
+ });
298
+
299
+ test("the heredoc block validator flags an indented terminator and accepts a column-1 one", () => {
300
+ // The concrete defect fixed in #843: an indented `EOF` is not a terminator.
301
+ assert(
302
+ malformedHeredocs([`cat > "$F" <<'EOF'`, `{"a":1}`, ` EOF`]).length > 0,
303
+ "indented terminator must be flagged as an unterminated heredoc",
304
+ );
305
+ assert(
306
+ malformedHeredocs([`cat > "$F" <<'EOF'`, `{"a":1}`, `EOF`]).length === 0,
307
+ "column-1 terminator must pass",
308
+ );
309
+ // A commented-out example opener is not a real heredoc and must not be flagged.
310
+ assert(
311
+ malformedHeredocs([` # BODY=$(cat <<'EOF'`, ` # EOF`]).length === 0,
312
+ "commented-out example must be ignored",
313
+ );
314
+ // A prose / inline-code mention of `<<'EOF'` is not an opener and must not be flagged.
315
+ assert(
316
+ malformedHeredocs([" Use the **quoted heredoc** shown above (`<<'EOF'`), never a single-quoted"]).length === 0,
317
+ "inline-code mention of <<'EOF' in prose must not be treated as an opener",
318
+ );
319
+ // A heredoc whose terminator is INDENTED is not terminated at that line, so the shell keeps
320
+ // reading body until a column-1 delimiter. Under correct shell semantics (verified against bash),
321
+ // a later `cat <<'EOF'` line INSIDE that pending body is literal text, not a second opener — so
322
+ // `cat <<'EOF'` / ` EOF` / `cat <<'EOF'` / `body` / `EOF` is ONE well-formed heredoc closed by the
323
+ // final column-1 `EOF`. The "reused delimiter lends its terminator" hazard this fixture guarded in
324
+ // round 8 is real only when the two blocks are SEPARATE runnable units — i.e. in different fences —
325
+ // which is exactly the cross-fence case `malformedHeredocsInFile` now isolates (review 5387415767).
326
+ // Within one pending body there is no second opener to mis-credit, so this must PASS.
327
+ assert(
328
+ malformedHeredocs([`cat <<'EOF'`, ` EOF`, `cat <<'EOF'`, `body`, `EOF`]).length === 0,
329
+ "a later same-delimiter line inside a pending body is literal text, not a borrowed terminator",
330
+ );
331
+ // The cross-fence borrow IS malformed: an unterminated opener in one fence must not be closed by a
332
+ // bare delimiter in later prose or another fence. This is the per-fence isolation fix.
333
+ assert(
334
+ malformedHeredocsInFile(["```sh", `cat <<'EOF'`, "```", "trailing prose", "EOF"]).length > 0,
335
+ "an unterminated fence must not borrow a terminator from later prose (review 5387415767)",
336
+ );
337
+ // Two correctly-terminated blocks reusing the same delimiter must still pass.
338
+ assert(
339
+ malformedHeredocs([`cat <<'EOF'`, `a`, `EOF`, `cat <<'EOF'`, `b`, `EOF`]).length === 0,
340
+ "two well-formed blocks reusing one delimiter must pass",
341
+ );
342
+ // Several heredocs STACKED on one line (`cat <<'EOF' <<'EOF'`) each need their own terminator: the
343
+ // shell reads each body in turn, so one `EOF` closes only the first and leaves the second open.
344
+ // A single terminator must not be credited to every same-line opener sharing the delimiter
345
+ // (review 5387296036).
346
+ assert(
347
+ malformedHeredocs([`cat <<'EOF' <<'EOF'`, `body`, `EOF`]).length > 0,
348
+ "one terminator must not close two same-line heredocs sharing a delimiter",
349
+ );
350
+ // The same stacked pair with BOTH terminators present is well-formed and must pass.
351
+ assert(
352
+ malformedHeredocs([`cat <<'EOF' <<'EOF'`, `a`, `EOF`, `b`, `EOF`]).length === 0,
353
+ "two same-line heredocs each with their own column-1 terminator must pass",
354
+ );
355
+ // A pending heredoc's body is LITERAL shell input: a body line containing the text `<<'INNER'` is
356
+ // data the command reads, not a redirection, so it must NOT enqueue an `INNER` terminator. The
357
+ // block is one well-formed heredoc closed by `OUTER` (review 5387415767).
358
+ assert(
359
+ malformedHeredocs([`cat <<'OUTER'`, `this body mentions <<'INNER' as literal text`, `OUTER`]).length === 0,
360
+ "a body line containing a heredoc-opener-looking text must not be treated as a new opener",
361
+ );
362
+ // The same holds when heredocs are stacked: once `A` is pending, a body line with `<<'C'` is
363
+ // literal until `A` (then `B`) terminate in turn.
364
+ assert(
365
+ malformedHeredocs([`cat <<'A' <<'B'`, `a`, `A`, `body with <<'C' inside`, `B`]).length === 0,
366
+ "a stacked heredoc body containing opener-like text must still pass",
367
+ );
368
+ // A heredoc body does not begin until a backslash-CONTINUED command completes: `cat <<'A' \`
369
+ // followed by `<<'B'` is ONE command stacking two heredocs, so the second line is more command,
370
+ // not body, and its opener must be discovered. Omitting the `B` terminator is therefore malformed
371
+ // (review 5387476399).
372
+ assert(
373
+ malformedHeredocs([`cat <<'A' \\`, `<<'B'`, `body-A`, `A`]).length > 0,
374
+ "a backslash-continued second opener must be discovered, so its omitted terminator is flagged",
375
+ );
376
+ // The same split stacked-heredoc command with BOTH terminators present is well-formed and passes.
377
+ assert(
378
+ malformedHeredocs([`cat <<'A' \\`, `<<'B'`, `body-A`, `A`, `body-B`, `B`]).length === 0,
379
+ "a backslash-continued stacked command with both terminators must pass",
380
+ );
381
+ // A `<<-` opener's terminator may be TAB-indented (shell strips leading tabs), so a tab-indented
382
+ // `EOF` closes a `<<-'EOF'` heredoc (review 5387476399, previously-missed).
383
+ assert(
384
+ malformedHeredocs([`cat <<-'EOF'`, `\tbody`, `\tEOF`]).length === 0,
385
+ "a <<- heredoc must accept a tab-indented terminator",
386
+ );
387
+ // A plain `<<` opener still requires the bare delimiter at column 1: a tab-indented `EOF` does NOT
388
+ // terminate it, so the heredoc runs to end-of-input and is malformed.
389
+ assert(
390
+ malformedHeredocs([`cat <<'EOF'`, `\tbody`, `\tEOF`]).length > 0,
391
+ "a plain << heredoc must still reject a tab-indented terminator",
392
+ );
393
+ // Continuation state is tracked only across the COMMAND, not recomputed per line (review 5387541694).
394
+ // (F1) A terminator is never credited while the command is still being continued: `cat <<'A' \`
395
+ // followed by a bare `A` is `cat <<'A' A` — the `A` is a command ARGUMENT (real bash: `cat: A: No
396
+ // such file or directory`), not the terminator, so the heredoc is unterminated and must be flagged.
397
+ assert(
398
+ malformedHeredocs([`cat <<'A' \\`, `A`]).length > 0,
399
+ "a bare delimiter on a backslash-continued command line is an argument, not a terminator",
400
+ );
401
+ // (F2) A trailing backslash inside a heredoc BODY must NOT re-enter command mode: once `cat <<'OUTER'`
402
+ // opens, a body line ending in `\` is still literal data, so the following `<<'INNER'` is body text,
403
+ // not a new opener — the block is well-formed with just the one `OUTER` terminator.
404
+ assert(
405
+ malformedHeredocs([`cat <<'OUTER'`, `some text \\`, `<<'INNER'`, `OUTER`]).length === 0,
406
+ "a backslash at the end of a body line must not reopen command mode and parse the next body line as an opener",
407
+ );
408
+ // A here-STRING `cat <<<'EOF'` is NOT a heredoc: it feeds the literal text `EOF` to the command's
409
+ // stdin (real bash prints `EOF`, exit 0 — no terminator is ever expected). The opener regex must
410
+ // not match the here-string's final two `<`, so no `EOF` terminator is required (review 5387687539).
411
+ assert(
412
+ malformedHeredocs([`cat <<<'EOF'`]).length === 0,
413
+ "a here-string `<<<'EOF'` must not be reported as an unterminated heredoc",
414
+ );
415
+ // A real `<<` heredoc still matches even when it directly follows other text on the line, and a
416
+ // here-string's leading `<<<` must not swallow a genuine `<<` opener later on the same line.
417
+ assert(
418
+ malformedHeredocs([`cat <<<'X' && cat <<'EOF'`, `body`, `EOF`]).length === 0,
419
+ "a real heredoc later on a line that also contains a here-string must still be validated",
420
+ );
421
+ });