@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +116 -0
- package/README.de.md +37 -0
- package/README.fr.md +37 -0
- package/README.it.md +37 -0
- package/README.ja.md +37 -0
- package/README.ko.md +37 -0
- package/README.md +44 -0
- package/README.zh-CN.md +37 -0
- package/SKILL.md +12 -10
- package/agents/design-reflector.md +50 -0
- package/package.json +3 -1
- package/reference/capability-gap-stage-gate.md +261 -0
- package/reference/known-failure-modes.md +185 -0
- package/reference/pseudonymization-rules.md +189 -0
- package/reference/registry.json +22 -1
- package/reference/schemas/events.schema.json +97 -3
- package/reference/schemas/generated.d.ts +319 -4
- package/scripts/build-distribution-bundles.cjs +549 -0
- package/scripts/cli/gdd-events.mjs +35 -2
- package/scripts/gsd-cleanup-incubator.cjs +367 -0
- package/scripts/install.cjs +61 -0
- package/scripts/lib/apply-reflections/incubator-proposals.cjs +448 -0
- package/scripts/lib/bandit-router.cjs +92 -9
- package/scripts/lib/gsd-health-mirror/index.cjs +37 -1
- package/scripts/lib/incubator-author.cjs +845 -0
- package/scripts/lib/install/config-dir.cjs +26 -0
- package/scripts/lib/install/converters/codex-plugin.cjs +407 -0
- package/scripts/lib/install/converters/cursor-marketplace.cjs +309 -0
- package/scripts/lib/install/doctor-codex-plugin.cjs +388 -0
- package/scripts/lib/install/doctor-cursor-marketplace.cjs +366 -0
- package/scripts/lib/install/doctor-tier2.cjs +586 -0
- package/scripts/lib/install/runtimes.cjs +48 -0
- package/scripts/lib/issue-reporter/cli-flag-report.cjs +153 -0
- package/scripts/lib/issue-reporter/consent-prompt.cjs +231 -0
- package/scripts/lib/issue-reporter/dedup.cjs +458 -0
- package/scripts/lib/issue-reporter/destination.cjs +37 -0
- package/scripts/lib/issue-reporter/draft-writer.cjs +157 -0
- package/scripts/lib/issue-reporter/gh-absent-fallback.cjs +220 -0
- package/scripts/lib/issue-reporter/gh-submit.cjs +114 -0
- package/scripts/lib/issue-reporter/kill-switch.cjs +122 -0
- package/scripts/lib/issue-reporter/payload-assembly.cjs +367 -0
- package/scripts/lib/issue-reporter/privacy-diff.cjs +385 -0
- package/scripts/lib/issue-reporter/report-flow.cjs +269 -0
- package/scripts/lib/issue-reporter/triage-matcher.cjs +270 -0
- package/scripts/lib/pseudonymize.cjs +444 -0
- package/scripts/lib/reflections-cycle-writer.cjs +172 -0
- package/scripts/lib/reflector/capability-gap-scan.cjs +751 -0
- package/scripts/lib/reflector-capability-gap-aggregator.cjs +320 -0
- package/scripts/lint-agentskills-spec.cjs +457 -0
- package/scripts/release-smoke-test.cjs +33 -2
- package/scripts/validate-incubator-scope.cjs +133 -0
- package/skills/apply-reflections/SKILL.md +16 -1
- package/skills/apply-reflections/apply-reflections-procedure.md +71 -3
- package/skills/compare/SKILL.md +2 -2
- package/skills/compare/compare-rubric.md +1 -1
- package/skills/darkmode/SKILL.md +2 -2
- package/skills/darkmode/darkmode-audit-procedure.md +1 -1
- package/skills/fast/SKILL.md +46 -0
- package/skills/figma-write/SKILL.md +2 -2
- package/skills/graphify/SKILL.md +2 -2
- package/skills/reflect/SKILL.md +9 -0
- package/skills/reflect/procedures/capability-gap-scan.md +120 -0
- package/skills/report-issue/SKILL.md +53 -0
- package/skills/report-issue/report-issue-procedure.md +120 -0
- package/skills/router/SKILL.md +5 -0
- package/skills/router/capability-gap-emitter.md +65 -0
- package/skills/style/SKILL.md +2 -2
- package/skills/style/style-doc-procedure.md +1 -1
- 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
|
|
165
|
-
|
|
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
|
|
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.
|