@hegemonart/get-design-done 1.28.8 → 1.30.5
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 +25 -0
- package/README.fr.md +25 -0
- package/README.it.md +25 -0
- package/README.ja.md +25 -0
- package/README.ko.md +25 -0
- package/README.md +30 -0
- package/README.zh-CN.md +25 -0
- package/SKILL.md +2 -0
- package/agents/design-authority-watcher.md +42 -1
- 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 +521 -0
- package/reference/pseudonymization-rules.md +189 -0
- package/reference/registry.json +22 -1
- package/reference/schemas/events.schema.json +158 -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 +455 -0
- package/scripts/lib/authority-watcher/index.cjs +201 -0
- package/scripts/lib/bandit-router.cjs +92 -9
- package/scripts/lib/failure-mode-matcher.cjs +460 -0
- package/scripts/lib/gsd-health-mirror/index.cjs +37 -1
- package/scripts/lib/incubator-author.cjs +845 -0
- package/scripts/lib/install/interactive.cjs +27 -2
- 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 +352 -0
- package/scripts/lib/reflector-kfm-proposer.cjs +468 -0
- package/scripts/release-smoke-test.cjs +33 -2
- package/scripts/validate-incubator-scope.cjs +133 -0
- package/skills/apply-reflections/SKILL.md +20 -1
- package/skills/apply-reflections/apply-reflections-procedure.md +106 -4
- 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,455 @@
|
|
|
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
|
|
61
|
+
// backslashes FIRST (so the escapes themselves don't become escaped
|
|
62
|
+
// separators), then escape embedded double quotes. Closes Code
|
|
63
|
+
// Scanning #23 (js/incomplete-sanitization). Order matters: if we
|
|
64
|
+
// escape quotes before backslashes, the inserted `\"` would then have
|
|
65
|
+
// its `\` doubled by the backslash pass, producing `\\\"` instead of
|
|
66
|
+
// the intended `\"`.
|
|
67
|
+
// Sufficient for tmpdir paths (no real shell metachars expected),
|
|
68
|
+
// but now safe for paths containing backslash-quote sequences too.
|
|
69
|
+
return `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- discoverIncubatorDrafts ---
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Walk the incubator directory and return one Draft per valid slug. Malformed
|
|
76
|
+
* subdirs (missing/unparseable manifest, missing DRAFT.md) are skipped with a
|
|
77
|
+
* stderr warning; never throws.
|
|
78
|
+
*/
|
|
79
|
+
function discoverIncubatorDrafts(options) {
|
|
80
|
+
const o = options || {};
|
|
81
|
+
const incubatorDir = o.incubatorDir || DEFAULT_INCUBATOR_DIR;
|
|
82
|
+
if (!fs.existsSync(incubatorDir)) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let entries;
|
|
87
|
+
try {
|
|
88
|
+
entries = fs.readdirSync(incubatorDir, { withFileTypes: true });
|
|
89
|
+
} catch (err) {
|
|
90
|
+
warn(`cannot read incubator dir ${incubatorDir}: ${err.message}`);
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const drafts = [];
|
|
95
|
+
for (const ent of entries) {
|
|
96
|
+
if (!ent.isDirectory()) continue;
|
|
97
|
+
if (ent.name === 'archive') continue; // D-06: archived drafts not surfaced
|
|
98
|
+
|
|
99
|
+
const slugDir = path.join(incubatorDir, ent.name);
|
|
100
|
+
const manifestPath = path.join(slugDir, 'manifest.json');
|
|
101
|
+
const draftPath = path.join(slugDir, 'DRAFT.md');
|
|
102
|
+
const originPath = path.join(slugDir, 'ORIGIN.md');
|
|
103
|
+
|
|
104
|
+
if (!fs.existsSync(manifestPath)) {
|
|
105
|
+
warn(`skip ${slugDir}: missing manifest.json`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!fs.existsSync(draftPath)) {
|
|
109
|
+
warn(`skip ${slugDir}: missing DRAFT.md`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
let manifest;
|
|
113
|
+
try {
|
|
114
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
115
|
+
} catch (err) {
|
|
116
|
+
warn(`skip ${slugDir}: manifest.json parse error: ${err.message}`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!manifest || typeof manifest !== 'object' || !manifest.slug || !manifest.kind || !manifest.target_path) {
|
|
120
|
+
warn(`skip ${slugDir}: manifest missing required fields (slug/kind/target_path)`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
drafts.push({
|
|
125
|
+
slug: manifest.slug,
|
|
126
|
+
kind: manifest.kind,
|
|
127
|
+
target_path: manifest.target_path,
|
|
128
|
+
draft_path: draftPath,
|
|
129
|
+
origin_path: fs.existsSync(originPath) ? originPath : null,
|
|
130
|
+
manifest,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Deterministic ordering by slug ascending — matches incubator-author.cjs style.
|
|
135
|
+
drafts.sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
|
|
136
|
+
return drafts;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- renderProposal ---
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Render a draft as markdown: header (slug + kind), diff vs nearest existing
|
|
143
|
+
* artifact (or "net-new"), Origin section, full draft body.
|
|
144
|
+
*/
|
|
145
|
+
function renderProposal(draft, options) {
|
|
146
|
+
const o = options || {};
|
|
147
|
+
const resolver = typeof o.existingArtifactResolver === 'function' ? o.existingArtifactResolver : () => null;
|
|
148
|
+
|
|
149
|
+
const body = safeReadFileSync(draft.draft_path) || '';
|
|
150
|
+
const origin = draft.origin_path ? safeReadFileSync(draft.origin_path) : null;
|
|
151
|
+
|
|
152
|
+
const existing = resolver(draft.target_path);
|
|
153
|
+
let diffSection;
|
|
154
|
+
if (existing == null) {
|
|
155
|
+
diffSection = `### Diff vs existing\n\nNo existing artifact — net-new proposal.\n`;
|
|
156
|
+
} else {
|
|
157
|
+
diffSection = `### Diff vs existing\n\n\`\`\`diff\n--- ${draft.target_path} (existing)\n+++ ${draft.target_path} (proposed)\n${renderUnifiedDiff(existing, body)}\n\`\`\`\n`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const originSection = origin
|
|
161
|
+
? `## Origin\n\n${origin.trim()}\n`
|
|
162
|
+
: `## Origin\n\n(no ORIGIN.md found in incubator subdir)\n`;
|
|
163
|
+
|
|
164
|
+
return [
|
|
165
|
+
`## Proposal — ${draft.slug} (${draft.kind})`,
|
|
166
|
+
`Target: \`${draft.target_path}\``,
|
|
167
|
+
'',
|
|
168
|
+
diffSection,
|
|
169
|
+
originSection,
|
|
170
|
+
'### Draft body',
|
|
171
|
+
'',
|
|
172
|
+
body.trim(),
|
|
173
|
+
'',
|
|
174
|
+
].join('\n');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Minimal unified-diff renderer (line-level, no LCS) for human review only.
|
|
179
|
+
* Not for round-trip patch application — that would need an actual diff
|
|
180
|
+
* library, which we deliberately avoid to keep deps at zero.
|
|
181
|
+
*/
|
|
182
|
+
function renderUnifiedDiff(oldText, newText) {
|
|
183
|
+
const oldLines = (oldText || '').split('\n');
|
|
184
|
+
const newLines = (newText || '').split('\n');
|
|
185
|
+
// Cheap diff: emit `-` for old lines absent in new, `+` for new lines
|
|
186
|
+
// absent in old, ` ` for shared lines. Order: all `-`, then all `+`.
|
|
187
|
+
const oldSet = new Set(oldLines);
|
|
188
|
+
const newSet = new Set(newLines);
|
|
189
|
+
const out = [];
|
|
190
|
+
for (const ln of oldLines) {
|
|
191
|
+
if (!newSet.has(ln)) out.push(`-${ln}`);
|
|
192
|
+
}
|
|
193
|
+
for (const ln of newLines) {
|
|
194
|
+
if (!oldSet.has(ln)) out.push(`+${ln}`);
|
|
195
|
+
}
|
|
196
|
+
if (out.length === 0) {
|
|
197
|
+
return '(no line-level differences)';
|
|
198
|
+
}
|
|
199
|
+
return out.join('\n');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// --- applyAccept (D-04 + D-05) ---
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Promote draft → final artifact + registry entry in one call (D-04).
|
|
206
|
+
*
|
|
207
|
+
* Order: validateScope (D-05; throws → no writes) → read DRAFT.md →
|
|
208
|
+
* [dryRun: return intent] → mkdirp parent → atomic-write target →
|
|
209
|
+
* append-and-atomic-write registry → fs.rm incubator subdir last
|
|
210
|
+
* (so partial failure leaves draft retryable — T-29.05-04).
|
|
211
|
+
*/
|
|
212
|
+
function applyAccept(draft, options) {
|
|
213
|
+
const o = options || {};
|
|
214
|
+
const repoRoot = o.repoRoot || process.cwd();
|
|
215
|
+
const registryPath = path.isAbsolute(o.registryPath || '')
|
|
216
|
+
? o.registryPath
|
|
217
|
+
: path.join(repoRoot, o.registryPath || DEFAULT_REGISTRY_PATH);
|
|
218
|
+
const dryRun = !!o.dryRun;
|
|
219
|
+
|
|
220
|
+
// Step 1 — D-05 scope guard. THROWS on failure; registry untouched.
|
|
221
|
+
validateScope(draft.target_path, { repoRoot });
|
|
222
|
+
|
|
223
|
+
const draftBody = fs.readFileSync(draft.draft_path, 'utf8');
|
|
224
|
+
const targetAbs = path.resolve(repoRoot, draft.target_path);
|
|
225
|
+
|
|
226
|
+
const registryEntry = {
|
|
227
|
+
slug: draft.slug,
|
|
228
|
+
path: draft.target_path.replace(/\\/g, '/'),
|
|
229
|
+
added: new Date().toISOString(),
|
|
230
|
+
origin: 'incubator',
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if (dryRun) {
|
|
234
|
+
return {
|
|
235
|
+
wouldWrite: draft.target_path.replace(/\\/g, '/'),
|
|
236
|
+
wouldRegister: registryEntry,
|
|
237
|
+
kind: draft.kind,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Step 4 — mkdirp parent
|
|
242
|
+
fs.mkdirSync(path.dirname(targetAbs), { recursive: true });
|
|
243
|
+
|
|
244
|
+
// Step 5 — atomic write of target file
|
|
245
|
+
atomicWriteFileSync(targetAbs, draftBody);
|
|
246
|
+
|
|
247
|
+
// Step 6 — append registry entry
|
|
248
|
+
appendRegistryEntry(registryPath, draft.kind, registryEntry);
|
|
249
|
+
|
|
250
|
+
// Step 7 — remove incubator subdir last (partial-failure rollback safety)
|
|
251
|
+
const slugDir = path.dirname(path.resolve(draft.draft_path));
|
|
252
|
+
fs.rmSync(slugDir, { recursive: true, force: true });
|
|
253
|
+
|
|
254
|
+
return { accepted: true, path: draft.target_path.replace(/\\/g, '/') };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function atomicWriteFileSync(targetAbs, body) {
|
|
258
|
+
const tmp = `${targetAbs}.tmp-${process.pid}-${Date.now()}`;
|
|
259
|
+
fs.writeFileSync(tmp, body);
|
|
260
|
+
fs.renameSync(tmp, targetAbs);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function appendRegistryEntry(registryPath, kind, entry) {
|
|
264
|
+
let registry;
|
|
265
|
+
if (fs.existsSync(registryPath)) {
|
|
266
|
+
try {
|
|
267
|
+
registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
268
|
+
} catch (err) {
|
|
269
|
+
throw new Error(`[incubator-proposals] registry parse error at ${registryPath}: ${err.message}`);
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
registry = { agents: [], skills: [] };
|
|
273
|
+
}
|
|
274
|
+
if (!registry || typeof registry !== 'object') {
|
|
275
|
+
throw new Error(`[incubator-proposals] registry root must be an object: ${registryPath}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Phase 14.5 self-authoring shape: { agents: [...], skills: [...] }.
|
|
279
|
+
// Initialize missing arrays additively so we never clobber another schema's data.
|
|
280
|
+
if (kind === 'agent') {
|
|
281
|
+
if (!Array.isArray(registry.agents)) registry.agents = [];
|
|
282
|
+
registry.agents.push(entry);
|
|
283
|
+
} else if (kind === 'skill') {
|
|
284
|
+
if (!Array.isArray(registry.skills)) registry.skills = [];
|
|
285
|
+
registry.skills.push(entry);
|
|
286
|
+
} else {
|
|
287
|
+
throw new Error(`[incubator-proposals] unknown kind: ${kind} (expected 'agent' or 'skill')`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
atomicWriteFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// --- applyReject ---
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Remove the incubator subdir for this draft. Registry untouched.
|
|
297
|
+
*
|
|
298
|
+
* @param {object} draft
|
|
299
|
+
* @returns {{rejected:true, slug:string}}
|
|
300
|
+
*/
|
|
301
|
+
function applyReject(draft) {
|
|
302
|
+
const slugDir = path.dirname(path.resolve(draft.draft_path));
|
|
303
|
+
fs.rmSync(slugDir, { recursive: true, force: true });
|
|
304
|
+
return { rejected: true, slug: draft.slug };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// --- applyEdit ---
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Open the user's editor ($EDITOR / editorEnv / 'vi' fallback) on a temp copy
|
|
311
|
+
* of DRAFT.md. On exit-0, copy edits back and return the reloaded draft. On
|
|
312
|
+
* non-zero exit, return {edited:false, reason}. editorEnv may include args
|
|
313
|
+
* (split on whitespace, e.g. "node /path/to/mock-editor.cjs").
|
|
314
|
+
*/
|
|
315
|
+
function applyEdit(draft, options) {
|
|
316
|
+
const o = options || {};
|
|
317
|
+
const editorEnv = o.editorEnv || process.env.EDITOR || 'vi';
|
|
318
|
+
|
|
319
|
+
// Write a temp copy
|
|
320
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'incu-edit-'));
|
|
321
|
+
const tmpFile = path.join(tmpDir, path.basename(draft.draft_path));
|
|
322
|
+
fs.copyFileSync(draft.draft_path, tmpFile);
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
// Two invocation modes:
|
|
326
|
+
// options.editorCmd: [exec, ...args] -- no shell, fully tokenized
|
|
327
|
+
// options.editorEnv: shell command line (default: $EDITOR or 'vi')
|
|
328
|
+
// The array form avoids shell quoting headaches for Windows editor paths
|
|
329
|
+
// that contain spaces (e.g. node.exe installed under a Program-Files
|
|
330
|
+
// location) in tests.
|
|
331
|
+
let r;
|
|
332
|
+
if (Array.isArray(o.editorCmd) && o.editorCmd.length) {
|
|
333
|
+
const [cmd, ...args] = o.editorCmd;
|
|
334
|
+
r = child_process.spawnSync(cmd, args.concat([tmpFile]), { stdio: 'inherit' });
|
|
335
|
+
} else {
|
|
336
|
+
const cmdline = `${editorEnv} ${quoteArg(tmpFile)}`;
|
|
337
|
+
r = child_process.spawnSync(cmdline, { stdio: 'inherit', shell: true });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (r.status !== 0) {
|
|
341
|
+
return { edited: false, reason: 'editor_aborted', exit_code: r.status };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Copy edited tmp back over the draft
|
|
345
|
+
fs.copyFileSync(tmpFile, draft.draft_path);
|
|
346
|
+
|
|
347
|
+
// Reload the draft so target_path / manifest re-sync if anything in
|
|
348
|
+
// the editor changed (manifest itself is not edited here, but the body
|
|
349
|
+
// may have changed). discoverIncubatorDrafts re-reads manifest.json.
|
|
350
|
+
const incubatorDir = path.dirname(path.dirname(path.resolve(draft.draft_path)));
|
|
351
|
+
const all = discoverIncubatorDrafts({ incubatorDir });
|
|
352
|
+
const reloaded = all.find((d) => d.slug === draft.slug);
|
|
353
|
+
return reloaded || { edited: false, reason: 'draft_vanished_post_edit' };
|
|
354
|
+
} finally {
|
|
355
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// --- checkStage1Gate (D-01: read-only) ---
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Read-only Stage-1 gate inspection (D-01).
|
|
363
|
+
* thresholdMet = count(registry entries with origin === 'incubator') ≥ K
|
|
364
|
+
* optInRecorded = state file contains an opt-in token
|
|
365
|
+
* summary = human-readable one-liner
|
|
366
|
+
* Reads only — never writes. Surfacing the threshold is a prompt, not a flip.
|
|
367
|
+
*/
|
|
368
|
+
function checkStage1Gate(options) {
|
|
369
|
+
const o = options || {};
|
|
370
|
+
const gateSpecPath = o.gateSpecPath || DEFAULT_GATE_SPEC_PATH;
|
|
371
|
+
const statePath = o.statePath || DEFAULT_STATE_PATH;
|
|
372
|
+
const registryPath = o.registryPath || DEFAULT_REGISTRY_PATH;
|
|
373
|
+
|
|
374
|
+
const K = readK(gateSpecPath);
|
|
375
|
+
|
|
376
|
+
let acceptedCount = 0;
|
|
377
|
+
const regSrc = safeReadFileSync(registryPath);
|
|
378
|
+
if (regSrc) {
|
|
379
|
+
try {
|
|
380
|
+
const reg = JSON.parse(regSrc);
|
|
381
|
+
const skills = Array.isArray(reg.skills) ? reg.skills : [];
|
|
382
|
+
const agents = Array.isArray(reg.agents) ? reg.agents : [];
|
|
383
|
+
for (const e of skills.concat(agents)) {
|
|
384
|
+
if (e && e.origin === 'incubator') acceptedCount += 1;
|
|
385
|
+
}
|
|
386
|
+
} catch (_) {
|
|
387
|
+
// Malformed registry — treat as zero accepted; do not throw.
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const thresholdMet = acceptedCount >= K;
|
|
392
|
+
|
|
393
|
+
const stateSrc = safeReadFileSync(statePath) || '';
|
|
394
|
+
const optInRecorded = OPT_IN_TOKEN_RE.test(stateSrc);
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
thresholdMet,
|
|
398
|
+
summary: `${acceptedCount} of ${K} incubator-origin entries accepted` +
|
|
399
|
+
(thresholdMet ? ' (Stage-1 gate met)' : ' (Stage-1 gate not yet met)'),
|
|
400
|
+
optInRecorded,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Pull `K` out of capability-gap-stage-gate.md. The doc encodes K as a row
|
|
406
|
+
* in a markdown table: `| K | 3 | Minimum number of stable clusters... |`.
|
|
407
|
+
* If absent or unparseable, fall back to 3 (Phase 29 D-03 default).
|
|
408
|
+
*/
|
|
409
|
+
function readK(gateSpecPath) {
|
|
410
|
+
const src = safeReadFileSync(gateSpecPath);
|
|
411
|
+
if (!src) return 3;
|
|
412
|
+
const m = src.match(/\|\s*`?K`?\s*\|\s*`?(\d+)`?\s*\|/);
|
|
413
|
+
if (!m) return 3;
|
|
414
|
+
const v = parseInt(m[1], 10);
|
|
415
|
+
return Number.isFinite(v) && v > 0 ? v : 3;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// --- recordOptIn (D-01: explicit-only) ---
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Persist the user's explicit Stage-1 opt-in to STATE.md. Idempotent.
|
|
422
|
+
* IMPORTANT: this is the SOLE state writer in this module. Only invoke after
|
|
423
|
+
* explicit user confirmation in the apply-reflections UX (D-01).
|
|
424
|
+
*/
|
|
425
|
+
function recordOptIn(options) {
|
|
426
|
+
const o = options || {};
|
|
427
|
+
const statePath = o.statePath || DEFAULT_STATE_PATH;
|
|
428
|
+
const confirmedBy = o.confirmedBy || 'user';
|
|
429
|
+
|
|
430
|
+
const existing = safeReadFileSync(statePath) || '';
|
|
431
|
+
if (OPT_IN_TOKEN_RE.test(existing)) {
|
|
432
|
+
return { alreadyRecorded: true };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const at = new Date().toISOString();
|
|
436
|
+
const block =
|
|
437
|
+
`\n${OPT_IN_HEADING}\n\n` +
|
|
438
|
+
`- recorded_at: ${at}\n` +
|
|
439
|
+
`- confirmed_by: ${confirmedBy}\n`;
|
|
440
|
+
const next = existing + (existing.endsWith('\n') ? '' : '\n') + block;
|
|
441
|
+
atomicWriteFileSync(statePath, next);
|
|
442
|
+
return { optInRecorded: true, at, confirmedBy };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// --- Exports ---
|
|
446
|
+
|
|
447
|
+
module.exports = {
|
|
448
|
+
discoverIncubatorDrafts,
|
|
449
|
+
renderProposal,
|
|
450
|
+
applyAccept,
|
|
451
|
+
applyReject,
|
|
452
|
+
applyEdit,
|
|
453
|
+
checkStage1Gate,
|
|
454
|
+
recordOptIn,
|
|
455
|
+
};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/authority-watcher/index.cjs — Plan 30.5-03 Task 2.
|
|
3
|
+
*
|
|
4
|
+
* Programmatic surface for the authority-watcher pipeline. The user-facing
|
|
5
|
+
* fetcher lives in `agents/design-authority-watcher.md` (Phase 13.2 — runs
|
|
6
|
+
* inside Claude's sub-agent harness with `WebFetch`). This module is the
|
|
7
|
+
* pure-CommonJS counterpart that consumes already-fetched article records
|
|
8
|
+
* and emits structured events for the Phase 30.5-03 reflector pipeline.
|
|
9
|
+
*
|
|
10
|
+
* D-06 ship: kfm-candidate event class. When an article's title matches
|
|
11
|
+
* the failure-mode whitelist patterns (case-insensitive), we emit a single
|
|
12
|
+
* `kfm-candidate` event. Reflector (Plan 30.5-03 Task 1) consumes these
|
|
13
|
+
* events into the SAME incubator draft surface as capability_gap clusters.
|
|
14
|
+
*
|
|
15
|
+
* Public API:
|
|
16
|
+
* classifyArticles(articles, options?) → Array<Event>
|
|
17
|
+
* matchesKfmWhitelist(title) → boolean
|
|
18
|
+
* buildKfmCandidate(article, options?) → Event
|
|
19
|
+
*
|
|
20
|
+
* Article shape (subset — matches the watcher agent's normalised entries):
|
|
21
|
+
* { id: string, title: string, url?: string, link?: string,
|
|
22
|
+
* summary?: string, feed_id?: string, published?: string }
|
|
23
|
+
*
|
|
24
|
+
* Event shape (validates against reference/schemas/events.schema.json
|
|
25
|
+
* KfmCandidatePayload, allOf[1] branch):
|
|
26
|
+
* {
|
|
27
|
+
* type: 'kfm-candidate',
|
|
28
|
+
* timestamp: '<ISO>',
|
|
29
|
+
* sessionId: '<id>',
|
|
30
|
+
* payload: { event_id, source: 'authority_watcher', article_url,
|
|
31
|
+
* article_title, suggested_symptom,
|
|
32
|
+
* suggested_pattern_hint, raw_excerpt },
|
|
33
|
+
* event_type: 'kfm-candidate' // duplicate of `type` for ergonomic .filter()
|
|
34
|
+
* }
|
|
35
|
+
*
|
|
36
|
+
* No `fs` writes — this module returns events for the caller (the agent's
|
|
37
|
+
* Bash sandbox) to persist. Zero npm deps.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
'use strict';
|
|
41
|
+
|
|
42
|
+
// -------------------------------------------------------------------
|
|
43
|
+
// Constants
|
|
44
|
+
// -------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whitelist patterns per Plan 30.5-03 Task 2 step 2. Each pattern matches
|
|
48
|
+
* a title that is plausibly about a failure mode / troubleshooting topic.
|
|
49
|
+
* Case-insensitive, deliberately broad — false positives are gated by
|
|
50
|
+
* the apply-reflections user-review step.
|
|
51
|
+
*/
|
|
52
|
+
const KFM_WHITELIST_PATTERNS = Object.freeze([
|
|
53
|
+
/common errors/i,
|
|
54
|
+
/failure modes/i,
|
|
55
|
+
/troubleshooting/i,
|
|
56
|
+
/known issues/i,
|
|
57
|
+
/pitfalls/i,
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const MAX_RAW_EXCERPT = 500;
|
|
61
|
+
|
|
62
|
+
// -------------------------------------------------------------------
|
|
63
|
+
// Helpers
|
|
64
|
+
// -------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
function asString(x) {
|
|
67
|
+
return typeof x === 'string' ? x : '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Truncate to maxLen with a `…` (single char, byte-counted) suffix.
|
|
72
|
+
* Returns at most maxLen characters including the suffix.
|
|
73
|
+
*/
|
|
74
|
+
function truncateExcerpt(text, maxLen) {
|
|
75
|
+
const s = asString(text);
|
|
76
|
+
if (s.length <= maxLen) return s;
|
|
77
|
+
// Hard truncate at maxLen, keep the last char as ellipsis.
|
|
78
|
+
return `${s.slice(0, maxLen - 1)}…`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Derive a one-line symptom string from an article record. Preference
|
|
83
|
+
* order: explicit title (≤180 chars), then first 180 chars of summary.
|
|
84
|
+
*/
|
|
85
|
+
function deriveSymptom(article) {
|
|
86
|
+
const title = asString(article && article.title).trim();
|
|
87
|
+
if (title.length > 0) {
|
|
88
|
+
return title.slice(0, 180);
|
|
89
|
+
}
|
|
90
|
+
const summary = asString(article && article.summary).trim().replace(/\s+/g, ' ');
|
|
91
|
+
if (summary.length > 0) {
|
|
92
|
+
return summary.slice(0, 180);
|
|
93
|
+
}
|
|
94
|
+
return 'untitled';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Best-effort regex fragment hint. We DO NOT emit a real regex — this is
|
|
99
|
+
* a keyword bag the user is expected to refine via the apply-reflections
|
|
100
|
+
* edit action. Empty string is legal (schema allows empty `suggested_pattern_hint`).
|
|
101
|
+
*/
|
|
102
|
+
function derivePatternHint(article) {
|
|
103
|
+
const title = asString(article && article.title);
|
|
104
|
+
const summary = asString(article && article.summary);
|
|
105
|
+
// Find ALL-CAPS error-code-shaped tokens (EACCES, ENOENT, EUSAGE, TS6133, etc.)
|
|
106
|
+
const codeRe = /\b[A-Z][A-Z0-9_]{3,15}\b/g;
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
const hits = [];
|
|
109
|
+
for (const src of [title, summary]) {
|
|
110
|
+
const matches = src.match(codeRe) || [];
|
|
111
|
+
for (const m of matches) {
|
|
112
|
+
if (!seen.has(m)) {
|
|
113
|
+
seen.add(m);
|
|
114
|
+
hits.push(m);
|
|
115
|
+
}
|
|
116
|
+
if (hits.length >= 3) break;
|
|
117
|
+
}
|
|
118
|
+
if (hits.length >= 3) break;
|
|
119
|
+
}
|
|
120
|
+
return hits.join('|');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// -------------------------------------------------------------------
|
|
124
|
+
// Public API
|
|
125
|
+
// -------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Returns true if an article title matches any whitelist pattern.
|
|
129
|
+
*/
|
|
130
|
+
function matchesKfmWhitelist(title) {
|
|
131
|
+
const s = asString(title);
|
|
132
|
+
if (s.length === 0) return false;
|
|
133
|
+
for (const re of KFM_WHITELIST_PATTERNS) {
|
|
134
|
+
if (re.test(s)) return true;
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Build a kfm-candidate event from a single article record.
|
|
141
|
+
* Schema-compliant — every required field present + raw_excerpt ≤ 500.
|
|
142
|
+
*/
|
|
143
|
+
function buildKfmCandidate(article, options) {
|
|
144
|
+
const opts = options || {};
|
|
145
|
+
const articleUrl = asString(article && (article.url || article.link || article.permalink));
|
|
146
|
+
const articleTitle = asString(article && article.title) || 'Untitled';
|
|
147
|
+
const summary = asString(article && article.summary);
|
|
148
|
+
const eventId = opts.eventId || `kfm-cand-${asString(article && article.id) || 'noid'}-${Date.now()}`;
|
|
149
|
+
const timestamp = opts.now || new Date().toISOString();
|
|
150
|
+
const sessionId = opts.sessionId || 'authority-watcher';
|
|
151
|
+
|
|
152
|
+
const payload = {
|
|
153
|
+
event_id: eventId,
|
|
154
|
+
source: 'authority_watcher',
|
|
155
|
+
article_url: articleUrl,
|
|
156
|
+
article_title: articleTitle,
|
|
157
|
+
suggested_symptom: deriveSymptom(article),
|
|
158
|
+
suggested_pattern_hint: derivePatternHint(article),
|
|
159
|
+
raw_excerpt: truncateExcerpt(summary, MAX_RAW_EXCERPT),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
type: 'kfm-candidate',
|
|
164
|
+
timestamp,
|
|
165
|
+
sessionId,
|
|
166
|
+
payload,
|
|
167
|
+
// duplicated at envelope-level for ergonomic .filter() in consumers
|
|
168
|
+
// that don't unpack the payload.
|
|
169
|
+
event_type: 'kfm-candidate',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Classify a list of fetched articles into events. Emits one kfm-candidate
|
|
175
|
+
* per whitelist-matched article. Other articles produce no events here
|
|
176
|
+
* (the watcher agent's pre-existing classification — heuristic-update,
|
|
177
|
+
* spec-change, etc. — is handled outside this module).
|
|
178
|
+
*/
|
|
179
|
+
function classifyArticles(articles, options) {
|
|
180
|
+
if (!Array.isArray(articles)) return [];
|
|
181
|
+
const out = [];
|
|
182
|
+
for (const a of articles) {
|
|
183
|
+
if (!a || typeof a !== 'object') continue;
|
|
184
|
+
if (matchesKfmWhitelist(a.title)) {
|
|
185
|
+
out.push(buildKfmCandidate(a, options));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = {
|
|
192
|
+
classifyArticles,
|
|
193
|
+
matchesKfmWhitelist,
|
|
194
|
+
buildKfmCandidate,
|
|
195
|
+
// Exposed for tests / advanced consumers.
|
|
196
|
+
KFM_WHITELIST_PATTERNS,
|
|
197
|
+
MAX_RAW_EXCERPT,
|
|
198
|
+
_deriveSymptom: deriveSymptom,
|
|
199
|
+
_derivePatternHint: derivePatternHint,
|
|
200
|
+
_truncateExcerpt: truncateExcerpt,
|
|
201
|
+
};
|