@hegemonart/get-design-done 1.28.8 → 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 +81 -0
- package/README.de.md +23 -0
- package/README.fr.md +23 -0
- package/README.it.md +23 -0
- package/README.ja.md +23 -0
- package/README.ko.md +23 -0
- package/README.md +28 -0
- package/README.zh-CN.md +23 -0
- package/SKILL.md +2 -0
- package/agents/design-reflector.md +50 -0
- package/package.json +1 -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/cli/gdd-events.mjs +35 -2
- package/scripts/gsd-cleanup-incubator.cjs +367 -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/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/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/fast/SKILL.md +46 -0
- 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/update/SKILL.md +3 -2
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reflector-capability-gap-aggregator.cjs — Plan 29-03.
|
|
3
|
+
*
|
|
4
|
+
* Aggregates `capability_gap` events (emitted by Plans 29-01 + 29-02) into
|
|
5
|
+
* per-cycle cluster rollups and evaluates the Stage-0 → Stage-1 gate (D-01).
|
|
6
|
+
*
|
|
7
|
+
* Three exports:
|
|
8
|
+
*
|
|
9
|
+
* aggregateCapabilityGaps(eventsOrPath, opts?)
|
|
10
|
+
* - Accepts an iterable of events OR a path string to a JSONL chain file.
|
|
11
|
+
* - Returns { clusters: Cluster[] } where each Cluster is:
|
|
12
|
+
* { id: string, // first 12 chars of context_hash
|
|
13
|
+
* size: number,
|
|
14
|
+
* sources: { fast, router, reflector_pattern },
|
|
15
|
+
* examples: string[] // up to 3 evidence_ref strings
|
|
16
|
+
* }
|
|
17
|
+
* - Filters to records where (record.type === 'capability_gap'
|
|
18
|
+
* OR record.outcome === 'capability_gap') AND payload.context_hash is
|
|
19
|
+
* a non-empty string. Other rows are ignored silently.
|
|
20
|
+
* - Clusters are ordered: size DESC, id ASC tie-break.
|
|
21
|
+
*
|
|
22
|
+
* renderGapsSection(clusters)
|
|
23
|
+
* - Returns a markdown string. Empty list → '' (no section emitted).
|
|
24
|
+
* - Non-empty → '## Capability gaps observed' header + table.
|
|
25
|
+
*
|
|
26
|
+
* evaluateStageGate(history, config)
|
|
27
|
+
* - history: [{ cycle_slug, clusters }] — at least 1 cycle.
|
|
28
|
+
* - config: { K, M, stddev_threshold }. Defaults: K=3, M=10, threshold=0.05.
|
|
29
|
+
* - Returns { crossed, stable_cluster_ids, cycles_observed }.
|
|
30
|
+
* - A cluster is "stable" iff: appears in ≥ M consecutive cycles AND
|
|
31
|
+
* posterior `stddev(Beta(α, β)) < threshold`, where
|
|
32
|
+
* α = appearances + 1, β = (cycles_observed - appearances) + 1
|
|
33
|
+
* (Laplace prior; matches Phase 23.5 posterior store).
|
|
34
|
+
* - D-01 honored: this function EMITS A DECISION ONLY. The caller
|
|
35
|
+
* prompts the user. No auto-stage-flip path exists in this module.
|
|
36
|
+
*
|
|
37
|
+
* D-11 compliance: this module is a pure reader. All tests use synthetic
|
|
38
|
+
* fixtures (tests/reflector-capability-gap-aggregation.test.cjs).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
'use strict';
|
|
42
|
+
|
|
43
|
+
const { readFileSync, existsSync } = require('node:fs');
|
|
44
|
+
|
|
45
|
+
const DEFAULT_GATE_CONFIG = Object.freeze({
|
|
46
|
+
K: 3,
|
|
47
|
+
M: 10,
|
|
48
|
+
stddev_threshold: 0.05,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const ALLOWED_SOURCES = ['fast', 'router', 'reflector_pattern'];
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Internal helpers
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Return a fresh source-count bucket.
|
|
58
|
+
*/
|
|
59
|
+
function emptySources() {
|
|
60
|
+
return { fast: 0, router: 0, reflector_pattern: 0 };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Iterate events from either an in-memory iterable or a JSONL path string.
|
|
65
|
+
* Yields parsed records; invalid JSON lines are skipped silently (matches
|
|
66
|
+
* `event-chain.cjs.readChain` and `event-stream/reader.ts.readEvents`).
|
|
67
|
+
*/
|
|
68
|
+
function* iterateRecords(eventsOrPath) {
|
|
69
|
+
if (eventsOrPath == null) return;
|
|
70
|
+
if (typeof eventsOrPath === 'string') {
|
|
71
|
+
if (!existsSync(eventsOrPath)) return;
|
|
72
|
+
const raw = readFileSync(eventsOrPath, 'utf8');
|
|
73
|
+
for (const line of raw.split('\n')) {
|
|
74
|
+
const trimmed = line.trim();
|
|
75
|
+
if (trimmed === '') continue;
|
|
76
|
+
try {
|
|
77
|
+
yield JSON.parse(trimmed);
|
|
78
|
+
} catch (_err) {
|
|
79
|
+
// Malformed JSON — skip (matches existing readers' tolerance).
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (typeof eventsOrPath[Symbol.iterator] === 'function' ||
|
|
85
|
+
typeof eventsOrPath[Symbol.asyncIterator] === 'function') {
|
|
86
|
+
for (const rec of eventsOrPath) {
|
|
87
|
+
if (rec != null) yield rec;
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// Unsupported input shape — yield nothing.
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Test whether a parsed record is a capability_gap event with a usable
|
|
96
|
+
* context_hash. Either the envelope `type` OR the chain-level `outcome`
|
|
97
|
+
* may carry the marker — `appendChainEvent` writes both.
|
|
98
|
+
*/
|
|
99
|
+
function isCapabilityGap(rec) {
|
|
100
|
+
if (rec == null || typeof rec !== 'object') return false;
|
|
101
|
+
const typeMatch = rec.type === 'capability_gap';
|
|
102
|
+
const outcomeMatch = rec.outcome === 'capability_gap';
|
|
103
|
+
if (!typeMatch && !outcomeMatch) return false;
|
|
104
|
+
const ctxHash = rec.payload && rec.payload.context_hash;
|
|
105
|
+
return typeof ctxHash === 'string' && ctxHash.length > 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Stringify an evidence_ref entry for the markdown example column. If it's
|
|
110
|
+
* already a string, return it; otherwise prefer `trajectory_path` and fall
|
|
111
|
+
* back to a JSON.stringify so the test can still match.
|
|
112
|
+
*/
|
|
113
|
+
function refToExample(ref) {
|
|
114
|
+
if (typeof ref === 'string') return ref;
|
|
115
|
+
if (ref && typeof ref === 'object') {
|
|
116
|
+
if (typeof ref.trajectory_path === 'string') return ref.trajectory_path;
|
|
117
|
+
try { return JSON.stringify(ref); } catch (_e) { return '[ref]'; }
|
|
118
|
+
}
|
|
119
|
+
return String(ref);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Closed-form posterior stddev of Beta(α, β):
|
|
124
|
+
* stddev = sqrt(αβ / ((α+β)^2 * (α+β+1)))
|
|
125
|
+
* No external math dependency. α + β > 0 (Laplace prior guarantees this).
|
|
126
|
+
*/
|
|
127
|
+
function betaStddev(alpha, beta) {
|
|
128
|
+
const sum = alpha + beta;
|
|
129
|
+
if (sum <= 0) return Infinity;
|
|
130
|
+
const variance = (alpha * beta) / (sum * sum * (sum + 1));
|
|
131
|
+
return Math.sqrt(variance);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Sanitize config — coerce to defaults if missing / invalid. Mirrors
|
|
136
|
+
* the trust-boundary mitigation in 29-03-PLAN.md threat T-29.03-02.
|
|
137
|
+
*/
|
|
138
|
+
function normalizeConfig(input) {
|
|
139
|
+
const out = { ...DEFAULT_GATE_CONFIG };
|
|
140
|
+
if (!input || typeof input !== 'object') return out;
|
|
141
|
+
if (Number.isInteger(input.K) && input.K > 0) out.K = input.K;
|
|
142
|
+
if (Number.isInteger(input.M) && input.M > 0) out.M = input.M;
|
|
143
|
+
if (typeof input.stddev_threshold === 'number'
|
|
144
|
+
&& Number.isFinite(input.stddev_threshold)
|
|
145
|
+
&& input.stddev_threshold > 0
|
|
146
|
+
&& input.stddev_threshold <= 1) {
|
|
147
|
+
out.stddev_threshold = input.stddev_threshold;
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Public API
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Aggregate capability_gap events into per-context_hash clusters.
|
|
157
|
+
*
|
|
158
|
+
* @param {Iterable<object> | string} eventsOrPath
|
|
159
|
+
* @param {{ exampleLimit?: number }} [opts]
|
|
160
|
+
* @returns {{ clusters: Array<{ id: string, size: number, sources: {fast:number,router:number,reflector_pattern:number}, examples: string[] }> }}
|
|
161
|
+
*/
|
|
162
|
+
function aggregateCapabilityGaps(eventsOrPath, opts = {}) {
|
|
163
|
+
const exampleLimit = Number.isInteger(opts.exampleLimit) && opts.exampleLimit > 0
|
|
164
|
+
? opts.exampleLimit : 3;
|
|
165
|
+
|
|
166
|
+
/** @type {Map<string, { id: string, size: number, sources: object, examples: string[], _hash: string }>} */
|
|
167
|
+
const byHash = new Map();
|
|
168
|
+
|
|
169
|
+
for (const rec of iterateRecords(eventsOrPath)) {
|
|
170
|
+
if (!isCapabilityGap(rec)) continue;
|
|
171
|
+
const payload = rec.payload;
|
|
172
|
+
const fullHash = payload.context_hash;
|
|
173
|
+
const id = fullHash.slice(0, 12);
|
|
174
|
+
let cluster = byHash.get(fullHash);
|
|
175
|
+
if (!cluster) {
|
|
176
|
+
cluster = { id, size: 0, sources: emptySources(), examples: [], _hash: fullHash };
|
|
177
|
+
byHash.set(fullHash, cluster);
|
|
178
|
+
}
|
|
179
|
+
cluster.size += 1;
|
|
180
|
+
const src = payload.source;
|
|
181
|
+
if (ALLOWED_SOURCES.includes(src)) {
|
|
182
|
+
cluster.sources[src] += 1;
|
|
183
|
+
}
|
|
184
|
+
if (Array.isArray(payload.evidence_refs)) {
|
|
185
|
+
for (const ref of payload.evidence_refs) {
|
|
186
|
+
if (cluster.examples.length >= exampleLimit) break;
|
|
187
|
+
cluster.examples.push(refToExample(ref));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const clusters = Array.from(byHash.values());
|
|
193
|
+
clusters.sort((a, b) => {
|
|
194
|
+
if (b.size !== a.size) return b.size - a.size;
|
|
195
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
196
|
+
});
|
|
197
|
+
// Strip internal full-hash field from output (not part of public Cluster shape).
|
|
198
|
+
for (const c of clusters) delete c._hash;
|
|
199
|
+
return { clusters };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Render the markdown section appended to a reflector cycle file.
|
|
204
|
+
* Returns '' when clusters is empty — caller appends unconditionally.
|
|
205
|
+
*
|
|
206
|
+
* @param {Array} clusters
|
|
207
|
+
* @returns {string}
|
|
208
|
+
*/
|
|
209
|
+
function renderGapsSection(clusters) {
|
|
210
|
+
if (!Array.isArray(clusters) || clusters.length === 0) return '';
|
|
211
|
+
const lines = [];
|
|
212
|
+
lines.push('## Capability gaps observed');
|
|
213
|
+
lines.push('');
|
|
214
|
+
lines.push('| Cluster | Size | fast | router | reflector_pattern | Example evidence |');
|
|
215
|
+
lines.push('|---|---|---|---|---|---|');
|
|
216
|
+
for (const c of clusters) {
|
|
217
|
+
const examples = (c.examples || [])
|
|
218
|
+
.slice(0, 3)
|
|
219
|
+
.map((e) => '`' + e + '`')
|
|
220
|
+
.join(', ');
|
|
221
|
+
lines.push(
|
|
222
|
+
`| \`${c.id}\` | ${c.size} | ${c.sources.fast || 0} | ${c.sources.router || 0} | ${c.sources.reflector_pattern || 0} | ${examples} |`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
lines.push('');
|
|
226
|
+
return lines.join('\n');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Evaluate the Stage-0 → Stage-1 gate per D-01 + D-03.
|
|
231
|
+
*
|
|
232
|
+
* @param {Array<{ cycle_slug: string, clusters: Array }>} history
|
|
233
|
+
* @param {{ K?: number, M?: number, stddev_threshold?: number }} [config]
|
|
234
|
+
* @returns {{ crossed: boolean, stable_cluster_ids: string[], cycles_observed: number }}
|
|
235
|
+
*/
|
|
236
|
+
function evaluateStageGate(history, config) {
|
|
237
|
+
const cfg = normalizeConfig(config);
|
|
238
|
+
if (!Array.isArray(history)) {
|
|
239
|
+
return { crossed: false, stable_cluster_ids: [], cycles_observed: 0 };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const cycles_observed = history.length;
|
|
243
|
+
|
|
244
|
+
// Need at least M cycles observed before we can evaluate stability.
|
|
245
|
+
if (cycles_observed < cfg.M) {
|
|
246
|
+
return { crossed: false, stable_cluster_ids: [], cycles_observed };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Stability checks per D-03:
|
|
250
|
+
// 1. Consecutive-presence: cluster appears in M consecutive cycles
|
|
251
|
+
// (the most recent run is the most reliable signal of "still here").
|
|
252
|
+
// 2. Posterior stddev: Beta(α, β) with α = appearances + 1,
|
|
253
|
+
// β = (cycles_observed - appearances) + 1 — Laplace prior matches
|
|
254
|
+
// Phase 23.5's bandit-router posterior store.
|
|
255
|
+
//
|
|
256
|
+
// Appearance counts use the FULL history (cycles_observed = history.length)
|
|
257
|
+
// so a cluster that has been present for many cycles accumulates evidence,
|
|
258
|
+
// even if it occasionally missed a cycle. The consecutive-presence check
|
|
259
|
+
// uses only the most recent run length (must be ≥ M).
|
|
260
|
+
|
|
261
|
+
/** @type {Map<string, number>} appearances across full history */
|
|
262
|
+
const appearances = new Map();
|
|
263
|
+
/** @type {Map<string, number>} most recent consecutive-presence run */
|
|
264
|
+
const currentRun = new Map();
|
|
265
|
+
/** @type {Map<string, number>} longest consecutive-presence run seen */
|
|
266
|
+
const maxConsecutive = new Map();
|
|
267
|
+
|
|
268
|
+
for (const cycle of history) {
|
|
269
|
+
const presentThisCycle = new Set();
|
|
270
|
+
if (Array.isArray(cycle.clusters)) {
|
|
271
|
+
for (const c of cycle.clusters) {
|
|
272
|
+
if (c && typeof c.id === 'string') {
|
|
273
|
+
presentThisCycle.add(c.id);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// Update appearance counts + consecutive runs for every id we've
|
|
278
|
+
// seen so far OR seen this cycle.
|
|
279
|
+
const allIds = new Set([...currentRun.keys(), ...presentThisCycle]);
|
|
280
|
+
for (const id of allIds) {
|
|
281
|
+
if (presentThisCycle.has(id)) {
|
|
282
|
+
appearances.set(id, (appearances.get(id) || 0) + 1);
|
|
283
|
+
const run = (currentRun.get(id) || 0) + 1;
|
|
284
|
+
currentRun.set(id, run);
|
|
285
|
+
if (run > (maxConsecutive.get(id) || 0)) {
|
|
286
|
+
maxConsecutive.set(id, run);
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
// Cluster missed this cycle — reset current run; max already captured.
|
|
290
|
+
currentRun.set(id, 0);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const stable_cluster_ids = [];
|
|
296
|
+
for (const [id, appCount] of appearances.entries()) {
|
|
297
|
+
const maxRun = maxConsecutive.get(id) || 0;
|
|
298
|
+
if (maxRun < cfg.M) continue; // must appear in M consecutive cycles
|
|
299
|
+
// Laplace prior (Phase 23.5): α = appearances+1, β = (cycles-appearances)+1
|
|
300
|
+
const alpha = appCount + 1;
|
|
301
|
+
const beta = (cycles_observed - appCount) + 1;
|
|
302
|
+
const sd = betaStddev(alpha, beta);
|
|
303
|
+
if (sd < cfg.stddev_threshold) {
|
|
304
|
+
stable_cluster_ids.push(id);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
stable_cluster_ids.sort();
|
|
309
|
+
const crossed = stable_cluster_ids.length >= cfg.K;
|
|
310
|
+
return { crossed, stable_cluster_ids, cycles_observed };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
module.exports = {
|
|
314
|
+
aggregateCapabilityGaps,
|
|
315
|
+
renderGapsSection,
|
|
316
|
+
evaluateStageGate,
|
|
317
|
+
// Exported for testing / introspection only:
|
|
318
|
+
_betaStddev: betaStddev,
|
|
319
|
+
_DEFAULT_GATE_CONFIG: DEFAULT_GATE_CONFIG,
|
|
320
|
+
};
|
|
@@ -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.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: apply-reflections-procedure
|
|
3
3
|
type: heuristic
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
phase: 28.5
|
|
6
|
-
tags: [apply-reflections, proposal, frontmatter, reference, budget, question, global-skill]
|
|
7
|
-
last_updated: 2026-05-
|
|
6
|
+
tags: [apply-reflections, proposal, frontmatter, reference, budget, question, global-skill, incubator]
|
|
7
|
+
last_updated: 2026-05-20
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Apply-Reflections — Per-Type Procedure
|
|
@@ -66,3 +66,71 @@ the proposal's bracketed type tag.
|
|
|
66
66
|
|
|
67
67
|
5. Print: "Global skill written to ~/.claude/gdd/global-skills/<name>.md — auto-loads in all future gdd sessions"
|
|
68
68
|
6. Append `**Applied**: <date>` to proposal in reflections file
|
|
69
|
+
|
|
70
|
+
### [INCUBATOR]
|
|
71
|
+
|
|
72
|
+
Incubator drafts come from `scripts/lib/incubator-author.cjs` (Phase 29-04). They live at
|
|
73
|
+
`.design/reflections/incubator/<slug>/` and contain `manifest.json` + `DRAFT.md` + (optional) `ORIGIN.md`.
|
|
74
|
+
|
|
75
|
+
Use `scripts/lib/apply-reflections/incubator-proposals.cjs` for all actions.
|
|
76
|
+
|
|
77
|
+
**Discovery + render** (once per cycle):
|
|
78
|
+
|
|
79
|
+
1. Call `discoverIncubatorDrafts()` → `Array<Draft>`. Skip malformed entries silently (already warned on stderr by the helper).
|
|
80
|
+
2. For each draft: call `renderProposal(draft)` and print the returned markdown block. The user sees a header (slug + kind), a diff vs the nearest existing artifact (or "net-new"), an Origin section listing capability-gap signals, and the full draft body.
|
|
81
|
+
3. Prompt: `(a) accept (r) reject (d) defer (e) edit (q) quit`.
|
|
82
|
+
|
|
83
|
+
**Per-action behavior:**
|
|
84
|
+
|
|
85
|
+
1. **accept** — call `applyAccept(draft, { registryPath, repoRoot })`.
|
|
86
|
+
- The helper calls `validateScope(draft.target_path)` from `scripts/validate-incubator-scope.cjs` **before** any write. Out-of-scope paths throw and the registry stays untouched. This is the non-bypassable scope guard (D-05).
|
|
87
|
+
- On success: target artifact written, `reference/registry.json` appended with `{ slug, path, added, origin: 'incubator' }`, incubator subdir removed last (T-29.05-04 — partial-failure leaves draft retryable).
|
|
88
|
+
- Print: "Accepted — promoted to <target_path>; registered."
|
|
89
|
+
- Append `**Applied**: <date>` to the proposal block.
|
|
90
|
+
|
|
91
|
+
2. **reject** — call `applyReject(draft)`. Only the incubator subdir is removed; registry is untouched. Append `**Reviewed: rejected**` to the reflections file.
|
|
92
|
+
|
|
93
|
+
3. **defer** — no-op. Print "Deferred — draft re-surfaces next run." Append `**Reviewed: deferred**`.
|
|
94
|
+
|
|
95
|
+
4. **edit** — call `applyEdit(draft)` (uses `$EDITOR` or the `editorCmd` array option). On clean exit, the helper reloads the draft and the caller re-runs `renderProposal` + the prompt. On non-zero exit, the original draft is preserved unchanged.
|
|
96
|
+
|
|
97
|
+
**Stage-1 gate (D-01 — no auto-flip):**
|
|
98
|
+
|
|
99
|
+
1. At the start of the cycle, call `checkStage1Gate({ gateSpecPath, statePath, registryPath })`. The call is **read-only** — never mutates state. The returned `{ thresholdMet, summary, optInRecorded }` is informational.
|
|
100
|
+
2. If `thresholdMet && !optInRecorded`, surface a one-time prompt:
|
|
101
|
+
```
|
|
102
|
+
Stage-1 capability-gap authoring threshold met: <summary>
|
|
103
|
+
Enable incubator-draft promotion? (y/N)
|
|
104
|
+
```
|
|
105
|
+
3. **Only on explicit `y`**, call `recordOptIn({ statePath, confirmedBy })`. The function is idempotent — a second call detects the existing record and returns `{ alreadyRecorded: true }`. Never call it on any other input.
|
|
106
|
+
|
|
107
|
+
**Why this is gated.** The `[INCUBATOR]` proposal class can write executable surface (agents + skills) into the plugin runtime. Both Phase 29 D-01 (no auto-flip) and D-05 (scope guard) exist because that surface has integration-test and security implications that exceed reflector autonomy. `validateScope` keeps the file landing zone confined to `agents/<slug>.md` or `skills/<slug>/SKILL.md`. The Stage-1 gate keeps the *whether* of opting in to incubator authoring under explicit user control even after the data threshold says we have enough signal.
|
|
108
|
+
|
|
109
|
+
**Bandit-fairness gate on `accept` (Phase 29 Plan 06 / CONTEXT D-04).**
|
|
110
|
+
|
|
111
|
+
When the `accept` action promotes an incubator draft, the bandit-router arms for the freshly-promoted agent/skill MUST be bootstrapped with `prior_class: 'promoted_incubator'`. This invokes a conservative `Beta(2, 8)` bootstrap prior (posterior mean 0.2) instead of the optimistic Phase 23.5 informed prior — the bandit-fairness gate IS the staging mechanism (D-04: no separate two-step ratify split). The conservative prior suppresses preferential selection until ~8-10 successful pulls accumulate.
|
|
112
|
+
|
|
113
|
+
Call shape (whether eagerly invoked on promotion or via first-pull lazy bootstrap):
|
|
114
|
+
|
|
115
|
+
```javascript
|
|
116
|
+
const bandit = require('./scripts/lib/bandit-router.cjs');
|
|
117
|
+
// Per arm bootstrapped for the freshly-promoted agent:
|
|
118
|
+
bandit.update({
|
|
119
|
+
agent: '<promoted-slug>',
|
|
120
|
+
bin: '<touches-bin>',
|
|
121
|
+
tier: '<chosen-tier>',
|
|
122
|
+
reward: <bernoulli>,
|
|
123
|
+
prior_class: 'promoted_incubator', // Phase 29 Plan 06 / D-04 — Beta(2,8) staging
|
|
124
|
+
});
|
|
125
|
+
// Or for the delegate-aware case (Plan 27-07):
|
|
126
|
+
bandit.updateWithDelegate({
|
|
127
|
+
agent: '<promoted-slug>',
|
|
128
|
+
bin: '<touches-bin>',
|
|
129
|
+
tier: '<chosen-tier>',
|
|
130
|
+
delegate: '<peer-cli-or-none>',
|
|
131
|
+
reward: <bernoulli>,
|
|
132
|
+
prior_class: 'promoted_incubator',
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Omitting `prior_class` reverts to Phase 23.5 informed-prior bootstrap (non-breaking). The reward math is unchanged — `prior_class` only affects bootstrap.
|
package/skills/fast/SKILL.md
CHANGED
|
@@ -41,5 +41,51 @@ The leanest possible execution path. No subagents, no STATE.md update, no DESIGN
|
|
|
41
41
|
- No STATE.md mutation.
|
|
42
42
|
- No pipeline stage invocation.
|
|
43
43
|
- Do not proceed if the change turns out to be non-trivial — bail out and recommend `/gdd:quick` or the full pipeline.
|
|
44
|
+
- Do not skip the `capability_gap` emit on bail-out — Stage-0 telemetry depends on it (Phase 29 D-01).
|
|
45
|
+
|
|
46
|
+
## Emitting capability_gap on no-skill-match
|
|
47
|
+
|
|
48
|
+
If step 2 cannot locate any candidate files for the task description (or all candidates are filtered out as off-topic), or if the change in step 4 turns out to be non-trivial in a way that has no obvious resolution without a dedicated skill/agent, emit ONE `capability_gap` event before returning control to the user. This feeds Phase 29 Stage-0 telemetry — the reflector pattern-detection pass (Plan 29-02) and aggregation (Plan 29-03) read these events from the chain file (`.design/gep/events.jsonl`) to surface recurring capability gaps in `/gdd:apply-reflections`.
|
|
49
|
+
|
|
50
|
+
Synchronous emitter call (via Bash):
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
node -e '
|
|
54
|
+
const { appendChainEvent } = require("./scripts/lib/event-chain.cjs");
|
|
55
|
+
const { createHash, randomUUID } = require("node:crypto");
|
|
56
|
+
const intent = process.env.GDD_INTENT || "";
|
|
57
|
+
const payload = {
|
|
58
|
+
event_id: randomUUID(),
|
|
59
|
+
parent_event_id: null,
|
|
60
|
+
source: "fast",
|
|
61
|
+
context_hash: createHash("sha256").update(intent).digest("hex"),
|
|
62
|
+
intent_summary: intent.slice(0, 256),
|
|
63
|
+
suggested_kind: "skill",
|
|
64
|
+
evidence_refs: [],
|
|
65
|
+
};
|
|
66
|
+
appendChainEvent({
|
|
67
|
+
agent: "fast",
|
|
68
|
+
outcome: "capability_gap",
|
|
69
|
+
payload,
|
|
70
|
+
type: "capability_gap",
|
|
71
|
+
timestamp: new Date().toISOString(),
|
|
72
|
+
sessionId: process.env.GDD_SESSION_ID || "fast-cli",
|
|
73
|
+
});
|
|
74
|
+
'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Notes:
|
|
78
|
+
- `evidence_refs` is empty `[]` for fast (no trajectory in `/gdd:fast` — that path is too lean by design).
|
|
79
|
+
- `parent_event_id` is null (root event for the fast bail-out).
|
|
80
|
+
- `suggested_kind` is `"skill"` because fast bail-outs are usually narrow primitives, not multi-step workflows. Plan 29-03's aggregator may upgrade to `"agent"` if a `context_hash` cluster shows multi-step usage.
|
|
81
|
+
- The emitter MUST NOT block — `appendChainEvent` already swallows IO errors via its existing try/catch (see `scripts/lib/event-chain.cjs:97-105`).
|
|
82
|
+
- The 7-field payload is preserved verbatim through `appendChainEvent`'s opaque-extras pattern; the chain row carries `type`, `timestamp`, `sessionId`, `payload` as opaque caller-supplied fields and is projected back to the events-schema envelope shape by readers (Plan 29-03 aggregator).
|
|
83
|
+
|
|
84
|
+
Trigger conditions:
|
|
85
|
+
- **Trigger 1**: Step 2 returns zero candidate files for the task description (target is genuinely missing).
|
|
86
|
+
- **Trigger 2**: Step 2 returns more than 2 candidates AND the user bail-out in step 2 fires (stop and ask).
|
|
87
|
+
- **Trigger 3**: Step 4 reveals the change is non-trivial in a way that has no obvious primitive resolution (the `## Do Not` "Do not proceed" line fires).
|
|
88
|
+
|
|
89
|
+
MCP-probe failures (connection down, transport-layer errors) do NOT emit `capability_gap` — those are Phase 22 connection-status concerns (D-08).
|
|
44
90
|
|
|
45
91
|
## FAST COMPLETE
|