@hegemonart/get-design-done 1.20.0 → 1.22.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 (69) hide show
  1. package/.claude-plugin/marketplace.json +9 -12
  2. package/.claude-plugin/plugin.json +8 -31
  3. package/CHANGELOG.md +200 -0
  4. package/README.md +48 -7
  5. package/bin/gdd-sdk +55 -0
  6. package/hooks/_hook-emit.js +81 -0
  7. package/hooks/gdd-bash-guard.js +8 -0
  8. package/hooks/gdd-decision-injector.js +2 -0
  9. package/hooks/gdd-protected-paths.js +8 -0
  10. package/hooks/gdd-trajectory-capture.js +64 -0
  11. package/hooks/hooks.json +9 -0
  12. package/package.json +19 -47
  13. package/reference/codex-tools.md +53 -0
  14. package/reference/gemini-tools.md +53 -0
  15. package/reference/registry.json +14 -0
  16. package/scripts/cli/gdd-events.mjs +283 -0
  17. package/scripts/e2e/run-headless.ts +514 -0
  18. package/scripts/lib/cli/commands/audit.ts +382 -0
  19. package/scripts/lib/cli/commands/init.ts +217 -0
  20. package/scripts/lib/cli/commands/query.ts +329 -0
  21. package/scripts/lib/cli/commands/run.ts +656 -0
  22. package/scripts/lib/cli/commands/stage.ts +468 -0
  23. package/scripts/lib/cli/index.ts +167 -0
  24. package/scripts/lib/cli/parse-args.ts +336 -0
  25. package/scripts/lib/connection-probe/index.cjs +263 -0
  26. package/scripts/lib/context-engine/index.ts +116 -0
  27. package/scripts/lib/context-engine/manifest.ts +69 -0
  28. package/scripts/lib/context-engine/truncate.ts +282 -0
  29. package/scripts/lib/context-engine/types.ts +59 -0
  30. package/scripts/lib/discuss-parallel-runner/aggregator.ts +448 -0
  31. package/scripts/lib/discuss-parallel-runner/discussants.ts +430 -0
  32. package/scripts/lib/discuss-parallel-runner/index.ts +223 -0
  33. package/scripts/lib/discuss-parallel-runner/types.ts +184 -0
  34. package/scripts/lib/event-chain.cjs +177 -0
  35. package/scripts/lib/event-stream/index.ts +31 -1
  36. package/scripts/lib/event-stream/reader.ts +139 -0
  37. package/scripts/lib/event-stream/types.ts +155 -1
  38. package/scripts/lib/event-stream/writer.ts +65 -8
  39. package/scripts/lib/explore-parallel-runner/index.ts +294 -0
  40. package/scripts/lib/explore-parallel-runner/mappers.ts +290 -0
  41. package/scripts/lib/explore-parallel-runner/synthesizer.ts +295 -0
  42. package/scripts/lib/explore-parallel-runner/types.ts +139 -0
  43. package/scripts/lib/harness/detect.ts +90 -0
  44. package/scripts/lib/harness/index.ts +64 -0
  45. package/scripts/lib/harness/tool-map.ts +142 -0
  46. package/scripts/lib/init-runner/index.ts +396 -0
  47. package/scripts/lib/init-runner/researchers.ts +245 -0
  48. package/scripts/lib/init-runner/scaffold.ts +224 -0
  49. package/scripts/lib/init-runner/synthesizer.ts +224 -0
  50. package/scripts/lib/init-runner/types.ts +143 -0
  51. package/scripts/lib/logger/index.ts +251 -0
  52. package/scripts/lib/logger/sinks.ts +269 -0
  53. package/scripts/lib/logger/types.ts +110 -0
  54. package/scripts/lib/pipeline-runner/human-gate.ts +134 -0
  55. package/scripts/lib/pipeline-runner/index.ts +527 -0
  56. package/scripts/lib/pipeline-runner/stage-handlers.ts +339 -0
  57. package/scripts/lib/pipeline-runner/state-machine.ts +144 -0
  58. package/scripts/lib/pipeline-runner/types.ts +183 -0
  59. package/scripts/lib/redact.cjs +122 -0
  60. package/scripts/lib/session-runner/errors.ts +406 -0
  61. package/scripts/lib/session-runner/index.ts +715 -0
  62. package/scripts/lib/session-runner/transcript.ts +189 -0
  63. package/scripts/lib/session-runner/types.ts +144 -0
  64. package/scripts/lib/tool-scoping/index.ts +219 -0
  65. package/scripts/lib/tool-scoping/parse-agent-tools.ts +207 -0
  66. package/scripts/lib/tool-scoping/stage-scopes.ts +139 -0
  67. package/scripts/lib/tool-scoping/types.ts +77 -0
  68. package/scripts/lib/trajectory/index.cjs +126 -0
  69. package/scripts/lib/transports/ws.cjs +179 -0
