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.
Files changed (55) hide show
  1. package/README.md +184 -0
  2. package/package.json +51 -0
  3. package/shared/SPIKE_RESULTS.md +597 -0
  4. package/shared/agents/ghs-context-haiku.md.template +124 -0
  5. package/shared/agents/ghs-plan-designer.md.template +128 -0
  6. package/shared/agents/ghs-plan-reviewer.md.template +170 -0
  7. package/shared/assets/features.json +67 -0
  8. package/shared/assets/progress.md +35 -0
  9. package/shared/ghs.default.json +7 -0
  10. package/shared/ghs.default.json.notes.md +34 -0
  11. package/shared/ghs.json.example +7 -0
  12. package/shared/opencode.json.example +11 -0
  13. package/shared/references/coding-agent.md +533 -0
  14. package/shared/references/context-snapshot-guide.md +98 -0
  15. package/shared/references/examples.md +299 -0
  16. package/shared/references/plan-designer.md +163 -0
  17. package/shared/references/plan-reviewer.md +193 -0
  18. package/shared/references/sprint-agent.md +261 -0
  19. package/src/index.ts +9 -0
  20. package/src/lib/assets.ts +31 -0
  21. package/src/lib/codegraph.ts +66 -0
  22. package/src/lib/config.ts +278 -0
  23. package/src/lib/nonce.ts +56 -0
  24. package/src/lib/parse.ts +175 -0
  25. package/src/lib/paths.ts +26 -0
  26. package/src/lib/project.ts +28 -0
  27. package/src/lib/scripts/append-progress-session.ts +178 -0
  28. package/src/lib/scripts/append-sprint.ts +121 -0
  29. package/src/lib/scripts/archive-sprint.ts +583 -0
  30. package/src/lib/scripts/init-project.ts +291 -0
  31. package/src/lib/scripts/parallel-utils.ts +380 -0
  32. package/src/lib/scripts/parse-completion-signal.ts +584 -0
  33. package/src/lib/scripts/parse-delimited-output.ts +632 -0
  34. package/src/lib/scripts/resolve-project-dir.ts +130 -0
  35. package/src/lib/scripts/status.ts +292 -0
  36. package/src/lib/scripts/update-feature-status.ts +169 -0
  37. package/src/lib/scripts/validate-structure.ts +290 -0
  38. package/src/lib/state.ts +305 -0
  39. package/src/plugin.ts +76 -0
  40. package/src/prompts/context-codegraph.ts +65 -0
  41. package/src/prompts/context-grep.ts +68 -0
  42. package/src/prompts/feature-impl.ts +78 -0
  43. package/src/prompts/plan-designer.ts +59 -0
  44. package/src/prompts/plan-reviewer.ts +61 -0
  45. package/src/prompts/sprint-planning.ts +47 -0
  46. package/src/tools/archive.ts +278 -0
  47. package/src/tools/code.ts +448 -0
  48. package/src/tools/config.ts +182 -0
  49. package/src/tools/force-archive.ts +195 -0
  50. package/src/tools/init.ts +193 -0
  51. package/src/tools/plan-finalize.ts +333 -0
  52. package/src/tools/plan-review.ts +759 -0
  53. package/src/tools/plan-start.ts +232 -0
  54. package/src/tools/sprint.ts +213 -0
  55. 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
+ }