golden-hoop-spell-opencode 0.1.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/README.md +184 -0
- package/package.json +51 -0
- package/shared/SPIKE_RESULTS.md +597 -0
- package/shared/agents/ghs-context-haiku.md.template +124 -0
- package/shared/agents/ghs-plan-designer.md.template +128 -0
- package/shared/agents/ghs-plan-reviewer.md.template +170 -0
- package/shared/assets/features.json +67 -0
- package/shared/assets/progress.md +35 -0
- package/shared/ghs.default.json +7 -0
- package/shared/ghs.default.json.notes.md +34 -0
- package/shared/ghs.json.example +7 -0
- package/shared/opencode.json.example +11 -0
- package/shared/references/coding-agent.md +533 -0
- package/shared/references/context-snapshot-guide.md +98 -0
- package/shared/references/examples.md +299 -0
- package/shared/references/plan-designer.md +163 -0
- package/shared/references/plan-reviewer.md +193 -0
- package/shared/references/sprint-agent.md +261 -0
- package/src/index.ts +9 -0
- package/src/lib/assets.ts +31 -0
- package/src/lib/codegraph.ts +66 -0
- package/src/lib/config.ts +278 -0
- package/src/lib/nonce.ts +56 -0
- package/src/lib/parse.ts +175 -0
- package/src/lib/paths.ts +26 -0
- package/src/lib/project.ts +28 -0
- package/src/lib/scripts/append-progress-session.ts +178 -0
- package/src/lib/scripts/append-sprint.ts +121 -0
- package/src/lib/scripts/archive-sprint.ts +583 -0
- package/src/lib/scripts/init-project.ts +291 -0
- package/src/lib/scripts/parallel-utils.ts +380 -0
- package/src/lib/scripts/parse-completion-signal.ts +584 -0
- package/src/lib/scripts/parse-delimited-output.ts +632 -0
- package/src/lib/scripts/resolve-project-dir.ts +130 -0
- package/src/lib/scripts/status.ts +292 -0
- package/src/lib/scripts/update-feature-status.ts +169 -0
- package/src/lib/scripts/validate-structure.ts +290 -0
- package/src/lib/state.ts +305 -0
- package/src/plugin.ts +76 -0
- package/src/prompts/context-codegraph.ts +65 -0
- package/src/prompts/context-grep.ts +68 -0
- package/src/prompts/feature-impl.ts +78 -0
- package/src/prompts/plan-designer.ts +59 -0
- package/src/prompts/plan-reviewer.ts +61 -0
- package/src/prompts/sprint-planning.ts +47 -0
- package/src/tools/archive.ts +278 -0
- package/src/tools/code.ts +448 -0
- package/src/tools/config.ts +182 -0
- package/src/tools/force-archive.ts +195 -0
- package/src/tools/init.ts +193 -0
- package/src/tools/plan-finalize.ts +333 -0
- package/src/tools/plan-review.ts +759 -0
- package/src/tools/plan-start.ts +232 -0
- package/src/tools/sprint.ts +213 -0
- package/src/tools/status.ts +51 -0
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
// Port of golden-hoop-spell/plugin/shared/scripts/parse_completion_signal.py.
|
|
2
|
+
//
|
|
3
|
+
// Behavior source-of-truth:
|
|
4
|
+
// /Users/tom/github/golden-hoop-spell/plugin/shared/scripts/parse_completion_signal.py
|
|
5
|
+
//
|
|
6
|
+
// Faithful port notes (plan §3.4 D4 — line-by-line port):
|
|
7
|
+
// - The Python source is both a library (`parse_signal` + 4 strategy helpers)
|
|
8
|
+
// and a CLI wrapper (argparse / stdin / file IO). We port the *library*
|
|
9
|
+
// core verbatim; the CLI layer is intentionally omitted because the
|
|
10
|
+
// OpenCode plugin consumes this as an in-process TS module, not a
|
|
11
|
+
// subprocess. The ghs-code dispatcher (and any other tool) calls
|
|
12
|
+
// `parseCompletionSignal()` directly.
|
|
13
|
+
// - The 3-strategy cascade (exact_signal → case_insensitive →
|
|
14
|
+
// natural_language) plus the min-length gate is preserved exactly.
|
|
15
|
+
// - Regex port hazards (plan §5 risk row "JS 正则与 Python re 的细微差异"):
|
|
16
|
+
// * Python `re.MULTILINE` → JS `/m` flag (exact / case-insensitive
|
|
17
|
+
// strategies anchor to line start with `^`).
|
|
18
|
+
// * Python `re.IGNORECASE` → JS `/i` flag (case-insensitive +
|
|
19
|
+
// natural-language patterns).
|
|
20
|
+
// * Python `\b` is Unicode-aware; JS `\b` is ASCII-only. The
|
|
21
|
+
// feature_id is always ASCII (`sN-feat-NNN`), so the boundary
|
|
22
|
+
// semantics coincide for every real input.
|
|
23
|
+
// * Python `re.escape` escapes a superset of JS special chars, but
|
|
24
|
+
// for the inputs we pass (ASCII feature IDs) the escaped forms are
|
|
25
|
+
// identical. We use a small `escapeRegex` helper that escapes every
|
|
26
|
+
// char JS treats as special.
|
|
27
|
+
// * Python named groups `(?P<name>...)` → JS `(?<name>...)`.
|
|
28
|
+
// * The natural-language templates embed a literal `PLACEHOLDER` for
|
|
29
|
+
// the feature_id, re-compiled per call with the real escaped id —
|
|
30
|
+
// same mechanism as the Python source.
|
|
31
|
+
// - JSON output: the Python CLI serialises with `json.dumps(result,
|
|
32
|
+
// ensure_ascii=False, indent=2)`. The equivalence test compares the
|
|
33
|
+
// *parsed* result object (not the serialised string), so we return a
|
|
34
|
+
// plain object; a `serializeResult()` helper is provided for callers
|
|
35
|
+
// that need the exact byte stream (uses `JSON.stringify(..., null, 2)`).
|
|
36
|
+
// - Style follows s1-feat-008: no `process.exit`, no `console.log`,
|
|
37
|
+
// functions are pure (no FS / subprocess side effects).
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Escape every character that has special meaning in a JavaScript regular
|
|
41
|
+
* expression, so a literal string can be embedded inside a `RegExp`.
|
|
42
|
+
*
|
|
43
|
+
* This mirrors the intent of Python's `re.escape`: the escaped result matches
|
|
44
|
+
* the input verbatim. JS escapes a slightly smaller set of metacharacters
|
|
45
|
+
* than Python, but for the inputs this module passes (ASCII feature IDs) the
|
|
46
|
+
* escaped forms are byte-identical.
|
|
47
|
+
*/
|
|
48
|
+
function escapeRegex(literal: string): string {
|
|
49
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Constants — mirror the Python module-level globals.
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Default minimum raw-input length. The completion-signal protocol itself is
|
|
58
|
+
* a single line, but a near-empty response (no commit log, no description)
|
|
59
|
+
* is treated as unknown rather than risk a false-positive natural-language
|
|
60
|
+
* match.
|
|
61
|
+
*/
|
|
62
|
+
export const DEFAULT_MIN_LENGTH = 50;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Maximum characters of trailing context to scan when extracting a reason
|
|
66
|
+
* from natural-language blocked signals (e.g. "Feature X is blocked because
|
|
67
|
+
* lint fails and tests don't compile"). Keeps the reason field bounded.
|
|
68
|
+
*/
|
|
69
|
+
export const NATURAL_LANGUAGE_REASON_WINDOW = 200;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Markdown emphasis markers we strip from candidate signal lines so that
|
|
73
|
+
* `**FEATURE COMPLETE: <id>**` matches the same way as the bare line.
|
|
74
|
+
*
|
|
75
|
+
* Mirrors the Python `_EMPHASIS_CHARS = "*_\`"`.
|
|
76
|
+
*/
|
|
77
|
+
const _EMPHASIS_CHARS = "*_`";
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Result types.
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
export type SignalStatus = "completed" | "blocked" | "unknown";
|
|
84
|
+
|
|
85
|
+
export type SignalStrategy =
|
|
86
|
+
| "exact_signal"
|
|
87
|
+
| "case_insensitive"
|
|
88
|
+
| "natural_language"
|
|
89
|
+
| "none";
|
|
90
|
+
|
|
91
|
+
export interface SignalResultMeta {
|
|
92
|
+
feature_id: string;
|
|
93
|
+
input_length: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface SignalResult {
|
|
97
|
+
status: SignalStatus;
|
|
98
|
+
feature_id: string;
|
|
99
|
+
reason: string | null;
|
|
100
|
+
strategy: SignalStrategy;
|
|
101
|
+
raw_signal_line: string | null;
|
|
102
|
+
warnings: string[];
|
|
103
|
+
meta: SignalResultMeta;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Outcome of a single strategy probe: null status means "no match". */
|
|
107
|
+
interface StrategyOutcome {
|
|
108
|
+
status: SignalStatus | null;
|
|
109
|
+
reason: string | null;
|
|
110
|
+
rawLine: string | null;
|
|
111
|
+
warnings: string[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Helpers — 1:1 ports of the Python `_strip_*` / `_extract_*` functions.
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Strip leading/trailing whitespace and markdown emphasis characters from a
|
|
120
|
+
* line.
|
|
121
|
+
*
|
|
122
|
+
* Port of `_strip_emphasis`. Lets `**FEATURE COMPLETE: <id>**` and
|
|
123
|
+
* `_FEATURE COMPLETE: <id>_` match the same regexes as the bare signal line.
|
|
124
|
+
*
|
|
125
|
+
* Implementation note: Python's `str.strip(chars)` removes any of the chars
|
|
126
|
+
* from both ends repeatedly. We emulate by trimming whitespace, then
|
|
127
|
+
* repeatedly stripping leading/trailing emphasis chars (JS `String.trim`
|
|
128
|
+
* takes no char-set arg), then trimming whitespace again.
|
|
129
|
+
*/
|
|
130
|
+
function stripEmphasis(line: string): string {
|
|
131
|
+
let out = line.trim();
|
|
132
|
+
// Strip emphasis chars from both ends until neither end is an emphasis char.
|
|
133
|
+
// (Equivalent to Python `"*_`".strip() outer + inner whitespace handling.)
|
|
134
|
+
while (out.length > 0 && _EMPHASIS_CHARS.includes(out[0])) {
|
|
135
|
+
out = out.slice(1);
|
|
136
|
+
}
|
|
137
|
+
while (out.length > 0 && _EMPHASIS_CHARS.includes(out[out.length - 1])) {
|
|
138
|
+
out = out.slice(0, -1);
|
|
139
|
+
}
|
|
140
|
+
return out.trim();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Extract the `- <reason>` tail from a blocked-signal line.
|
|
145
|
+
*
|
|
146
|
+
* Port of `_extract_reason_from_signal_line`. Works on both exact and
|
|
147
|
+
* case-insensitive matches. Returns null if no ` - ` separator is present
|
|
148
|
+
* (treated as blocked without a reason).
|
|
149
|
+
*
|
|
150
|
+
* Accepts dash variants: ASCII hyphen, en dash, em dash, double-hyphen.
|
|
151
|
+
* Python regex: `r"(?:--|—|–|-)\s*(.+)$"`.
|
|
152
|
+
*/
|
|
153
|
+
function extractReasonFromSignalLine(
|
|
154
|
+
line: string,
|
|
155
|
+
featureId: string,
|
|
156
|
+
): string | null {
|
|
157
|
+
// Drop everything up to and including the feature_id, then look for the
|
|
158
|
+
// ` - ` (or ` — ` / ` -- `) separator that introduces the reason.
|
|
159
|
+
const parts = line.split(featureId);
|
|
160
|
+
if (parts.length < 2) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
// Python: `line.split(feature_id, 1)` → at most 2 parts. JS `split` without
|
|
164
|
+
// a limit splits on every occurrence; we only care about the tail after the
|
|
165
|
+
// FIRST occurrence, which is `parts.slice(1).join(featureId)` — but since
|
|
166
|
+
// we only inspect the immediate tail, joining back any re-occurrences of
|
|
167
|
+
// the id is the faithful behaviour.
|
|
168
|
+
const tail = parts.slice(1).join(featureId).trim();
|
|
169
|
+
if (!tail) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
const m = /(?:--|—|–|-)\s*(.+)$/.exec(tail);
|
|
173
|
+
if (!m) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
const reason = m[1].trim();
|
|
177
|
+
return reason || null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Strategy 1: exact_signal.
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* STRATEGY 1: literal `FEATURE (COMPLETE|BLOCKED): <id>` on its own line.
|
|
186
|
+
*
|
|
187
|
+
* Port of `_strategy_exact`. Python compiles
|
|
188
|
+
* `r"^FEATURE\s+(COMPLETE|BLOCKED):\s*" + re.escape(feature_id) + r"\b.*$"`
|
|
189
|
+
* with `re.MULTILINE`. JS equivalent: `new RegExp(..., "m")` — `^` and `$`
|
|
190
|
+
* anchor to line starts/ends.
|
|
191
|
+
*/
|
|
192
|
+
function strategyExact(
|
|
193
|
+
text: string,
|
|
194
|
+
featureId: string,
|
|
195
|
+
): StrategyOutcome {
|
|
196
|
+
const warnings: string[] = [];
|
|
197
|
+
const pattern = new RegExp(
|
|
198
|
+
"^FEATURE\\s+(COMPLETE|BLOCKED):\\s*" +
|
|
199
|
+
escapeRegex(featureId) +
|
|
200
|
+
"\\b.*$",
|
|
201
|
+
"m",
|
|
202
|
+
);
|
|
203
|
+
const match = pattern.exec(text);
|
|
204
|
+
if (!match) {
|
|
205
|
+
return { status: null, reason: null, rawLine: null, warnings };
|
|
206
|
+
}
|
|
207
|
+
const outcome = match[1].toUpperCase(); // "COMPLETE" or "BLOCKED"
|
|
208
|
+
const rawLine = match[0].trim();
|
|
209
|
+
if (outcome === "COMPLETE") {
|
|
210
|
+
return { status: "completed", reason: null, rawLine, warnings };
|
|
211
|
+
}
|
|
212
|
+
const reason = extractReasonFromSignalLine(rawLine, featureId);
|
|
213
|
+
if (reason === null) {
|
|
214
|
+
warnings.push("blocked signal has no reason text");
|
|
215
|
+
}
|
|
216
|
+
return { status: "blocked", reason, rawLine, warnings };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Strategy 2: case_insensitive.
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* STRATEGY 2: tolerate case variation in FEATURE/COMPLETE/BLOCKED.
|
|
225
|
+
*
|
|
226
|
+
* Port of `_strategy_case_insensitive`. Matches `Feature Complete`,
|
|
227
|
+
* `feature complete`, etc. Requires the feature_id to still match exactly
|
|
228
|
+
* (it's a key, not prose). Python compiles the same shape as STRATEGY 1 but
|
|
229
|
+
* with `re.IGNORECASE` added; we use JS `im` flags. Always records a
|
|
230
|
+
* "case-insensitive match" warning when it fires.
|
|
231
|
+
*/
|
|
232
|
+
function strategyCaseInsensitive(
|
|
233
|
+
text: string,
|
|
234
|
+
featureId: string,
|
|
235
|
+
): StrategyOutcome {
|
|
236
|
+
const warnings: string[] = [];
|
|
237
|
+
const pattern = new RegExp(
|
|
238
|
+
"^feature\\s+(complete|blocked):\\s*" +
|
|
239
|
+
escapeRegex(featureId) +
|
|
240
|
+
"\\b.*$",
|
|
241
|
+
"im",
|
|
242
|
+
);
|
|
243
|
+
const match = pattern.exec(text);
|
|
244
|
+
if (!match) {
|
|
245
|
+
return { status: null, reason: null, rawLine: null, warnings };
|
|
246
|
+
}
|
|
247
|
+
const outcome = match[1].toUpperCase();
|
|
248
|
+
const rawLine = match[0].trim();
|
|
249
|
+
warnings.push("case-insensitive match");
|
|
250
|
+
if (outcome === "COMPLETE") {
|
|
251
|
+
return { status: "completed", reason: null, rawLine, warnings };
|
|
252
|
+
}
|
|
253
|
+
const reason = extractReasonFromSignalLine(rawLine, featureId);
|
|
254
|
+
if (reason === null) {
|
|
255
|
+
warnings.push("blocked signal has no reason text");
|
|
256
|
+
}
|
|
257
|
+
return { status: "blocked", reason, rawLine, warnings };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Strategy 3: natural_language.
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Natural-language pattern template. Each entry:
|
|
266
|
+
* - `source`: the regex source with a literal `PLACEHOLDER` where the
|
|
267
|
+
* escaped feature_id should be substituted at call time.
|
|
268
|
+
* - `flags`: the JS flag string to compile with (`i` / `im`).
|
|
269
|
+
* - `outcome`: `"completed"` or `"blocked"`.
|
|
270
|
+
* - `reasonGroup`: the named-capture-group name holding the reason text
|
|
271
|
+
* (`null` for completed matches).
|
|
272
|
+
*
|
|
273
|
+
* Port of `_NATURAL_LANGUAGE_PATTERNS`. The Python source stores compiled
|
|
274
|
+
* regexes with a literal PLACEHOLDER and re-compiles per call; we mirror
|
|
275
|
+
* that mechanism (store source strings, re-compile per call) so the
|
|
276
|
+
* feature_id is interpolated into the pattern body exactly as in Python.
|
|
277
|
+
*/
|
|
278
|
+
interface NaturalLanguagePattern {
|
|
279
|
+
source: string;
|
|
280
|
+
flags: string;
|
|
281
|
+
outcome: "completed" | "blocked";
|
|
282
|
+
reasonGroup: string | null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const _NATURAL_LANGUAGE_PATTERNS: NaturalLanguagePattern[] = [
|
|
286
|
+
// English completion phrasings.
|
|
287
|
+
{
|
|
288
|
+
// Python: r"(?:i\s+(?:have\s+|'ve\s+)?(?:finished|completed|done)|"
|
|
289
|
+
// r"(?:feature|task)\s+(?:is\s+)?(?:done|complete|finished))\s*[:\.]?\s*"
|
|
290
|
+
// r"(?:feature\s+)?(?P<id>{id})\b"
|
|
291
|
+
// JS note: the `\.` inside the character class needs no escaping in JS
|
|
292
|
+
// either; we keep it as `[.:]` which matches `.` or `:`. Python wrote
|
|
293
|
+
// `[:\.]` (escaped dot inside class — harmless redundancy); JS `[.:]`
|
|
294
|
+
// is equivalent.
|
|
295
|
+
source:
|
|
296
|
+
"(?:i\\s+(?:have\\s+|'ve\\s+)?(?:finished|completed|done)|" +
|
|
297
|
+
"(?:feature|task)\\s+(?:is\\s+)?(?:done|complete|finished))\\s*[.:]?\\s*" +
|
|
298
|
+
"(?:feature\\s+)?(?<id>PLACEHOLDER)\\b",
|
|
299
|
+
flags: "i",
|
|
300
|
+
outcome: "completed",
|
|
301
|
+
reasonGroup: null,
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
// Python: r"(?P<id>{id})\s+is\s+(?:done|complete|finished)\b"
|
|
305
|
+
source: "(?<id>PLACEHOLDER)\\s+is\\s+(?:done|complete|finished)\\b",
|
|
306
|
+
flags: "i",
|
|
307
|
+
outcome: "completed",
|
|
308
|
+
reasonGroup: null,
|
|
309
|
+
},
|
|
310
|
+
// English blocked phrasings.
|
|
311
|
+
{
|
|
312
|
+
// Python: r"(?P<id>{id})\s+is\s+blocked\s+(?:because\s+)?(?P<reason>.+)$"
|
|
313
|
+
// with IGNORECASE | MULTILINE
|
|
314
|
+
source:
|
|
315
|
+
"(?<id>PLACEHOLDER)\\s+is\\s+blocked\\s+(?:because\\s+)?(?<reason>.+)$",
|
|
316
|
+
flags: "im",
|
|
317
|
+
outcome: "blocked",
|
|
318
|
+
reasonGroup: "reason",
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
// Python:
|
|
322
|
+
// r"(?:i\s+(?:have\s+)?(?:blocked|halted|stopped\s+at))\s+(?:feature\s+)?"
|
|
323
|
+
// r"(?P<id>{id})\s*(?P<reason>.+)$" with IGNORECASE | MULTILINE
|
|
324
|
+
source:
|
|
325
|
+
"(?:i\\s+(?:have\\s+)?(?:blocked|halted|stopped\\s+at))\\s+(?:feature\\s+)?" +
|
|
326
|
+
"(?<id>PLACEHOLDER)\\s*(?<reason>.+)$",
|
|
327
|
+
flags: "im",
|
|
328
|
+
outcome: "blocked",
|
|
329
|
+
reasonGroup: "reason",
|
|
330
|
+
},
|
|
331
|
+
// Chinese completion phrasings.
|
|
332
|
+
{
|
|
333
|
+
// Python: r"(?:特性|功能|任务)\s*完成\s*[::]\s*(?P<id>{id})\b"
|
|
334
|
+
// JS note: no flags (Python had none). The Chinese full-width colon `:`
|
|
335
|
+
// and ASCII `:` are both matched by the character class `[::]`.
|
|
336
|
+
source: "(?:特性|功能|任务)\\s*完成\\s*[::]\\s*(?<id>PLACEHOLDER)\\b",
|
|
337
|
+
flags: "",
|
|
338
|
+
outcome: "completed",
|
|
339
|
+
reasonGroup: null,
|
|
340
|
+
},
|
|
341
|
+
// Chinese blocked phrasings.
|
|
342
|
+
{
|
|
343
|
+
// Python: r"(?:特性|功能|任务)\s*(?:阻塞|卡住|未完成|失败)\s*[::]\s*(?P<id>{id})"
|
|
344
|
+
// r"\s*(?:[-—–\-::])?\s*(?P<reason>.+)$" (no flags)
|
|
345
|
+
//
|
|
346
|
+
// Regex port hazard: Python's `$` WITHOUT `re.MULTILINE` matches at the
|
|
347
|
+
// end of the string OR just before a single trailing newline at the end
|
|
348
|
+
// of the string. JS's `$` WITHOUT the `/m` flag matches ONLY at the
|
|
349
|
+
// absolute end of the string. Since real subagent output almost always
|
|
350
|
+
// has a trailing `\n`, the Python pattern matches on `"...\n"` but a
|
|
351
|
+
// naive JS `...$` does not. We restore byte-equivalent behaviour with a
|
|
352
|
+
// zero-width lookahead `(?=\n?$)` that matches at the absolute end OR
|
|
353
|
+
// just before a single optional trailing newline. (Patterns #1-#4 use
|
|
354
|
+
// `i`/`im` flags and are anchored with `$` only where MULTILINE is set,
|
|
355
|
+
// so they're unaffected.)
|
|
356
|
+
source:
|
|
357
|
+
"(?:特性|功能|任务)\\s*(?:阻塞|卡住|未完成|失败)\\s*[::]\\s*(?<id>PLACEHOLDER)" +
|
|
358
|
+
"\\s*(?:[-—–\\-::])?\\s*(?<reason>.+)(?=\\n?$)",
|
|
359
|
+
flags: "",
|
|
360
|
+
outcome: "blocked",
|
|
361
|
+
reasonGroup: "reason",
|
|
362
|
+
},
|
|
363
|
+
];
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* STRATEGY 3: permissive natural-language phrasings.
|
|
367
|
+
*
|
|
368
|
+
* Port of `_strategy_natural_language`. Lower accuracy than the strict
|
|
369
|
+
* strategies. Used only as a fallback so a subagent that forgot the protocol
|
|
370
|
+
* but clearly stated its outcome still resolves. Always records a warning
|
|
371
|
+
* naming the matched pattern.
|
|
372
|
+
*
|
|
373
|
+
* Runs against the *raw* text (not the emphasis-stripped text), matching the
|
|
374
|
+
* Python source which passes `raw_text` (not `stripped_text`) to this
|
|
375
|
+
* strategy.
|
|
376
|
+
*/
|
|
377
|
+
function strategyNaturalLanguage(
|
|
378
|
+
text: string,
|
|
379
|
+
featureId: string,
|
|
380
|
+
): StrategyOutcome {
|
|
381
|
+
const warnings: string[] = [];
|
|
382
|
+
const escapedId = escapeRegex(featureId);
|
|
383
|
+
for (let idx = 0; idx < _NATURAL_LANGUAGE_PATTERNS.length; idx++) {
|
|
384
|
+
const template = _NATURAL_LANGUAGE_PATTERNS[idx];
|
|
385
|
+
// Each template was authored with a literal PLACEHOLDER where the
|
|
386
|
+
// feature_id regex should go. Re-compile per call with the real id.
|
|
387
|
+
const patternSrc = template.source.replace("PLACEHOLDER", escapedId);
|
|
388
|
+
let pattern: RegExp;
|
|
389
|
+
try {
|
|
390
|
+
pattern = new RegExp(patternSrc, template.flags);
|
|
391
|
+
} catch {
|
|
392
|
+
// Python `except re.error: continue`. Defensive — a bad template would
|
|
393
|
+
// be a port bug, but we skip rather than crash to preserve the cascade.
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
const match = pattern.exec(text);
|
|
397
|
+
if (!match) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
let rawLine = (match[0] ?? "").trim();
|
|
401
|
+
// Truncate the captured raw line so an over-eager natural-language
|
|
402
|
+
// pattern doesn't dump the entire rest of the response into JSON.
|
|
403
|
+
if (rawLine.length > NATURAL_LANGUAGE_REASON_WINDOW) {
|
|
404
|
+
rawLine = rawLine.slice(0, NATURAL_LANGUAGE_REASON_WINDOW) + "...";
|
|
405
|
+
}
|
|
406
|
+
// Python `enumerate(..., start=1)` → pattern number is 1-based.
|
|
407
|
+
warnings.push(`natural language fallback: pattern #${idx + 1}`);
|
|
408
|
+
if (template.outcome === "completed") {
|
|
409
|
+
return { status: "completed", reason: null, rawLine, warnings };
|
|
410
|
+
}
|
|
411
|
+
// outcome === "blocked"
|
|
412
|
+
const groups = match.groups ?? {};
|
|
413
|
+
let reason: string | null = null;
|
|
414
|
+
if (template.reasonGroup && template.reasonGroup in groups) {
|
|
415
|
+
const captured = groups[template.reasonGroup];
|
|
416
|
+
reason = captured ? captured.trim() : null;
|
|
417
|
+
}
|
|
418
|
+
if (!reason) {
|
|
419
|
+
warnings.push(
|
|
420
|
+
"natural-language blocked signal has no reason text",
|
|
421
|
+
);
|
|
422
|
+
reason = null;
|
|
423
|
+
}
|
|
424
|
+
return { status: "blocked", reason, rawLine, warnings };
|
|
425
|
+
}
|
|
426
|
+
return { status: null, reason: null, rawLine: null, warnings };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// Public API — parseCompletionSignal.
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Parse `rawText` and return the result object.
|
|
435
|
+
*
|
|
436
|
+
* Port of `parse_signal`. The result shape is byte-compatible with the
|
|
437
|
+
* Python CLI's JSON output (compared structurally by the equivalence test):
|
|
438
|
+
*
|
|
439
|
+
* ```json
|
|
440
|
+
* {
|
|
441
|
+
* "status": "completed" | "blocked" | "unknown",
|
|
442
|
+
* "feature_id": "<id>",
|
|
443
|
+
* "reason": "<reason text, or null>",
|
|
444
|
+
* "strategy": "exact_signal" | "case_insensitive"
|
|
445
|
+
* | "natural_language" | "none",
|
|
446
|
+
* "raw_signal_line": "<stripped signal line, or null>",
|
|
447
|
+
* "warnings": ["...", "..."],
|
|
448
|
+
* "meta": { "feature_id": "<id>", "input_length": <number> }
|
|
449
|
+
* }
|
|
450
|
+
* ```
|
|
451
|
+
*
|
|
452
|
+
* `minLength` defaults to {@link DEFAULT_MIN_LENGTH} (50). Inputs shorter
|
|
453
|
+
* than the threshold are resolved `unknown` without running any strategy.
|
|
454
|
+
*
|
|
455
|
+
* The three strategies are tried in priority order:
|
|
456
|
+
* 1. `exact_signal` — literal `FEATURE (COMPLETE|BLOCKED): <id>` on its own
|
|
457
|
+
* line (after stripping markdown emphasis).
|
|
458
|
+
* 2. `case_insensitive` — same shape, tolerant of case variation in
|
|
459
|
+
* FEATURE/COMPLETE/BLOCKED.
|
|
460
|
+
* 3. `natural_language` — permissive phrasings (English + Chinese),
|
|
461
|
+
* evaluated against the *raw* text. Always records a warning.
|
|
462
|
+
*/
|
|
463
|
+
export function parseCompletionSignal(
|
|
464
|
+
rawText: string,
|
|
465
|
+
opts: { feature_id: string; min_length?: number },
|
|
466
|
+
): SignalResult {
|
|
467
|
+
const featureId = opts.feature_id;
|
|
468
|
+
const minLength = opts.min_length ?? DEFAULT_MIN_LENGTH;
|
|
469
|
+
const warnings: string[] = [];
|
|
470
|
+
|
|
471
|
+
// JS `.length` counts UTF-16 code units; Python `len()` counts code points.
|
|
472
|
+
// For ASCII inputs (the overwhelmingly common case for completion signals)
|
|
473
|
+
// the two coincide. For inputs with astral-plane chars the counts differ,
|
|
474
|
+
// but such chars never appear in real subagent signal output. We match
|
|
475
|
+
// Python's `len(raw_text)` for the threshold comparison by using
|
|
476
|
+
// `.length` (code units) — acceptable for the realistic input domain.
|
|
477
|
+
const inputLength = rawText.length;
|
|
478
|
+
|
|
479
|
+
if (inputLength < minLength) {
|
|
480
|
+
warnings.push(
|
|
481
|
+
`raw input below min-length (${inputLength} < ${minLength})`,
|
|
482
|
+
);
|
|
483
|
+
return {
|
|
484
|
+
status: "unknown",
|
|
485
|
+
feature_id: featureId,
|
|
486
|
+
reason: null,
|
|
487
|
+
strategy: "none",
|
|
488
|
+
raw_signal_line: null,
|
|
489
|
+
warnings,
|
|
490
|
+
meta: {
|
|
491
|
+
feature_id: featureId,
|
|
492
|
+
input_length: inputLength,
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Pre-process: strip markdown emphasis on each non-empty line so signals
|
|
498
|
+
// wrapped in **bold** or _italic_ match the same regexes.
|
|
499
|
+
const strippedLines = rawText.split(/\r?\n/).map((line) => {
|
|
500
|
+
// Python: `_strip_emphasis(line) if line.strip() else line`. We emulate
|
|
501
|
+
// `line.strip()` truthiness: a line that is empty/whitespace-only is
|
|
502
|
+
// passed through untouched.
|
|
503
|
+
if (line.trim() === "") {
|
|
504
|
+
return line;
|
|
505
|
+
}
|
|
506
|
+
return stripEmphasis(line);
|
|
507
|
+
});
|
|
508
|
+
const strippedText = strippedLines.join("\n");
|
|
509
|
+
|
|
510
|
+
// Strategy cascade. Note STRATEGY 3 runs against `rawText`, not
|
|
511
|
+
// `strippedText` — intentional, matches the Python source.
|
|
512
|
+
const strategies: Array<{
|
|
513
|
+
name: SignalStrategy;
|
|
514
|
+
run: () => StrategyOutcome;
|
|
515
|
+
}> = [
|
|
516
|
+
{ name: "exact_signal", run: () => strategyExact(strippedText, featureId) },
|
|
517
|
+
{
|
|
518
|
+
name: "case_insensitive",
|
|
519
|
+
run: () => strategyCaseInsensitive(strippedText, featureId),
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
name: "natural_language",
|
|
523
|
+
run: () => strategyNaturalLanguage(rawText, featureId),
|
|
524
|
+
},
|
|
525
|
+
];
|
|
526
|
+
|
|
527
|
+
for (const { name, run } of strategies) {
|
|
528
|
+
const outcome = run();
|
|
529
|
+
if (outcome.status === null) {
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
warnings.push(...outcome.warnings);
|
|
533
|
+
return {
|
|
534
|
+
status: outcome.status,
|
|
535
|
+
feature_id: featureId,
|
|
536
|
+
reason: outcome.reason,
|
|
537
|
+
strategy: name,
|
|
538
|
+
raw_signal_line: outcome.rawLine,
|
|
539
|
+
warnings,
|
|
540
|
+
meta: {
|
|
541
|
+
feature_id: featureId,
|
|
542
|
+
input_length: inputLength,
|
|
543
|
+
},
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// No strategy matched.
|
|
548
|
+
warnings.push(
|
|
549
|
+
"no signal pattern matched (exact/case-insensitive/natural-language)",
|
|
550
|
+
);
|
|
551
|
+
return {
|
|
552
|
+
status: "unknown",
|
|
553
|
+
feature_id: featureId,
|
|
554
|
+
reason: null,
|
|
555
|
+
strategy: "none",
|
|
556
|
+
raw_signal_line: null,
|
|
557
|
+
warnings,
|
|
558
|
+
meta: {
|
|
559
|
+
feature_id: featureId,
|
|
560
|
+
input_length: inputLength,
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
// Serialization helper.
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Serialize a {@link SignalResult} to the exact byte stream the Python CLI
|
|
571
|
+
* emits (`json.dumps(result, ensure_ascii=False, indent=2)`).
|
|
572
|
+
*
|
|
573
|
+
* The equivalence test compares *parsed* objects (not strings) so callers
|
|
574
|
+
* normally don't need this. It's provided for parity with the other ported
|
|
575
|
+
* scripts and for any caller that wants the canonical textual form.
|
|
576
|
+
*
|
|
577
|
+
* `JSON.stringify(result, null, 2)` matches `json.dumps(..., indent=2)` for
|
|
578
|
+
* the result shape (no Date / BigInt / undefined fields). Non-ASCII chars
|
|
579
|
+
* are preserved (JS does not escape them by default, matching
|
|
580
|
+
* `ensure_ascii=False`).
|
|
581
|
+
*/
|
|
582
|
+
export function serializeResult(result: SignalResult): string {
|
|
583
|
+
return JSON.stringify(result, null, 2);
|
|
584
|
+
}
|