@jaimevalasek/aioson 1.30.2 → 1.33.1
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/CHANGELOG.md +27 -0
- package/README.md +19 -6
- package/docs/en/5-reference/cli-reference.md +1 -1
- package/docs/pt/5-referencia/comandos-cli.md +1 -1
- package/docs/pt/5-referencia/harness-retro.md +2 -1
- package/package.json +1 -1
- package/src/cli.js +38 -21
- package/src/commands/classify.js +389 -327
- package/src/commands/harness-retro-promote.js +387 -0
- package/src/commands/prototype-check.js +163 -0
- package/src/commands/verify-implementation.js +428 -0
- package/src/commands/workflow-next.js +222 -9
- package/src/constants.js +2 -0
- package/src/lib/retro/retro-render.js +10 -1
- package/src/lib/retro/retro-sources.js +45 -27
- package/src/lib/retro/verification-reports.js +230 -0
- package/src/runtime-store.js +13 -9
- package/src/verification/evidence-bundle.js +251 -0
- package/src/verification/ledger-store.js +221 -0
- package/src/verification/path-policy.js +74 -0
- package/src/verification/policy-engine.js +95 -0
- package/src/verification/prompt-package.js +314 -0
- package/src/verification/redaction.js +77 -0
- package/src/verification/report-parser.js +132 -0
- package/src/verification/report-store.js +97 -0
- package/src/verification/result.js +16 -0
- package/src/verification/runners/index.js +319 -0
- package/src/verification/runtime-telemetry.js +144 -0
- package/src/verification/schema.js +276 -0
- package/src/verification/source-discovery.js +153 -0
- package/template/.aioson/agents/analyst.md +9 -6
- package/template/.aioson/agents/briefing-refiner.md +22 -0
- package/template/.aioson/agents/briefing.md +69 -12
- package/template/.aioson/agents/design-hybrid-forge.md +18 -14
- package/template/.aioson/agents/dev.md +23 -10
- package/template/.aioson/agents/deyvin.md +3 -2
- package/template/.aioson/agents/product.md +15 -11
- package/template/.aioson/agents/qa.md +13 -4
- package/template/.aioson/agents/scope-check.md +19 -5
- package/template/.aioson/agents/sheldon.md +4 -3
- package/template/.aioson/agents/ux-ui.md +3 -2
- package/template/.aioson/docs/feature-expansion-taxonomy.md +31 -3
- package/template/.aioson/docs/prototype-contract.md +81 -0
- package/template/.aioson/skills/process/briefing-expansion-scout/SKILL.md +25 -3
- package/template/.aioson/skills/process/design-hybrid-forge/SKILL.md +5 -3
- package/template/.aioson/skills/process/design-hybrid-forge/references/external-source-ingestion.md +89 -0
- package/template/.aioson/skills/process/product-scope-expansion/SKILL.md +29 -2
- package/template/.aioson/skills/process/prototype-forge/SKILL.md +92 -0
- package/template/.aioson/skills/process/sheldon-expansion-audit/SKILL.md +23 -2
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `aioson harness:retro-promote [path] --feature=<slug> [--to=learnings|rules]`
|
|
5
|
+
*
|
|
6
|
+
* Human-approved promotion path for candidates mined by `harness:retro`.
|
|
7
|
+
* Default is dry-run. `--apply --select=<candidate-key|all>` is required before
|
|
8
|
+
* writing `.aioson/learnings/` or `.aioson/rules/`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('node:fs/promises');
|
|
12
|
+
const fsSync = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
collectSources,
|
|
17
|
+
resolveFeatureExists
|
|
18
|
+
} = require('../lib/retro/retro-sources');
|
|
19
|
+
const { aggregate } = require('../lib/retro/retro-aggregate');
|
|
20
|
+
const { openRuntimeDb, promoteProjectLearning } = require('../runtime-store');
|
|
21
|
+
const { upsertProjectLearning } = require('./devlog-process');
|
|
22
|
+
|
|
23
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
24
|
+
const TARGETS = new Set(['learnings', 'rules']);
|
|
25
|
+
const EXIT_OK = 0;
|
|
26
|
+
const EXIT_IO = 1;
|
|
27
|
+
const EXIT_INPUT = 12;
|
|
28
|
+
const BAR = '-'.repeat(42);
|
|
29
|
+
|
|
30
|
+
function relPosix(value) {
|
|
31
|
+
return String(value || '').replace(/\\/g, '/');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function nowIso() {
|
|
35
|
+
return new Date().toISOString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function inputError(logger, message, reason) {
|
|
39
|
+
if (logger && typeof logger.error === 'function') logger.error(message);
|
|
40
|
+
process.exitCode = EXIT_INPUT;
|
|
41
|
+
return { ok: false, exitCode: EXIT_INPUT, reason, message };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ioError(logger, message, err) {
|
|
45
|
+
if (logger && typeof logger.error === 'function') logger.error(message);
|
|
46
|
+
process.exitCode = EXIT_IO;
|
|
47
|
+
return { ok: false, exitCode: EXIT_IO, reason: 'io_error', message: err && err.message ? err.message : String(err) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sanitizeInline(value) {
|
|
51
|
+
return String(value || '')
|
|
52
|
+
.replace(/[\u0000-\u001F\u007F\u200B-\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069\uFEFF]/g, ' ')
|
|
53
|
+
.replace(/\s+/g, ' ')
|
|
54
|
+
.trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function yamlString(value) {
|
|
58
|
+
return JSON.stringify(sanitizeInline(value));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function slugify(value, fallback = 'retro-finding') {
|
|
62
|
+
const slug = String(value || '')
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
65
|
+
.replace(/^-+|-+$/g, '')
|
|
66
|
+
.slice(0, 90);
|
|
67
|
+
return slug || fallback;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function candidateTitle(candidate) {
|
|
71
|
+
const first = candidate.occurrences && candidate.occurrences[0] ? candidate.occurrences[0] : null;
|
|
72
|
+
const title = first && first.title ? sanitizeInline(first.title) : null;
|
|
73
|
+
const anchor = candidate.finding_id || (candidate.signature ? `sig:${candidate.signature.slice(0, 12)}` : candidate.key);
|
|
74
|
+
return title
|
|
75
|
+
? `Retro check: ${title}`
|
|
76
|
+
: `Retro check: ${candidate.feature_slug} ${anchor}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function candidateSummary(candidate) {
|
|
80
|
+
const reasons = Array.isArray(candidate.reasons) ? candidate.reasons.join(', ') : 'unknown';
|
|
81
|
+
return [
|
|
82
|
+
`key=${candidate.key}`,
|
|
83
|
+
`severity=${candidate.max_severity || 'unknown'}`,
|
|
84
|
+
`reasons=${reasons}`,
|
|
85
|
+
`occurrences=${candidate.cost ? candidate.cost.occurrences : (candidate.occurrences || []).length}`,
|
|
86
|
+
`fail_pass_cycles=${candidate.cost ? candidate.cost.fail_pass_cycles : 0}`
|
|
87
|
+
].join('; ');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function occurrenceLines(candidate) {
|
|
91
|
+
const occurrences = Array.isArray(candidate.occurrences) ? candidate.occurrences : [];
|
|
92
|
+
if (occurrences.length === 0) {
|
|
93
|
+
return ['- No single finding occurrence; candidate came from repeated FAIL/PASS cycles.'];
|
|
94
|
+
}
|
|
95
|
+
return occurrences.map((o) => {
|
|
96
|
+
const id = o.finding_id || (o.signature ? `sig:${String(o.signature).slice(0, 12)}` : 'n/a');
|
|
97
|
+
return `- ${relPosix(o.source_path)} | id=${sanitizeInline(id)} | severity=${sanitizeInline(o.severity || 'unknown')} | status=${sanitizeInline(o.status || 'unknown')} | date=${sanitizeInline(o.date || 'unknown')}`;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildLearningContent(candidate, { dossierRelPath, generatedAt }) {
|
|
102
|
+
const title = candidateTitle(candidate);
|
|
103
|
+
const lines = [
|
|
104
|
+
'---',
|
|
105
|
+
`title: ${yamlString(title)}`,
|
|
106
|
+
`feature: ${candidate.feature_slug}`,
|
|
107
|
+
'signal_type: retro-verification',
|
|
108
|
+
'source: harness-retro-promote',
|
|
109
|
+
`source_dossier: ${dossierRelPath}`,
|
|
110
|
+
'status: active',
|
|
111
|
+
`promoted_at: ${generatedAt}`,
|
|
112
|
+
'---',
|
|
113
|
+
'',
|
|
114
|
+
`# ${sanitizeInline(title)}`,
|
|
115
|
+
'',
|
|
116
|
+
'## Rule of Thumb',
|
|
117
|
+
'',
|
|
118
|
+
'Before closing a similar implementation, check that this recurring verification issue is explicitly covered by code evidence and tests.',
|
|
119
|
+
'',
|
|
120
|
+
'## Retro Candidate',
|
|
121
|
+
'',
|
|
122
|
+
`- key: ${candidate.key}`,
|
|
123
|
+
`- severity: ${candidate.max_severity || 'unknown'}`,
|
|
124
|
+
`- reasons: ${(candidate.reasons || []).join(', ') || 'unknown'}`,
|
|
125
|
+
`- source dossier: ${dossierRelPath}`,
|
|
126
|
+
'',
|
|
127
|
+
'## Bounded Evidence',
|
|
128
|
+
'',
|
|
129
|
+
...occurrenceLines(candidate),
|
|
130
|
+
'',
|
|
131
|
+
'No raw auditor output, stderr, prompt package, or finding evidence text is stored in this learning.',
|
|
132
|
+
''
|
|
133
|
+
];
|
|
134
|
+
return lines.join('\n');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildRuleContent(candidate, { dossierRelPath, generatedAt, ruleName }) {
|
|
138
|
+
const title = candidateTitle(candidate);
|
|
139
|
+
const lines = [
|
|
140
|
+
'---',
|
|
141
|
+
`name: ${ruleName}`,
|
|
142
|
+
`description: ${yamlString(`Prevent recurring implementation verification issue: ${title}`)}`,
|
|
143
|
+
'agents: [dev, deyvin, scope-check, qa]',
|
|
144
|
+
'priority: 6',
|
|
145
|
+
'version: 1.0.0',
|
|
146
|
+
'modes: [planning, executing, reviewing]',
|
|
147
|
+
'task_types: [implementation, verification, testing]',
|
|
148
|
+
'load_tier: trigger',
|
|
149
|
+
`triggers: [${candidate.feature_slug}, implementation verification, retro candidate, recurring miss]`,
|
|
150
|
+
'retrieval_intents: [implementation, testing, memory]',
|
|
151
|
+
'paths: [src/**, tests/**, .aioson/context/features/**]',
|
|
152
|
+
'---',
|
|
153
|
+
'',
|
|
154
|
+
`# ${sanitizeInline(title)}`,
|
|
155
|
+
'',
|
|
156
|
+
'When implementing or verifying similar behavior, explicitly check this recurring retro issue before claiming the feature is done.',
|
|
157
|
+
'',
|
|
158
|
+
'## Candidate Metadata',
|
|
159
|
+
'',
|
|
160
|
+
`- key: ${candidate.key}`,
|
|
161
|
+
`- feature: ${candidate.feature_slug}`,
|
|
162
|
+
`- severity: ${candidate.max_severity || 'unknown'}`,
|
|
163
|
+
`- reasons: ${(candidate.reasons || []).join(', ') || 'unknown'}`,
|
|
164
|
+
`- source dossier: ${dossierRelPath}`,
|
|
165
|
+
`- promoted at: ${generatedAt}`,
|
|
166
|
+
'',
|
|
167
|
+
'## Bounded Evidence',
|
|
168
|
+
'',
|
|
169
|
+
...occurrenceLines(candidate),
|
|
170
|
+
'',
|
|
171
|
+
'This rule intentionally excludes raw auditor output, stderr, prompt packages, and finding evidence text.',
|
|
172
|
+
''
|
|
173
|
+
];
|
|
174
|
+
return lines.join('\n');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function selectCandidates(candidates, selectValue) {
|
|
178
|
+
if (!selectValue || selectValue === true) return { ok: true, selected: candidates, selection: 'preview_all' };
|
|
179
|
+
const raw = String(selectValue).trim();
|
|
180
|
+
if (raw === 'all') return { ok: true, selected: candidates, selection: 'all' };
|
|
181
|
+
const wanted = new Set(raw.split(',').map((item) => item.trim()).filter(Boolean));
|
|
182
|
+
const selected = candidates.filter((candidate) => wanted.has(candidate.key));
|
|
183
|
+
const missing = [...wanted].filter((key) => !candidates.some((candidate) => candidate.key === key));
|
|
184
|
+
if (missing.length > 0) return { ok: false, reason: 'unknown_selection', missing };
|
|
185
|
+
return { ok: true, selected, selection: raw };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function promotionItems(candidates, to) {
|
|
189
|
+
return candidates.map((candidate) => {
|
|
190
|
+
const fileSlug = slugify(`${candidate.feature_slug}-${candidate.key}`);
|
|
191
|
+
const fileName = to === 'rules' ? `retro-${fileSlug}.md` : `${fileSlug}.md`;
|
|
192
|
+
const targetPath = to === 'rules'
|
|
193
|
+
? path.join('.aioson', 'rules', fileName)
|
|
194
|
+
: path.join('.aioson', 'learnings', 'gotchas', fileName);
|
|
195
|
+
return {
|
|
196
|
+
key: candidate.key,
|
|
197
|
+
feature_slug: candidate.feature_slug,
|
|
198
|
+
title: candidateTitle(candidate),
|
|
199
|
+
severity: candidate.max_severity || 'unknown',
|
|
200
|
+
reasons: candidate.reasons || [],
|
|
201
|
+
occurrences: candidate.cost ? candidate.cost.occurrences : (candidate.occurrences || []).length,
|
|
202
|
+
target: to,
|
|
203
|
+
target_path: relPosix(targetPath)
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function ensureLearningIndex(rootDir, item) {
|
|
209
|
+
const indexRel = path.join('.aioson', 'learnings', 'INDEX.md');
|
|
210
|
+
const indexAbs = path.join(rootDir, indexRel);
|
|
211
|
+
let content = '# Project Learnings\n\n';
|
|
212
|
+
try {
|
|
213
|
+
content = await fs.readFile(indexAbs, 'utf8');
|
|
214
|
+
} catch {
|
|
215
|
+
// create below
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const targetRel = relPosix(path.relative(path.join(rootDir, '.aioson', 'learnings'), path.join(rootDir, item.target_path)));
|
|
219
|
+
if (content.includes(`](${targetRel})`)) return false;
|
|
220
|
+
|
|
221
|
+
const entry = `- [${sanitizeInline(item.title)}](${targetRel}) - retro candidate ${item.key}\n`;
|
|
222
|
+
const next = content
|
|
223
|
+
.replace(/\r\n/g, '\n')
|
|
224
|
+
.replace(/\n?_No project learnings yet\._\n?/i, '\n')
|
|
225
|
+
.replace(/\s*$/, '\n');
|
|
226
|
+
await fs.mkdir(path.dirname(indexAbs), { recursive: true });
|
|
227
|
+
await fs.writeFile(indexAbs, `${next}${entry}`, 'utf8');
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function writePromotionFile(rootDir, candidate, item, { dossierRelPath, generatedAt }) {
|
|
232
|
+
const abs = path.join(rootDir, item.target_path);
|
|
233
|
+
if (fsSync.existsSync(abs)) return { action: 'skipped', reason: 'already_exists', path: item.target_path };
|
|
234
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
235
|
+
const content = item.target === 'rules'
|
|
236
|
+
? buildRuleContent(candidate, {
|
|
237
|
+
dossierRelPath,
|
|
238
|
+
generatedAt,
|
|
239
|
+
ruleName: path.basename(item.target_path, '.md')
|
|
240
|
+
})
|
|
241
|
+
: buildLearningContent(candidate, { dossierRelPath, generatedAt });
|
|
242
|
+
await fs.writeFile(abs, content, 'utf8');
|
|
243
|
+
return { action: 'written', path: item.target_path };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function recordRuntimeLearning(rootDir, candidate, item) {
|
|
247
|
+
const handle = await openRuntimeDb(rootDir);
|
|
248
|
+
try {
|
|
249
|
+
const result = upsertProjectLearning(handle.db, {
|
|
250
|
+
title: item.title,
|
|
251
|
+
type: 'quality',
|
|
252
|
+
kind: 'gotcha',
|
|
253
|
+
featureSlug: item.feature_slug,
|
|
254
|
+
evidence: candidateSummary(candidate),
|
|
255
|
+
sourceSession: `harness-retro-promote:${candidate.key}`
|
|
256
|
+
});
|
|
257
|
+
if (item.target === 'rules') {
|
|
258
|
+
promoteProjectLearning(handle.db, result.learningId, item.target_path);
|
|
259
|
+
}
|
|
260
|
+
return result;
|
|
261
|
+
} finally {
|
|
262
|
+
handle.db.close();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function applyPromotions(rootDir, selectedCandidates, items, { dossierRelPath, generatedAt, target }) {
|
|
267
|
+
const written = [];
|
|
268
|
+
const skipped = [];
|
|
269
|
+
const runtime = [];
|
|
270
|
+
|
|
271
|
+
for (let index = 0; index < selectedCandidates.length; index += 1) {
|
|
272
|
+
const candidate = selectedCandidates[index];
|
|
273
|
+
const item = items[index];
|
|
274
|
+
const fileResult = await writePromotionFile(rootDir, candidate, item, { dossierRelPath, generatedAt });
|
|
275
|
+
if (fileResult.action === 'written') written.push(fileResult);
|
|
276
|
+
else skipped.push(fileResult);
|
|
277
|
+
|
|
278
|
+
if (target === 'learnings') {
|
|
279
|
+
await ensureLearningIndex(rootDir, item);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
runtime.push({
|
|
283
|
+
key: item.key,
|
|
284
|
+
...(await recordRuntimeLearning(rootDir, candidate, item))
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { written, skipped, runtime };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function runHarnessRetroPromote({ args, options = {}, logger } = {}) {
|
|
292
|
+
const log = logger || { log() {}, error() {} };
|
|
293
|
+
const rootDir = path.resolve(process.cwd(), (args && args[0]) || '.');
|
|
294
|
+
const slug = options.feature !== undefined && options.feature !== true
|
|
295
|
+
? String(options.feature || '').trim()
|
|
296
|
+
: '';
|
|
297
|
+
const target = options.to === undefined || options.to === true
|
|
298
|
+
? 'learnings'
|
|
299
|
+
: String(options.to).trim();
|
|
300
|
+
const apply = Boolean(options.apply);
|
|
301
|
+
|
|
302
|
+
if (!slug) return inputError(log, 'harness:retro-promote requires --feature=<slug>', 'missing_feature');
|
|
303
|
+
if (!SLUG_RE.test(slug)) return inputError(log, `Invalid feature slug: ${slug}`, 'invalid_slug');
|
|
304
|
+
if (!TARGETS.has(target)) return inputError(log, `Invalid --to target: ${target} (use learnings or rules)`, 'invalid_target');
|
|
305
|
+
if (!resolveFeatureExists(rootDir, slug)) return inputError(log, `Feature not found: ${slug}`, 'feature_not_found');
|
|
306
|
+
|
|
307
|
+
const dossierRelPath = relPosix(path.join('.aioson', 'context', 'retro', `${slug}.md`));
|
|
308
|
+
if (!fsSync.existsSync(path.join(rootDir, dossierRelPath))) {
|
|
309
|
+
return inputError(log, `Retro dossier not found: ${dossierRelPath}. Run harness:retro first.`, 'dossier_missing');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const sources = collectSources(rootDir, [slug]);
|
|
313
|
+
const { candidates } = aggregate(sources);
|
|
314
|
+
const selection = selectCandidates(candidates, options.select);
|
|
315
|
+
if (!selection.ok) {
|
|
316
|
+
return inputError(log, `Unknown candidate key(s): ${selection.missing.join(', ')}`, selection.reason);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (apply && (options.select === undefined || options.select === true || String(options.select).trim() === '')) {
|
|
320
|
+
return inputError(log, 'Applying retro promotion requires --select=<candidate-key|all>', 'selection_required');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const items = promotionItems(selection.selected, target);
|
|
324
|
+
const result = {
|
|
325
|
+
ok: true,
|
|
326
|
+
exitCode: EXIT_OK,
|
|
327
|
+
dry_run: !apply,
|
|
328
|
+
applied: apply,
|
|
329
|
+
target,
|
|
330
|
+
feature: slug,
|
|
331
|
+
dossier: dossierRelPath,
|
|
332
|
+
candidates: candidates.length,
|
|
333
|
+
selected: selection.selected.length,
|
|
334
|
+
selection: selection.selection,
|
|
335
|
+
items,
|
|
336
|
+
written: [],
|
|
337
|
+
skipped: [],
|
|
338
|
+
runtime: []
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
if (apply && selection.selected.length > 0) {
|
|
342
|
+
try {
|
|
343
|
+
const applied = await applyPromotions(rootDir, selection.selected, items, {
|
|
344
|
+
dossierRelPath,
|
|
345
|
+
generatedAt: nowIso(),
|
|
346
|
+
target
|
|
347
|
+
});
|
|
348
|
+
result.written = applied.written;
|
|
349
|
+
result.skipped = applied.skipped;
|
|
350
|
+
result.runtime = applied.runtime;
|
|
351
|
+
} catch (err) {
|
|
352
|
+
return ioError(log, `Failed to apply retro promotion: ${err.message}`, err);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (options.json) return result;
|
|
357
|
+
|
|
358
|
+
log.log('');
|
|
359
|
+
log.log('Retro Promotion');
|
|
360
|
+
log.log(BAR);
|
|
361
|
+
log.log(`Feature: ${slug}`);
|
|
362
|
+
log.log(`Target: ${target}`);
|
|
363
|
+
log.log(`Mode: ${apply ? 'apply' : 'dry-run'}`);
|
|
364
|
+
log.log(`Candidates: ${candidates.length}; selected: ${selection.selected.length}`);
|
|
365
|
+
for (const item of items) {
|
|
366
|
+
log.log(`- ${item.key} -> ${item.target_path}`);
|
|
367
|
+
}
|
|
368
|
+
if (!apply && items.length > 0) {
|
|
369
|
+
log.log('');
|
|
370
|
+
log.log('Apply explicitly with: --apply --select=<candidate-key|all>');
|
|
371
|
+
}
|
|
372
|
+
log.log('');
|
|
373
|
+
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
module.exports = {
|
|
378
|
+
runHarnessRetroPromote,
|
|
379
|
+
_internal: {
|
|
380
|
+
buildLearningContent,
|
|
381
|
+
buildRuleContent,
|
|
382
|
+
candidateTitle,
|
|
383
|
+
selectCandidates,
|
|
384
|
+
promotionItems
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* aioson prototype:check — deterministic fidelity guard for the prototype contract.
|
|
5
|
+
*
|
|
6
|
+
* When a PRD carries a `## Prototype reference` (the carrier @product writes), this
|
|
7
|
+
* command verifies that the prototype actually reaches the build:
|
|
8
|
+
* 1. the referenced prototype.html + manifest exist (no dangling pointer);
|
|
9
|
+
* 2. a requirements-{slug}.md bridge exists;
|
|
10
|
+
* 3. the Core interactions the manifest lists are echoed as acceptance criteria
|
|
11
|
+
* in requirements (this is the only place infidelity becomes machine-checkable —
|
|
12
|
+
* @validator never reads the prototype, only the AC authored by @analyst).
|
|
13
|
+
*
|
|
14
|
+
* It is a STRUCTURAL check, not a semantic one: coverage is a folded substring match
|
|
15
|
+
* of each manifest interaction phrase against the requirements text. The prototype
|
|
16
|
+
* contract instructs @analyst to echo the interaction names verbatim
|
|
17
|
+
* (e.g. "add card persists and re-renders"), so the match is deterministic, not fuzzy.
|
|
18
|
+
*
|
|
19
|
+
* Features with no `## Prototype reference` are a no-op (status: not_applicable).
|
|
20
|
+
*
|
|
21
|
+
* Usage:
|
|
22
|
+
* aioson prototype:check . --feature=kanban
|
|
23
|
+
* aioson prototype:check . --feature=kanban --json
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
const { readFileSafe, contextDir } = require('../preflight-engine');
|
|
28
|
+
const { resolveInsideRoot } = require('../verification/path-policy');
|
|
29
|
+
|
|
30
|
+
const BAR = '━'.repeat(30);
|
|
31
|
+
|
|
32
|
+
// Match the surface detectors: fold diacritics so a localized requirements file
|
|
33
|
+
// (pt-BR ACs) is compared the same way as an English one.
|
|
34
|
+
function fold(s) {
|
|
35
|
+
return String(s || '').normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The `## Prototype reference` section body, if present. Captures from the heading
|
|
39
|
+
// to the next `## ` heading or end of file (no `m` flag, so `$` means end-of-input).
|
|
40
|
+
function prototypeReferenceSection(prd) {
|
|
41
|
+
const m = String(prd || '').match(/##\s+Prototype reference[^\n]*\n([\s\S]*?)(?=\n##\s|$)/i);
|
|
42
|
+
return m ? m[1] : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parsePath(section, key) {
|
|
46
|
+
const m = String(section || '').match(new RegExp(`^[-*]\\s*${key}:\\s*(\\S+)`, 'mi'));
|
|
47
|
+
return m ? m[1] : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Core interactions are listed in the manifest as backtick-quoted tokens
|
|
51
|
+
// (e.g. - `add card` — ...). Prefer a "Core interactions" section; fall back to the
|
|
52
|
+
// whole manifest so older manifests still yield tokens. Deduped, normalized.
|
|
53
|
+
function extractInteractions(manifest) {
|
|
54
|
+
const text = String(manifest || '');
|
|
55
|
+
const section = text.match(/##\s+Core interactions[\s\S]*?(?=\n##\s|$)/i);
|
|
56
|
+
const scope = section ? section[0] : text;
|
|
57
|
+
const tokens = [...scope.matchAll(/`([^`]+)`/g)].map((t) => t[1].trim()).filter(Boolean);
|
|
58
|
+
return [...new Set(tokens)];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function runPrototypeCheck({ args, options = {}, logger }) {
|
|
62
|
+
const targetDir = path.resolve(process.cwd(), args[0] || '.');
|
|
63
|
+
const slug = options.feature ? String(options.feature) : null;
|
|
64
|
+
const strict = Boolean(options.strict || String(options.policy || '').toLowerCase() === 'strict');
|
|
65
|
+
const dir = contextDir(targetDir);
|
|
66
|
+
|
|
67
|
+
const prdFile = slug ? `prd-${slug}.md` : 'prd.md';
|
|
68
|
+
const reqFile = slug ? `requirements-${slug}.md` : 'requirements.md';
|
|
69
|
+
const prd = await readFileSafe(path.join(dir, prdFile));
|
|
70
|
+
|
|
71
|
+
const emit = (result) => {
|
|
72
|
+
if (options.json) return result;
|
|
73
|
+
logger.log('');
|
|
74
|
+
logger.log(slug ? `Prototype check — ${slug}` : 'Prototype check');
|
|
75
|
+
logger.log(BAR);
|
|
76
|
+
logger.log(`Status: ${result.status}`);
|
|
77
|
+
if (result.message) logger.log(result.message);
|
|
78
|
+
if (result.interactions && result.interactions.total > 0) {
|
|
79
|
+
logger.log(`Interactions covered: ${result.interactions.covered}/${result.interactions.total}`);
|
|
80
|
+
if (result.interactions.uncovered.length) {
|
|
81
|
+
logger.log(`Uncovered: ${result.interactions.uncovered.map((i) => `"${i}"`).join(', ')}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
logger.log('');
|
|
85
|
+
return result;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (!prd) {
|
|
89
|
+
return emit({ ok: true, status: 'skipped', reason: 'no_prd', feature_slug: slug,
|
|
90
|
+
message: `No ${prdFile} found — nothing to check.` });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const section = prototypeReferenceSection(prd);
|
|
94
|
+
if (!section) {
|
|
95
|
+
return emit({ ok: true, status: 'not_applicable', feature_slug: slug,
|
|
96
|
+
message: 'PRD has no `## Prototype reference` — feature has no prototype contract.' });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Resolve prototype + manifest paths (from the section, else the default location).
|
|
100
|
+
const protoRel = parsePath(section, 'prototype')
|
|
101
|
+
|| (slug ? `.aioson/briefings/${slug}/prototype.html` : null);
|
|
102
|
+
const manifestRel = parsePath(section, 'manifest')
|
|
103
|
+
|| (slug ? `.aioson/briefings/${slug}/prototype-manifest.md` : null);
|
|
104
|
+
|
|
105
|
+
const checks = { prototype_exists: false, manifest_exists: false, requirements_exists: false };
|
|
106
|
+
|
|
107
|
+
const protoSafe = protoRel ? resolveInsideRoot(targetDir, protoRel) : { ok: false, reason: 'missing_path' };
|
|
108
|
+
if (!protoSafe.ok) {
|
|
109
|
+
return emit({ ok: false, status: 'fail', reason: protoSafe.reason, field: 'prototype', feature_slug: slug, checks,
|
|
110
|
+
message: `\`## Prototype reference\` prototype path is invalid: ${protoRel || '(unspecified)'}.` });
|
|
111
|
+
}
|
|
112
|
+
const protoContent = await readFileSafe(protoSafe.path);
|
|
113
|
+
checks.prototype_exists = protoContent !== null;
|
|
114
|
+
if (!checks.prototype_exists) {
|
|
115
|
+
return emit({ ok: false, status: 'fail', reason: 'dangling_prototype', feature_slug: slug, checks,
|
|
116
|
+
message: `\`## Prototype reference\` points to ${protoRel || '(unspecified)'}, but that file is missing.` });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const manifestSafe = manifestRel ? resolveInsideRoot(targetDir, manifestRel) : { ok: false, reason: 'missing_path' };
|
|
120
|
+
if (!manifestSafe.ok) {
|
|
121
|
+
return emit({ ok: false, status: 'fail', reason: manifestSafe.reason, field: 'manifest', feature_slug: slug, checks,
|
|
122
|
+
message: `\`## Prototype reference\` manifest path is invalid: ${manifestRel || '(unspecified)'}.` });
|
|
123
|
+
}
|
|
124
|
+
const manifest = await readFileSafe(manifestSafe.path);
|
|
125
|
+
checks.manifest_exists = manifest !== null;
|
|
126
|
+
if (!checks.manifest_exists) {
|
|
127
|
+
return emit({ ok: false, status: 'fail', reason: 'missing_manifest', feature_slug: slug, checks,
|
|
128
|
+
message: `Prototype exists but its manifest ${manifestRel || '(unspecified)'} is missing.` });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const requirements = await readFileSafe(path.join(dir, reqFile));
|
|
132
|
+
checks.requirements_exists = requirements !== null;
|
|
133
|
+
if (!checks.requirements_exists) {
|
|
134
|
+
return emit({ ok: false, status: 'fail', reason: 'missing_requirements', feature_slug: slug, checks,
|
|
135
|
+
message: `PRD references a prototype but ${reqFile} is missing — @analyst has not authored the acceptance-criteria bridge.` });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const interactions = extractInteractions(manifest);
|
|
139
|
+
const reqFolded = fold(requirements);
|
|
140
|
+
const uncovered = interactions.filter((i) => !reqFolded.includes(fold(i)));
|
|
141
|
+
const covered = interactions.length - uncovered.length;
|
|
142
|
+
const interactionsResult = { total: interactions.length, covered, uncovered };
|
|
143
|
+
|
|
144
|
+
if (interactions.length === 0) {
|
|
145
|
+
return emit({ ok: true, status: 'ok', feature_slug: slug, checks, interactions: interactionsResult,
|
|
146
|
+
message: 'Prototype, manifest, and requirements all present. Manifest lists no machine-readable Core interactions to cover.' });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (covered === 0) {
|
|
150
|
+
return emit({ ok: false, status: 'fail', reason: 'no_ac_coverage', feature_slug: slug, checks, interactions: interactionsResult,
|
|
151
|
+
message: 'None of the prototype Core interactions appear in the requirements ACs — the prototype is not reaching @validator.' });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (uncovered.length > 0) {
|
|
155
|
+
return emit({ ok: !strict, status: strict ? 'fail' : 'warn', reason: 'partial_ac_coverage', feature_slug: slug, checks, interactions: interactionsResult,
|
|
156
|
+
message: 'Some prototype Core interactions have no matching acceptance criterion. Add an AC per interaction (or defer it explicitly in the PRD).' });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return emit({ ok: true, status: 'ok', feature_slug: slug, checks, interactions: interactionsResult,
|
|
160
|
+
message: 'Every prototype Core interaction is echoed in the requirements ACs.' });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { runPrototypeCheck };
|