@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.
- package/.claude-plugin/marketplace.json +9 -12
- package/.claude-plugin/plugin.json +8 -31
- package/CHANGELOG.md +200 -0
- package/README.md +48 -7
- package/bin/gdd-sdk +55 -0
- package/hooks/_hook-emit.js +81 -0
- package/hooks/gdd-bash-guard.js +8 -0
- package/hooks/gdd-decision-injector.js +2 -0
- package/hooks/gdd-protected-paths.js +8 -0
- package/hooks/gdd-trajectory-capture.js +64 -0
- package/hooks/hooks.json +9 -0
- package/package.json +19 -47
- package/reference/codex-tools.md +53 -0
- package/reference/gemini-tools.md +53 -0
- package/reference/registry.json +14 -0
- package/scripts/cli/gdd-events.mjs +283 -0
- package/scripts/e2e/run-headless.ts +514 -0
- package/scripts/lib/cli/commands/audit.ts +382 -0
- package/scripts/lib/cli/commands/init.ts +217 -0
- package/scripts/lib/cli/commands/query.ts +329 -0
- package/scripts/lib/cli/commands/run.ts +656 -0
- package/scripts/lib/cli/commands/stage.ts +468 -0
- package/scripts/lib/cli/index.ts +167 -0
- package/scripts/lib/cli/parse-args.ts +336 -0
- package/scripts/lib/connection-probe/index.cjs +263 -0
- package/scripts/lib/context-engine/index.ts +116 -0
- package/scripts/lib/context-engine/manifest.ts +69 -0
- package/scripts/lib/context-engine/truncate.ts +282 -0
- package/scripts/lib/context-engine/types.ts +59 -0
- package/scripts/lib/discuss-parallel-runner/aggregator.ts +448 -0
- package/scripts/lib/discuss-parallel-runner/discussants.ts +430 -0
- package/scripts/lib/discuss-parallel-runner/index.ts +223 -0
- package/scripts/lib/discuss-parallel-runner/types.ts +184 -0
- package/scripts/lib/event-chain.cjs +177 -0
- package/scripts/lib/event-stream/index.ts +31 -1
- package/scripts/lib/event-stream/reader.ts +139 -0
- package/scripts/lib/event-stream/types.ts +155 -1
- package/scripts/lib/event-stream/writer.ts +65 -8
- package/scripts/lib/explore-parallel-runner/index.ts +294 -0
- package/scripts/lib/explore-parallel-runner/mappers.ts +290 -0
- package/scripts/lib/explore-parallel-runner/synthesizer.ts +295 -0
- package/scripts/lib/explore-parallel-runner/types.ts +139 -0
- package/scripts/lib/harness/detect.ts +90 -0
- package/scripts/lib/harness/index.ts +64 -0
- package/scripts/lib/harness/tool-map.ts +142 -0
- package/scripts/lib/init-runner/index.ts +396 -0
- package/scripts/lib/init-runner/researchers.ts +245 -0
- package/scripts/lib/init-runner/scaffold.ts +224 -0
- package/scripts/lib/init-runner/synthesizer.ts +224 -0
- package/scripts/lib/init-runner/types.ts +143 -0
- package/scripts/lib/logger/index.ts +251 -0
- package/scripts/lib/logger/sinks.ts +269 -0
- package/scripts/lib/logger/types.ts +110 -0
- package/scripts/lib/pipeline-runner/human-gate.ts +134 -0
- package/scripts/lib/pipeline-runner/index.ts +527 -0
- package/scripts/lib/pipeline-runner/stage-handlers.ts +339 -0
- package/scripts/lib/pipeline-runner/state-machine.ts +144 -0
- package/scripts/lib/pipeline-runner/types.ts +183 -0
- package/scripts/lib/redact.cjs +122 -0
- package/scripts/lib/session-runner/errors.ts +406 -0
- package/scripts/lib/session-runner/index.ts +715 -0
- package/scripts/lib/session-runner/transcript.ts +189 -0
- package/scripts/lib/session-runner/types.ts +144 -0
- package/scripts/lib/tool-scoping/index.ts +219 -0
- package/scripts/lib/tool-scoping/parse-agent-tools.ts +207 -0
- package/scripts/lib/tool-scoping/stage-scopes.ts +139 -0
- package/scripts/lib/tool-scoping/types.ts +77 -0
- package/scripts/lib/trajectory/index.cjs +126 -0
- 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
|
+
}
|