@hegemonart/get-design-done 1.28.7 → 1.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +116 -0
  4. package/README.de.md +37 -0
  5. package/README.fr.md +37 -0
  6. package/README.it.md +37 -0
  7. package/README.ja.md +37 -0
  8. package/README.ko.md +37 -0
  9. package/README.md +44 -0
  10. package/README.zh-CN.md +37 -0
  11. package/SKILL.md +12 -10
  12. package/agents/design-reflector.md +50 -0
  13. package/package.json +3 -1
  14. package/reference/capability-gap-stage-gate.md +261 -0
  15. package/reference/known-failure-modes.md +185 -0
  16. package/reference/pseudonymization-rules.md +189 -0
  17. package/reference/registry.json +22 -1
  18. package/reference/schemas/events.schema.json +97 -3
  19. package/reference/schemas/generated.d.ts +319 -4
  20. package/scripts/build-distribution-bundles.cjs +549 -0
  21. package/scripts/cli/gdd-events.mjs +35 -2
  22. package/scripts/gsd-cleanup-incubator.cjs +367 -0
  23. package/scripts/install.cjs +61 -0
  24. package/scripts/lib/apply-reflections/incubator-proposals.cjs +448 -0
  25. package/scripts/lib/bandit-router.cjs +92 -9
  26. package/scripts/lib/gsd-health-mirror/index.cjs +37 -1
  27. package/scripts/lib/incubator-author.cjs +845 -0
  28. package/scripts/lib/install/config-dir.cjs +26 -0
  29. package/scripts/lib/install/converters/codex-plugin.cjs +407 -0
  30. package/scripts/lib/install/converters/cursor-marketplace.cjs +309 -0
  31. package/scripts/lib/install/doctor-codex-plugin.cjs +388 -0
  32. package/scripts/lib/install/doctor-cursor-marketplace.cjs +366 -0
  33. package/scripts/lib/install/doctor-tier2.cjs +586 -0
  34. package/scripts/lib/install/runtimes.cjs +48 -0
  35. package/scripts/lib/issue-reporter/cli-flag-report.cjs +153 -0
  36. package/scripts/lib/issue-reporter/consent-prompt.cjs +231 -0
  37. package/scripts/lib/issue-reporter/dedup.cjs +458 -0
  38. package/scripts/lib/issue-reporter/destination.cjs +37 -0
  39. package/scripts/lib/issue-reporter/draft-writer.cjs +157 -0
  40. package/scripts/lib/issue-reporter/gh-absent-fallback.cjs +220 -0
  41. package/scripts/lib/issue-reporter/gh-submit.cjs +114 -0
  42. package/scripts/lib/issue-reporter/kill-switch.cjs +122 -0
  43. package/scripts/lib/issue-reporter/payload-assembly.cjs +367 -0
  44. package/scripts/lib/issue-reporter/privacy-diff.cjs +385 -0
  45. package/scripts/lib/issue-reporter/report-flow.cjs +269 -0
  46. package/scripts/lib/issue-reporter/triage-matcher.cjs +270 -0
  47. package/scripts/lib/pseudonymize.cjs +444 -0
  48. package/scripts/lib/reflections-cycle-writer.cjs +172 -0
  49. package/scripts/lib/reflector/capability-gap-scan.cjs +751 -0
  50. package/scripts/lib/reflector-capability-gap-aggregator.cjs +320 -0
  51. package/scripts/lint-agentskills-spec.cjs +457 -0
  52. package/scripts/release-smoke-test.cjs +33 -2
  53. package/scripts/validate-incubator-scope.cjs +133 -0
  54. package/skills/apply-reflections/SKILL.md +16 -1
  55. package/skills/apply-reflections/apply-reflections-procedure.md +71 -3
  56. package/skills/compare/SKILL.md +2 -2
  57. package/skills/compare/compare-rubric.md +1 -1
  58. package/skills/darkmode/SKILL.md +2 -2
  59. package/skills/darkmode/darkmode-audit-procedure.md +1 -1
  60. package/skills/fast/SKILL.md +46 -0
  61. package/skills/figma-write/SKILL.md +2 -2
  62. package/skills/graphify/SKILL.md +2 -2
  63. package/skills/reflect/SKILL.md +9 -0
  64. package/skills/reflect/procedures/capability-gap-scan.md +120 -0
  65. package/skills/report-issue/SKILL.md +53 -0
  66. package/skills/report-issue/report-issue-procedure.md +120 -0
  67. package/skills/router/SKILL.md +5 -0
  68. package/skills/router/capability-gap-emitter.md +65 -0
  69. package/skills/style/SKILL.md +2 -2
  70. package/skills/style/style-doc-procedure.md +1 -1
  71. package/skills/update/SKILL.md +3 -2
