@hegemonart/get-design-done 1.21.0 → 1.23.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 +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +184 -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 +7 -2
- package/reference/output-contracts/planner-decision.schema.json +94 -0
- package/reference/output-contracts/verifier-decision.schema.json +66 -0
- package/scripts/cli/gdd-events.mjs +283 -0
- package/scripts/lib/audit-aggregator/index.cjs +219 -0
- package/scripts/lib/connection-probe/index.cjs +263 -0
- package/scripts/lib/design-solidify.mjs +265 -0
- package/scripts/lib/design-tokens/_js-harness.cjs +66 -0
- package/scripts/lib/design-tokens/css-vars.cjs +55 -0
- package/scripts/lib/design-tokens/figma.cjs +121 -0
- package/scripts/lib/design-tokens/index.cjs +100 -0
- package/scripts/lib/design-tokens/js-const.cjs +107 -0
- package/scripts/lib/design-tokens/tailwind.cjs +98 -0
- package/scripts/lib/domain-primitives/anti-patterns.cjs +66 -0
- package/scripts/lib/domain-primitives/nng.cjs +136 -0
- package/scripts/lib/domain-primitives/wcag.cjs +166 -0
- package/scripts/lib/event-chain.cjs +177 -0
- package/scripts/lib/event-stream/index.ts +20 -0
- 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/parse-contract.cjs +168 -0
- package/scripts/lib/redact.cjs +122 -0
- package/scripts/lib/reference-resolver.cjs +184 -0
- package/scripts/lib/touches-analyzer/index.cjs +201 -0
- package/scripts/lib/touches-pattern-miner.cjs +195 -0
- package/scripts/lib/trajectory/index.cjs +126 -0
- package/scripts/lib/transports/ws.cjs +179 -0
- package/scripts/lib/visual-baseline/diff.cjs +137 -0
- package/scripts/lib/visual-baseline/index.cjs +139 -0
|
@@ -21,11 +21,64 @@
|
|
|
21
21
|
// `payload` with `{ _truncated_placeholder: true }`, then re-serialize
|
|
22
22
|
// and stamp `_truncated: true` on the line.
|
|
23
23
|
|
|
24
|
-
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
24
|
+
import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
25
25
|
import { dirname, resolve, isAbsolute, join } from 'node:path';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
26
27
|
|
|
27
28
|
import type { BaseEvent } from './types.ts';
|
|
28
29
|
|
|
30
|
+
// Phase 22 Plan 22-02: write-time secret scrubbing. `redact()` deep-walks
|
|
31
|
+
// the event and replaces secret-shaped strings with `[REDACTED:<type>]`
|
|
32
|
+
// placeholders before serialization. Loaded via createRequire so the
|
|
33
|
+
// CommonJS `redact.cjs` interops cleanly. We avoid `import.meta.url` —
|
|
34
|
+
// tsc's Node16 module mode classifies this .ts file as CJS output for
|
|
35
|
+
// typecheck purposes (even though it runs as ESM under
|
|
36
|
+
// `--experimental-strip-types`), and `import.meta` is forbidden in CJS
|
|
37
|
+
// output. Mirror the pattern from `scripts/lib/session-runner/errors.ts`:
|
|
38
|
+
// anchor createRequire on the repo-root package.json discovered by
|
|
39
|
+
// walking up from `process.cwd()`.
|
|
40
|
+
function _findRepoRoot(): string {
|
|
41
|
+
let dir = process.cwd();
|
|
42
|
+
for (let i = 0; i < 8; i++) {
|
|
43
|
+
if (existsSync(join(dir, 'package.json'))) return dir;
|
|
44
|
+
const parent = dirname(dir);
|
|
45
|
+
if (parent === dir) break;
|
|
46
|
+
dir = parent;
|
|
47
|
+
}
|
|
48
|
+
return process.cwd();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Soft load: if redact.cjs is unreachable from the runtime cwd (e.g. a
|
|
52
|
+
// hook subprocess running in a temp test dir three directories above
|
|
53
|
+
// the plugin root), fall through to the identity function. The writer
|
|
54
|
+
// keeps working — events just aren't scrubbed in that environment.
|
|
55
|
+
// Production callers always run from inside the plugin tree.
|
|
56
|
+
let _redact: (v: unknown) => unknown;
|
|
57
|
+
try {
|
|
58
|
+
const _root = _findRepoRoot();
|
|
59
|
+
const _candidate = resolve(_root, 'scripts/lib/redact.cjs');
|
|
60
|
+
if (existsSync(_candidate)) {
|
|
61
|
+
const _redactRequire = createRequire(join(_root, 'package.json'));
|
|
62
|
+
const _mod = _redactRequire(_candidate) as { redact: (v: unknown) => unknown };
|
|
63
|
+
_redact = _mod.redact;
|
|
64
|
+
} else {
|
|
65
|
+
// Fallback: also try walking up from this source file's logical
|
|
66
|
+
// position (3 dirs above writer.ts → repo root).
|
|
67
|
+
const _altRoot = resolve(_root, '..', '..');
|
|
68
|
+
const _altCandidate = resolve(_altRoot, 'scripts/lib/redact.cjs');
|
|
69
|
+
if (existsSync(_altCandidate)) {
|
|
70
|
+
const _altRequire = createRequire(join(_altRoot, 'package.json'));
|
|
71
|
+
const _altMod = _altRequire(_altCandidate) as { redact: (v: unknown) => unknown };
|
|
72
|
+
_redact = _altMod.redact;
|
|
73
|
+
} else {
|
|
74
|
+
_redact = (v) => v;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
_redact = (v) => v;
|
|
79
|
+
}
|
|
80
|
+
const redact = _redact;
|
|
81
|
+
|
|
29
82
|
/** Default relative path for the persisted event stream. */
|
|
30
83
|
export const DEFAULT_EVENTS_PATH = '.design/telemetry/events.jsonl';
|
|
31
84
|
|
|
@@ -113,22 +166,26 @@ export class EventWriter {
|
|
|
113
166
|
* {@link append}.
|
|
114
167
|
*/
|
|
115
168
|
serialize(ev: BaseEvent): string {
|
|
116
|
-
|
|
169
|
+
// Phase 22 Plan 22-02: scrub secrets from the entire event (envelope +
|
|
170
|
+
// payload) before serialization. Redaction is non-mutating and runs
|
|
171
|
+
// exactly once per event, here at the write boundary.
|
|
172
|
+
const scrubbed = redact(ev) as BaseEvent;
|
|
173
|
+
const raw = JSON.stringify(scrubbed) + '\n';
|
|
117
174
|
if (Buffer.byteLength(raw, 'utf8') <= this.maxLineBytes) {
|
|
118
175
|
return raw;
|
|
119
176
|
}
|
|
120
177
|
|
|
121
178
|
// Truncate: keep envelope fields, drop payload content.
|
|
122
179
|
const truncated: BaseEvent = {
|
|
123
|
-
type:
|
|
124
|
-
timestamp:
|
|
125
|
-
sessionId:
|
|
180
|
+
type: scrubbed.type,
|
|
181
|
+
timestamp: scrubbed.timestamp,
|
|
182
|
+
sessionId: scrubbed.sessionId,
|
|
126
183
|
payload: { _truncated_placeholder: true },
|
|
127
184
|
_truncated: true,
|
|
128
185
|
};
|
|
129
|
-
if (
|
|
130
|
-
if (
|
|
131
|
-
if (
|
|
186
|
+
if (scrubbed.stage !== undefined) truncated.stage = scrubbed.stage;
|
|
187
|
+
if (scrubbed.cycle !== undefined) truncated.cycle = scrubbed.cycle;
|
|
188
|
+
if (scrubbed._meta !== undefined) truncated._meta = scrubbed._meta;
|
|
132
189
|
return JSON.stringify(truncated) + '\n';
|
|
133
190
|
}
|
|
134
191
|
|
|
@@ -204,11 +204,176 @@ function loadMotionMapSchema(projectRoot) {
|
|
|
204
204
|
return JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Phase 23 Plan 23-01 — planner + verifier decision contracts.
|
|
209
|
+
//
|
|
210
|
+
// These parsers ride the same extract→parse→validate pipeline as
|
|
211
|
+
// parseMotionMap. Validation is structural (required fields, enums,
|
|
212
|
+
// types) — full JSON Schema validation is delegated to ajv when callers
|
|
213
|
+
// want strict enforcement; the in-line validators keep the no-deps
|
|
214
|
+
// guarantee for the hot path.
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
const VALID_VERIFIER_VERDICTS = ['pass', 'fail', 'gap'];
|
|
218
|
+
const VALID_GAP_SEVERITIES = ['P0', 'P1', 'P2', 'P3'];
|
|
219
|
+
const VALID_VERIFIER_CONFIDENCE = ['high', 'med', 'low'];
|
|
220
|
+
|
|
221
|
+
function validatePlannerDecision(data) {
|
|
222
|
+
const errors = [];
|
|
223
|
+
if (!data || typeof data !== 'object') {
|
|
224
|
+
return { ok: false, errors: ['Top-level value is not an object'] };
|
|
225
|
+
}
|
|
226
|
+
if (data.schema_version !== '1.0.0') {
|
|
227
|
+
errors.push(`schema_version must be "1.0.0" (got ${JSON.stringify(data.schema_version)})`);
|
|
228
|
+
}
|
|
229
|
+
if (typeof data.plan_id !== 'string' || data.plan_id.length === 0) {
|
|
230
|
+
errors.push('plan_id is required (non-empty string)');
|
|
231
|
+
}
|
|
232
|
+
if (!Array.isArray(data.tasks) || data.tasks.length === 0) {
|
|
233
|
+
errors.push('tasks must be a non-empty array');
|
|
234
|
+
} else {
|
|
235
|
+
data.tasks.forEach((task, i) => {
|
|
236
|
+
const tag = `tasks[${i}]`;
|
|
237
|
+
if (!task || typeof task !== 'object') {
|
|
238
|
+
errors.push(`${tag} is not an object`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (typeof task.task_id !== 'string' || task.task_id.length === 0) {
|
|
242
|
+
errors.push(`${tag}.task_id is required (non-empty string)`);
|
|
243
|
+
}
|
|
244
|
+
if (typeof task.summary !== 'string' || task.summary.length < 3) {
|
|
245
|
+
errors.push(`${tag}.summary required, ≥3 chars`);
|
|
246
|
+
}
|
|
247
|
+
if (!Array.isArray(task.touches) || task.touches.some((t) => typeof t !== 'string')) {
|
|
248
|
+
errors.push(`${tag}.touches must be an array of strings`);
|
|
249
|
+
}
|
|
250
|
+
if (task.dependencies !== undefined &&
|
|
251
|
+
(!Array.isArray(task.dependencies) ||
|
|
252
|
+
task.dependencies.some((d) => typeof d !== 'string'))) {
|
|
253
|
+
errors.push(`${tag}.dependencies must be an array of strings`);
|
|
254
|
+
}
|
|
255
|
+
if (task.parallel_safe !== undefined && typeof task.parallel_safe !== 'boolean') {
|
|
256
|
+
errors.push(`${tag}.parallel_safe must be boolean`);
|
|
257
|
+
}
|
|
258
|
+
if (task.estimated_minutes !== undefined &&
|
|
259
|
+
(typeof task.estimated_minutes !== 'number' || task.estimated_minutes < 0)) {
|
|
260
|
+
errors.push(`${tag}.estimated_minutes must be a non-negative number`);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
if (!Array.isArray(data.waves) || data.waves.length === 0) {
|
|
265
|
+
errors.push('waves must be a non-empty array');
|
|
266
|
+
} else {
|
|
267
|
+
data.waves.forEach((wave, i) => {
|
|
268
|
+
const tag = `waves[${i}]`;
|
|
269
|
+
if (!wave || typeof wave !== 'object') {
|
|
270
|
+
errors.push(`${tag} is not an object`);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (typeof wave.wave !== 'string' || wave.wave.length === 0) {
|
|
274
|
+
errors.push(`${tag}.wave required (non-empty string)`);
|
|
275
|
+
}
|
|
276
|
+
if (!Array.isArray(wave.task_ids) || wave.task_ids.length === 0) {
|
|
277
|
+
errors.push(`${tag}.task_ids must be a non-empty array`);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return errors.length === 0 ? { ok: true, data } : { ok: false, errors };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function validateVerifierDecision(data) {
|
|
285
|
+
const errors = [];
|
|
286
|
+
if (!data || typeof data !== 'object') {
|
|
287
|
+
return { ok: false, errors: ['Top-level value is not an object'] };
|
|
288
|
+
}
|
|
289
|
+
if (data.schema_version !== '1.0.0') {
|
|
290
|
+
errors.push(`schema_version must be "1.0.0" (got ${JSON.stringify(data.schema_version)})`);
|
|
291
|
+
}
|
|
292
|
+
if (!VALID_VERIFIER_VERDICTS.includes(data.verdict)) {
|
|
293
|
+
errors.push(`verdict must be one of [${VALID_VERIFIER_VERDICTS.join('|')}] (got ${JSON.stringify(data.verdict)})`);
|
|
294
|
+
}
|
|
295
|
+
if (!Array.isArray(data.gaps)) {
|
|
296
|
+
errors.push('gaps must be an array');
|
|
297
|
+
} else {
|
|
298
|
+
data.gaps.forEach((gap, i) => {
|
|
299
|
+
const tag = `gaps[${i}]`;
|
|
300
|
+
if (!gap || typeof gap !== 'object') {
|
|
301
|
+
errors.push(`${tag} is not an object`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (typeof gap.id !== 'string' || gap.id.length === 0) {
|
|
305
|
+
errors.push(`${tag}.id is required (non-empty string)`);
|
|
306
|
+
}
|
|
307
|
+
if (!VALID_GAP_SEVERITIES.includes(gap.severity)) {
|
|
308
|
+
errors.push(`${tag}.severity must be one of [${VALID_GAP_SEVERITIES.join('|')}]`);
|
|
309
|
+
}
|
|
310
|
+
if (typeof gap.area !== 'string' || gap.area.length === 0) {
|
|
311
|
+
errors.push(`${tag}.area is required (non-empty string)`);
|
|
312
|
+
}
|
|
313
|
+
if (typeof gap.summary !== 'string' || gap.summary.length < 3) {
|
|
314
|
+
errors.push(`${tag}.summary required, ≥3 chars`);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
if (!Array.isArray(data.must_fix_before_ship) ||
|
|
319
|
+
data.must_fix_before_ship.some((s) => typeof s !== 'string')) {
|
|
320
|
+
errors.push('must_fix_before_ship must be an array of strings');
|
|
321
|
+
}
|
|
322
|
+
if (!VALID_VERIFIER_CONFIDENCE.includes(data.confidence)) {
|
|
323
|
+
errors.push(`confidence must be one of [${VALID_VERIFIER_CONFIDENCE.join('|')}]`);
|
|
324
|
+
}
|
|
325
|
+
return errors.length === 0 ? { ok: true, data } : { ok: false, errors };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Extract + validate the planner decision JSON block from markdown output.
|
|
330
|
+
* @param {string} markdown
|
|
331
|
+
* @returns {{ ok: true, data: object } | { ok: false, error: string }}
|
|
332
|
+
*/
|
|
333
|
+
function parsePlannerDecision(markdown) {
|
|
334
|
+
const extracted = extractJsonBlock(markdown);
|
|
335
|
+
if (!extracted.ok) return { ok: false, error: extracted.error };
|
|
336
|
+
const parsed = parseJson(extracted.raw);
|
|
337
|
+
if (!parsed.ok) return { ok: false, error: parsed.error };
|
|
338
|
+
const validated = validatePlannerDecision(parsed.data);
|
|
339
|
+
if (!validated.ok) {
|
|
340
|
+
return {
|
|
341
|
+
ok: false,
|
|
342
|
+
error: `Planner decision contract violations:\n${validated.errors.map((e) => ` - ${e}`).join('\n')}`,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
return { ok: true, data: validated.data };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Extract + validate the verifier decision JSON block from markdown output.
|
|
350
|
+
* @param {string} markdown
|
|
351
|
+
* @returns {{ ok: true, data: object } | { ok: false, error: string }}
|
|
352
|
+
*/
|
|
353
|
+
function parseVerifierDecision(markdown) {
|
|
354
|
+
const extracted = extractJsonBlock(markdown);
|
|
355
|
+
if (!extracted.ok) return { ok: false, error: extracted.error };
|
|
356
|
+
const parsed = parseJson(extracted.raw);
|
|
357
|
+
if (!parsed.ok) return { ok: false, error: parsed.error };
|
|
358
|
+
const validated = validateVerifierDecision(parsed.data);
|
|
359
|
+
if (!validated.ok) {
|
|
360
|
+
return {
|
|
361
|
+
ok: false,
|
|
362
|
+
error: `Verifier decision contract violations:\n${validated.errors.map((e) => ` - ${e}`).join('\n')}`,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
return { ok: true, data: validated.data };
|
|
366
|
+
}
|
|
367
|
+
|
|
207
368
|
module.exports = {
|
|
208
369
|
parseMotionMap,
|
|
370
|
+
parsePlannerDecision,
|
|
371
|
+
parseVerifierDecision,
|
|
209
372
|
parseGenericContract,
|
|
210
373
|
loadMotionMapSchema,
|
|
211
374
|
validateMotionMap,
|
|
375
|
+
validatePlannerDecision,
|
|
376
|
+
validateVerifierDecision,
|
|
212
377
|
extractJsonBlock,
|
|
213
378
|
parseJson,
|
|
214
379
|
// Exported for testing
|
|
@@ -217,4 +382,7 @@ module.exports = {
|
|
|
217
382
|
VALID_TRANSITION_FAMILIES,
|
|
218
383
|
VALID_DURATION_CLASSES,
|
|
219
384
|
VALID_TRIGGERS,
|
|
385
|
+
VALID_VERIFIER_VERDICTS,
|
|
386
|
+
VALID_GAP_SEVERITIES,
|
|
387
|
+
VALID_VERIFIER_CONFIDENCE,
|
|
220
388
|
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* redact.cjs — secret scrubbing for event-stream payloads (Plan 22-02).
|
|
3
|
+
*
|
|
4
|
+
* Deep-walks a value, replacing any string that matches a known secret
|
|
5
|
+
* pattern with a `[REDACTED:<type>]` placeholder. Non-mutating — returns
|
|
6
|
+
* a new structure; the input is not modified.
|
|
7
|
+
*
|
|
8
|
+
* Called from `event-stream/writer.ts` at serialize time so every event
|
|
9
|
+
* that hits disk (and every bus subscriber) sees the redacted form.
|
|
10
|
+
*
|
|
11
|
+
* Patterns are intentionally conservative: false-positives on redaction
|
|
12
|
+
* are low-cost (logged telemetry becomes slightly harder to read); false-
|
|
13
|
+
* negatives (real secrets leaking) are high-cost. When in doubt, match.
|
|
14
|
+
*
|
|
15
|
+
* Adding a pattern: append an entry to `PATTERNS` with a stable `type`
|
|
16
|
+
* key. The type string is the label emitted into the placeholder.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
/** @type {Array<{type: string, re: RegExp}>} */
|
|
22
|
+
const PATTERNS = [
|
|
23
|
+
// PEM first — must redact before generic base64 patterns would hit.
|
|
24
|
+
{
|
|
25
|
+
type: 'pem',
|
|
26
|
+
re: /-----BEGIN [A-Z ]+-----[\s\S]+?-----END [A-Z ]+-----/g,
|
|
27
|
+
},
|
|
28
|
+
// JWT — 3 dot-separated base64url segments, beginning with eyJ.
|
|
29
|
+
{
|
|
30
|
+
type: 'jwt',
|
|
31
|
+
re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
|
|
32
|
+
},
|
|
33
|
+
// Anthropic API keys (sk-ant-…) — matched before generic sk- to win.
|
|
34
|
+
{
|
|
35
|
+
type: 'anthropic',
|
|
36
|
+
re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g,
|
|
37
|
+
},
|
|
38
|
+
// Stripe live secret key.
|
|
39
|
+
{
|
|
40
|
+
type: 'stripe',
|
|
41
|
+
re: /\bsk_live_[A-Za-z0-9]{20,}\b/g,
|
|
42
|
+
},
|
|
43
|
+
// Slack tokens — xoxb/xoxp/xoxa/xoxr/xoxs.
|
|
44
|
+
{
|
|
45
|
+
type: 'slack',
|
|
46
|
+
re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
|
|
47
|
+
},
|
|
48
|
+
// GitHub personal access token.
|
|
49
|
+
{
|
|
50
|
+
type: 'github_pat',
|
|
51
|
+
re: /\bghp_[A-Za-z0-9]{36,}\b/g,
|
|
52
|
+
},
|
|
53
|
+
// AWS access key ID.
|
|
54
|
+
{
|
|
55
|
+
type: 'aws',
|
|
56
|
+
re: /\bAKIA[0-9A-Z]{16}\b/g,
|
|
57
|
+
},
|
|
58
|
+
// Generic OpenAI-style sk-… (last in the sk-* family — lower priority
|
|
59
|
+
// than anthropic/stripe which start with `sk_live_`/`sk-ant-`).
|
|
60
|
+
{
|
|
61
|
+
type: 'sk',
|
|
62
|
+
re: /\bsk-[A-Za-z0-9_-]{20,}\b/g,
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Redact every secret-shaped substring in `s`, returning the scrubbed
|
|
68
|
+
* string. Patterns are applied in `PATTERNS` order — more-specific
|
|
69
|
+
* patterns (anthropic, stripe) first so they win over the generic
|
|
70
|
+
* `sk-` catch-all.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} s
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
function redactString(s) {
|
|
76
|
+
if (typeof s !== 'string' || s.length < 10) return s;
|
|
77
|
+
let out = s;
|
|
78
|
+
for (const { type, re } of PATTERNS) {
|
|
79
|
+
re.lastIndex = 0; // safety: `g` flag carries state across calls
|
|
80
|
+
out = out.replace(re, `[REDACTED:${type}]`);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Deep-walk `value`, redacting every string encountered. Arrays and
|
|
87
|
+
* plain objects recurse; everything else returns unchanged.
|
|
88
|
+
*
|
|
89
|
+
* Cycle-safe via a WeakSet.
|
|
90
|
+
*
|
|
91
|
+
* @param {unknown} value
|
|
92
|
+
* @param {WeakSet<object>} [seen]
|
|
93
|
+
* @returns {unknown}
|
|
94
|
+
*/
|
|
95
|
+
function redact(value, seen) {
|
|
96
|
+
if (value === null || value === undefined) return value;
|
|
97
|
+
if (typeof value === 'string') return redactString(value);
|
|
98
|
+
if (typeof value !== 'object') return value;
|
|
99
|
+
|
|
100
|
+
const visited = seen ?? new WeakSet();
|
|
101
|
+
if (visited.has(/** @type {object} */ (value))) return value;
|
|
102
|
+
visited.add(/** @type {object} */ (value));
|
|
103
|
+
|
|
104
|
+
if (Array.isArray(value)) {
|
|
105
|
+
return value.map((v) => redact(v, visited));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Plain object. Don't try to preserve class instances — event payloads
|
|
109
|
+
// are expected to be JSON-shaped bags.
|
|
110
|
+
/** @type {Record<string, unknown>} */
|
|
111
|
+
const out = {};
|
|
112
|
+
for (const key of Object.keys(/** @type {object} */ (value))) {
|
|
113
|
+
out[key] = redact(/** @type {Record<string, unknown>} */ (value)[key], visited);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = {
|
|
119
|
+
redact,
|
|
120
|
+
redactString,
|
|
121
|
+
PATTERNS,
|
|
122
|
+
};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reference-resolver.cjs — `type:<key>` → registry entry + excerpt
|
|
3
|
+
* (Plan 23-05).
|
|
4
|
+
*
|
|
5
|
+
* Builds on `scripts/lib/reference-registry.cjs#list` (Phase 14.5).
|
|
6
|
+
* Adds the resolution direction: given a key surfaced by an agent
|
|
7
|
+
* author, return the single matching entry plus a short excerpt
|
|
8
|
+
* suitable for inlining into prompts.
|
|
9
|
+
*
|
|
10
|
+
* Lookup order (first match wins):
|
|
11
|
+
* 1. exact `name` match
|
|
12
|
+
* 2. slug match against path basename without extension
|
|
13
|
+
* 3. singularize fuzzy match (strip trailing 's')
|
|
14
|
+
* 4. type==key AND only one entry exists at that type
|
|
15
|
+
*
|
|
16
|
+
* Ambiguous match → throws RangeError with candidate list.
|
|
17
|
+
* No match → returns null.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
|
|
25
|
+
const registry = require('./reference-registry.cjs');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {Object} ResolverHit
|
|
29
|
+
* @property {string} name
|
|
30
|
+
* @property {string} path
|
|
31
|
+
* @property {string} type
|
|
32
|
+
* @property {string} excerpt
|
|
33
|
+
* @property {string} [tier]
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const DEFAULT_MAX_CHARS = 200;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pull a 200-char excerpt from a markdown file. Strips frontmatter,
|
|
40
|
+
* fences, comments, headers; collapses whitespace; truncates with `'…'`.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} absolutePath
|
|
43
|
+
* @param {{maxChars?: number}} [opts]
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function excerptOf(absolutePath, opts = {}) {
|
|
47
|
+
const maxChars = typeof opts.maxChars === 'number' ? opts.maxChars : DEFAULT_MAX_CHARS;
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = fs.readFileSync(absolutePath, 'utf8');
|
|
51
|
+
} catch {
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
// Drop YAML frontmatter.
|
|
55
|
+
raw = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
|
|
56
|
+
// Drop fenced code blocks.
|
|
57
|
+
raw = raw.replace(/```[\s\S]*?```/g, '');
|
|
58
|
+
// Drop HTML comments. Iterate until stable so that nested or
|
|
59
|
+
// adjacent `<!-- … -->` sequences cannot smuggle a residual `<!--`
|
|
60
|
+
// through a single regex pass (CodeQL js/incomplete-multi-character-
|
|
61
|
+
// sanitization). We're not building defense-in-depth against
|
|
62
|
+
// real markup attacks here — these excerpts are local doc files —
|
|
63
|
+
// but the loop costs nothing and silences the alert.
|
|
64
|
+
let prev;
|
|
65
|
+
do {
|
|
66
|
+
prev = raw;
|
|
67
|
+
raw = raw.replace(/<!--[\s\S]*?-->/g, '');
|
|
68
|
+
} while (raw !== prev);
|
|
69
|
+
// Drop heading lines.
|
|
70
|
+
raw = raw.replace(/^#{1,6}\s.*$/gm, '');
|
|
71
|
+
// Take first non-empty paragraph.
|
|
72
|
+
const paragraphs = raw.split(/\r?\n\s*\r?\n/).map((p) => p.trim()).filter(Boolean);
|
|
73
|
+
if (paragraphs.length === 0) return '';
|
|
74
|
+
let p = paragraphs[0].replace(/\s+/g, ' ').trim();
|
|
75
|
+
if (p.length > maxChars) {
|
|
76
|
+
p = p.slice(0, Math.max(0, maxChars - 1)) + '…';
|
|
77
|
+
}
|
|
78
|
+
return p;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Map a registry entry + cwd to a ResolverHit.
|
|
83
|
+
*/
|
|
84
|
+
function hitFor(entry, cwd) {
|
|
85
|
+
const abs = path.resolve(cwd, entry.path);
|
|
86
|
+
/** @type {ResolverHit} */
|
|
87
|
+
const hit = {
|
|
88
|
+
name: entry.name,
|
|
89
|
+
path: entry.path,
|
|
90
|
+
type: entry.type,
|
|
91
|
+
excerpt: excerptOf(abs),
|
|
92
|
+
};
|
|
93
|
+
if (entry.tier) hit.tier = entry.tier;
|
|
94
|
+
return hit;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve `type:<key>` (or bare `<key>`) to a single registry hit.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} typeKey
|
|
101
|
+
* @param {{cwd?: string}} [opts]
|
|
102
|
+
* @returns {ResolverHit | null}
|
|
103
|
+
*/
|
|
104
|
+
function resolve(typeKey, opts = {}) {
|
|
105
|
+
if (typeof typeKey !== 'string' || typeKey.length === 0) return null;
|
|
106
|
+
const cwd = opts.cwd ?? path.resolve(__dirname, '..', '..');
|
|
107
|
+
const key = typeKey.replace(/^type:/, '').trim().toLowerCase();
|
|
108
|
+
if (key.length === 0) return null;
|
|
109
|
+
const all = registry.list({ cwd });
|
|
110
|
+
|
|
111
|
+
// 1. Exact name match.
|
|
112
|
+
const exact = all.find((e) => e.name.toLowerCase() === key);
|
|
113
|
+
if (exact) return hitFor(exact, cwd);
|
|
114
|
+
|
|
115
|
+
// 2. Slug match against path basename (no extension).
|
|
116
|
+
const bySlug = all.filter((e) => {
|
|
117
|
+
const slug = path.posix.basename(e.path, path.posix.extname(e.path)).toLowerCase();
|
|
118
|
+
return slug === key;
|
|
119
|
+
});
|
|
120
|
+
if (bySlug.length === 1) return hitFor(bySlug[0], cwd);
|
|
121
|
+
if (bySlug.length > 1) {
|
|
122
|
+
throw new RangeError(
|
|
123
|
+
`reference-resolver: ambiguous slug match for "${typeKey}" — candidates: ${bySlug.map((e) => e.name).join(', ')}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 3. Singularize: strip trailing 's' from key, then prefix-match name.
|
|
128
|
+
if (key.endsWith('s') && key.length > 1) {
|
|
129
|
+
const stem = key.slice(0, -1);
|
|
130
|
+
const stemHits = all.filter((e) => e.name.toLowerCase().startsWith(stem));
|
|
131
|
+
if (stemHits.length === 1) return hitFor(stemHits[0], cwd);
|
|
132
|
+
if (stemHits.length > 1) {
|
|
133
|
+
throw new RangeError(
|
|
134
|
+
`reference-resolver: ambiguous singularize match for "${typeKey}" — candidates: ${stemHits.map((e) => e.name).join(', ')}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 4. type==key AND single entry at that type.
|
|
140
|
+
const byType = all.filter((e) => e.type.toLowerCase() === key);
|
|
141
|
+
if (byType.length === 1) return hitFor(byType[0], cwd);
|
|
142
|
+
if (byType.length > 1) {
|
|
143
|
+
throw new RangeError(
|
|
144
|
+
`reference-resolver: ambiguous type-only match for "${typeKey}" — multiple entries at type=${key}: ${byType.map((e) => e.name).join(', ')}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Bulk resolver — used by the prompt-builder.
|
|
153
|
+
*
|
|
154
|
+
* @param {string[]} typeKeys
|
|
155
|
+
* @param {{cwd?: string, ignoreMissing?: boolean}} [opts]
|
|
156
|
+
* @returns {ResolverHit[]}
|
|
157
|
+
*/
|
|
158
|
+
function resolveAll(typeKeys, opts = {}) {
|
|
159
|
+
if (!Array.isArray(typeKeys)) {
|
|
160
|
+
throw new TypeError('reference-resolver: typeKeys must be an array');
|
|
161
|
+
}
|
|
162
|
+
/** @type {ResolverHit[]} */
|
|
163
|
+
const hits = [];
|
|
164
|
+
/** @type {string[]} */
|
|
165
|
+
const missing = [];
|
|
166
|
+
for (const k of typeKeys) {
|
|
167
|
+
const h = resolve(k, opts);
|
|
168
|
+
if (h) hits.push(h);
|
|
169
|
+
else missing.push(k);
|
|
170
|
+
}
|
|
171
|
+
if (missing.length > 0 && !opts.ignoreMissing) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`reference-resolver: unresolved keys: ${missing.join(', ')}. Pass {ignoreMissing: true} to skip.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return hits;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = {
|
|
180
|
+
resolve,
|
|
181
|
+
resolveAll,
|
|
182
|
+
excerptOf,
|
|
183
|
+
DEFAULT_MAX_CHARS,
|
|
184
|
+
};
|