@@ -0,0 +1,116 @@
1
+ // scripts/lib/context-engine/index.ts — public API for the context-engine.
2
+ // Pipes { stage, cwd } → typed ContextBundle. Never touches the Agent SDK.
3
+
4
+ import { resolve } from 'node:path';
5
+ import { Buffer } from 'node:buffer';
6
+
7
+ import type { Stage, ContextFile, ContextBundle, BundleOptions } from './types.ts';
8
+ import { MANIFEST, manifestFor, readFileRaw } from './manifest.ts';
9
+ import { truncateMarkdown } from './truncate.ts';
10
+ import { getLogger } from '../logger/index.ts';
11
+
12
+ /** Default 8 KiB truncation threshold. */
13
+ const DEFAULT_THRESHOLD_BYTES = 8192;
14
+
15
+ export type { Stage, ContextFile, ContextBundle, BundleOptions } from './types.ts';
16
+ export { MANIFEST, manifestFor, readFileRaw } from './manifest.ts';
17
+ export { truncateMarkdown } from './truncate.ts';
18
+
19
+ /**
20
+ * Build the context bundle for a given stage. Reads every file in
21
+ * `MANIFEST[stage]` from disk, applies markdown-aware truncation to any file
22
+ * whose raw size exceeds `truncationThresholdBytes` (default 8 KiB), and
23
+ * returns the typed bundle.
24
+ *
25
+ * Missing files are recorded as `present: false` with empty content (unless
26
+ * `strict: true`, in which case the first missing file throws). ENOENT never
27
+ * surfaces to the caller in default mode — other IO errors still propagate.
28
+ */
29
+ export function buildContextBundle(stage: Stage, opts: BundleOptions = {}): ContextBundle {
30
+ const cwd = opts.cwd ?? process.cwd();
31
+ const threshold = opts.truncationThresholdBytes ?? DEFAULT_THRESHOLD_BYTES;
32
+ const strict = opts.strict === true;
33
+
34
+ const manifest = manifestFor(stage);
35
+ const files: ContextFile[] = [];
36
+ let total_bytes = 0;
37
+
38
+ for (const entry of manifest) {
39
+ const absPath = resolve(cwd, entry);
40
+ const { present, raw, raw_bytes } = readFileRaw(absPath);
41
+
42
+ if (!present) {
43
+ if (strict) {
44
+ throw new Error(`context-engine: required file not found: ${entry}`);
45
+ }
46
+ files.push({
47
+ path: entry,
48
+ present: false,
49
+ raw_bytes: 0,
50
+ content: '',
51
+ content_bytes: 0,
52
+ truncated_lines: 0,
53
+ });
54
+ continue;
55
+ }
56
+
57
+ const { content, truncated_lines } = truncateMarkdown(raw, threshold);
58
+ const content_bytes = Buffer.byteLength(content, 'utf8');
59
+ files.push({
60
+ path: entry,
61
+ present: true,
62
+ raw_bytes,
63
+ content,
64
+ content_bytes,
65
+ truncated_lines,
66
+ });
67
+ total_bytes += content_bytes;
68
+ }
69
+
70
+ const bundle: ContextBundle = {
71
+ stage,
72
+ files,
73
+ total_bytes,
74
+ built_at: new Date().toISOString(),
75
+ };
76
+
77
+ // Diagnostic emit. Plan 21-04 Task 4: context-engine consumes the
78
+ // structured logger so CI and the E2E harness can observe bundle
79
+ // construction without screen-scraping stdout.
80
+ try {
81
+ getLogger().debug('bundle built', {
82
+ stage,
83
+ files: files.length,
84
+ total_bytes,
85
+ });
86
+ } catch {
87
+ // getLogger() is defensive; any failure here must not block bundle
88
+ // construction. Callers depend on buildContextBundle returning a
89
+ // valid ContextBundle.
90
+ }
91
+
92
+ return bundle;
93
+ }
94
+
95
+ /**
96
+ * Render a bundle as a single prompt-ready string with per-file HTML-comment
97
+ * headers and `\n---\n` dividers between files. Missing files render as
98
+ * `<!-- file: PATH (missing) -->` with no body.
99
+ *
100
+ * Consumed by pipeline-runner (21-05) and parallel runners (21-06..08) to
101
+ * build the system prompt's context section.
102
+ */
103
+ export function renderBundle(bundle: ContextBundle): string {
104
+ const parts: string[] = [];
105
+ for (const f of bundle.files) {
106
+ if (!f.present) {
107
+ parts.push(`<!-- file: ${f.path} (missing) -->`);
108
+ continue;
109
+ }
110
+ parts.push(`<!-- file: ${f.path} (${f.content_bytes} bytes) -->\n${f.content}`);
111
+ }
112
+ // Ensure MANIFEST import remains live-referenced for consumers that depend
113
+ // on side-effects of module loading (none currently, but harmless).
114
+ void MANIFEST;
115
+ return parts.join('\n---\n');
116
+ }
@@ -0,0 +1,69 @@
1
+ // scripts/lib/context-engine/manifest.ts — locked per-stage file manifest +
2
+ // ENOENT-tolerant disk reader. The MANIFEST object is the single
3
+ // source-of-truth for which `.design/*.md` files each headless stage skill
4
+ // reads; runners in Plans 21-05..08 query it indirectly via manifestFor().
5
+ //
6
+ // Frozen shape: outer Record and every inner array are Object.freeze()d so
7
+ // downstream mutation attempts fail fast in strict mode (TypeScript strict
8
+ // mode already flags them at compile time).
9
+
10
+ import { readFileSync } from 'node:fs';
11
+ import { Buffer } from 'node:buffer';
12
+
13
+ import type { Stage } from './types.ts';
14
+
15
+ /**
16
+ * Per-stage file manifest. Order within each tuple is significant — it is the
17
+ * order files appear in the rendered bundle (see `renderBundle` in index.ts).
18
+ *
19
+ * LOCKED: do not modify without revisiting the skill prompts for every stage
20
+ * listed below. Changes here cascade into Phase 21 runner prompts.
21
+ */
22
+ export const MANIFEST: Readonly<Record<Stage, readonly string[]>> = Object.freeze({
23
+ brief: Object.freeze(['.design/STATE.md', '.design/BRIEF.md']),
24
+ explore: Object.freeze(['.design/STATE.md', '.design/BRIEF.md', '.design/DESIGN-CONTEXT.md']),
25
+ plan: Object.freeze([
26
+ '.design/STATE.md',
27
+ '.design/DESIGN-PLAN.md',
28
+ '.design/DESIGN-CONTEXT.md',
29
+ '.design/RESEARCH.md',
30
+ ]),
31
+ design: Object.freeze(['.design/STATE.md', '.design/DESIGN-PLAN.md']),
32
+ verify: Object.freeze(['.design/STATE.md', '.design/DESIGN-PLAN.md', '.design/SUMMARY.md']),
33
+ init: Object.freeze(['.design/STATE.md']),
34
+ });
35
+
36
+ /**
37
+ * Return the locked manifest entries for a stage. Returned array is
38
+ * Object.freeze()d — callers must not mutate.
39
+ */
40
+ export function manifestFor(stage: Stage): readonly string[] {
41
+ const entries = MANIFEST[stage];
42
+ // Defensive: MANIFEST is typed as Readonly<Record<Stage, ...>> but a caller
43
+ // passing a value that has been cast to Stage at runtime could land here
44
+ // with an unknown key. Return empty array rather than undefined to keep the
45
+ // caller's control flow simple (they iterate and get zero files).
46
+ return entries ?? Object.freeze([]);
47
+ }
48
+
49
+ /**
50
+ * Read one file from disk. Returns `{ present, raw, raw_bytes }`. Never
51
+ * throws on `ENOENT` — returns `{ present: false, raw: '', raw_bytes: 0 }`.
52
+ * Other IO errors (EACCES, EIO, EISDIR, …) propagate to the caller because
53
+ * those are configuration bugs, not missing-file conditions.
54
+ */
55
+ export function readFileRaw(absPath: string): { present: boolean; raw: string; raw_bytes: number } {
56
+ try {
57
+ const raw = readFileSync(absPath, 'utf8');
58
+ return { present: true, raw, raw_bytes: Buffer.byteLength(raw, 'utf8') };
59
+ } catch (err) {
60
+ // Node fs errors carry a `.code` string. Only swallow the missing-file
61
+ // family; everything else is re-thrown so the caller (or strict mode in
62
+ // buildContextBundle) surfaces the real problem.
63
+ const code = (err as NodeJS.ErrnoException | null)?.code;
64
+ if (code === 'ENOENT') {
65
+ return { present: false, raw: '', raw_bytes: 0 };
66
+ }
67
+ throw err;
68
+ }
69
+ }
@@ -0,0 +1,282 @@
1
+ // scripts/lib/context-engine/truncate.ts — markdown-aware truncation that
2
+ // preserves frontmatter verbatim, every heading line, and the first paragraph
3
+ // of each section. Files at or below the threshold pass through byte-identical.
4
+ //
5
+ // Algorithm is deterministic and line-based: no regex over the full buffer,
6
+ // no streaming. Designed for `.design/*.md` files which are short enough that
7
+ // a line array fits comfortably in memory.
8
+
9
+ import { Buffer } from 'node:buffer';
10
+
11
+ /** Byte budget for preamble text between frontmatter close and the first heading. */
12
+ const PREAMBLE_BYTE_BUDGET = 500;
13
+ /** Cap on frontmatter scan depth to avoid pathological files. */
14
+ const FRONTMATTER_SCAN_CAP = 200;
15
+ /** Marker emitted when preamble exceeds its byte budget. */
16
+ const PREAMBLE_MARKER = '<!-- truncated preamble -->';
17
+
18
+ export interface TruncateResult {
19
+ content: string;
20
+ /** Lines removed in aggregate. */
21
+ truncated_lines: number;
22
+ }
23
+
24
+ /**
25
+ * If `raw` is at or under `thresholdBytes`, returns the input byte-identical
26
+ * with `truncated_lines: 0`. Otherwise applies markdown-aware truncation per
27
+ * the Plan 21-02 spec.
28
+ */
29
+ export function truncateMarkdown(raw: string, thresholdBytes: number): TruncateResult {
30
+ if (Buffer.byteLength(raw, 'utf8') <= thresholdBytes) {
31
+ return { content: raw, truncated_lines: 0 };
32
+ }
33
+
34
+ // Split on \n; JavaScript's split drops the trailing empty fragment only
35
+ // when the string ends with exactly one \n, so a file ending in "\n" gives
36
+ // us an empty last entry we must preserve to round-trip exactly.
37
+ const lines = raw.split('\n');
38
+
39
+ const frontmatterEnd = detectFrontmatterEnd(lines);
40
+ const frontmatterLines = frontmatterEnd >= 0 ? lines.slice(0, frontmatterEnd + 1) : [];
41
+ const bodyStart = frontmatterEnd >= 0 ? frontmatterEnd + 1 : 0;
42
+ const body = lines.slice(bodyStart);
43
+
44
+ // First heading index inside the body slice (relative to body, not lines).
45
+ const firstHeadingInBody = body.findIndex(isHeadingLine);
46
+
47
+ // Preamble: lines between frontmatter close and the first heading.
48
+ let preambleLines: string[] = [];
49
+ let preambleDrops = 0;
50
+ let preambleEndInBody = 0;
51
+ if (firstHeadingInBody < 0) {
52
+ // No headings at all. Everything in body is preamble — and the spec says
53
+ // "entire body becomes one preamble -> truncated preamble marker" when
54
+ // there are no headings in an over-threshold file. We still apply the
55
+ // 500-byte budget: short prose is kept, long prose is replaced.
56
+ preambleEndInBody = body.length;
57
+ const preambleRaw = body.join('\n');
58
+ if (Buffer.byteLength(preambleRaw, 'utf8') <= PREAMBLE_BYTE_BUDGET && preambleRaw.length > 0) {
59
+ preambleLines = body.slice();
60
+ } else if (body.length > 0) {
61
+ preambleLines = [PREAMBLE_MARKER];
62
+ preambleDrops = body.length;
63
+ }
64
+ } else {
65
+ preambleEndInBody = firstHeadingInBody;
66
+ if (firstHeadingInBody > 0) {
67
+ const preambleSlice = body.slice(0, firstHeadingInBody);
68
+ // Strip leading + trailing blanks so spacing is owned by the output
69
+ // assembler, not double-emitted here. Leading blanks come from the
70
+ // `\n` that follows frontmatter's closing `---`; trailing blanks
71
+ // come from the gap before the first heading.
72
+ const trimmed = stripTrailingBlank(stripLeadingBlank(preambleSlice));
73
+ const preambleRaw = trimmed.join('\n');
74
+ if (trimmed.length === 0) {
75
+ preambleLines = [];
76
+ } else if (Buffer.byteLength(preambleRaw, 'utf8') <= PREAMBLE_BYTE_BUDGET) {
77
+ preambleLines = trimmed;
78
+ } else {
79
+ preambleLines = [PREAMBLE_MARKER];
80
+ preambleDrops = trimmed.length;
81
+ }
82
+ }
83
+ }
84
+
85
+ // Walk headings + first paragraphs from the first heading onward.
86
+ const afterPreamble = body.slice(preambleEndInBody);
87
+ const { kept, droppedLines } = walkHeadingsAndParagraphs(afterPreamble);
88
+
89
+ // Assemble output: frontmatter + (optional blank) + preamble + (optional blank) + kept.
90
+ const out: string[] = [];
91
+ if (frontmatterLines.length > 0) {
92
+ out.push(...frontmatterLines);
93
+ }
94
+ if (preambleLines.length > 0) {
95
+ // Spacing: ensure exactly one blank line between frontmatter and preamble
96
+ // if both are present.
97
+ if (out.length > 0 && out[out.length - 1] !== '') {
98
+ out.push('');
99
+ }
100
+ out.push(...preambleLines);
101
+ }
102
+ if (kept.length > 0) {
103
+ if (out.length > 0 && out[out.length - 1] !== '') {
104
+ out.push('');
105
+ }
106
+ out.push(...kept);
107
+ }
108
+
109
+ const content = out.join('\n');
110
+ const truncated_lines = preambleDrops + droppedLines;
111
+ return { content, truncated_lines };
112
+ }
113
+
114
+ /**
115
+ * Detect a YAML frontmatter block's closing index. Returns the index of the
116
+ * closing `---` line, or -1 if no well-formed frontmatter is present.
117
+ *
118
+ * A well-formed frontmatter requires: first non-empty line is `---`, AND a
119
+ * matching closing `---` exists within the first FRONTMATTER_SCAN_CAP lines
120
+ * OR within the file length (whichever is smaller).
121
+ */
122
+ function detectFrontmatterEnd(lines: string[]): number {
123
+ // Find the first non-empty line.
124
+ let firstNonEmpty = -1;
125
+ for (let i = 0; i < lines.length; i++) {
126
+ if ((lines[i] ?? '').length > 0) {
127
+ firstNonEmpty = i;
128
+ break;
129
+ }
130
+ }
131
+ if (firstNonEmpty < 0) return -1;
132
+ if (lines[firstNonEmpty] !== '---') return -1;
133
+
134
+ const scanCap = Math.min(FRONTMATTER_SCAN_CAP, lines.length - 1);
135
+ for (let i = firstNonEmpty + 1; i <= scanCap; i++) {
136
+ if (lines[i] === '---') return i;
137
+ }
138
+ return -1;
139
+ }
140
+
141
+ /**
142
+ * True when `line` starts with 1-6 hashes followed by whitespace and at
143
+ * least one non-whitespace character — i.e. a real ATX heading, not `####`
144
+ * on its own (which is legal markdown but uninformative here).
145
+ */
146
+ function isHeadingLine(line: string): boolean {
147
+ // Quick reject: must start with '#'.
148
+ if (line.length === 0 || line.charCodeAt(0) !== 35 /* '#' */) return false;
149
+ return /^#{1,6}\s+\S/.test(line);
150
+ }
151
+
152
+ function stripTrailingBlank(arr: string[]): string[] {
153
+ let end = arr.length;
154
+ while (end > 0 && arr[end - 1] === '') end--;
155
+ return arr.slice(0, end);
156
+ }
157
+
158
+ function stripLeadingBlank(arr: string[]): string[] {
159
+ let start = 0;
160
+ while (start < arr.length && arr[start] === '') start++;
161
+ return arr.slice(start);
162
+ }
163
+
164
+ /**
165
+ * Core walker over the post-preamble body. Produces `kept` — the output
166
+ * lines (headings, first paragraphs, markers) — and `droppedLines` — the
167
+ * aggregate count of lines classified as drop.
168
+ *
169
+ * Classification per line:
170
+ * - heading (#{1,6} + space) -> keep
171
+ * - non-blank line immediately after a heading or after a prior keep-para
172
+ * line (still in the same first-paragraph run) -> keep
173
+ * - blank line -> terminates the current paragraph; itself dropped
174
+ * from output (we re-insert exactly one blank between preserved chunks)
175
+ * - everything else -> drop (counts into droppedLines)
176
+ *
177
+ * When a run of drop lines flushes (i.e. the next keeper arrives), we emit
178
+ * `<!-- truncated: N lines removed -->` exactly once before that keeper.
179
+ */
180
+ function walkHeadingsAndParagraphs(body: string[]): { kept: string[]; droppedLines: number } {
181
+ const kept: string[] = [];
182
+ let droppedLines = 0;
183
+ let pendingDropCount = 0;
184
+ // Modes:
185
+ // 'start' — before the first heading (normally empty after preamble).
186
+ // 'await-para' — after a heading, skipping leading blanks until the
187
+ // first non-blank line (which starts the first paragraph).
188
+ // Blank lines in this mode are neither kept nor counted
189
+ // as drops — they are standard heading/paragraph gaps.
190
+ // 'para' — collecting first-paragraph lines under the current
191
+ // heading. Terminates on blank or next heading.
192
+ // 'gap' — after the paragraph's terminating blank; dropping
193
+ // until the next heading.
194
+ type Mode = 'start' | 'await-para' | 'para' | 'gap';
195
+ let mode: Mode = 'start';
196
+
197
+ const flushDropsBeforeKeeper = (): void => {
198
+ if (pendingDropCount > 0) {
199
+ // Separate the marker from whatever preceded it with a blank line,
200
+ // but only if `kept` is non-empty and its last line is not already
201
+ // blank. This matches the spec's "single blank between preserved
202
+ // runs" rule.
203
+ if (kept.length > 0 && kept[kept.length - 1] !== '') {
204
+ kept.push('');
205
+ }
206
+ kept.push(`<!-- truncated: ${pendingDropCount} lines removed -->`);
207
+ pendingDropCount = 0;
208
+ }
209
+ };
210
+
211
+ for (let i = 0; i < body.length; i++) {
212
+ const line = body[i] ?? '';
213
+ const heading = isHeadingLine(line);
214
+
215
+ if (heading) {
216
+ flushDropsBeforeKeeper();
217
+ // Ensure a single blank line before the heading if the previous output
218
+ // isn't already blank (and there is previous output).
219
+ if (kept.length > 0 && kept[kept.length - 1] !== '') {
220
+ kept.push('');
221
+ }
222
+ kept.push(line);
223
+ // Enter await-para: standard markdown wraps heading/paragraph with a
224
+ // blank line; we must skip that blank before collecting the first
225
+ // paragraph, otherwise the paragraph is dropped entirely.
226
+ mode = 'await-para';
227
+ continue;
228
+ }
229
+
230
+ if (mode === 'await-para') {
231
+ if (line === '') {
232
+ // Leading blank between heading and its first paragraph. Skip it —
233
+ // do not count as a drop (it is structural whitespace).
234
+ continue;
235
+ }
236
+ // First non-blank line after the heading: emit our blank separator
237
+ // (so the heading is followed by exactly one blank) and start the
238
+ // paragraph run.
239
+ kept.push('');
240
+ kept.push(line);
241
+ mode = 'para';
242
+ continue;
243
+ }
244
+
245
+ if (mode === 'para') {
246
+ if (line === '') {
247
+ // End the first-paragraph run; switch to drop-gap mode. The blank
248
+ // itself is not kept (the next heading will re-insert one blank
249
+ // before its own line).
250
+ mode = 'gap';
251
+ continue;
252
+ }
253
+ // Non-blank line inside first-paragraph run: keep verbatim.
254
+ kept.push(line);
255
+ continue;
256
+ }
257
+
258
+ // mode is 'start' or 'gap' — drop everything until the next heading.
259
+ if (line === '') {
260
+ // Blank lines inside the drop-gap don't count as lines-we-dropped for
261
+ // byte-saving purposes but the spec counts dropped lines strictly, so
262
+ // include them in the count. This matches plan acceptance 3.8:
263
+ // "truncated_lines count matches exactly the number of dropped lines".
264
+ pendingDropCount += 1;
265
+ droppedLines += 1;
266
+ continue;
267
+ }
268
+ pendingDropCount += 1;
269
+ droppedLines += 1;
270
+ }
271
+
272
+ // Trailing drop run: emit the marker so callers can see the removed count
273
+ // for the file tail even when no heading follows.
274
+ if (pendingDropCount > 0) {
275
+ if (kept.length > 0 && kept[kept.length - 1] !== '') {
276
+ kept.push('');
277
+ }
278
+ kept.push(`<!-- truncated: ${pendingDropCount} lines removed -->`);
279
+ }
280
+
281
+ return { kept, droppedLines };
282
+ }
@@ -0,0 +1,59 @@
1
+ // scripts/lib/context-engine/types.ts — typed shapes for the context-engine
2
+ // module. The context-engine deterministically assembles the per-stage file
3
+ // manifest a headless Phase 21 session needs. Types are data-only; all
4
+ // behavior lives in sibling modules (manifest.ts, truncate.ts, index.ts).
5
+
6
+ /**
7
+ * Pipeline stage identifier. Mirrors the `stage` field in `.design/STATE.md`
8
+ * and the orchestrator routes in skills/*. The `init` stage is headless-only —
9
+ * it prepares a fresh working directory before the `brief` stage begins.
10
+ */
11
+ export type Stage = 'brief' | 'explore' | 'plan' | 'design' | 'verify' | 'init';
12
+
13
+ /**
14
+ * Per-file record inside a {@link ContextBundle}. Captures both raw on-disk
15
+ * size and post-truncation content size so callers can preview prompt budget
16
+ * before invoking the Agent SDK.
17
+ */
18
+ export interface ContextFile {
19
+ /** Absolute or cwd-relative path as given in the manifest. */
20
+ path: string;
21
+ /** True if the file exists on disk at bundling time. */
22
+ present: boolean;
23
+ /** Raw UTF-8 byte length on disk. 0 when present=false. */
24
+ raw_bytes: number;
25
+ /** File content, with markdown-aware truncation applied when > 8 KiB. Empty string when present=false. */
26
+ content: string;
27
+ /** Byte length of .content (post-truncation). */
28
+ content_bytes: number;
29
+ /** Number of lines stripped by truncation; 0 when file was <= 8 KiB. */
30
+ truncated_lines: number;
31
+ }
32
+
33
+ /**
34
+ * Typed context bundle returned by {@link buildContextBundle}. Consumed by
35
+ * pipeline-runner (21-05), explore-parallel-runner (21-06),
36
+ * discuss-parallel-runner (21-07), and init (21-08).
37
+ */
38
+ export interface ContextBundle {
39
+ stage: Stage;
40
+ /** Ordered list of files for this stage (manifest order preserved). */
41
+ files: ContextFile[];
42
+ /** Total .content_bytes summed across files. Used by pipeline-runner for budget preview. */
43
+ total_bytes: number;
44
+ /** ISO 8601 timestamp when bundle was built. */
45
+ built_at: string;
46
+ }
47
+
48
+ /**
49
+ * Options accepted by {@link buildContextBundle}. Every field is optional;
50
+ * default cwd is `process.cwd()` and default threshold is 8192 bytes.
51
+ */
52
+ export interface BundleOptions {
53
+ /** Repo root / working directory; defaults to process.cwd(). */
54
+ cwd?: string;
55
+ /** Override 8 KiB truncation threshold (bytes). Default 8192. */
56
+ truncationThresholdBytes?: number;
57
+ /** When true, throw on any missing manifest file instead of recording present:false. */
58
+ strict?: boolean;
59
+ }