@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,448 @@
|
|
|
1
|
+
// scripts/lib/apply-reflections/incubator-proposals.cjs — Plan 29-05
|
|
2
|
+
//
|
|
3
|
+
// Incubator-draft proposal class for /gdd:apply-reflections. Consumes drafts
|
|
4
|
+
// authored by scripts/lib/incubator-author.cjs (Plan 29-04) at
|
|
5
|
+
// `.design/reflections/incubator/<slug>/` and exposes the 7 actions surfaced
|
|
6
|
+
// in skills/apply-reflections/SKILL.md.
|
|
7
|
+
//
|
|
8
|
+
// Exports: discoverIncubatorDrafts, renderProposal, applyAccept, applyReject,
|
|
9
|
+
// applyEdit, checkStage1Gate, recordOptIn.
|
|
10
|
+
//
|
|
11
|
+
// Decisions honoured:
|
|
12
|
+
// * D-01 — checkStage1Gate is read-only; recordOptIn is the sole writer and
|
|
13
|
+
// only fires on explicit user confirmation. No auto-flip ever.
|
|
14
|
+
// * D-04 — applyAccept performs the full draft → final-artifact write +
|
|
15
|
+
// registry append in one call. No intermediate state.
|
|
16
|
+
// * D-05 — applyAccept calls validateScope from
|
|
17
|
+
// scripts/validate-incubator-scope.cjs BEFORE any filesystem
|
|
18
|
+
// mutation. Failure throws; registry and incubator subdir
|
|
19
|
+
// untouched. Non-bypassable.
|
|
20
|
+
// * D-12 — DRAFT.md is copied verbatim, so the drafter's `delegate_to: null`
|
|
21
|
+
// frontmatter survives the promotion.
|
|
22
|
+
//
|
|
23
|
+
// Style: CommonJS, zero external deps (node:fs / node:path / node:child_process /
|
|
24
|
+
// node:os only).
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
const child_process = require('node:child_process');
|
|
31
|
+
const os = require('node:os');
|
|
32
|
+
|
|
33
|
+
const { validateScope } = require('../../validate-incubator-scope.cjs');
|
|
34
|
+
|
|
35
|
+
// --- Constants ---
|
|
36
|
+
|
|
37
|
+
const DEFAULT_INCUBATOR_DIR = '.design/reflections/incubator';
|
|
38
|
+
const DEFAULT_REGISTRY_PATH = 'reference/registry.json';
|
|
39
|
+
const DEFAULT_GATE_SPEC_PATH = 'reference/capability-gap-stage-gate.md';
|
|
40
|
+
const DEFAULT_STATE_PATH = '.planning/STATE.md';
|
|
41
|
+
const OPT_IN_HEADING = '## Capability-gap Stage-1 opt-in';
|
|
42
|
+
const OPT_IN_TOKEN_RE = /Stage-1 opt-in|capability.gap.*opt.in|confirmed_by/i;
|
|
43
|
+
|
|
44
|
+
// --- Helpers ---
|
|
45
|
+
|
|
46
|
+
function safeReadFileSync(p) {
|
|
47
|
+
try {
|
|
48
|
+
return fs.readFileSync(p, 'utf8');
|
|
49
|
+
} catch (_) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function warn(msg) {
|
|
55
|
+
// Single-line stderr warning. Keeps SKILL.md UX clean.
|
|
56
|
+
process.stderr.write(`[incubator-proposals] WARN: ${msg}\n`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function quoteArg(s) {
|
|
60
|
+
// Cross-platform quote: wrap in double quotes and escape embedded ones.
|
|
61
|
+
// Sufficient for tmpdir paths (no real shell metachars expected).
|
|
62
|
+
return `"${String(s).replace(/"/g, '\\"')}"`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- discoverIncubatorDrafts ---
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Walk the incubator directory and return one Draft per valid slug. Malformed
|
|
69
|
+
* subdirs (missing/unparseable manifest, missing DRAFT.md) are skipped with a
|
|
70
|
+
* stderr warning; never throws.
|
|
71
|
+
*/
|
|
72
|
+
function discoverIncubatorDrafts(options) {
|
|
73
|
+
const o = options || {};
|
|
74
|
+
const incubatorDir = o.incubatorDir || DEFAULT_INCUBATOR_DIR;
|
|
75
|
+
if (!fs.existsSync(incubatorDir)) {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let entries;
|
|
80
|
+
try {
|
|
81
|
+
entries = fs.readdirSync(incubatorDir, { withFileTypes: true });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
warn(`cannot read incubator dir ${incubatorDir}: ${err.message}`);
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const drafts = [];
|
|
88
|
+
for (const ent of entries) {
|
|
89
|
+
if (!ent.isDirectory()) continue;
|
|
90
|
+
if (ent.name === 'archive') continue; // D-06: archived drafts not surfaced
|
|
91
|
+
|
|
92
|
+
const slugDir = path.join(incubatorDir, ent.name);
|
|
93
|
+
const manifestPath = path.join(slugDir, 'manifest.json');
|
|
94
|
+
const draftPath = path.join(slugDir, 'DRAFT.md');
|
|
95
|
+
const originPath = path.join(slugDir, 'ORIGIN.md');
|
|
96
|
+
|
|
97
|
+
if (!fs.existsSync(manifestPath)) {
|
|
98
|
+
warn(`skip ${slugDir}: missing manifest.json`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (!fs.existsSync(draftPath)) {
|
|
102
|
+
warn(`skip ${slugDir}: missing DRAFT.md`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
let manifest;
|
|
106
|
+
try {
|
|
107
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
108
|
+
} catch (err) {
|
|
109
|
+
warn(`skip ${slugDir}: manifest.json parse error: ${err.message}`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!manifest || typeof manifest !== 'object' || !manifest.slug || !manifest.kind || !manifest.target_path) {
|
|
113
|
+
warn(`skip ${slugDir}: manifest missing required fields (slug/kind/target_path)`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
drafts.push({
|
|
118
|
+
slug: manifest.slug,
|
|
119
|
+
kind: manifest.kind,
|
|
120
|
+
target_path: manifest.target_path,
|
|
121
|
+
draft_path: draftPath,
|
|
122
|
+
origin_path: fs.existsSync(originPath) ? originPath : null,
|
|
123
|
+
manifest,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Deterministic ordering by slug ascending — matches incubator-author.cjs style.
|
|
128
|
+
drafts.sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
|
|
129
|
+
return drafts;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- renderProposal ---
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Render a draft as markdown: header (slug + kind), diff vs nearest existing
|
|
136
|
+
* artifact (or "net-new"), Origin section, full draft body.
|
|
137
|
+
*/
|
|
138
|
+
function renderProposal(draft, options) {
|
|
139
|
+
const o = options || {};
|
|
140
|
+
const resolver = typeof o.existingArtifactResolver === 'function' ? o.existingArtifactResolver : () => null;
|
|
141
|
+
|
|
142
|
+
const body = safeReadFileSync(draft.draft_path) || '';
|
|
143
|
+
const origin = draft.origin_path ? safeReadFileSync(draft.origin_path) : null;
|
|
144
|
+
|
|
145
|
+
const existing = resolver(draft.target_path);
|
|
146
|
+
let diffSection;
|
|
147
|
+
if (existing == null) {
|
|
148
|
+
diffSection = `### Diff vs existing\n\nNo existing artifact — net-new proposal.\n`;
|
|
149
|
+
} else {
|
|
150
|
+
diffSection = `### Diff vs existing\n\n\`\`\`diff\n--- ${draft.target_path} (existing)\n+++ ${draft.target_path} (proposed)\n${renderUnifiedDiff(existing, body)}\n\`\`\`\n`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const originSection = origin
|
|
154
|
+
? `## Origin\n\n${origin.trim()}\n`
|
|
155
|
+
: `## Origin\n\n(no ORIGIN.md found in incubator subdir)\n`;
|
|
156
|
+
|
|
157
|
+
return [
|
|
158
|
+
`## Proposal — ${draft.slug} (${draft.kind})`,
|
|
159
|
+
`Target: \`${draft.target_path}\``,
|
|
160
|
+
'',
|
|
161
|
+
diffSection,
|
|
162
|
+
originSection,
|
|
163
|
+
'### Draft body',
|
|
164
|
+
'',
|
|
165
|
+
body.trim(),
|
|
166
|
+
'',
|
|
167
|
+
].join('\n');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Minimal unified-diff renderer (line-level, no LCS) for human review only.
|
|
172
|
+
* Not for round-trip patch application — that would need an actual diff
|
|
173
|
+
* library, which we deliberately avoid to keep deps at zero.
|
|
174
|
+
*/
|
|
175
|
+
function renderUnifiedDiff(oldText, newText) {
|
|
176
|
+
const oldLines = (oldText || '').split('\n');
|
|
177
|
+
const newLines = (newText || '').split('\n');
|
|
178
|
+
// Cheap diff: emit `-` for old lines absent in new, `+` for new lines
|
|
179
|
+
// absent in old, ` ` for shared lines. Order: all `-`, then all `+`.
|
|
180
|
+
const oldSet = new Set(oldLines);
|
|
181
|
+
const newSet = new Set(newLines);
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const ln of oldLines) {
|
|
184
|
+
if (!newSet.has(ln)) out.push(`-${ln}`);
|
|
185
|
+
}
|
|
186
|
+
for (const ln of newLines) {
|
|
187
|
+
if (!oldSet.has(ln)) out.push(`+${ln}`);
|
|
188
|
+
}
|
|
189
|
+
if (out.length === 0) {
|
|
190
|
+
return '(no line-level differences)';
|
|
191
|
+
}
|
|
192
|
+
return out.join('\n');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- applyAccept (D-04 + D-05) ---
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Promote draft → final artifact + registry entry in one call (D-04).
|
|
199
|
+
*
|
|
200
|
+
* Order: validateScope (D-05; throws → no writes) → read DRAFT.md →
|
|
201
|
+
* [dryRun: return intent] → mkdirp parent → atomic-write target →
|
|
202
|
+
* append-and-atomic-write registry → fs.rm incubator subdir last
|
|
203
|
+
* (so partial failure leaves draft retryable — T-29.05-04).
|
|
204
|
+
*/
|
|
205
|
+
function applyAccept(draft, options) {
|
|
206
|
+
const o = options || {};
|
|
207
|
+
const repoRoot = o.repoRoot || process.cwd();
|
|
208
|
+
const registryPath = path.isAbsolute(o.registryPath || '')
|
|
209
|
+
? o.registryPath
|
|
210
|
+
: path.join(repoRoot, o.registryPath || DEFAULT_REGISTRY_PATH);
|
|
211
|
+
const dryRun = !!o.dryRun;
|
|
212
|
+
|
|
213
|
+
// Step 1 — D-05 scope guard. THROWS on failure; registry untouched.
|
|
214
|
+
validateScope(draft.target_path, { repoRoot });
|
|
215
|
+
|
|
216
|
+
const draftBody = fs.readFileSync(draft.draft_path, 'utf8');
|
|
217
|
+
const targetAbs = path.resolve(repoRoot, draft.target_path);
|
|
218
|
+
|
|
219
|
+
const registryEntry = {
|
|
220
|
+
slug: draft.slug,
|
|
221
|
+
path: draft.target_path.replace(/\\/g, '/'),
|
|
222
|
+
added: new Date().toISOString(),
|
|
223
|
+
origin: 'incubator',
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
if (dryRun) {
|
|
227
|
+
return {
|
|
228
|
+
wouldWrite: draft.target_path.replace(/\\/g, '/'),
|
|
229
|
+
wouldRegister: registryEntry,
|
|
230
|
+
kind: draft.kind,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Step 4 — mkdirp parent
|
|
235
|
+
fs.mkdirSync(path.dirname(targetAbs), { recursive: true });
|
|
236
|
+
|
|
237
|
+
// Step 5 — atomic write of target file
|
|
238
|
+
atomicWriteFileSync(targetAbs, draftBody);
|
|
239
|
+
|
|
240
|
+
// Step 6 — append registry entry
|
|
241
|
+
appendRegistryEntry(registryPath, draft.kind, registryEntry);
|
|
242
|
+
|
|
243
|
+
// Step 7 — remove incubator subdir last (partial-failure rollback safety)
|
|
244
|
+
const slugDir = path.dirname(path.resolve(draft.draft_path));
|
|
245
|
+
fs.rmSync(slugDir, { recursive: true, force: true });
|
|
246
|
+
|
|
247
|
+
return { accepted: true, path: draft.target_path.replace(/\\/g, '/') };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function atomicWriteFileSync(targetAbs, body) {
|
|
251
|
+
const tmp = `${targetAbs}.tmp-${process.pid}-${Date.now()}`;
|
|
252
|
+
fs.writeFileSync(tmp, body);
|
|
253
|
+
fs.renameSync(tmp, targetAbs);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function appendRegistryEntry(registryPath, kind, entry) {
|
|
257
|
+
let registry;
|
|
258
|
+
if (fs.existsSync(registryPath)) {
|
|
259
|
+
try {
|
|
260
|
+
registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
261
|
+
} catch (err) {
|
|
262
|
+
throw new Error(`[incubator-proposals] registry parse error at ${registryPath}: ${err.message}`);
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
registry = { agents: [], skills: [] };
|
|
266
|
+
}
|
|
267
|
+
if (!registry || typeof registry !== 'object') {
|
|
268
|
+
throw new Error(`[incubator-proposals] registry root must be an object: ${registryPath}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Phase 14.5 self-authoring shape: { agents: [...], skills: [...] }.
|
|
272
|
+
// Initialize missing arrays additively so we never clobber another schema's data.
|
|
273
|
+
if (kind === 'agent') {
|
|
274
|
+
if (!Array.isArray(registry.agents)) registry.agents = [];
|
|
275
|
+
registry.agents.push(entry);
|
|
276
|
+
} else if (kind === 'skill') {
|
|
277
|
+
if (!Array.isArray(registry.skills)) registry.skills = [];
|
|
278
|
+
registry.skills.push(entry);
|
|
279
|
+
} else {
|
|
280
|
+
throw new Error(`[incubator-proposals] unknown kind: ${kind} (expected 'agent' or 'skill')`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
atomicWriteFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// --- applyReject ---
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Remove the incubator subdir for this draft. Registry untouched.
|
|
290
|
+
*
|
|
291
|
+
* @param {object} draft
|
|
292
|
+
* @returns {{rejected:true, slug:string}}
|
|
293
|
+
*/
|
|
294
|
+
function applyReject(draft) {
|
|
295
|
+
const slugDir = path.dirname(path.resolve(draft.draft_path));
|
|
296
|
+
fs.rmSync(slugDir, { recursive: true, force: true });
|
|
297
|
+
return { rejected: true, slug: draft.slug };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// --- applyEdit ---
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Open the user's editor ($EDITOR / editorEnv / 'vi' fallback) on a temp copy
|
|
304
|
+
* of DRAFT.md. On exit-0, copy edits back and return the reloaded draft. On
|
|
305
|
+
* non-zero exit, return {edited:false, reason}. editorEnv may include args
|
|
306
|
+
* (split on whitespace, e.g. "node /path/to/mock-editor.cjs").
|
|
307
|
+
*/
|
|
308
|
+
function applyEdit(draft, options) {
|
|
309
|
+
const o = options || {};
|
|
310
|
+
const editorEnv = o.editorEnv || process.env.EDITOR || 'vi';
|
|
311
|
+
|
|
312
|
+
// Write a temp copy
|
|
313
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'incu-edit-'));
|
|
314
|
+
const tmpFile = path.join(tmpDir, path.basename(draft.draft_path));
|
|
315
|
+
fs.copyFileSync(draft.draft_path, tmpFile);
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
// Two invocation modes:
|
|
319
|
+
// options.editorCmd: [exec, ...args] -- no shell, fully tokenized
|
|
320
|
+
// options.editorEnv: shell command line (default: $EDITOR or 'vi')
|
|
321
|
+
// The array form avoids shell quoting headaches for Windows editor paths
|
|
322
|
+
// that contain spaces (e.g. node.exe installed under a Program-Files
|
|
323
|
+
// location) in tests.
|
|
324
|
+
let r;
|
|
325
|
+
if (Array.isArray(o.editorCmd) && o.editorCmd.length) {
|
|
326
|
+
const [cmd, ...args] = o.editorCmd;
|
|
327
|
+
r = child_process.spawnSync(cmd, args.concat([tmpFile]), { stdio: 'inherit' });
|
|
328
|
+
} else {
|
|
329
|
+
const cmdline = `${editorEnv} ${quoteArg(tmpFile)}`;
|
|
330
|
+
r = child_process.spawnSync(cmdline, { stdio: 'inherit', shell: true });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (r.status !== 0) {
|
|
334
|
+
return { edited: false, reason: 'editor_aborted', exit_code: r.status };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Copy edited tmp back over the draft
|
|
338
|
+
fs.copyFileSync(tmpFile, draft.draft_path);
|
|
339
|
+
|
|
340
|
+
// Reload the draft so target_path / manifest re-sync if anything in
|
|
341
|
+
// the editor changed (manifest itself is not edited here, but the body
|
|
342
|
+
// may have changed). discoverIncubatorDrafts re-reads manifest.json.
|
|
343
|
+
const incubatorDir = path.dirname(path.dirname(path.resolve(draft.draft_path)));
|
|
344
|
+
const all = discoverIncubatorDrafts({ incubatorDir });
|
|
345
|
+
const reloaded = all.find((d) => d.slug === draft.slug);
|
|
346
|
+
return reloaded || { edited: false, reason: 'draft_vanished_post_edit' };
|
|
347
|
+
} finally {
|
|
348
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// --- checkStage1Gate (D-01: read-only) ---
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Read-only Stage-1 gate inspection (D-01).
|
|
356
|
+
* thresholdMet = count(registry entries with origin === 'incubator') ≥ K
|
|
357
|
+
* optInRecorded = state file contains an opt-in token
|
|
358
|
+
* summary = human-readable one-liner
|
|
359
|
+
* Reads only — never writes. Surfacing the threshold is a prompt, not a flip.
|
|
360
|
+
*/
|
|
361
|
+
function checkStage1Gate(options) {
|
|
362
|
+
const o = options || {};
|
|
363
|
+
const gateSpecPath = o.gateSpecPath || DEFAULT_GATE_SPEC_PATH;
|
|
364
|
+
const statePath = o.statePath || DEFAULT_STATE_PATH;
|
|
365
|
+
const registryPath = o.registryPath || DEFAULT_REGISTRY_PATH;
|
|
366
|
+
|
|
367
|
+
const K = readK(gateSpecPath);
|
|
368
|
+
|
|
369
|
+
let acceptedCount = 0;
|
|
370
|
+
const regSrc = safeReadFileSync(registryPath);
|
|
371
|
+
if (regSrc) {
|
|
372
|
+
try {
|
|
373
|
+
const reg = JSON.parse(regSrc);
|
|
374
|
+
const skills = Array.isArray(reg.skills) ? reg.skills : [];
|
|
375
|
+
const agents = Array.isArray(reg.agents) ? reg.agents : [];
|
|
376
|
+
for (const e of skills.concat(agents)) {
|
|
377
|
+
if (e && e.origin === 'incubator') acceptedCount += 1;
|
|
378
|
+
}
|
|
379
|
+
} catch (_) {
|
|
380
|
+
// Malformed registry — treat as zero accepted; do not throw.
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const thresholdMet = acceptedCount >= K;
|
|
385
|
+
|
|
386
|
+
const stateSrc = safeReadFileSync(statePath) || '';
|
|
387
|
+
const optInRecorded = OPT_IN_TOKEN_RE.test(stateSrc);
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
thresholdMet,
|
|
391
|
+
summary: `${acceptedCount} of ${K} incubator-origin entries accepted` +
|
|
392
|
+
(thresholdMet ? ' (Stage-1 gate met)' : ' (Stage-1 gate not yet met)'),
|
|
393
|
+
optInRecorded,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Pull `K` out of capability-gap-stage-gate.md. The doc encodes K as a row
|
|
399
|
+
* in a markdown table: `| K | 3 | Minimum number of stable clusters... |`.
|
|
400
|
+
* If absent or unparseable, fall back to 3 (Phase 29 D-03 default).
|
|
401
|
+
*/
|
|
402
|
+
function readK(gateSpecPath) {
|
|
403
|
+
const src = safeReadFileSync(gateSpecPath);
|
|
404
|
+
if (!src) return 3;
|
|
405
|
+
const m = src.match(/\|\s*`?K`?\s*\|\s*`?(\d+)`?\s*\|/);
|
|
406
|
+
if (!m) return 3;
|
|
407
|
+
const v = parseInt(m[1], 10);
|
|
408
|
+
return Number.isFinite(v) && v > 0 ? v : 3;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// --- recordOptIn (D-01: explicit-only) ---
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Persist the user's explicit Stage-1 opt-in to STATE.md. Idempotent.
|
|
415
|
+
* IMPORTANT: this is the SOLE state writer in this module. Only invoke after
|
|
416
|
+
* explicit user confirmation in the apply-reflections UX (D-01).
|
|
417
|
+
*/
|
|
418
|
+
function recordOptIn(options) {
|
|
419
|
+
const o = options || {};
|
|
420
|
+
const statePath = o.statePath || DEFAULT_STATE_PATH;
|
|
421
|
+
const confirmedBy = o.confirmedBy || 'user';
|
|
422
|
+
|
|
423
|
+
const existing = safeReadFileSync(statePath) || '';
|
|
424
|
+
if (OPT_IN_TOKEN_RE.test(existing)) {
|
|
425
|
+
return { alreadyRecorded: true };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const at = new Date().toISOString();
|
|
429
|
+
const block =
|
|
430
|
+
`\n${OPT_IN_HEADING}\n\n` +
|
|
431
|
+
`- recorded_at: ${at}\n` +
|
|
432
|
+
`- confirmed_by: ${confirmedBy}\n`;
|
|
433
|
+
const next = existing + (existing.endsWith('\n') ? '' : '\n') + block;
|
|
434
|
+
atomicWriteFileSync(statePath, next);
|
|
435
|
+
return { optInRecorded: true, at, confirmedBy };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// --- Exports ---
|
|
439
|
+
|
|
440
|
+
module.exports = {
|
|
441
|
+
discoverIncubatorDrafts,
|
|
442
|
+
renderProposal,
|
|
443
|
+
applyAccept,
|
|
444
|
+
applyReject,
|
|
445
|
+
applyEdit,
|
|
446
|
+
checkStage1Gate,
|
|
447
|
+
recordOptIn,
|
|
448
|
+
};
|
|
@@ -26,6 +26,18 @@
|
|
|
26
26
|
* neutral — the same TIER_PRIOR shape, on the assumption that we
|
|
27
27
|
* have no prior to favour any delegate over local; data drives.
|
|
28
28
|
*
|
|
29
|
+
* Bootstrap discipline (Phase 29 Plan 06 / CONTEXT D-04):
|
|
30
|
+
* - Default `prior_class` (omitted or 'default'): existing informed
|
|
31
|
+
* TIER_PRIOR bootstrap (Phase 23.5) — byte-for-byte unchanged.
|
|
32
|
+
* - `prior_class: 'promoted_incubator'`: Beta(2, 8) bootstrap for
|
|
33
|
+
* arms registered when `/gdd:apply-reflections accept` promotes
|
|
34
|
+
* an incubator draft. The conservative prior (posterior mean 0.2)
|
|
35
|
+
* suppresses preferential selection until ~8-10 successful pulls
|
|
36
|
+
* accumulate. The bandit-fairness gate IS the promotion staging
|
|
37
|
+
* mechanism (D-04: no two-step staging/ratify split).
|
|
38
|
+
* - The `prior_class` value is persisted on the arm so subsequent
|
|
39
|
+
* reads + decay calculations preserve it (forward-compat).
|
|
40
|
+
*
|
|
29
41
|
* Atomic .tmp + rename. Discounted Thompson via per-arm time-decay
|
|
30
42
|
* factor `rho^days_since_last_use` applied at sample time, not stored.
|
|
31
43
|
*
|
|
@@ -59,6 +71,14 @@ const TIER_PRIOR = Object.freeze({
|
|
|
59
71
|
const PRIOR_STRENGTH = 10;
|
|
60
72
|
const DEFAULT_TIERS = Object.freeze(['haiku', 'sonnet', 'opus']);
|
|
61
73
|
|
|
74
|
+
// Phase 29 Plan 06 / CONTEXT D-04. Conservative prior for arms
|
|
75
|
+
// bootstrapped via `/gdd:apply-reflections accept` (incubator → live
|
|
76
|
+
// agent/skill). Beta(2, 8) — posterior mean 0.2 — suppresses
|
|
77
|
+
// preferential selection until ~8-10 successful pulls accumulate.
|
|
78
|
+
// The bandit-fairness gate IS the staging mechanism (D-04: no
|
|
79
|
+
// two-step staging/ratify split).
|
|
80
|
+
const PROMOTED_INCUBATOR_PRIOR = Object.freeze({ alpha: 2, beta: 8 });
|
|
81
|
+
|
|
62
82
|
// Plan 27-07 / D-08. Delegate context dimension. 'none' = local Anthropic
|
|
63
83
|
// call; the other 5 are peer-CLI delegations via ACP/ASP. Adding this as
|
|
64
84
|
// a third context dimension expands the arm space 6× (78 → ~468 contexts).
|
|
@@ -158,7 +178,26 @@ function reset(opts = {}) {
|
|
|
158
178
|
return { deleted: existed, path: p, reason: opts.reason };
|
|
159
179
|
}
|
|
160
180
|
|
|
161
|
-
|
|
181
|
+
/**
|
|
182
|
+
* Compute the bootstrap prior for a freshly-created arm.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} tier
|
|
185
|
+
* @param {number} strength
|
|
186
|
+
* @param {string} [prior_class] — 'default' (existing behaviour, omittable)
|
|
187
|
+
* or 'promoted_incubator' (Beta(2,8) bootstrap per Phase 29 Plan 06 /
|
|
188
|
+
* CONTEXT D-04). The promoted-incubator class is tier-independent —
|
|
189
|
+
* the conservative suppression applies uniformly across haiku/sonnet/
|
|
190
|
+
* opus until evidence accumulates.
|
|
191
|
+
* @returns {{alpha: number, beta: number}}
|
|
192
|
+
*/
|
|
193
|
+
function priorFor(tier, strength, prior_class) {
|
|
194
|
+
if (prior_class === 'promoted_incubator') {
|
|
195
|
+
return {
|
|
196
|
+
alpha: PROMOTED_INCUBATOR_PRIOR.alpha,
|
|
197
|
+
beta: PROMOTED_INCUBATOR_PRIOR.beta,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
// Default-path (Phase 23.5) — byte-for-byte unchanged.
|
|
162
201
|
const prior = TIER_PRIOR[tier];
|
|
163
202
|
if (prior === undefined) {
|
|
164
203
|
return { alpha: strength / 2, beta: strength / 2 };
|
|
@@ -207,11 +246,17 @@ function findArm(arms, agent, bin, tier, delegate) {
|
|
|
207
246
|
* 23.5 prior — no migration needed because the legacy slice and the
|
|
208
247
|
* 'none' slice are independent contexts) and for the 5 peer delegates
|
|
209
248
|
* (each starts neutral with the same TIER_PRIOR shape; data drives).
|
|
249
|
+
*
|
|
250
|
+
* For Phase 29 Plan 06: when `prior_class === 'promoted_incubator'`, the
|
|
251
|
+
* bootstrap prior is Beta(2, 8) regardless of tier/delegate (CONTEXT D-04).
|
|
252
|
+
* The `prior_class` is persisted on the arm so re-reads + decay preserve it.
|
|
253
|
+
* If omitted or 'default', no `prior_class` field is added (clean
|
|
254
|
+
* round-trip with existing posterior files — non-breaking change).
|
|
210
255
|
*/
|
|
211
|
-
function ensureArm(posterior, agent, bin, tier, strength, delegate) {
|
|
256
|
+
function ensureArm(posterior, agent, bin, tier, strength, delegate, prior_class) {
|
|
212
257
|
let arm = findArm(posterior.arms, agent, bin, tier, delegate);
|
|
213
258
|
if (arm) return arm;
|
|
214
|
-
const { alpha, beta } = priorFor(tier, strength);
|
|
259
|
+
const { alpha, beta } = priorFor(tier, strength, prior_class);
|
|
215
260
|
arm = {
|
|
216
261
|
agent,
|
|
217
262
|
bin,
|
|
@@ -224,6 +269,9 @@ function ensureArm(posterior, agent, bin, tier, strength, delegate) {
|
|
|
224
269
|
if (delegate !== undefined) {
|
|
225
270
|
arm.delegate = delegate;
|
|
226
271
|
}
|
|
272
|
+
if (prior_class !== undefined && prior_class !== 'default') {
|
|
273
|
+
arm.prior_class = prior_class;
|
|
274
|
+
}
|
|
227
275
|
posterior.arms.push(arm);
|
|
228
276
|
return arm;
|
|
229
277
|
}
|
|
@@ -312,7 +360,10 @@ function decayArm(arm, opts = {}) {
|
|
|
312
360
|
* counters. Bandit pull does NOT update the success/fail counters —
|
|
313
361
|
* that happens in `update()` once the outcome is known.
|
|
314
362
|
*
|
|
315
|
-
* @param {{agent: string, bin: string, tiers?: string[], baseDir?: string, posteriorPath?: string, decay?: number, strength?: number, now?: Date}} input
|
|
363
|
+
* @param {{agent: string, bin: string, tiers?: string[], baseDir?: string, posteriorPath?: string, decay?: number, strength?: number, now?: Date, prior_class?: string}} input
|
|
364
|
+
* `prior_class` (optional, Phase 29 Plan 06 / D-04): 'promoted_incubator'
|
|
365
|
+
* bootstraps fresh arms with Beta(2,8). Omitting it preserves Phase 23.5
|
|
366
|
+
* informed-prior behaviour (non-breaking).
|
|
316
367
|
* @returns {{tier: string, samples: Record<string, number>, posteriorPath: string}}
|
|
317
368
|
*/
|
|
318
369
|
function pull(input) {
|
|
@@ -332,7 +383,7 @@ function pull(input) {
|
|
|
332
383
|
let bestTier = tiers[0];
|
|
333
384
|
let bestSample = -1;
|
|
334
385
|
for (const tier of tiers) {
|
|
335
|
-
const arm = ensureArm(posterior, input.agent, input.bin, tier, strength);
|
|
386
|
+
const arm = ensureArm(posterior, input.agent, input.bin, tier, strength, undefined, input.prior_class);
|
|
336
387
|
const decayed = decayArm(arm, { decay: input.decay, now, strength });
|
|
337
388
|
const s = sampleBeta(decayed.alpha, decayed.beta);
|
|
338
389
|
samples[tier] = s;
|
|
@@ -342,7 +393,7 @@ function pull(input) {
|
|
|
342
393
|
}
|
|
343
394
|
}
|
|
344
395
|
// Bump counters on the chosen arm.
|
|
345
|
-
const chosen = ensureArm(posterior, input.agent, input.bin, bestTier, strength);
|
|
396
|
+
const chosen = ensureArm(posterior, input.agent, input.bin, bestTier, strength, undefined, input.prior_class);
|
|
346
397
|
chosen.last_used = now.toISOString();
|
|
347
398
|
chosen.count += 1;
|
|
348
399
|
const written = savePosterior(posterior, input);
|
|
@@ -353,7 +404,11 @@ function pull(input) {
|
|
|
353
404
|
* Update the posterior with a reward signal. Reward is applied as a
|
|
354
405
|
* Bernoulli observation: success → α += reward, β += (1 - reward).
|
|
355
406
|
*
|
|
356
|
-
* @param {{agent: string, bin: string, tier: string, reward: number, baseDir?: string, posteriorPath?: string, strength?: number}} input
|
|
407
|
+
* @param {{agent: string, bin: string, tier: string, reward: number, baseDir?: string, posteriorPath?: string, strength?: number, prior_class?: string}} input
|
|
408
|
+
* `prior_class` (optional, Phase 29 Plan 06 / D-04): 'promoted_incubator'
|
|
409
|
+
* bootstraps fresh arms with Beta(2,8). Omitting preserves Phase 23.5
|
|
410
|
+
* informed-prior behaviour (non-breaking). The reward math is unchanged
|
|
411
|
+
* — `prior_class` only affects bootstrap, not the Bernoulli update.
|
|
357
412
|
* @returns {{alpha: number, beta: number, posteriorPath: string}}
|
|
358
413
|
*/
|
|
359
414
|
function update(input) {
|
|
@@ -369,7 +424,15 @@ function update(input) {
|
|
|
369
424
|
// Reward must be in [0, 1].
|
|
370
425
|
const r = Math.min(1, Math.max(0, input.reward));
|
|
371
426
|
const posterior = loadPosterior(input);
|
|
372
|
-
const arm = ensureArm(
|
|
427
|
+
const arm = ensureArm(
|
|
428
|
+
posterior,
|
|
429
|
+
input.agent,
|
|
430
|
+
input.bin,
|
|
431
|
+
input.tier,
|
|
432
|
+
input.strength ?? PRIOR_STRENGTH,
|
|
433
|
+
undefined,
|
|
434
|
+
input.prior_class,
|
|
435
|
+
);
|
|
373
436
|
arm.alpha += r;
|
|
374
437
|
arm.beta += 1 - r;
|
|
375
438
|
const p = savePosterior(posterior, input);
|
|
@@ -401,7 +464,11 @@ function update(input) {
|
|
|
401
464
|
* decay?: number,
|
|
402
465
|
* strength?: number,
|
|
403
466
|
* now?: Date,
|
|
467
|
+
* prior_class?: string,
|
|
404
468
|
* }} input
|
|
469
|
+
* `prior_class` (optional, Phase 29 Plan 06 / D-04): 'promoted_incubator'
|
|
470
|
+
* bootstraps fresh arms with Beta(2,8). Omitting preserves Phase 23.5 +
|
|
471
|
+
* Plan 27-07 behaviour (non-breaking).
|
|
405
472
|
* @returns {{
|
|
406
473
|
* tier: string,
|
|
407
474
|
* delegate: string,
|
|
@@ -435,7 +502,15 @@ function pullWithDelegate(input) {
|
|
|
435
502
|
for (const delegate of delegates) {
|
|
436
503
|
samples[delegate] = {};
|
|
437
504
|
for (const tier of tiers) {
|
|
438
|
-
const arm = ensureArm(
|
|
505
|
+
const arm = ensureArm(
|
|
506
|
+
posterior,
|
|
507
|
+
input.agent,
|
|
508
|
+
input.bin,
|
|
509
|
+
tier,
|
|
510
|
+
strength,
|
|
511
|
+
delegate,
|
|
512
|
+
input.prior_class,
|
|
513
|
+
);
|
|
439
514
|
const decayed = decayArm(arm, { decay: input.decay, now, strength });
|
|
440
515
|
const s = sampleBeta(decayed.alpha, decayed.beta);
|
|
441
516
|
samples[delegate][tier] = s;
|
|
@@ -453,6 +528,7 @@ function pullWithDelegate(input) {
|
|
|
453
528
|
bestTier,
|
|
454
529
|
strength,
|
|
455
530
|
bestDelegate,
|
|
531
|
+
input.prior_class,
|
|
456
532
|
);
|
|
457
533
|
chosen.last_used = now.toISOString();
|
|
458
534
|
chosen.count += 1;
|
|
@@ -482,7 +558,12 @@ function pullWithDelegate(input) {
|
|
|
482
558
|
* baseDir?: string,
|
|
483
559
|
* posteriorPath?: string,
|
|
484
560
|
* strength?: number,
|
|
561
|
+
* prior_class?: string,
|
|
485
562
|
* }} input
|
|
563
|
+
* `prior_class` (optional, Phase 29 Plan 06 / D-04): 'promoted_incubator'
|
|
564
|
+
* bootstraps fresh arms with Beta(2,8). Omitting preserves Plan 27-07
|
|
565
|
+
* behaviour (non-breaking). The reward math is unchanged — `prior_class`
|
|
566
|
+
* only affects bootstrap, not the Bernoulli update.
|
|
486
567
|
* @returns {{alpha: number, beta: number, posteriorPath: string}}
|
|
487
568
|
*/
|
|
488
569
|
function updateWithDelegate(input) {
|
|
@@ -506,6 +587,7 @@ function updateWithDelegate(input) {
|
|
|
506
587
|
input.tier,
|
|
507
588
|
input.strength ?? PRIOR_STRENGTH,
|
|
508
589
|
input.delegate,
|
|
590
|
+
input.prior_class,
|
|
509
591
|
);
|
|
510
592
|
arm.alpha += r;
|
|
511
593
|
arm.beta += 1 - r;
|
|
@@ -569,6 +651,7 @@ module.exports = {
|
|
|
569
651
|
DELEGATE_NONE,
|
|
570
652
|
TIER_PRIOR,
|
|
571
653
|
PRIOR_STRENGTH,
|
|
654
|
+
PROMOTED_INCUBATOR_PRIOR,
|
|
572
655
|
TOUCHES_BINS,
|
|
573
656
|
DEFAULT_POSTERIOR_PATH,
|
|
574
657
|
SCHEMA_VERSION,
|