@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,207 @@
|
|
|
1
|
+
// scripts/lib/tool-scoping/parse-agent-tools.ts — extract the `tools:`
|
|
2
|
+
// list from an agent markdown file's YAML frontmatter.
|
|
3
|
+
//
|
|
4
|
+
// Why a hand-rolled parser instead of pulling in js-yaml:
|
|
5
|
+
// * No new npm deps (Plan 21-03 hard constraint).
|
|
6
|
+
// * The `tools:` field grammar is narrow (4 YAML shapes + wildcard +
|
|
7
|
+
// empty). A minimal parser covering exactly those shapes is
|
|
8
|
+
// maintainable and keeps the surface area tight.
|
|
9
|
+
//
|
|
10
|
+
// Supported frontmatter shapes:
|
|
11
|
+
// tools: Read, Write, Grep → ['Read','Write','Grep']
|
|
12
|
+
// tools: [Read, Write] → ['Read','Write']
|
|
13
|
+
// tools: "*" → null (wildcard fallback)
|
|
14
|
+
// tools: [] → [] (MCP-only narrow)
|
|
15
|
+
// tools:
|
|
16
|
+
// - Read
|
|
17
|
+
// - Write → ['Read','Write']
|
|
18
|
+
//
|
|
19
|
+
// Return contract:
|
|
20
|
+
// null — file missing, no frontmatter, tools key absent, OR wildcard.
|
|
21
|
+
// [] — tools: [] OR tools: (no children).
|
|
22
|
+
// string[] — the declared, trimmed, de-quoted list.
|
|
23
|
+
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
import { resolve } from 'node:path';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read the `tools:` field from an agent markdown file's YAML
|
|
29
|
+
* frontmatter. See module header for the full grammar.
|
|
30
|
+
*
|
|
31
|
+
* @param agentMdPath absolute or cwd-relative path to an `agents/*.md`.
|
|
32
|
+
* @returns readonly string[] | null per the contract above.
|
|
33
|
+
*/
|
|
34
|
+
export function parseAgentTools(
|
|
35
|
+
agentMdPath: string,
|
|
36
|
+
): readonly string[] | null {
|
|
37
|
+
let raw: string;
|
|
38
|
+
try {
|
|
39
|
+
raw = readFileSync(agentMdPath, 'utf8');
|
|
40
|
+
} catch (err) {
|
|
41
|
+
// ENOENT or any read error → treat as "no override" (null).
|
|
42
|
+
if (
|
|
43
|
+
err !== null &&
|
|
44
|
+
typeof err === 'object' &&
|
|
45
|
+
'code' in err &&
|
|
46
|
+
(err as { code: string }).code === 'ENOENT'
|
|
47
|
+
) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
// Permission/IO errors also fall through to null — a parser that
|
|
51
|
+
// throws on any fs hiccup would crash the entire session at scope
|
|
52
|
+
// computation time; fail-closed (null = stage default) is safer.
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const frontmatter: string | null = extractFrontmatter(raw);
|
|
57
|
+
if (frontmatter === null) return null;
|
|
58
|
+
|
|
59
|
+
return extractToolsField(frontmatter);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Convenience: look up an agent by bare name under `<agentsRoot>/<name>.md`
|
|
64
|
+
* and delegate to `parseAgentTools`. Defaults to `./agents` when no root
|
|
65
|
+
* is supplied.
|
|
66
|
+
*/
|
|
67
|
+
export function parseAgentToolsByName(
|
|
68
|
+
name: string,
|
|
69
|
+
agentsRoot: string = 'agents',
|
|
70
|
+
): readonly string[] | null {
|
|
71
|
+
const path: string = resolve(agentsRoot, `${name}.md`);
|
|
72
|
+
return parseAgentTools(path);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Internal — frontmatter splitter
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Return the text between the opening `---\n` and closing `---\n` lines,
|
|
81
|
+
* or null when no valid frontmatter block exists.
|
|
82
|
+
*
|
|
83
|
+
* Matches the splitter in `scripts/lib/prompt-sanitizer/index.ts` — kept
|
|
84
|
+
* local (rather than imported) to avoid coupling the tool-scoping module
|
|
85
|
+
* to prompt-sanitizer internals.
|
|
86
|
+
*/
|
|
87
|
+
function extractFrontmatter(raw: string): string | null {
|
|
88
|
+
// Accept LF or CRLF. First line must be exactly `---`.
|
|
89
|
+
const match: RegExpExecArray | null = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(
|
|
90
|
+
raw,
|
|
91
|
+
);
|
|
92
|
+
if (match === null) return null;
|
|
93
|
+
const body: string | undefined = match[1];
|
|
94
|
+
return body ?? null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Internal — tools field extractor
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Given the frontmatter body (text between `---` fences), return the
|
|
103
|
+
* parsed `tools:` field per the contract. Absence returns null.
|
|
104
|
+
*/
|
|
105
|
+
function extractToolsField(frontmatter: string): readonly string[] | null {
|
|
106
|
+
const lines: string[] = frontmatter.split(/\r?\n/);
|
|
107
|
+
const toolsLineRe = /^tools:\s*(.*)$/;
|
|
108
|
+
|
|
109
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
110
|
+
const line: string = lines[i] ?? '';
|
|
111
|
+
const m: RegExpExecArray | null = toolsLineRe.exec(line);
|
|
112
|
+
if (m === null) continue;
|
|
113
|
+
|
|
114
|
+
const rest: string = (m[1] ?? '').trim();
|
|
115
|
+
|
|
116
|
+
// Case 1: wildcard — `tools: "*"` or `tools: *`.
|
|
117
|
+
// Per the Plan 21-03 frontmatter contract, this is a forward-compat
|
|
118
|
+
// escape that falls back to stage default (NOT "everything"), so we
|
|
119
|
+
// return null to signal "no override".
|
|
120
|
+
if (rest === '*' || rest === '"*"' || rest === "'*'") {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Case 2: flow-style `tools: [...]` or empty `tools: []`.
|
|
125
|
+
if (rest.startsWith('[') && rest.endsWith(']')) {
|
|
126
|
+
const inner: string = rest.slice(1, -1).trim();
|
|
127
|
+
if (inner === '') return Object.freeze([]);
|
|
128
|
+
return Object.freeze(splitAndClean(inner));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Case 3: YAML list (empty value on tools: line, items follow).
|
|
132
|
+
if (rest === '') {
|
|
133
|
+
const items: string[] = [];
|
|
134
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
135
|
+
const next: string = lines[j] ?? '';
|
|
136
|
+
// A blank line or a non-list-item line ends the block.
|
|
137
|
+
if (next.trim() === '') continue;
|
|
138
|
+
const listItem: RegExpExecArray | null = /^\s*-\s*(\S.*)$/.exec(next);
|
|
139
|
+
if (listItem === null) break;
|
|
140
|
+
const entry: string | undefined = listItem[1];
|
|
141
|
+
if (entry === undefined) break;
|
|
142
|
+
items.push(cleanToken(entry));
|
|
143
|
+
}
|
|
144
|
+
return Object.freeze(items);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Case 4: inline comma-separated list (may have quoted entries).
|
|
148
|
+
return Object.freeze(splitAndClean(rest));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Split a comma-separated list while honoring double-quoted entries
|
|
156
|
+
* (so `"Read, with-comma", "Write"` stays a 2-element list). Trims
|
|
157
|
+
* whitespace and strips surrounding single / double quotes from each
|
|
158
|
+
* token.
|
|
159
|
+
*/
|
|
160
|
+
function splitAndClean(input: string): string[] {
|
|
161
|
+
const out: string[] = [];
|
|
162
|
+
let buf: string[] = [];
|
|
163
|
+
let inDouble = false;
|
|
164
|
+
let inSingle = false;
|
|
165
|
+
|
|
166
|
+
for (const ch of input) {
|
|
167
|
+
if (ch === '"' && !inSingle) {
|
|
168
|
+
inDouble = !inDouble;
|
|
169
|
+
buf.push(ch);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (ch === "'" && !inDouble) {
|
|
173
|
+
inSingle = !inSingle;
|
|
174
|
+
buf.push(ch);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (ch === ',' && !inDouble && !inSingle) {
|
|
178
|
+
out.push(cleanToken(buf.join('')));
|
|
179
|
+
buf = [];
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
buf.push(ch);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const tail: string = cleanToken(buf.join(''));
|
|
186
|
+
if (tail !== '' || out.length === 0) {
|
|
187
|
+
out.push(tail);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return out.filter((t) => t !== '');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Trim whitespace + strip matching leading/trailing quote pairs.
|
|
195
|
+
* Applied to each split list entry.
|
|
196
|
+
*/
|
|
197
|
+
function cleanToken(token: string): string {
|
|
198
|
+
let t: string = token.trim();
|
|
199
|
+
if (t.length >= 2) {
|
|
200
|
+
const first: string = t[0] ?? '';
|
|
201
|
+
const last: string = t[t.length - 1] ?? '';
|
|
202
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
203
|
+
t = t.slice(1, -1).trim();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return t;
|
|
207
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// scripts/lib/tool-scoping/stage-scopes.ts — frozen per-stage default
|
|
2
|
+
// scope registry and native-tool classifier.
|
|
3
|
+
//
|
|
4
|
+
// The locked table below is the single source of truth for what each
|
|
5
|
+
// pipeline stage is permitted. DO NOT MODIFY without a follow-up plan:
|
|
6
|
+
// widening a stage here silently expands every headless session that
|
|
7
|
+
// falls back to defaults.
|
|
8
|
+
//
|
|
9
|
+
// MCP tools (`mcp__*`) are NEVER in this registry — they're always
|
|
10
|
+
// permitted and bypass the native filter entirely. See `isMcpTool`.
|
|
11
|
+
|
|
12
|
+
import type { Stage } from './types.ts';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Shape of a single registry entry. Frozen at module load.
|
|
16
|
+
*/
|
|
17
|
+
interface StageDefault {
|
|
18
|
+
readonly allowed: readonly string[];
|
|
19
|
+
readonly bashMutation: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Per-stage default scope table. Every `Stage` key must have an entry —
|
|
24
|
+
* `computeScope` relies on this for invariant lookup.
|
|
25
|
+
*
|
|
26
|
+
* Locked contract (see PLAN 21-03):
|
|
27
|
+
* brief — Read/Write/Edit/Grep/Glob/Bash (Bash read-only, advisory)
|
|
28
|
+
* explore — Read/Grep/Glob/Bash/WebSearch/WebFetch/Task (Bash read-only)
|
|
29
|
+
* plan — Read/Write/Edit/Grep/Glob/Bash/Task (Bash read-only)
|
|
30
|
+
* design — Read/Write/Edit/Grep/Glob/Bash/Task (Bash mutation ALLOWED)
|
|
31
|
+
* verify — Read/Grep/Glob/Bash (NO Write/Edit/Task; Bash read-only)
|
|
32
|
+
* init — Read/Write/Grep/Glob/Bash/Task/WebSearch/WebFetch (bootstrap)
|
|
33
|
+
* custom — empty (caller-provided only; no defaults)
|
|
34
|
+
*/
|
|
35
|
+
export const STAGE_SCOPES: Readonly<Record<Stage, StageDefault>> =
|
|
36
|
+
Object.freeze({
|
|
37
|
+
brief: Object.freeze({
|
|
38
|
+
allowed: Object.freeze(['Read', 'Write', 'Edit', 'Grep', 'Glob', 'Bash']),
|
|
39
|
+
bashMutation: false,
|
|
40
|
+
}),
|
|
41
|
+
explore: Object.freeze({
|
|
42
|
+
allowed: Object.freeze([
|
|
43
|
+
'Read',
|
|
44
|
+
'Grep',
|
|
45
|
+
'Glob',
|
|
46
|
+
'Bash',
|
|
47
|
+
'WebSearch',
|
|
48
|
+
'WebFetch',
|
|
49
|
+
'Task',
|
|
50
|
+
]),
|
|
51
|
+
bashMutation: false,
|
|
52
|
+
}),
|
|
53
|
+
plan: Object.freeze({
|
|
54
|
+
allowed: Object.freeze([
|
|
55
|
+
'Read',
|
|
56
|
+
'Write',
|
|
57
|
+
'Edit',
|
|
58
|
+
'Grep',
|
|
59
|
+
'Glob',
|
|
60
|
+
'Bash',
|
|
61
|
+
'Task',
|
|
62
|
+
]),
|
|
63
|
+
bashMutation: false,
|
|
64
|
+
}),
|
|
65
|
+
design: Object.freeze({
|
|
66
|
+
allowed: Object.freeze([
|
|
67
|
+
'Read',
|
|
68
|
+
'Write',
|
|
69
|
+
'Edit',
|
|
70
|
+
'Grep',
|
|
71
|
+
'Glob',
|
|
72
|
+
'Bash',
|
|
73
|
+
'Task',
|
|
74
|
+
]),
|
|
75
|
+
bashMutation: true,
|
|
76
|
+
}),
|
|
77
|
+
verify: Object.freeze({
|
|
78
|
+
allowed: Object.freeze(['Read', 'Grep', 'Glob', 'Bash']),
|
|
79
|
+
bashMutation: false,
|
|
80
|
+
}),
|
|
81
|
+
init: Object.freeze({
|
|
82
|
+
allowed: Object.freeze([
|
|
83
|
+
'Read',
|
|
84
|
+
'Write',
|
|
85
|
+
'Grep',
|
|
86
|
+
'Glob',
|
|
87
|
+
'Bash',
|
|
88
|
+
'Task',
|
|
89
|
+
'WebSearch',
|
|
90
|
+
'WebFetch',
|
|
91
|
+
]),
|
|
92
|
+
bashMutation: false,
|
|
93
|
+
}),
|
|
94
|
+
custom: Object.freeze({
|
|
95
|
+
allowed: Object.freeze([]),
|
|
96
|
+
bashMutation: false,
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Authoritative list of native (harness-managed) tool names. Anything
|
|
102
|
+
* NOT in this list and NOT MCP-prefixed is unknown and treated as a
|
|
103
|
+
* native miss by `checkTool`.
|
|
104
|
+
*
|
|
105
|
+
* Order matches the documented stage scopes; tests assert that every
|
|
106
|
+
* tool referenced in STAGE_SCOPES is a member of NATIVE_TOOLS.
|
|
107
|
+
*/
|
|
108
|
+
export const NATIVE_TOOLS: readonly string[] = Object.freeze([
|
|
109
|
+
'Read',
|
|
110
|
+
'Write',
|
|
111
|
+
'Edit',
|
|
112
|
+
'Grep',
|
|
113
|
+
'Glob',
|
|
114
|
+
'Bash',
|
|
115
|
+
'Task',
|
|
116
|
+
'WebSearch',
|
|
117
|
+
'WebFetch',
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
/** MCP tools carry the `mcp__` prefix by convention. */
|
|
121
|
+
const MCP_PREFIX = 'mcp__';
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* True when `name` is an MCP tool. MCP tools always pass scope checks —
|
|
125
|
+
* each MCP server declares its own security perimeter, so the stage
|
|
126
|
+
* filter only gates native harness tools.
|
|
127
|
+
*/
|
|
128
|
+
export function isMcpTool(name: string): boolean {
|
|
129
|
+
return typeof name === 'string' && name.startsWith(MCP_PREFIX);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* True when `name` is a known native harness tool. Used by
|
|
134
|
+
* `computeScope` to split caller-supplied lists into native vs MCP
|
|
135
|
+
* buckets.
|
|
136
|
+
*/
|
|
137
|
+
export function isNativeTool(name: string): boolean {
|
|
138
|
+
return NATIVE_TOOLS.includes(name);
|
|
139
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// scripts/lib/tool-scoping/types.ts — type definitions for per-stage
|
|
2
|
+
// allowed-tools enforcement.
|
|
3
|
+
//
|
|
4
|
+
// See ./index.ts for the public API surface. Types are kept in this file
|
|
5
|
+
// so fixtures, tests, and callers can import them without pulling the
|
|
6
|
+
// full parser/compute machinery.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Canonical pipeline stage name. `custom` is the escape valve for
|
|
10
|
+
* callers that manage their own scope entirely; it has no defaults.
|
|
11
|
+
*/
|
|
12
|
+
export type Stage =
|
|
13
|
+
| 'brief'
|
|
14
|
+
| 'explore'
|
|
15
|
+
| 'plan'
|
|
16
|
+
| 'design'
|
|
17
|
+
| 'verify'
|
|
18
|
+
| 'init'
|
|
19
|
+
| 'custom';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Computed scope for a headless Agent SDK session. Produced by
|
|
23
|
+
* `computeScope`; consumed by `checkTool`, `enforceScope`, and
|
|
24
|
+
* `session-runner`'s `allowedTools` parameter.
|
|
25
|
+
*
|
|
26
|
+
* `allowed` is a flattened, deduplicated, alphabetically sorted list
|
|
27
|
+
* (deterministic output — stable across runs).
|
|
28
|
+
*
|
|
29
|
+
* `denied` is `NATIVE_TOOLS \ allowed_native`: the set of native
|
|
30
|
+
* harness tools explicitly NOT permitted on this session. MCP tools
|
|
31
|
+
* are never in `denied` — they always pass.
|
|
32
|
+
*/
|
|
33
|
+
export interface Scope {
|
|
34
|
+
readonly stage: Stage;
|
|
35
|
+
/** Flattened, deduplicated, sorted list of allowed tool names. */
|
|
36
|
+
readonly allowed: readonly string[];
|
|
37
|
+
/** Native tools explicitly denied by the stage (e.g., verify denies Write). */
|
|
38
|
+
readonly denied: readonly string[];
|
|
39
|
+
/**
|
|
40
|
+
* Whether bash mutations are permitted (stage-level flag, advisory —
|
|
41
|
+
* hard gating is future work in Phase 22's `gdd-router`).
|
|
42
|
+
*/
|
|
43
|
+
readonly bashMutation: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Input to `computeScope` / `enforceScope`.
|
|
48
|
+
*
|
|
49
|
+
* `agentTools` precedence rules (documented in stage-scopes.ts):
|
|
50
|
+
* undefined / null → stage default applies
|
|
51
|
+
* [] → scope narrows to MCP-only (empty native list)
|
|
52
|
+
* string[] (non-empty)→ overrides stage defaults entirely
|
|
53
|
+
*
|
|
54
|
+
* `additional` is unioned with the scope (caller-supplied, e.g.,
|
|
55
|
+
* `mcp__gdd_state__*` tool names the session needs access to).
|
|
56
|
+
*/
|
|
57
|
+
export interface ScopeInput {
|
|
58
|
+
readonly stage: Stage;
|
|
59
|
+
/** Optional agent-frontmatter override (from parseAgentTools). */
|
|
60
|
+
readonly agentTools?: readonly string[] | null;
|
|
61
|
+
/** Additional tools to union with the scope (caller-supplied). */
|
|
62
|
+
readonly additional?: readonly string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Structured denial record returned by `checkTool`. `enforceScope`
|
|
67
|
+
* lifts these into `ValidationError` instances (from gdd-errors).
|
|
68
|
+
*
|
|
69
|
+
* `tool` is absent when the violation is not tool-specific
|
|
70
|
+
* (e.g., `INVALID_STAGE`, `EMPTY_SCOPE`).
|
|
71
|
+
*/
|
|
72
|
+
export interface ScopeViolation {
|
|
73
|
+
readonly code: 'TOOL_NOT_ALLOWED' | 'INVALID_STAGE' | 'EMPTY_SCOPE';
|
|
74
|
+
readonly tool?: string;
|
|
75
|
+
readonly stage: Stage;
|
|
76
|
+
readonly message: string;
|
|
77
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trajectory/index.cjs — per-tool-call trajectory stream (Plan 22-03).
|
|
3
|
+
*
|
|
4
|
+
* Records every agent tool-use as one JSONL line at
|
|
5
|
+
* `.design/telemetry/trajectories/<cycle>.jsonl`
|
|
6
|
+
*
|
|
7
|
+
* Why hash args/result instead of storing full content:
|
|
8
|
+
* * keeps line size bounded regardless of argument payload
|
|
9
|
+
* * de-identifies prompts that may contain user-private content
|
|
10
|
+
* * still allows replay via dedup-by-hash if a future analyzer wants it
|
|
11
|
+
*
|
|
12
|
+
* Schema (one JSONL line):
|
|
13
|
+
* {
|
|
14
|
+
* ts: ISO-8601 with ms,
|
|
15
|
+
* session_id: string | null,
|
|
16
|
+
* cycle: string, // 'current' if not supplied
|
|
17
|
+
* agent: string, // calling agent name
|
|
18
|
+
* tool: string, // 'Bash' / 'Edit' / 'mcp__…'
|
|
19
|
+
* args_hash: 16-char sha256 prefix of canonical-JSON args
|
|
20
|
+
* result_hash: 16-char sha256 prefix of canonical-JSON result
|
|
21
|
+
* latency_ms: number,
|
|
22
|
+
* status: 'ok' | 'error',
|
|
23
|
+
* }
|
|
24
|
+
*
|
|
25
|
+
* Side effects:
|
|
26
|
+
* * appendFileSync to the trajectory file (atomic per line on POSIX/NT)
|
|
27
|
+
* * NEVER throws — IO failure logs to stderr and returns silently
|
|
28
|
+
* * Optionally appends a `tool_call.completed` event to the
|
|
29
|
+
* event-stream so live subscribers can see the same call without
|
|
30
|
+
* scanning trajectory files. Skipped if `event_stream` arg is null.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
const { appendFileSync, mkdirSync } = require('node:fs');
|
|
36
|
+
const { dirname, isAbsolute, join, resolve } = require('node:path');
|
|
37
|
+
const { createHash } = require('node:crypto');
|
|
38
|
+
|
|
39
|
+
const DEFAULT_TRAJECTORY_DIR = '.design/telemetry/trajectories';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Compute a stable 16-char sha256-hex prefix for arbitrary JSON-shaped
|
|
43
|
+
* input. Falls back to `'0'.repeat(16)` if `JSON.stringify` throws.
|
|
44
|
+
*
|
|
45
|
+
* @param {unknown} value
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
function hashOf(value) {
|
|
49
|
+
let serialized;
|
|
50
|
+
try {
|
|
51
|
+
serialized = JSON.stringify(value ?? null);
|
|
52
|
+
} catch {
|
|
53
|
+
return '0'.repeat(16);
|
|
54
|
+
}
|
|
55
|
+
return createHash('sha256').update(serialized ?? '').digest('hex').slice(0, 16);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the on-disk trajectory file for `cycle` against `baseDir`.
|
|
60
|
+
*
|
|
61
|
+
* @param {{baseDir?: string, cycle?: string, dir?: string}} [opts]
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
function trajectoryPath(opts = {}) {
|
|
65
|
+
const baseDir = opts.baseDir ?? process.cwd();
|
|
66
|
+
const dir = opts.dir ?? DEFAULT_TRAJECTORY_DIR;
|
|
67
|
+
const cycle = (opts.cycle ?? 'current').replace(/[^A-Za-z0-9._-]/g, '_');
|
|
68
|
+
const resolvedDir = isAbsolute(dir) ? dir : resolve(baseDir, dir);
|
|
69
|
+
return join(resolvedDir, `${cycle}.jsonl`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Append one trajectory record. Returns the recorded line for tests
|
|
74
|
+
* that want to assert on shape without re-reading the file.
|
|
75
|
+
*
|
|
76
|
+
* @param {{
|
|
77
|
+
* cycle?: string,
|
|
78
|
+
* session_id?: string | null,
|
|
79
|
+
* agent: string,
|
|
80
|
+
* tool: string,
|
|
81
|
+
* args?: unknown,
|
|
82
|
+
* result?: unknown,
|
|
83
|
+
* latency_ms?: number,
|
|
84
|
+
* status?: 'ok' | 'error',
|
|
85
|
+
* baseDir?: string,
|
|
86
|
+
* path?: string,
|
|
87
|
+
* }} call
|
|
88
|
+
* @returns {string} the JSONL line that was appended (without trailing \n)
|
|
89
|
+
*/
|
|
90
|
+
function recordCall(call) {
|
|
91
|
+
const ts = new Date().toISOString();
|
|
92
|
+
const record = {
|
|
93
|
+
ts,
|
|
94
|
+
session_id: call.session_id ?? null,
|
|
95
|
+
cycle: call.cycle ?? 'current',
|
|
96
|
+
agent: call.agent,
|
|
97
|
+
tool: call.tool,
|
|
98
|
+
args_hash: hashOf(call.args),
|
|
99
|
+
result_hash: hashOf(call.result),
|
|
100
|
+
latency_ms: typeof call.latency_ms === 'number' ? call.latency_ms : 0,
|
|
101
|
+
status: call.status ?? 'ok',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const path = call.path ?? trajectoryPath({ baseDir: call.baseDir, cycle: record.cycle });
|
|
105
|
+
const line = JSON.stringify(record);
|
|
106
|
+
try {
|
|
107
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
108
|
+
appendFileSync(path, line + '\n', { flag: 'a' });
|
|
109
|
+
} catch (err) {
|
|
110
|
+
try {
|
|
111
|
+
process.stderr.write(
|
|
112
|
+
`[trajectory] write failed: ${err && err.message ? err.message : String(err)}\n`,
|
|
113
|
+
);
|
|
114
|
+
} catch {
|
|
115
|
+
/* swallow */
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return line;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = {
|
|
122
|
+
recordCall,
|
|
123
|
+
trajectoryPath,
|
|
124
|
+
hashOf,
|
|
125
|
+
DEFAULT_TRAJECTORY_DIR,
|
|
126
|
+
};
|