@@ -0,0 +1,457 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * scripts/lint-agentskills-spec.cjs — agentskills.io spec lint.
5
+ *
6
+ * Phase 28.8 Plan 28-8-A1 (D-13 `lint-only` outcome).
7
+ * See .planning/research/agentskills-io-2026-05-19.md § Implementation Implications
8
+ * → Plan 28-8-A1 — what to ship — for the source-of-truth rule list.
9
+ *
10
+ * Walks `skills/<name>/SKILL.md` and applies the following rules per skill:
11
+ *
12
+ * R1 (FAIL) — frontmatter contains a non-empty `name`.
13
+ * R2 (FAIL) — `name` matches /^[a-z0-9]+(-[a-z0-9]+)*$/ AND length ≤ 64.
14
+ * R3 (FAIL) — `name` matches the parent directory (allow bare slug OR `gdd-`-prefixed
15
+ * slug, because source-tree uses bare and install-tree uses prefixed per
16
+ * Phase 28.7 D-05).
17
+ * R4 (FAIL) — `description` is non-empty AND ≤ 1024 chars (spec hard cap).
18
+ * R5 (FAIL) — SKILL.md body line count ≤ 500 (spec readability guidance).
19
+ *
20
+ * W1 (WARN) — both `tools` and `allowed-tools` are present. `allowed-tools` is marked
21
+ * Experimental in the spec; pick one form to avoid drift.
22
+ * W2 (WARN) — description length > 200 chars (Phase 28.5 D-01 advisory; distinct
23
+ * from R4's 1024-char hard cap).
24
+ * W3 — reserved slot (covered today by R2). No emission.
25
+ *
26
+ * CLI:
27
+ * node scripts/lint-agentskills-spec.cjs # default: lint ./skills
28
+ * node scripts/lint-agentskills-spec.cjs <dir> # lint <dir>/<name>/SKILL.md
29
+ * node scripts/lint-agentskills-spec.cjs --json # emit JSON instead of table
30
+ * node scripts/lint-agentskills-spec.cjs --summary # one-line PASS/WARN/FAIL counts
31
+ * node scripts/lint-agentskills-spec.cjs --summary --json
32
+ * # JSON {pass,warn,fail} counts
33
+ *
34
+ * Exit codes:
35
+ * 0 — no FAIL rows (WARN rows do NOT fail the run)
36
+ * 1 — at least one FAIL row
37
+ * 2 — internal error (I/O failure, parse exception, bad CLI arg)
38
+ *
39
+ * Empty / missing skills directory:
40
+ * Prints `Lint: no skills found at <dir> — nothing to lint.` and exits 0.
41
+ * Under `--summary`, prints `PASS=0 WARN=0 FAIL=0` (or `{"pass":0,"warn":0,"fail":0}`
42
+ * with `--summary --json`) and exits 0 — empty dirs never fail the run.
43
+ *
44
+ * Exports (for tests + Plan 28-8-X2 in-process consumption):
45
+ * lint(skillsDir, opts?) → { rows, summary, emptyDir }
46
+ * lintSummary({sourceRoot}) → { pass, warn, fail } // Plan 28-8-X2 doctor seam
47
+ * main(argv) → number (exit code; pure — does NOT call process.exit)
48
+ * parseFrontmatter(content) → { frontmatter, body, hasFrontmatter }
49
+ * lintSkill(skillDir, skillName) → Array<{status, skill, rule, detail}>
50
+ *
51
+ * Plan 28-8-X2 wiring:
52
+ * `lintSummary` is consumed in-process by `scripts/lib/install/doctor-tier2.cjs`
53
+ * (Tier-2 doctor aggregator). It returns `{pass, warn, fail}` counts only — no
54
+ * table, no JSON, no IO beyond fs.readFileSync of SKILL.md files. The doctor wraps
55
+ * it as the agentskills.io channel state per D-13 (lint-only adoption).
56
+ */
57
+
58
+ const fs = require('fs');
59
+ const path = require('path');
60
+
61
+ const NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
62
+ const NAME_MAX = 64;
63
+ const DESC_MAX = 1024;
64
+ const DESC_ADVISORY = 200;
65
+ const BODY_MAX_LINES = 500;
66
+ const DETAIL_TRUNCATE = 100;
67
+
68
+ /**
69
+ * Parse YAML-ish frontmatter at the top of a markdown document.
70
+ *
71
+ * Zero-dep: handles only what our Phase 28.5 frontmatter contract emits:
72
+ * - leading `---\n` block delimited by `\n---\n`
73
+ * - scalar `key: value` lines (no nested maps, no arrays)
74
+ * - surrounding single or double quotes stripped from value
75
+ * - for values containing colons (URLs in description), the substring after the FIRST `:`
76
+ * is taken — so `description: "https://example.com"` works.
77
+ *
78
+ * Returns:
79
+ * { frontmatter: object, body: string, hasFrontmatter: boolean }
80
+ *
81
+ * If the opening delimiter is missing or the closing delimiter is not found, returns
82
+ * `hasFrontmatter: false` with `frontmatter: {}` and `body` set to the original content.
83
+ */
84
+ function parseFrontmatter(content) {
85
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
86
+ if (!match) {
87
+ return { frontmatter: {}, body: content, hasFrontmatter: false };
88
+ }
89
+ const block = match[1];
90
+ const body = match[2];
91
+ const frontmatter = {};
92
+ const lines = block.split(/\r?\n/);
93
+ for (const line of lines) {
94
+ if (!line.trim()) continue;
95
+ const colonIdx = line.indexOf(':');
96
+ if (colonIdx < 0) continue;
97
+ const key = line.slice(0, colonIdx).trim();
98
+ let value = line.slice(colonIdx + 1).trim();
99
+ if (
100
+ value.length >= 2 &&
101
+ ((value.startsWith('"') && value.endsWith('"')) ||
102
+ (value.startsWith("'") && value.endsWith("'")))
103
+ ) {
104
+ value = value.slice(1, -1);
105
+ }
106
+ frontmatter[key] = value;
107
+ }
108
+ return { frontmatter, body, hasFrontmatter: true };
109
+ }
110
+
111
+ /**
112
+ * Lint a single skill directory. Returns one or more rows.
113
+ *
114
+ * Row shape: { status: 'PASS'|'WARN'|'FAIL', skill: string, rule: string, detail: string }
115
+ *
116
+ * If no rule fires, returns a single PASS row with rule='-' and detail='-'.
117
+ */
118
+ function lintSkill(skillDir, skillName) {
119
+ const skillPath = path.join(skillDir, 'SKILL.md');
120
+ let content;
121
+ try {
122
+ content = fs.readFileSync(skillPath, 'utf8');
123
+ } catch (err) {
124
+ if (err && err.code === 'ENOENT') {
125
+ return [
126
+ {
127
+ status: 'FAIL',
128
+ skill: skillName,
129
+ rule: 'IO',
130
+ detail: 'SKILL.md not found',
131
+ },
132
+ ];
133
+ }
134
+ throw err;
135
+ }
136
+
137
+ const { frontmatter, body } = parseFrontmatter(content);
138
+ const rows = [];
139
+
140
+ const name = (frontmatter.name || '').trim();
141
+ const description = (frontmatter.description || '').trim();
142
+ const hasTools = Object.prototype.hasOwnProperty.call(frontmatter, 'tools');
143
+ const hasAllowedTools = Object.prototype.hasOwnProperty.call(frontmatter, 'allowed-tools');
144
+
145
+ // R1
146
+ if (!name) {
147
+ rows.push({
148
+ status: 'FAIL',
149
+ skill: skillName,
150
+ rule: 'R1',
151
+ detail: 'frontmatter missing or empty `name`',
152
+ });
153
+ } else {
154
+ // R2
155
+ if (!NAME_REGEX.test(name) || name.length > NAME_MAX) {
156
+ rows.push({
157
+ status: 'FAIL',
158
+ skill: skillName,
159
+ rule: 'R2',
160
+ detail: `name "${name}" fails slug regex /^[a-z0-9]+(-[a-z0-9]+)*$/ or exceeds ${NAME_MAX} chars`,
161
+ });
162
+ }
163
+ // R3 — name must match parent dir (bare or gdd-prefixed)
164
+ if (
165
+ name !== skillName &&
166
+ name !== `gdd-${skillName}` &&
167
+ skillName !== `gdd-${name}`
168
+ ) {
169
+ rows.push({
170
+ status: 'FAIL',
171
+ skill: skillName,
172
+ rule: 'R3',
173
+ detail: `name "${name}" does not match parent dir "${skillName}" (allowed: bare slug or gdd-prefixed slug per Phase 28.7 D-05)`,
174
+ });
175
+ }
176
+ }
177
+
178
+ // R4
179
+ if (!description) {
180
+ rows.push({
181
+ status: 'FAIL',
182
+ skill: skillName,
183
+ rule: 'R4',
184
+ detail: 'description missing or empty',
185
+ });
186
+ } else if (description.length > DESC_MAX) {
187
+ rows.push({
188
+ status: 'FAIL',
189
+ skill: skillName,
190
+ rule: 'R4',
191
+ detail: `description: ${description.length} chars (>${DESC_MAX} hard cap)`,
192
+ });
193
+ }
194
+
195
+ // R5 — body line count
196
+ const bodyLines = body ? body.split(/\r?\n/).length : 0;
197
+ if (bodyLines > BODY_MAX_LINES) {
198
+ rows.push({
199
+ status: 'FAIL',
200
+ skill: skillName,
201
+ rule: 'R5',
202
+ detail: `body ${bodyLines} lines (>${BODY_MAX_LINES} spec guidance)`,
203
+ });
204
+ }
205
+
206
+ // W1 — both tools and allowed-tools present
207
+ if (hasTools && hasAllowedTools) {
208
+ rows.push({
209
+ status: 'WARN',
210
+ skill: skillName,
211
+ rule: 'W1',
212
+ detail: '`tools` and `allowed-tools` both present; spec marks `allowed-tools` Experimental — pick one to avoid drift',
213
+ });
214
+ }
215
+
216
+ // W2 — description over advisory cap but under hard cap
217
+ if (
218
+ description &&
219
+ description.length > DESC_ADVISORY &&
220
+ description.length <= DESC_MAX
221
+ ) {
222
+ rows.push({
223
+ status: 'WARN',
224
+ skill: skillName,
225
+ rule: 'W2',
226
+ detail: `description: ${description.length} chars (>${DESC_ADVISORY} advisory cap, Phase 28.5 D-01)`,
227
+ });
228
+ }
229
+
230
+ // W3 — reserved (covered by R2).
231
+
232
+ if (rows.length === 0) {
233
+ rows.push({
234
+ status: 'PASS',
235
+ skill: skillName,
236
+ rule: '-',
237
+ detail: '-',
238
+ });
239
+ }
240
+ return rows;
241
+ }
242
+
243
+ /**
244
+ * Walk a skills directory and lint each child subdirectory containing SKILL.md.
245
+ *
246
+ * Returns:
247
+ * { rows: Array, summary: {total, pass, warn, fail}, emptyDir: boolean }
248
+ */
249
+ function lint(skillsDir, opts) {
250
+ const _opts = opts || {};
251
+ if (!fs.existsSync(skillsDir)) {
252
+ return {
253
+ rows: [],
254
+ summary: { total: 0, pass: 0, warn: 0, fail: 0 },
255
+ emptyDir: true,
256
+ };
257
+ }
258
+ let stat;
259
+ try {
260
+ stat = fs.statSync(skillsDir);
261
+ } catch (err) {
262
+ return {
263
+ rows: [],
264
+ summary: { total: 0, pass: 0, warn: 0, fail: 0 },
265
+ emptyDir: true,
266
+ };
267
+ }
268
+ if (!stat.isDirectory()) {
269
+ return {
270
+ rows: [],
271
+ summary: { total: 0, pass: 0, warn: 0, fail: 0 },
272
+ emptyDir: true,
273
+ };
274
+ }
275
+
276
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
277
+ const skillDirs = entries
278
+ .filter((e) => e.isDirectory())
279
+ .map((e) => e.name)
280
+ .filter((name) => fs.existsSync(path.join(skillsDir, name, 'SKILL.md')))
281
+ .sort();
282
+
283
+ if (skillDirs.length === 0) {
284
+ return {
285
+ rows: [],
286
+ summary: { total: 0, pass: 0, warn: 0, fail: 0 },
287
+ emptyDir: true,
288
+ };
289
+ }
290
+
291
+ const rows = [];
292
+ for (const skillName of skillDirs) {
293
+ const skillDir = path.join(skillsDir, skillName);
294
+ const skillRows = lintSkill(skillDir, skillName);
295
+ for (const row of skillRows) rows.push(row);
296
+ }
297
+
298
+ const summary = {
299
+ total: skillDirs.length,
300
+ pass: rows.filter((r) => r.status === 'PASS').length,
301
+ warn: rows.filter((r) => r.status === 'WARN').length,
302
+ fail: rows.filter((r) => r.status === 'FAIL').length,
303
+ };
304
+
305
+ return { rows, summary, emptyDir: false };
306
+ }
307
+
308
+ /**
309
+ * Plan 28-8-X2 seam — return only the PASS/WARN/FAIL counts as a flat object.
310
+ *
311
+ * Consumed in-process by `scripts/lib/install/doctor-tier2.cjs` (Tier-2 doctor
312
+ * aggregator). Wraps `lint()` and projects its `summary` onto a 3-field shape
313
+ * matching the X2 interface contract: `{ pass, warn, fail }`. Empty dirs
314
+ * yield `{ pass: 0, warn: 0, fail: 0 }`. The `total` field is dropped — callers
315
+ * compute it from `pass + warn + fail` if needed, matching D-13 lint-only contract.
316
+ *
317
+ * Pure: no IO beyond what `lint()` already does. Never calls process.exit.
318
+ *
319
+ * @param {{ sourceRoot?: string }} [opts] Optional. `sourceRoot` is the directory
320
+ * containing `skills/` (NOT the skills/
321
+ * directory itself — matches Phase 28.7
322
+ * `findInstallSourceRoot()` return contract).
323
+ * Defaults to `process.cwd()` when omitted.
324
+ * @returns {{ pass: number, warn: number, fail: number }}
325
+ */
326
+ function lintSummary(opts) {
327
+ const _opts = opts || {};
328
+ const sourceRoot = _opts.sourceRoot || process.cwd();
329
+ const skillsDir = path.join(sourceRoot, 'skills');
330
+ const result = lint(skillsDir);
331
+ return {
332
+ pass: result.summary.pass,
333
+ warn: result.summary.warn,
334
+ fail: result.summary.fail,
335
+ };
336
+ }
337
+
338
+ /**
339
+ * Format the row set as an aligned plain-text table.
340
+ *
341
+ * Columns: STATUS SKILL RULE DETAIL
342
+ * DETAIL is truncated to DETAIL_TRUNCATE chars with a `…` suffix for terminal sanity.
343
+ */
344
+ function formatTable(rows) {
345
+ const headers = ['STATUS', 'SKILL', 'RULE', 'DETAIL'];
346
+ const display = rows.map((r) => {
347
+ let detail = String(r.detail);
348
+ if (detail.length > DETAIL_TRUNCATE) {
349
+ detail = detail.slice(0, DETAIL_TRUNCATE) + '…';
350
+ }
351
+ return [r.status, r.skill, r.rule, detail];
352
+ });
353
+ const widths = headers.map((h, i) =>
354
+ Math.max(h.length, ...display.map((row) => row[i].length))
355
+ );
356
+ const pad = (cells) =>
357
+ cells.map((c, i) => c.padEnd(widths[i])).join(' ');
358
+ const sep = widths.map((w) => '-'.repeat(w)).join(' ');
359
+ const out = [pad(headers), sep];
360
+ for (const row of display) out.push(pad(row));
361
+ return out.join('\n');
362
+ }
363
+
364
+ /**
365
+ * Main CLI entry. Pure — returns exit code rather than calling process.exit.
366
+ *
367
+ * argv is the argv slice AFTER node + script (i.e. process.argv.slice(2)).
368
+ */
369
+ function main(argv) {
370
+ try {
371
+ let skillsDir = './skills';
372
+ let jsonMode = false;
373
+ let summaryMode = false;
374
+ for (const arg of argv) {
375
+ if (arg === '--json') {
376
+ jsonMode = true;
377
+ } else if (arg === '--summary') {
378
+ summaryMode = true;
379
+ } else if (arg === '--help' || arg === '-h') {
380
+ process.stdout.write(
381
+ 'lint-agentskills-spec.cjs — agentskills.io spec lint over skills/<name>/SKILL.md\n' +
382
+ '\n' +
383
+ 'Usage:\n' +
384
+ ' node scripts/lint-agentskills-spec.cjs [<dir>] [--json]\n' +
385
+ ' node scripts/lint-agentskills-spec.cjs [<dir>] --summary [--json]\n' +
386
+ '\n' +
387
+ 'Modes:\n' +
388
+ ' default Aligned table of all rows + final summary line\n' +
389
+ ' --json JSON {rows, summary} object\n' +
390
+ ' --summary One-line `PASS=N WARN=N FAIL=N`\n' +
391
+ ' --summary --json JSON {pass, warn, fail} (Plan 28-8-X2 seam)\n' +
392
+ '\n' +
393
+ 'Exit codes:\n' +
394
+ ' 0 no FAIL rows (WARN rows do NOT fail the run)\n' +
395
+ ' 1 at least one FAIL row\n' +
396
+ ' 2 internal error\n'
397
+ );
398
+ return 0;
399
+ } else if (arg.startsWith('--')) {
400
+ process.stderr.write(`lint-agentskills-spec: unknown flag: ${arg}\n`);
401
+ return 2;
402
+ } else {
403
+ skillsDir = arg;
404
+ }
405
+ }
406
+
407
+ const result = lint(skillsDir);
408
+
409
+ // Plan 28-8-X2 — --summary mode short-circuits the table renderer for
410
+ // doctor-tier2 callers. Empty dir is treated as 0-everything (exits 0)
411
+ // matching the default-mode "no skills found" exit-0 contract.
412
+ if (summaryMode) {
413
+ const pass = result.summary.pass || 0;
414
+ const warn = result.summary.warn || 0;
415
+ const fail = result.summary.fail || 0;
416
+ if (jsonMode) {
417
+ process.stdout.write(JSON.stringify({ pass, warn, fail }) + '\n');
418
+ } else {
419
+ process.stdout.write(`PASS=${pass} WARN=${warn} FAIL=${fail}\n`);
420
+ }
421
+ return fail > 0 ? 1 : 0;
422
+ }
423
+
424
+ if (result.emptyDir) {
425
+ process.stdout.write(
426
+ `Lint: no skills found at ${skillsDir} — nothing to lint.\n`
427
+ );
428
+ return 0;
429
+ }
430
+
431
+ if (jsonMode) {
432
+ process.stdout.write(
433
+ JSON.stringify({ rows: result.rows, summary: result.summary }, null, 2) +
434
+ '\n'
435
+ );
436
+ } else {
437
+ process.stdout.write(formatTable(result.rows) + '\n');
438
+ const { total, pass, warn, fail } = result.summary;
439
+ process.stdout.write(
440
+ `\nLint summary: ${total} skills, ${pass} PASS, ${warn} WARN, ${fail} FAIL\n`
441
+ );
442
+ }
443
+
444
+ return result.summary.fail > 0 ? 1 : 0;
445
+ } catch (err) {
446
+ process.stderr.write(
447
+ `lint-agentskills-spec: internal error: ${err && err.message ? err.message : err}\n`
448
+ );
449
+ return 2;
450
+ }
451
+ }
452
+
453
+ if (require.main === module) {
454
+ process.exit(main(process.argv.slice(2)));
455
+ }
456
+
457
+ module.exports = { lint, lintSummary, main, parseFrontmatter, lintSkill };
@@ -50,6 +50,27 @@ if (!fs.existsSync(FIXTURE_SRC)) {
50
50
  const tmpDir = path.join(os.tmpdir(), `gdd-smoke-${Date.now()}`);
51
51
  fs.mkdirSync(tmpDir, { recursive: true });
52
52
 
53
+ // Snapshot REPO_ROOT/.design/ contents BEFORE the smoke test runs. This lets
54
+ // the post-test pollution assertion (below) detect actual pollution (new
55
+ // files created during the run) rather than tripping on legitimately-tracked
56
+ // `.design/` files that exist in a fresh checkout — e.g.
57
+ // `.design/config.example.json` shipped by Plan 29-02 for discoverability.
58
+ function snapshotDesignDir() {
59
+ const designDir = path.join(REPO_ROOT, '.design');
60
+ if (!fs.existsSync(designDir)) return '<absent>';
61
+ const entries = [];
62
+ function walk(d) {
63
+ for (const ent of fs.readdirSync(d, { withFileTypes: true })) {
64
+ const full = path.join(d, ent.name);
65
+ if (ent.isDirectory()) walk(full);
66
+ else entries.push(path.relative(designDir, full).replace(/\\/g, '/'));
67
+ }
68
+ }
69
+ walk(designDir);
70
+ return entries.sort().join('\n');
71
+ }
72
+ const designSnapshotBefore = snapshotDesignDir();
73
+
53
74
  function copyRecursive(src, dst) {
54
75
  fs.mkdirSync(dst, { recursive: true });
55
76
  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
@@ -161,8 +182,18 @@ if (missing.length) {
161
182
  console.log(`smoke-test: ${diffs.length} diffs, ${missing.length} baseline artifacts not in fresh run`);
162
183
 
163
184
  // Ensure .design/ was not created in the real repo root.
164
- if (fs.existsSync(path.join(REPO_ROOT, '.design'))) {
165
- console.error('ERROR: .design/ polluted repo root — smoke test must use temp dir only');
185
+ // Pollution check: only fail if NEW files appeared in REPO_ROOT/.design/ during
186
+ // the smoke test. Tracked files (like `.design/config.example.json` shipped by
187
+ // Plan 29-02) are present in the fresh checkout and are NOT pollution. We
188
+ // compare the directory snapshot taken before the test (top of file) against
189
+ // the post-test snapshot.
190
+ const designSnapshotAfter = snapshotDesignDir();
191
+ if (designSnapshotBefore !== designSnapshotAfter) {
192
+ console.error('ERROR: .design/ contents changed during smoke test — pipeline wrote to REPO_ROOT instead of temp dir');
193
+ console.error('Before:');
194
+ for (const line of designSnapshotBefore.split('\n')) console.error(` ${line}`);
195
+ console.error('After:');
196
+ for (const line of designSnapshotAfter.split('\n')) console.error(` ${line}`);
166
197
  process.exit(1);
167
198
  }
168
199
 
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ // scripts/validate-incubator-scope.cjs — Plan 29-05
3
+ //
4
+ // Phase 29 D-05: scope guard for incubator-draft promotion.
5
+ //
6
+ // Purpose
7
+ // Enforce that a drafted incubator artifact can only resolve to one of:
8
+ // * `agents/<slug>.md` (Phase 28.5 agent files)
9
+ // * `skills/<slug>/SKILL.md` (Phase 28.5 skill files)
10
+ // Any other path (script, hook, runtime, transport, root-escape, absolute
11
+ // path outside the repo, traversal segment) is rejected with a non-zero
12
+ // exit and an informative error message.
13
+ //
14
+ // This script is invoked BEFORE any file write inside
15
+ // `scripts/lib/apply-reflections/incubator-proposals.cjs#applyAccept`, and
16
+ // is the second non-bypassable line of defense after the floor enforced by
17
+ // `scripts/lib/incubator-author.cjs#safeWritePath` at draft-time.
18
+ //
19
+ // Non-bypassable (D-05)
20
+ // No flag, env var, or argument disables the check. Promotion targets that
21
+ // fail the regex check throw — period. There is no opt-out flag, no
22
+ // environment override, and the CLI offers no escape hatch. (The scan in
23
+ // tests/apply-reflections-incubator.test.cjs grep-asserts the absence of
24
+ // bypass tokens in this file's source, so even adding such an option in
25
+ // future would break the build.)
26
+ //
27
+ // API
28
+ // validateScope(targetPath, { repoRoot } = {})
29
+ // → { ok: true } // accepted
30
+ // → throws Error(...) // rejected; message names offending path + allowed patterns
31
+ //
32
+ // CLI
33
+ // node scripts/validate-incubator-scope.cjs <path>
34
+ // exit 0 + `[scope-guard] ok: <relPath>` on success
35
+ // exit 1 + descriptive stderr on failure
36
+ //
37
+ // Style: zero deps beyond node:fs + node:path (matches scripts/lib/incubator-author.cjs).
38
+
39
+ 'use strict';
40
+
41
+ const path = require('node:path');
42
+
43
+ // Allowed target patterns — slug rules match the Phase 28.5 frontmatter slug
44
+ // regex (lowercase, digits, hyphens; must start with [a-z0-9]).
45
+ const SLUG_RE_FRAGMENT = '[a-z0-9][a-z0-9-]*';
46
+ const AGENT_RE = new RegExp(`^agents/${SLUG_RE_FRAGMENT}\\.md$`);
47
+ const SKILL_RE = new RegExp(`^skills/${SLUG_RE_FRAGMENT}/SKILL\\.md$`);
48
+
49
+ /**
50
+ * Validate that a target path is in scope for incubator promotion.
51
+ *
52
+ * Algorithm:
53
+ * 1. Resolve to absolute path under repoRoot.
54
+ * 2. Reject if the resolved path escapes repoRoot (path traversal or
55
+ * absolute path pointing outside the repository).
56
+ * 3. Compute repo-relative path with forward-slash normalization.
57
+ * 4. Reject if the relative path doesn't match exactly one of the two
58
+ * allowed patterns.
59
+ *
60
+ * @param {string} targetPath - file path to validate; relative paths are
61
+ * resolved against repoRoot.
62
+ * @param {{repoRoot?: string}} [opts] - configuration. repoRoot defaults to
63
+ * process.cwd().
64
+ * @returns {{ok: true}} on success.
65
+ * @throws {Error} on any rejection. Message includes the offending path and
66
+ * the allowed patterns.
67
+ */
68
+ function validateScope(targetPath, opts) {
69
+ const o = opts || {};
70
+ const repoRoot = path.resolve(o.repoRoot || process.cwd());
71
+
72
+ if (typeof targetPath !== 'string' || !targetPath.length) {
73
+ throw new Error(
74
+ `[scope-guard] invalid input: targetPath must be a non-empty string. ` +
75
+ `Allowed: ${AGENT_RE.source} or ${SKILL_RE.source}`,
76
+ );
77
+ }
78
+
79
+ // Resolve relative to repoRoot. Absolute paths bypass repoRoot prefixing;
80
+ // that's fine — the prefix check below catches them anyway.
81
+ const resolved = path.resolve(repoRoot, targetPath);
82
+
83
+ // Step 1: confirm resolved path is inside repoRoot. We compare with a
84
+ // trailing separator to avoid `repoRoot-evil/...` slipping past a startsWith
85
+ // check.
86
+ const rootWithSep = repoRoot + path.sep;
87
+ if (!(resolved === repoRoot || resolved.startsWith(rootWithSep))) {
88
+ throw new Error(
89
+ `[scope-guard] path escapes repository: ${targetPath} → ${resolved} ` +
90
+ `(outside ${repoRoot}). Allowed: agents/<slug>.md or skills/<slug>/SKILL.md`,
91
+ );
92
+ }
93
+
94
+ // Step 2: compute repo-relative path and normalize separators to '/'
95
+ // (Windows uses '\\' natively).
96
+ const rel = path.relative(repoRoot, resolved).replace(/\\/g, '/');
97
+
98
+ // Step 3: match exactly one of the allowed shapes.
99
+ if (AGENT_RE.test(rel) || SKILL_RE.test(rel)) {
100
+ return { ok: true };
101
+ }
102
+
103
+ throw new Error(
104
+ `[scope-guard] path not in allowed scope: ${rel} ` +
105
+ `(input: ${targetPath}). Allowed patterns: ` +
106
+ `agents/<slug>.md (regex ${AGENT_RE.source}) ` +
107
+ `or skills/<slug>/SKILL.md (regex ${SKILL_RE.source}). ` +
108
+ `Note: scope guard is non-bypassable per Phase 29 D-05.`,
109
+ );
110
+ }
111
+
112
+ module.exports = { validateScope };
113
+
114
+ // -------------------------------------------------------------------
115
+ // CLI entry
116
+ // -------------------------------------------------------------------
117
+
118
+ if (require.main === module) {
119
+ const input = process.argv[2];
120
+ if (!input) {
121
+ console.error('[scope-guard] usage: node scripts/validate-incubator-scope.cjs <path>');
122
+ process.exit(1);
123
+ }
124
+ try {
125
+ validateScope(input);
126
+ const rel = path.relative(process.cwd(), path.resolve(process.cwd(), input)).replace(/\\/g, '/');
127
+ console.log(`[scope-guard] ok: ${rel}`);
128
+ process.exit(0);
129
+ } catch (err) {
130
+ console.error(err.message);
131
+ process.exit(1);
132
+ }
133
+ }
@@ -66,8 +66,23 @@ Apply-reflections complete
66
66
  ─────────────────────────────────────────
67
67
  ```
68
68
 
69
+ ## [INCUBATOR]
70
+
71
+ Incubator drafts authored by `scripts/lib/incubator-author.cjs` (Phase 29-04) appear as a distinct proposal class. For each draft under `.design/reflections/incubator/<slug>/`, use `scripts/lib/apply-reflections/incubator-proposals.cjs`:
72
+
73
+ 1. `discoverIncubatorDrafts()` → list pending drafts.
74
+ 2. `renderProposal(draft)` → show full body + diff + origin signals.
75
+ 3. User chooses **accept** | **reject** | **defer** | **edit**.
76
+ 4. **accept** — scope-guard runs FIRST (`validateScope` from `scripts/validate-incubator-scope.cjs`); `applyAccept` then promotes draft → `agents/<slug>.md` or `skills/<slug>/SKILL.md` and appends a registry entry. Single-step per D-04.
77
+ 5. **reject** — `applyReject` removes the incubator subdir.
78
+ 6. **defer** — no-op; draft re-surfaces next run.
79
+ 7. **edit** — `applyEdit` opens `$EDITOR`; re-prompt user on close.
80
+
81
+ **Stage-1 gate.** At session start, call `checkStage1Gate()`. If `thresholdMet && !optInRecorded`, display the opt-in prompt once. NEVER auto-flip per D-01 — recording opt-in requires explicit user confirmation via `recordOptIn()`. Full procedure: `./apply-reflections-procedure.md` §[INCUBATOR].
82
+
69
83
  ## Do Not
70
84
 
71
85
  - Do not apply any proposal without the user explicitly choosing `a` or `e`.
72
- - Do not modify source code files (`.ts`, `.tsx`, `.css`, `.js`) — only agent files, reference files, budget.json, discussant questions, and global skills.
86
+ - Do not modify source code files (`.ts`, `.tsx`, `.css`, `.js`) — only agent files, reference files, budget.json, discussant questions, global skills, and incubator drafts.
73
87
  - Do not re-run the reflector — this skill only applies existing proposals.
88
+ - Do not bypass the scope guard or auto-flip Stage-1 — both are non-negotiable per D-05 / D-01.