@hegemonart/get-design-done 1.27.5 → 1.27.7
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 +2 -2
- package/.claude-plugin/plugin.json +6 -3
- package/CHANGELOG.md +99 -0
- package/agents/perf-analyzer.md +166 -0
- package/hooks/gdd-precompact-snapshot.js +334 -0
- package/hooks/gdd-sessionstart-recap.js +281 -0
- package/hooks/hooks.json +18 -0
- package/package.json +6 -5
- package/reference/perf-budget.md +142 -0
- package/reference/registry.json +14 -0
- package/reference/retrieval-contract.md +16 -0
- package/reference/schemas/mcp-gdd-tools.schema.json +381 -0
- package/scripts/install.cjs +42 -0
- package/scripts/lib/cache/gdd-cache-manager.cjs +292 -0
- package/scripts/lib/discuss-parallel-runner/index.ts +5 -1
- package/scripts/lib/explore-parallel-runner/index.ts +5 -1
- package/scripts/lib/gsd-health-mirror/index.cjs +105 -0
- package/scripts/lib/gsd-health-mirror/index.d.cts +14 -0
- package/scripts/lib/install/mcp-register.cjs +235 -0
- package/scripts/lib/install/mcp-register.d.cts +64 -0
- package/scripts/lib/intel-store/index.cjs +55 -0
- package/scripts/lib/intel-store/index.d.cts +11 -0
- package/scripts/lib/mcp-tools-lint/index.cjs +216 -0
- package/scripts/lib/mcp-tools-lint/index.d.cts +74 -0
- package/scripts/lib/parallelism-engine/concurrency-tuner.cjs +259 -0
- package/scripts/lib/parallelism-engine/concurrency-tuner.d.cts +53 -0
- package/scripts/lib/perf-analyzer/cost-regression.cjs +299 -0
- package/scripts/lib/perf-analyzer/index.cjs +139 -0
- package/scripts/lib/prompt-dedup/index.cjs +161 -0
- package/scripts/lib/reflections-reader/index.cjs +107 -0
- package/scripts/lib/reflections-reader/index.d.cts +18 -0
- package/scripts/lib/roadmap-reader/index.cjs +81 -0
- package/scripts/lib/roadmap-reader/index.d.cts +13 -0
- package/scripts/lib/snapshot-reader/index.cjs +70 -0
- package/scripts/lib/snapshot-reader/index.d.cts +28 -0
- package/scripts/mcp-servers/gdd-mcp/README.md +66 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_cycle_recap.schema.json +30 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_decisions_list.schema.json +32 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_events_tail.schema.json +22 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_health.schema.json +30 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_intel_get.schema.json +24 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_learnings_digest.schema.json +22 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_phase_current.schema.json +22 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_phases_list.schema.json +31 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_plans_list.schema.json +33 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_reflections_latest.schema.json +21 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_status.schema.json +23 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_telemetry_query.schema.json +23 -0
- package/scripts/mcp-servers/gdd-mcp/server.ts +317 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_cycle_recap.ts +37 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_decisions_list.ts +33 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_events_tail.ts +26 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_health.ts +19 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_intel_get.ts +32 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_learnings_digest.ts +23 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_phase_current.ts +29 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_phases_list.ts +26 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_plans_list.ts +39 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_reflections_latest.ts +25 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_status.ts +31 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_telemetry_query.ts +27 -0
- package/scripts/mcp-servers/gdd-mcp/tools/index.ts +75 -0
- package/scripts/mcp-servers/gdd-mcp/tools/shared.ts +134 -0
- package/skills/health/SKILL.md +36 -0
- package/skills/next/SKILL.md +28 -3
- package/skills/progress/SKILL.md +21 -6
- package/skills/resume/SKILL.md +26 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// scripts/lib/install/mcp-register.d.cts
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Plan 27.7-04 — TypeScript ambient declarations for the mcp-register lib.
|
|
4
|
+
// Sibling .d.cts kept in sync with mcp-register.cjs (Phase 27.6 lesson —
|
|
5
|
+
// precautionary for TS consumers).
|
|
6
|
+
|
|
7
|
+
import type { spawnSync } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
export interface Harness {
|
|
10
|
+
readonly binary: string;
|
|
11
|
+
readonly addArgs: readonly string[];
|
|
12
|
+
readonly listArgs: readonly string[];
|
|
13
|
+
readonly listMatchPattern: RegExp;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const HARNESSES: Readonly<Record<'claude' | 'codex', Harness>>;
|
|
17
|
+
export const MCP_NAME: string;
|
|
18
|
+
|
|
19
|
+
export type HarnessId = 'claude' | 'codex';
|
|
20
|
+
export type RegisterMode = 'register' | 'unregister' | 'detect';
|
|
21
|
+
|
|
22
|
+
export interface RegisterMcpResult {
|
|
23
|
+
harness: HarnessId;
|
|
24
|
+
action: RegisterMode;
|
|
25
|
+
detected: boolean;
|
|
26
|
+
command: string | null;
|
|
27
|
+
applied: boolean;
|
|
28
|
+
idempotent_skip: boolean;
|
|
29
|
+
notice?: string;
|
|
30
|
+
stdout?: string;
|
|
31
|
+
stderr?: string;
|
|
32
|
+
exit_code?: number | null;
|
|
33
|
+
dry_run?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface HarnessDetectEntry {
|
|
37
|
+
harness: HarnessId;
|
|
38
|
+
present: boolean;
|
|
39
|
+
registered: boolean | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DetectResult {
|
|
43
|
+
harnesses: HarnessDetectEntry[];
|
|
44
|
+
summary: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type SpawnFn = typeof spawnSync;
|
|
48
|
+
|
|
49
|
+
export interface RegisterMcpOptions {
|
|
50
|
+
harness: HarnessId;
|
|
51
|
+
mode?: RegisterMode;
|
|
52
|
+
dryRun?: boolean;
|
|
53
|
+
spawnFn?: SpawnFn;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface DetectMcpRegistrationOptions {
|
|
57
|
+
spawnFn?: SpawnFn;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function registerMcp(opts: RegisterMcpOptions): RegisterMcpResult;
|
|
61
|
+
export function detectMcpRegistration(opts?: DetectMcpRegistrationOptions): DetectResult;
|
|
62
|
+
export function detectHarnessPresent(harness: HarnessId, spawnFn?: SpawnFn): boolean;
|
|
63
|
+
export function isAlreadyRegistered(harness: HarnessId, spawnFn?: SpawnFn): boolean;
|
|
64
|
+
export function buildHarnessCommand(harness: HarnessId, mode?: RegisterMode): { binary: string; args: string[] };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// scripts/lib/intel-store/index.cjs — Plan 27.7-02
|
|
3
|
+
//
|
|
4
|
+
// Slice reader over <rootDir>/.design/intel/<slice_id>.json. Different
|
|
5
|
+
// surface from scripts/lib/design-search.cjs (which does cross-cycle
|
|
6
|
+
// FTS/grep recall) — see CONTEXT.md Warning #7.
|
|
7
|
+
//
|
|
8
|
+
// Surface:
|
|
9
|
+
// class IntelNotFoundError extends Error — code='directory_not_found'
|
|
10
|
+
// async readSlice(rootDir, sliceId) — parsed slice | null
|
|
11
|
+
// listSlices(rootDir) — string[] of slice ids
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
|
|
16
|
+
class IntelNotFoundError extends Error {
|
|
17
|
+
constructor(dir) {
|
|
18
|
+
super('source directory not found: ' + dir);
|
|
19
|
+
this.name = 'IntelNotFoundError';
|
|
20
|
+
this.code = 'directory_not_found';
|
|
21
|
+
this.dir = dir;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Read slice <rootDir>/.design/intel/<sliceId>.json. Returns parsed
|
|
26
|
+
* JSON or `null` if the slice file is missing. Throws
|
|
27
|
+
* IntelNotFoundError when the intel directory itself is absent. */
|
|
28
|
+
async function readSlice(rootDir, sliceId) {
|
|
29
|
+
const dir = path.join(rootDir, '.design', 'intel');
|
|
30
|
+
if (!fs.existsSync(dir)) {
|
|
31
|
+
throw new IntelNotFoundError(dir);
|
|
32
|
+
}
|
|
33
|
+
const file = path.join(dir, sliceId + '.json');
|
|
34
|
+
if (!fs.existsSync(file)) return null;
|
|
35
|
+
const body = await fs.promises.readFile(file, 'utf8');
|
|
36
|
+
return JSON.parse(body);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** List slice ids (file basenames without extension) under .design/intel/.
|
|
40
|
+
* Throws IntelNotFoundError when the directory is absent. */
|
|
41
|
+
function listSlices(rootDir) {
|
|
42
|
+
const dir = path.join(rootDir, '.design', 'intel');
|
|
43
|
+
if (!fs.existsSync(dir)) {
|
|
44
|
+
throw new IntelNotFoundError(dir);
|
|
45
|
+
}
|
|
46
|
+
const entries = fs.readdirSync(dir);
|
|
47
|
+
const ids = [];
|
|
48
|
+
for (const name of entries) {
|
|
49
|
+
if (!name.endsWith('.json')) continue;
|
|
50
|
+
ids.push(name.slice(0, -'.json'.length));
|
|
51
|
+
}
|
|
52
|
+
return ids;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { readSlice, listSlices, IntelNotFoundError };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// scripts/lib/intel-store/index.d.cts — TypeScript ambient declarations
|
|
2
|
+
// for the intel-store CJS module. Plan 27.7-02.
|
|
3
|
+
|
|
4
|
+
export class IntelNotFoundError extends Error {
|
|
5
|
+
code: 'directory_not_found';
|
|
6
|
+
dir: string;
|
|
7
|
+
constructor(dir: string);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function readSlice(rootDir: string, sliceId: string): Promise<unknown | null>;
|
|
11
|
+
export function listSlices(rootDir: string): string[];
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// scripts/lib/mcp-tools-lint/index.cjs
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Plan 27.7-03 — static lint for the gdd-mcp tools directory.
|
|
5
|
+
//
|
|
6
|
+
// 4 invariants enforced (origin: Phase 27.7 CONTEXT decisions):
|
|
7
|
+
//
|
|
8
|
+
// forbid-fs-path (D-06): No direct `node:fs`/`node:path` (or bare `fs`/
|
|
9
|
+
// `path`) imports inside individual tool .ts
|
|
10
|
+
// files. Tools must be thin wrappers — all
|
|
11
|
+
// filesystem I/O routes through scripts/lib/*
|
|
12
|
+
// helpers (gdd-state, intel-store, etc.). The
|
|
13
|
+
// `index.ts` and `shared.ts` siblings ARE
|
|
14
|
+
// infrastructure and are exempt.
|
|
15
|
+
//
|
|
16
|
+
// max-loc (D-06): Each tool .ts file ≤ 30 non-blank-non-comment
|
|
17
|
+
// LOC. Exempt: index.ts, shared.ts.
|
|
18
|
+
//
|
|
19
|
+
// no-write-names (D-04): Hard-block every write-verb tool name. A tool
|
|
20
|
+
// name matching /_(create|update|delete|append|
|
|
21
|
+
// clear|write|set)(_|$)/ is rejected. The MCP
|
|
22
|
+
// server is read-only by design.
|
|
23
|
+
//
|
|
24
|
+
// tool-count-cap (D-03): ≤ 12 files matching `gdd_*.ts` glob in the
|
|
25
|
+
// tools directory. Hard cap. Adding a 13th tool
|
|
26
|
+
// requires re-scoping in a new plan.
|
|
27
|
+
//
|
|
28
|
+
// Public API:
|
|
29
|
+
// lintMcpToolsDir({dir, maxLoc?, toolCap?, exemptions?}) →
|
|
30
|
+
// { violations: LintViolation[], summary: { files_scanned, violations_count } }
|
|
31
|
+
//
|
|
32
|
+
// Consumed by tests/gdd-mcp-tools-lint.test.cjs and
|
|
33
|
+
// tests/phase-27-7-baseline.test.cjs (Plan 27.7-07).
|
|
34
|
+
|
|
35
|
+
const fs = require('node:fs');
|
|
36
|
+
const path = require('node:path');
|
|
37
|
+
|
|
38
|
+
const DEFAULT_EXEMPTIONS = new Set(['index.ts', 'shared.ts']);
|
|
39
|
+
const DEFAULT_MAX_LOC = 30;
|
|
40
|
+
const DEFAULT_TOOL_CAP = 12;
|
|
41
|
+
const TOOL_FILE_GLOB = /^gdd_[a-z0-9_]+\.ts$/;
|
|
42
|
+
|
|
43
|
+
const FORBIDDEN_IMPORT_PATTERNS = Object.freeze([
|
|
44
|
+
/from\s+['"]node:fs['"]/,
|
|
45
|
+
/from\s+['"]node:fs\/promises['"]/,
|
|
46
|
+
/from\s+['"]node:path['"]/,
|
|
47
|
+
/from\s+['"]fs['"]/,
|
|
48
|
+
/from\s+['"]path['"]/,
|
|
49
|
+
/require\s*\(\s*['"]node:fs['"]\s*\)/,
|
|
50
|
+
/require\s*\(\s*['"]node:fs\/promises['"]\s*\)/,
|
|
51
|
+
/require\s*\(\s*['"]fs['"]\s*\)/,
|
|
52
|
+
/require\s*\(\s*['"]node:path['"]\s*\)/,
|
|
53
|
+
/require\s*\(\s*['"]path['"]\s*\)/,
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
// Write-verb pattern: matches when the verb is preceded by `_` and either
|
|
57
|
+
// followed by `_` or end-of-string. e.g. `gdd_decision_append` matches;
|
|
58
|
+
// `gdd_appendix_list` does NOT (the verb must be the trailing token of a
|
|
59
|
+
// `_`-separated name).
|
|
60
|
+
const WRITE_NAME_PATTERN = /_(create|update|delete|append|clear|write|set)(?:_|$)/;
|
|
61
|
+
|
|
62
|
+
const RULES = Object.freeze([
|
|
63
|
+
'forbid-fs-path',
|
|
64
|
+
'max-loc',
|
|
65
|
+
'no-write-names',
|
|
66
|
+
'tool-count-cap',
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Count non-blank-non-comment lines.
|
|
71
|
+
* - Blank lines (whitespace-only) → excluded.
|
|
72
|
+
* - Lines whose first non-whitespace char is `//` (line comment) → excluded.
|
|
73
|
+
* - Lines starting with `/*` or `*` (block comment opener/continuation) → excluded.
|
|
74
|
+
*/
|
|
75
|
+
function countLoc(text) {
|
|
76
|
+
return text.split('\n').filter((line) => {
|
|
77
|
+
const t = line.trim();
|
|
78
|
+
if (t.length === 0) return false;
|
|
79
|
+
if (t.startsWith('//')) return false;
|
|
80
|
+
if (t.startsWith('/*')) return false;
|
|
81
|
+
if (t.startsWith('*')) return false;
|
|
82
|
+
return true;
|
|
83
|
+
}).length;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Scan source text line-by-line for the FORBIDDEN_IMPORT_PATTERNS.
|
|
88
|
+
* Returns [{rule, line, message}, …] (file is filled by the caller).
|
|
89
|
+
*/
|
|
90
|
+
function scanForbiddenImports(text) {
|
|
91
|
+
const violations = [];
|
|
92
|
+
const lines = text.split('\n');
|
|
93
|
+
for (let i = 0; i < lines.length; i++) {
|
|
94
|
+
const line = lines[i];
|
|
95
|
+
for (const re of FORBIDDEN_IMPORT_PATTERNS) {
|
|
96
|
+
const m = line.match(re);
|
|
97
|
+
if (m) {
|
|
98
|
+
violations.push({
|
|
99
|
+
rule: 'forbid-fs-path',
|
|
100
|
+
line: i + 1,
|
|
101
|
+
message: 'forbidden import: ' + m[0],
|
|
102
|
+
});
|
|
103
|
+
break; // one violation per line is enough.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return violations;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Extract the `export const name = '…'` value. Tolerates `"` or `'` quotes
|
|
112
|
+
* and arbitrary whitespace. Returns {name, line} or null.
|
|
113
|
+
*/
|
|
114
|
+
function extractToolName(text) {
|
|
115
|
+
const lines = text.split('\n');
|
|
116
|
+
const re = /export\s+const\s+name\s*(?::\s*[A-Za-z<>{}\s,|]+)?\s*=\s*['"]([^'"]+)['"]/;
|
|
117
|
+
for (let i = 0; i < lines.length; i++) {
|
|
118
|
+
const m = lines[i].match(re);
|
|
119
|
+
if (m) return { name: m[1], line: i + 1 };
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Main entry point. Scans `dir` for *.ts files, applies the 4 rules,
|
|
126
|
+
* and returns a structured result.
|
|
127
|
+
*
|
|
128
|
+
* @param {{dir: string, maxLoc?: number, toolCap?: number, exemptions?: Set<string>}} opts
|
|
129
|
+
* @returns {{violations: Array<{file: string, rule: string, line: number, message: string}>, summary: {files_scanned: number, violations_count: number}}}
|
|
130
|
+
*/
|
|
131
|
+
function lintMcpToolsDir(opts) {
|
|
132
|
+
if (!opts || typeof opts.dir !== 'string' || opts.dir.length === 0) {
|
|
133
|
+
throw new Error('lintMcpToolsDir: opts.dir is required');
|
|
134
|
+
}
|
|
135
|
+
const dir = opts.dir;
|
|
136
|
+
const maxLoc = typeof opts.maxLoc === 'number' ? opts.maxLoc : DEFAULT_MAX_LOC;
|
|
137
|
+
const toolCap =
|
|
138
|
+
typeof opts.toolCap === 'number' ? opts.toolCap : DEFAULT_TOOL_CAP;
|
|
139
|
+
const exemptions =
|
|
140
|
+
opts.exemptions instanceof Set ? opts.exemptions : DEFAULT_EXEMPTIONS;
|
|
141
|
+
|
|
142
|
+
const violations = [];
|
|
143
|
+
|
|
144
|
+
const entries = fs.readdirSync(dir);
|
|
145
|
+
const tsFiles = entries.filter((e) => e.endsWith('.ts'));
|
|
146
|
+
|
|
147
|
+
// Rule D — tool-count-cap (matches `gdd_*.ts` files only; index/shared
|
|
148
|
+
// never count toward the cap).
|
|
149
|
+
const toolFiles = entries.filter((e) => TOOL_FILE_GLOB.test(e));
|
|
150
|
+
if (toolFiles.length > toolCap) {
|
|
151
|
+
violations.push({
|
|
152
|
+
file: dir,
|
|
153
|
+
rule: 'tool-count-cap',
|
|
154
|
+
line: 0,
|
|
155
|
+
message: 'count=' + toolFiles.length + ' > cap=' + toolCap,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Rules A, B, C — per-file scans.
|
|
160
|
+
for (const fname of tsFiles) {
|
|
161
|
+
const text = fs.readFileSync(path.join(dir, fname), 'utf8');
|
|
162
|
+
const isExempt = exemptions.has(fname);
|
|
163
|
+
|
|
164
|
+
// Rule A — forbid-fs-path (skip exemptions).
|
|
165
|
+
if (!isExempt) {
|
|
166
|
+
const fsViolations = scanForbiddenImports(text);
|
|
167
|
+
for (const v of fsViolations) {
|
|
168
|
+
violations.push({ file: fname, ...v });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Rule B — max-loc (skip exemptions).
|
|
173
|
+
if (!isExempt) {
|
|
174
|
+
const loc = countLoc(text);
|
|
175
|
+
if (loc > maxLoc) {
|
|
176
|
+
violations.push({
|
|
177
|
+
file: fname,
|
|
178
|
+
rule: 'max-loc',
|
|
179
|
+
line: 0,
|
|
180
|
+
message: 'loc=' + loc + ' > max=' + maxLoc,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Rule C — no-write-names. Applies to ALL ts files including
|
|
186
|
+
// exemptions (you should not even define a write-named symbol in
|
|
187
|
+
// index.ts or shared.ts — that would be a different bug).
|
|
188
|
+
const ext = extractToolName(text);
|
|
189
|
+
if (ext && WRITE_NAME_PATTERN.test(ext.name)) {
|
|
190
|
+
violations.push({
|
|
191
|
+
file: fname,
|
|
192
|
+
rule: 'no-write-names',
|
|
193
|
+
line: ext.line,
|
|
194
|
+
message: 'write tool name: ' + ext.name,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
violations,
|
|
201
|
+
summary: {
|
|
202
|
+
files_scanned: tsFiles.length,
|
|
203
|
+
violations_count: violations.length,
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = {
|
|
209
|
+
lintMcpToolsDir,
|
|
210
|
+
RULES,
|
|
211
|
+
DEFAULT_EXEMPTIONS,
|
|
212
|
+
DEFAULT_MAX_LOC,
|
|
213
|
+
DEFAULT_TOOL_CAP,
|
|
214
|
+
FORBIDDEN_IMPORT_PATTERNS,
|
|
215
|
+
WRITE_NAME_PATTERN,
|
|
216
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// scripts/lib/mcp-tools-lint/index.d.cts — ambient types for the .cjs lib.
|
|
2
|
+
//
|
|
3
|
+
// The runtime consumer is tests/gdd-mcp-tools-lint.test.cjs (CommonJS, so
|
|
4
|
+
// types are not strictly required). This .d.cts ships the Phase 27.6
|
|
5
|
+
// convention (any .cjs lib that may be imported from a .ts file gets a
|
|
6
|
+
// sibling .d.cts) so that a future TypeScript consumer (e.g. a /lint:gdd
|
|
7
|
+
// slash command) gets correct types without a follow-up patch.
|
|
8
|
+
|
|
9
|
+
/** One detected lint failure. */
|
|
10
|
+
export interface LintViolation {
|
|
11
|
+
/** Filename (relative to scan dir) or the dir itself for cap violations. */
|
|
12
|
+
file: string;
|
|
13
|
+
rule: 'forbid-fs-path' | 'max-loc' | 'no-write-names' | 'tool-count-cap';
|
|
14
|
+
/** 1-based source line; 0 for whole-file or whole-directory violations. */
|
|
15
|
+
line: number;
|
|
16
|
+
/** Human-readable diagnostic, e.g. `loc=42 > max=30`. */
|
|
17
|
+
message: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Counts for the scan as a whole. */
|
|
21
|
+
export interface LintSummary {
|
|
22
|
+
files_scanned: number;
|
|
23
|
+
violations_count: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Return shape of {@link lintMcpToolsDir}. */
|
|
27
|
+
export interface LintResult {
|
|
28
|
+
violations: LintViolation[];
|
|
29
|
+
summary: LintSummary;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Inputs to {@link lintMcpToolsDir}. Only `dir` is required. */
|
|
33
|
+
export interface LintOptions {
|
|
34
|
+
/** Directory to scan. Tool files match `gdd_*.ts`. */
|
|
35
|
+
dir: string;
|
|
36
|
+
/** Max non-blank-non-comment LOC per tool file. Defaults to 30. */
|
|
37
|
+
maxLoc?: number;
|
|
38
|
+
/** Max number of `gdd_*.ts` tool files. Defaults to 12. */
|
|
39
|
+
toolCap?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Filenames in `dir` exempt from forbid-fs-path + max-loc rules.
|
|
42
|
+
* Defaults to {'index.ts', 'shared.ts'}.
|
|
43
|
+
*/
|
|
44
|
+
exemptions?: Set<string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Scan a directory of MCP tool .ts files and apply the 4 invariant rules.
|
|
49
|
+
* Pure-static — never executes the modules.
|
|
50
|
+
*
|
|
51
|
+
* - Rule A (`forbid-fs-path`): no fs/path imports in tool files (D-06).
|
|
52
|
+
* - Rule B (`max-loc`): each tool ≤ {@link LintOptions.maxLoc} LOC (D-06).
|
|
53
|
+
* - Rule C (`no-write-names`): no tool name with write-verb substring (D-04).
|
|
54
|
+
* - Rule D (`tool-count-cap`): ≤ {@link LintOptions.toolCap} tool files (D-03).
|
|
55
|
+
*/
|
|
56
|
+
export function lintMcpToolsDir(opts: LintOptions): LintResult;
|
|
57
|
+
|
|
58
|
+
/** Ordered list of all rule names this module enforces. */
|
|
59
|
+
export const RULES: readonly LintViolation['rule'][];
|
|
60
|
+
|
|
61
|
+
/** Default exempt filenames (index.ts + shared.ts). */
|
|
62
|
+
export const DEFAULT_EXEMPTIONS: Set<string>;
|
|
63
|
+
|
|
64
|
+
/** Default value for the LOC ceiling. */
|
|
65
|
+
export const DEFAULT_MAX_LOC: number;
|
|
66
|
+
|
|
67
|
+
/** Default value for the tool-count cap. */
|
|
68
|
+
export const DEFAULT_TOOL_CAP: number;
|
|
69
|
+
|
|
70
|
+
/** The regexes Rule A scans for, line by line. */
|
|
71
|
+
export const FORBIDDEN_IMPORT_PATTERNS: readonly RegExp[];
|
|
72
|
+
|
|
73
|
+
/** The regex Rule C matches against the extracted `export const name`. */
|
|
74
|
+
export const WRITE_NAME_PATTERN: RegExp;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/parallelism-engine/concurrency-tuner.cjs — Plan 27.6-04
|
|
3
|
+
*
|
|
4
|
+
* Data-driven concurrency resolver per Phase 27.6 D-07. Reads the
|
|
5
|
+
* most-recent `parallelism.verdict` event from .design/telemetry/
|
|
6
|
+
* events.jsonl (Phase 22 stream) and computes:
|
|
7
|
+
*
|
|
8
|
+
* resolveConcurrency = max(1, min(min(cpu-1, last_observed), ceiling))
|
|
9
|
+
*
|
|
10
|
+
* where:
|
|
11
|
+
* cpu = os.cpus().length (override via cpuCount opt)
|
|
12
|
+
* last_observed = payload.observed_concurrency from the latest
|
|
13
|
+
* parallelism.verdict event (null if absent)
|
|
14
|
+
* ceiling = process.env.GDD_CONCURRENCY_CEILING (default 8)
|
|
15
|
+
*
|
|
16
|
+
* Hard ceiling of 8 prevents pathological process-spawn storms on
|
|
17
|
+
* high-core machines (D-07 wording: "Hard ceiling prevents pathological
|
|
18
|
+
* process-spawn storms").
|
|
19
|
+
*
|
|
20
|
+
* Public surface:
|
|
21
|
+
* * resolveConcurrency({cpuCount?, lastObservedOptimum?, hardCeiling?,
|
|
22
|
+
* eventsPath?, baseDir?}) -> number (>=1)
|
|
23
|
+
* * readLastObservedOptimum({eventsPath?, baseDir?}) -> number|null
|
|
24
|
+
* * emitParallelismVerdict({task_ids, verdict, reason,
|
|
25
|
+
* intended_concurrency?, observed_concurrency?,
|
|
26
|
+
* contention_detected?, wall_clock_ms?,
|
|
27
|
+
* sessionId?}) -> void
|
|
28
|
+
* * DEFAULT_HARD_CEILING (=8)
|
|
29
|
+
* * DEFAULT_EVENTS_PATH (='.design/telemetry/events.jsonl')
|
|
30
|
+
*
|
|
31
|
+
* The `parallelism.verdict` payload extension is purely additive
|
|
32
|
+
* (`intended_concurrency`, `observed_concurrency`, `contention_detected`,
|
|
33
|
+
* `wall_clock_ms` are all optional). Existing consumers that only read
|
|
34
|
+
* `{task_ids, verdict, reason}` keep working unchanged.
|
|
35
|
+
*
|
|
36
|
+
* No external deps. Lazy event-stream require for emit (best-effort
|
|
37
|
+
* telemetry — a failed event-stream load must not break the resolver).
|
|
38
|
+
*/
|
|
39
|
+
'use strict';
|
|
40
|
+
|
|
41
|
+
const fs = require('node:fs');
|
|
42
|
+
const path = require('node:path');
|
|
43
|
+
const os = require('node:os');
|
|
44
|
+
|
|
45
|
+
const DEFAULT_HARD_CEILING = 8;
|
|
46
|
+
const DEFAULT_EVENTS_PATH = '.design/telemetry/events.jsonl';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Lazy-require the event-stream module. Returns a no-op `appendEvent`
|
|
50
|
+
* when the module is unavailable so callers never have to wrap emit
|
|
51
|
+
* calls in try/catch themselves.
|
|
52
|
+
*
|
|
53
|
+
* @returns {(ev: object) => void}
|
|
54
|
+
*/
|
|
55
|
+
function getAppendEvent() {
|
|
56
|
+
try {
|
|
57
|
+
// Resolved relative to this file: scripts/lib/parallelism-engine/
|
|
58
|
+
// -> ../event-stream. The event-stream module is .ts; Node 22+
|
|
59
|
+
// with --experimental-strip-types (or Node 24 built-in TS) can
|
|
60
|
+
// require it. If require fails (e.g., older runtime, missing
|
|
61
|
+
// module), fall through to the no-op.
|
|
62
|
+
const m = require('../event-stream');
|
|
63
|
+
if (m && typeof m.appendEvent === 'function') return m.appendEvent;
|
|
64
|
+
} catch {
|
|
65
|
+
// Swallow — best-effort telemetry. Losing one verdict is
|
|
66
|
+
// acceptable; breaking concurrency resolution is not.
|
|
67
|
+
}
|
|
68
|
+
return function noopAppend(_ev) {
|
|
69
|
+
/* event-stream unavailable */
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolve the hard ceiling. Operator override via GDD_CONCURRENCY_CEILING
|
|
75
|
+
* env var (parsed as integer) takes precedence; the explicit `override`
|
|
76
|
+
* argument wins over the env. Default is 8 (D-07).
|
|
77
|
+
*
|
|
78
|
+
* @param {number|undefined} override
|
|
79
|
+
* @returns {number}
|
|
80
|
+
*/
|
|
81
|
+
function resolveCeiling(override) {
|
|
82
|
+
if (typeof override === 'number' && override >= 1) return Math.floor(override);
|
|
83
|
+
const env = process.env.GDD_CONCURRENCY_CEILING;
|
|
84
|
+
if (typeof env === 'string' && env.length > 0) {
|
|
85
|
+
const parsed = parseInt(env, 10);
|
|
86
|
+
if (Number.isFinite(parsed) && parsed >= 1) return parsed;
|
|
87
|
+
}
|
|
88
|
+
return DEFAULT_HARD_CEILING;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Compose the JSONL events path. Relative paths are joined to baseDir
|
|
93
|
+
* when supplied; absolute paths are returned as-is.
|
|
94
|
+
*
|
|
95
|
+
* @param {{eventsPath?: string, baseDir?: string}} opts
|
|
96
|
+
* @returns {string}
|
|
97
|
+
*/
|
|
98
|
+
function resolvePath({ eventsPath, baseDir }) {
|
|
99
|
+
let p = typeof eventsPath === 'string' && eventsPath.length > 0
|
|
100
|
+
? eventsPath
|
|
101
|
+
: DEFAULT_EVENTS_PATH;
|
|
102
|
+
if (baseDir && !path.isAbsolute(p)) p = path.join(baseDir, p);
|
|
103
|
+
return p;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read .design/telemetry/events.jsonl and return the
|
|
108
|
+
* `observed_concurrency` from the MOST RECENT parallelism.verdict event
|
|
109
|
+
* (sequential read order). Tolerates malformed lines and absent file.
|
|
110
|
+
*
|
|
111
|
+
* @param {object} [opts]
|
|
112
|
+
* @param {string} [opts.eventsPath] override events.jsonl path
|
|
113
|
+
* @param {string} [opts.baseDir] base for relative eventsPath
|
|
114
|
+
* @returns {number|null}
|
|
115
|
+
*/
|
|
116
|
+
function readLastObservedOptimum({ eventsPath, baseDir } = {} ) {
|
|
117
|
+
const target = resolvePath({ eventsPath, baseDir });
|
|
118
|
+
if (!fs.existsSync(target)) return null;
|
|
119
|
+
let body;
|
|
120
|
+
try {
|
|
121
|
+
body = fs.readFileSync(target, 'utf8');
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const lines = body.split(/\r?\n/);
|
|
126
|
+
let lastOptimum = null;
|
|
127
|
+
for (const line of lines) {
|
|
128
|
+
const trimmed = line.trim();
|
|
129
|
+
if (trimmed.length === 0) continue;
|
|
130
|
+
try {
|
|
131
|
+
const ev = JSON.parse(trimmed);
|
|
132
|
+
if (
|
|
133
|
+
ev
|
|
134
|
+
&& ev.type === 'parallelism.verdict'
|
|
135
|
+
&& ev.payload
|
|
136
|
+
&& typeof ev.payload.observed_concurrency === 'number'
|
|
137
|
+
) {
|
|
138
|
+
lastOptimum = Math.floor(ev.payload.observed_concurrency);
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// Tolerate malformed line — JSONL is best-effort.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return lastOptimum;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Resolve the recommended concurrency per D-07.
|
|
149
|
+
*
|
|
150
|
+
* 1. base = max(1, cpuCount - 1) // never below 1
|
|
151
|
+
* 2. optimum = lastObservedOptimum // explicit override
|
|
152
|
+
* ?? readLastObservedOptimum() // or read from JSONL
|
|
153
|
+
* 3. candidate = optimum > 0 ? min(base, optimum) : base
|
|
154
|
+
* 4. ceiling = override ?? GDD_CONCURRENCY_CEILING ?? 8
|
|
155
|
+
* 5. return max(1, min(candidate, ceiling))
|
|
156
|
+
*
|
|
157
|
+
* @param {object} [opts]
|
|
158
|
+
* @param {number} [opts.cpuCount] override os.cpus().length
|
|
159
|
+
* @param {number|null} [opts.lastObservedOptimum] explicit override; null/undefined triggers JSONL read
|
|
160
|
+
* @param {number} [opts.hardCeiling] override the env/default ceiling
|
|
161
|
+
* @param {string} [opts.eventsPath] override events.jsonl path
|
|
162
|
+
* @param {string} [opts.baseDir] base for relative eventsPath
|
|
163
|
+
* @returns {number} integer >= 1
|
|
164
|
+
*/
|
|
165
|
+
function resolveConcurrency({
|
|
166
|
+
cpuCount,
|
|
167
|
+
lastObservedOptimum,
|
|
168
|
+
hardCeiling,
|
|
169
|
+
eventsPath,
|
|
170
|
+
baseDir,
|
|
171
|
+
} = {}) {
|
|
172
|
+
const cpu = typeof cpuCount === 'number' && cpuCount >= 1
|
|
173
|
+
? Math.floor(cpuCount)
|
|
174
|
+
: os.cpus().length;
|
|
175
|
+
const base = Math.max(1, cpu - 1);
|
|
176
|
+
let optimum = lastObservedOptimum;
|
|
177
|
+
if (optimum === undefined || optimum === null) {
|
|
178
|
+
optimum = readLastObservedOptimum({ eventsPath, baseDir });
|
|
179
|
+
}
|
|
180
|
+
const candidate = typeof optimum === 'number' && optimum >= 1
|
|
181
|
+
? Math.min(base, Math.floor(optimum))
|
|
182
|
+
: base;
|
|
183
|
+
const ceiling = resolveCeiling(hardCeiling);
|
|
184
|
+
return Math.max(1, Math.min(candidate, ceiling));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Emit a `parallelism.verdict` event with the Phase 27.6 superset
|
|
189
|
+
* payload. Existing fields ({task_ids, verdict, reason}) are always
|
|
190
|
+
* present; the new fields (intended_concurrency, observed_concurrency,
|
|
191
|
+
* contention_detected, wall_clock_ms) are appended only when supplied.
|
|
192
|
+
*
|
|
193
|
+
* Side effect: appendEvent({type: 'parallelism.verdict', ...}). When
|
|
194
|
+
* event-stream is unavailable, this is a no-op (lazy require fallback).
|
|
195
|
+
*
|
|
196
|
+
* @param {object} opts
|
|
197
|
+
* @param {string[]} opts.task_ids
|
|
198
|
+
* @param {'parallel'|'sequential'} opts.verdict
|
|
199
|
+
* @param {string} opts.reason
|
|
200
|
+
* @param {number} [opts.intended_concurrency]
|
|
201
|
+
* @param {number} [opts.observed_concurrency]
|
|
202
|
+
* @param {boolean} [opts.contention_detected]
|
|
203
|
+
* @param {number} [opts.wall_clock_ms]
|
|
204
|
+
* @param {string} [opts.sessionId]
|
|
205
|
+
* @returns {void}
|
|
206
|
+
*/
|
|
207
|
+
function emitParallelismVerdict({
|
|
208
|
+
task_ids,
|
|
209
|
+
verdict,
|
|
210
|
+
reason,
|
|
211
|
+
intended_concurrency,
|
|
212
|
+
observed_concurrency,
|
|
213
|
+
contention_detected,
|
|
214
|
+
wall_clock_ms,
|
|
215
|
+
sessionId,
|
|
216
|
+
} = {}) {
|
|
217
|
+
const append = getAppendEvent();
|
|
218
|
+
/** @type {Record<string, unknown>} */
|
|
219
|
+
const payload = {
|
|
220
|
+
task_ids: Array.isArray(task_ids) ? task_ids : [],
|
|
221
|
+
verdict: verdict === 'parallel' || verdict === 'sequential' ? verdict : 'sequential',
|
|
222
|
+
reason: typeof reason === 'string' ? reason : 'unspecified',
|
|
223
|
+
};
|
|
224
|
+
// Additive 27.6 fields — only include when set, to keep payloads
|
|
225
|
+
// compact and avoid noisy `undefined` keys on the wire.
|
|
226
|
+
if (typeof intended_concurrency === 'number') {
|
|
227
|
+
payload.intended_concurrency = intended_concurrency;
|
|
228
|
+
}
|
|
229
|
+
if (typeof observed_concurrency === 'number') {
|
|
230
|
+
payload.observed_concurrency = observed_concurrency;
|
|
231
|
+
}
|
|
232
|
+
if (typeof contention_detected === 'boolean') {
|
|
233
|
+
payload.contention_detected = contention_detected;
|
|
234
|
+
}
|
|
235
|
+
if (typeof wall_clock_ms === 'number') {
|
|
236
|
+
payload.wall_clock_ms = wall_clock_ms;
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
append({
|
|
240
|
+
type: 'parallelism.verdict',
|
|
241
|
+
timestamp: new Date().toISOString(),
|
|
242
|
+
sessionId: typeof sessionId === 'string' && sessionId.length > 0
|
|
243
|
+
? sessionId
|
|
244
|
+
: 'concurrency-tuner',
|
|
245
|
+
payload,
|
|
246
|
+
});
|
|
247
|
+
} catch {
|
|
248
|
+
// Best-effort telemetry. A failed write must never break the
|
|
249
|
+
// caller's wave-execution flow.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = {
|
|
254
|
+
resolveConcurrency,
|
|
255
|
+
readLastObservedOptimum,
|
|
256
|
+
emitParallelismVerdict,
|
|
257
|
+
DEFAULT_HARD_CEILING,
|
|
258
|
+
DEFAULT_EVENTS_PATH,
|
|
259
|
+
};
|