@jaimevalasek/aioson 1.36.0 → 1.37.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/CHANGELOG.md +28 -0
- package/docs/en/1-understand/ecosystem-map.md +1 -1
- package/docs/en/1-understand/glossary.md +1 -1
- package/docs/en/2-start/first-project.md +1 -1
- package/docs/en/2-start/initial-decisions.md +4 -2
- package/docs/en/3-recipes/from-idea-to-prd-via-briefing.md +8 -1
- package/docs/en/3-recipes/full-feature-with-sheldon.md +3 -1
- package/docs/en/4-agents/README.md +6 -3
- package/docs/en/4-agents/briefing-refiner.md +146 -0
- package/docs/en/5-reference/README.md +1 -0
- package/docs/en/5-reference/autopilot-handoff.md +286 -0
- package/docs/en/5-reference/cli-reference.md +6 -0
- package/docs/pt/1-entender/glossario.md +1 -1
- package/docs/pt/1-entender/mapa-do-ecossistema.md +1 -1
- package/docs/pt/2-comecar/decisoes-iniciais.md +4 -2
- package/docs/pt/2-comecar/primeiro-projeto.md +1 -1
- package/docs/pt/3-receitas/da-ideia-ao-prd-via-briefing.md +8 -1
- package/docs/pt/3-receitas/feature-completa-com-sheldon.md +3 -1
- package/docs/pt/4-agentes/README.md +13 -11
- package/docs/pt/4-agentes/briefing-refiner.md +64 -40
- package/docs/pt/4-agentes/briefing.md +6 -1
- package/docs/pt/4-agentes/dev.md +19 -1
- package/docs/pt/4-agentes/deyvin.md +4 -0
- package/docs/pt/4-agentes/discover.md +4 -0
- package/docs/pt/4-agentes/neo.md +4 -0
- package/docs/pt/4-agentes/orache.md +6 -0
- package/docs/pt/4-agentes/orchestrator.md +12 -0
- package/docs/pt/4-agentes/pentester.md +6 -0
- package/docs/pt/4-agentes/product.md +19 -1
- package/docs/pt/4-agentes/qa.md +10 -2
- package/docs/pt/4-agentes/setup.md +3 -1
- package/docs/pt/4-agentes/sheldon.md +12 -0
- package/docs/pt/4-agentes/tester.md +6 -0
- package/docs/pt/4-agentes/ux-ui.md +2 -1
- package/docs/pt/5-referencia/README.md +1 -1
- package/docs/pt/5-referencia/agent-chain-continuity.md +1 -1
- package/docs/pt/5-referencia/autopilot-handoff.md +191 -74
- package/docs/pt/5-referencia/comandos-cli.md +16 -7
- package/docs/pt/5-referencia/skills.md +2 -0
- package/docs/pt/agentes.md +3 -1
- package/package.json +1 -1
- package/src/agents.js +1 -1
- package/src/artifact-kinds.js +2 -1
- package/src/autopilot-signal.js +71 -0
- package/src/cli.js +9 -1
- package/src/commands/agents.js +18 -2
- package/src/commands/briefing.js +337 -1
- package/src/commands/feature-close.js +136 -43
- package/src/commands/live.js +47 -11
- package/src/commands/update.js +5 -1
- package/src/commands/verification-plan.js +28 -1
- package/src/commands/verify-artifact.js +64 -1
- package/src/commands/workflow-execute.js +149 -31
- package/src/commands/workflow-next.js +60 -16
- package/src/doctor.js +4 -2
- package/src/i18n/messages/en.js +2 -1
- package/src/i18n/messages/es.js +2 -1
- package/src/i18n/messages/fr.js +2 -1
- package/src/i18n/messages/pt-BR.js +2 -1
- package/src/lib/briefing-refiner/apply-feedback.js +18 -4
- package/src/lib/briefing-refiner/feedback-schema.js +73 -4
- package/src/lib/briefing-refiner/refinement-report.js +11 -0
- package/src/lib/briefing-refiner/review-html.js +388 -68
- package/src/parser.js +6 -0
- package/template/.aioson/agents/briefing-refiner.md +87 -47
- package/template/.aioson/agents/briefing.md +4 -0
- package/template/.aioson/agents/dev.md +9 -2
- package/template/.aioson/agents/deyvin.md +4 -0
- package/template/.aioson/agents/discover.md +4 -0
- package/template/.aioson/agents/neo.md +4 -0
- package/template/.aioson/agents/orache.md +4 -0
- package/template/.aioson/agents/orchestrator.md +16 -0
- package/template/.aioson/agents/pentester.md +4 -0
- package/template/.aioson/agents/product.md +25 -1
- package/template/.aioson/agents/qa.md +5 -1
- package/template/.aioson/agents/sheldon.md +9 -1
- package/template/.aioson/agents/tester.md +4 -0
- package/template/.aioson/agents/ux-ui.md +1 -1
- package/template/.aioson/docs/agent-help.md +126 -0
- package/template/.aioson/docs/autopilot-handoff.md +26 -16
- package/template/.aioson/docs/dev/phase-loop.md +8 -5
- package/template/.aioson/docs/play/llm-data-and-bindings.md +70 -7
- package/template/.aioson/skills/design/interface-design/SKILL.md +17 -0
- package/template/AGENTS.md +36 -36
- package/template/CLAUDE.md +1 -1
package/src/commands/agents.js
CHANGED
|
@@ -12,7 +12,8 @@ const {
|
|
|
12
12
|
const { normalizeInteractionLanguage } = require('../locales');
|
|
13
13
|
const { validateProjectContextFile, getInteractionLanguage } = require('../context');
|
|
14
14
|
const { exists } = require('../utils');
|
|
15
|
-
const { loadOrCreateState, runWorkflowNext } = require('./workflow-next');
|
|
15
|
+
const { AUTOPILOT_HANDOFF_STAGES, loadOrCreateState, runWorkflowNext } = require('./workflow-next');
|
|
16
|
+
const { resolveAutopilotSignal } = require('../autopilot-signal');
|
|
16
17
|
const {
|
|
17
18
|
bootstrapDirectAgentPrompt,
|
|
18
19
|
classifyDirectAgentRuntime
|
|
@@ -278,12 +279,27 @@ async function runAgentPrompt({ args, options, logger, t }) {
|
|
|
278
279
|
manifest,
|
|
279
280
|
requestedMode: promptAgent.id === 'scope-check' && getScopeCheckModeOption(options) ? null : options.mode || null
|
|
280
281
|
});
|
|
282
|
+
// Direct handoffs carry the autopilot exception too — without it the
|
|
283
|
+
// injected scope boundary instructs a manual stop and overrides the agent
|
|
284
|
+
// .md's autopilot section even when the flag/scheme says to chain.
|
|
285
|
+
let autoHandoff = false;
|
|
286
|
+
if (AUTOPILOT_HANDOFF_STAGES.has(promptAgent.id)) {
|
|
287
|
+
try {
|
|
288
|
+
const signal = await resolveAutopilotSignal(targetDir, {
|
|
289
|
+
slug: options.feature ? String(options.feature).trim() : null
|
|
290
|
+
});
|
|
291
|
+
autoHandoff = signal.enabled;
|
|
292
|
+
} catch {
|
|
293
|
+
autoHandoff = false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
281
296
|
prompt = buildAgentPrompt(promptAgent, tool, {
|
|
282
297
|
instructionPath,
|
|
283
298
|
interactionLanguage: locale,
|
|
284
299
|
autonomyMode: effectiveMode,
|
|
285
300
|
capabilitySummary: buildAgentCapabilitySummary(manifest, tool),
|
|
286
|
-
activationContext
|
|
301
|
+
activationContext,
|
|
302
|
+
autoHandoff
|
|
287
303
|
});
|
|
288
304
|
const runtimeClass = classifyDirectAgentRuntime(promptAgent.id);
|
|
289
305
|
const handoffLabel = runtimeClass.source === 'squad_session'
|
package/src/commands/briefing.js
CHANGED
|
@@ -14,13 +14,20 @@
|
|
|
14
14
|
* Format: YAML frontmatter (briefings: array) + Markdown table
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
const fsp = require('node:fs/promises');
|
|
17
18
|
const path = require('node:path');
|
|
18
19
|
const readline = require('node:readline');
|
|
19
20
|
const {
|
|
20
21
|
configPath: registryConfigPath,
|
|
22
|
+
markRefinementState,
|
|
21
23
|
readBriefingRegistry,
|
|
22
24
|
writeBriefingRegistry
|
|
23
25
|
} = require('../lib/briefing-refiner/briefing-registry');
|
|
26
|
+
const { hashText, parseBriefingSections } = require('../lib/briefing-refiner/briefing-sections');
|
|
27
|
+
const { assertFeedbackPath, validateFeedback, validateFindingsInput } = require('../lib/briefing-refiner/feedback-schema');
|
|
28
|
+
const { resolveBriefingPath } = require('../lib/briefing-refiner/briefing-paths');
|
|
29
|
+
const { writeReviewArtifacts } = require('../lib/briefing-refiner/review-html');
|
|
30
|
+
const { applyConfirmedFeedback, applyDeclinedFeedback } = require('../lib/briefing-refiner/apply-feedback');
|
|
24
31
|
|
|
25
32
|
// ─── Interactive prompt helpers ───────────────────────────────────────────────
|
|
26
33
|
|
|
@@ -219,4 +226,333 @@ async function runBriefingUnapprove({ args, options = {}, logger }) {
|
|
|
219
226
|
return { ok: true, unapproved: names };
|
|
220
227
|
}
|
|
221
228
|
|
|
222
|
-
|
|
229
|
+
// ─── briefing:review / briefing:apply-feedback ────────────────────────────────
|
|
230
|
+
//
|
|
231
|
+
// The deterministic half of @briefing-refiner: the agent does the intelligent
|
|
232
|
+
// audit (findings), the CLI owns the surface — parse briefings.md, render
|
|
233
|
+
// review.html + refinement-feedback.json + refinement-report.md, and later
|
|
234
|
+
// validate + apply the exported feedback. This kills the per-run hand-written
|
|
235
|
+
// HTML (token cost + schema drift) that used to stand in for these commands.
|
|
236
|
+
|
|
237
|
+
const USER_EXPORT_METHODS = new Set(['download', 'copy-paste', 'file-system-access']);
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Resolve which briefing a refinement command targets. Refinable = `draft`, or
|
|
241
|
+
* `approved` with no PRD yet (same rule as the @briefing-refiner contract).
|
|
242
|
+
* Unambiguous cases resolve without --slug; ambiguity is an error (these
|
|
243
|
+
* commands are agent-driven, so no interactive prompt).
|
|
244
|
+
*/
|
|
245
|
+
async function resolveRefinableSlug(projectDir, slugOpt) {
|
|
246
|
+
let data;
|
|
247
|
+
try {
|
|
248
|
+
data = await readBriefingRegistry(projectDir);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
return { ok: false, error: error && error.code === 'invalid_frontmatter' ? 'invalid_frontmatter' : 'no_config' };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const refinable = data.briefings.filter(
|
|
254
|
+
(b) => b.status === 'draft' || (b.status === 'approved' && !b.prd_generated)
|
|
255
|
+
);
|
|
256
|
+
const slug = String(slugOpt || '').trim() || null;
|
|
257
|
+
|
|
258
|
+
if (slug) {
|
|
259
|
+
if (!refinable.some((b) => b.slug === slug)) {
|
|
260
|
+
return { ok: false, error: 'slug_not_refinable', slug, candidates: refinable.map((b) => b.slug) };
|
|
261
|
+
}
|
|
262
|
+
return { ok: true, slug };
|
|
263
|
+
}
|
|
264
|
+
if (refinable.length === 1) return { ok: true, slug: refinable[0].slug };
|
|
265
|
+
if (refinable.length === 0) return { ok: false, error: 'no_refinable' };
|
|
266
|
+
return { ok: false, error: 'ambiguous_slug', candidates: refinable.map((b) => b.slug) };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function logSlugResolutionError(resolved, logger) {
|
|
270
|
+
if (resolved.error === 'no_config') {
|
|
271
|
+
logger.error('No briefing registry found. Activate @briefing to create the first briefing.');
|
|
272
|
+
} else if (resolved.error === 'invalid_frontmatter') {
|
|
273
|
+
logger.error('briefings/config.md has invalid frontmatter. Fix it manually.');
|
|
274
|
+
} else if (resolved.error === 'no_refinable') {
|
|
275
|
+
logger.error('No refinable briefing (draft, or approved without a PRD).');
|
|
276
|
+
} else if (resolved.error === 'ambiguous_slug') {
|
|
277
|
+
logger.error(`Multiple refinable briefings — pass --slug=<slug>. Candidates: ${resolved.candidates.join(', ')}`);
|
|
278
|
+
} else if (resolved.error === 'slug_not_refinable') {
|
|
279
|
+
logger.error(`Briefing "${resolved.slug}" is not refinable. Candidates: ${(resolved.candidates || []).join(', ') || 'none'}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function readOptionalJson(filePath) {
|
|
284
|
+
let raw;
|
|
285
|
+
try {
|
|
286
|
+
raw = await fsp.readFile(filePath, 'utf8');
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (error && error.code === 'ENOENT') return { exists: false, value: null };
|
|
289
|
+
return { exists: true, error: error.message, value: null };
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
return { exists: true, value: JSON.parse(raw) };
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return { exists: true, error: `invalid JSON: ${error.message}`, value: null };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function runBriefingReview({ args, options = {}, logger }) {
|
|
299
|
+
const projectDir = path.resolve(process.cwd(), args[0] || '.');
|
|
300
|
+
const resolved = await resolveRefinableSlug(projectDir, options.slug);
|
|
301
|
+
if (!resolved.ok) {
|
|
302
|
+
logSlugResolutionError(resolved, logger);
|
|
303
|
+
return resolved;
|
|
304
|
+
}
|
|
305
|
+
const slug = resolved.slug;
|
|
306
|
+
|
|
307
|
+
let markdown;
|
|
308
|
+
try {
|
|
309
|
+
markdown = await fsp.readFile(resolveBriefingPath(projectDir, slug, 'briefings.md'), 'utf8');
|
|
310
|
+
} catch {
|
|
311
|
+
logger.error(`briefings.md not found for "${slug}".`);
|
|
312
|
+
return { ok: false, error: 'briefing_not_found', slug };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const parsed = parseBriefingSections(markdown, `.aioson/briefings/${slug}/briefings.md`);
|
|
316
|
+
if (parsed.missing.length > 0) {
|
|
317
|
+
logger.error(`briefings.md is missing mandatory sections: ${parsed.missing.join(', ')}`);
|
|
318
|
+
return { ok: false, error: 'missing_sections', slug, missing: parsed.missing };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Findings: --findings=<path>, else the canonical drop path the agent writes.
|
|
322
|
+
const findingsExplicit = Boolean(options.findings);
|
|
323
|
+
const findingsPath = findingsExplicit
|
|
324
|
+
? path.resolve(projectDir, String(options.findings))
|
|
325
|
+
: resolveBriefingPath(projectDir, slug, 'refinement-findings.json');
|
|
326
|
+
const findingsRead = await readOptionalJson(findingsPath);
|
|
327
|
+
if (findingsRead.error) {
|
|
328
|
+
logger.error(`cannot read findings file (${findingsPath}): ${findingsRead.error}`);
|
|
329
|
+
return { ok: false, error: 'invalid_findings', slug };
|
|
330
|
+
}
|
|
331
|
+
if (findingsExplicit && !findingsRead.exists) {
|
|
332
|
+
logger.error(`findings file not found: ${findingsPath}`);
|
|
333
|
+
return { ok: false, error: 'invalid_findings', slug };
|
|
334
|
+
}
|
|
335
|
+
let findings = [];
|
|
336
|
+
if (findingsRead.exists) {
|
|
337
|
+
const value = findingsRead.value;
|
|
338
|
+
findings = Array.isArray(value) ? value : (value && Array.isArray(value.findings) ? value.findings : null);
|
|
339
|
+
if (!findings) {
|
|
340
|
+
logger.error('findings file must be a JSON array or { "findings": [...] }');
|
|
341
|
+
return { ok: false, error: 'invalid_findings', slug };
|
|
342
|
+
}
|
|
343
|
+
const check = validateFindingsInput(findings, { sectionIds: parsed.sections.map((s) => s.id) });
|
|
344
|
+
if (!check.ok) {
|
|
345
|
+
for (const err of check.errors) logger.error(`findings: ${err}`);
|
|
346
|
+
return { ok: false, error: 'invalid_findings', slug, errors: check.errors };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Round counter + pending-feedback protection: never silently clobber
|
|
351
|
+
// feedback the user already exported for the CURRENT briefing text. The
|
|
352
|
+
// round survives apply (which archives the canonical feedback) by also
|
|
353
|
+
// counting the applied-round archives.
|
|
354
|
+
const feedbackPath = resolveBriefingPath(projectDir, slug, 'refinement-feedback.json');
|
|
355
|
+
let lastRound = 0;
|
|
356
|
+
const prior = await readOptionalJson(feedbackPath);
|
|
357
|
+
if (prior.exists && prior.value && typeof prior.value === 'object') {
|
|
358
|
+
lastRound = Number.isInteger(prior.value.round) ? prior.value.round : 0;
|
|
359
|
+
const userExported = USER_EXPORT_METHODS.has(prior.value.export_method);
|
|
360
|
+
const fresh = prior.value.source_hash === parsed.source_hash;
|
|
361
|
+
if (userExported && fresh && !options.force) {
|
|
362
|
+
logger.error('refinement-feedback.json holds user-exported feedback for the current briefing text.');
|
|
363
|
+
logger.error('Apply it first (aioson briefing:apply-feedback) or pass --force to overwrite it.');
|
|
364
|
+
return { ok: false, error: 'pending_feedback', slug };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
const entries = await fsp.readdir(resolveBriefingPath(projectDir, slug));
|
|
369
|
+
for (const entry of entries) {
|
|
370
|
+
const match = entry.match(/^refinement-feedback\.(?:applied|declined)-round(\d+)/);
|
|
371
|
+
if (match) lastRound = Math.max(lastRound, Number(match[1]));
|
|
372
|
+
}
|
|
373
|
+
} catch { /* directory listing is best-effort */ }
|
|
374
|
+
const round = lastRound + 1;
|
|
375
|
+
|
|
376
|
+
const locale = String(options.locale || process.env.AIOS_LITE_LOCALE || 'en');
|
|
377
|
+
await writeReviewArtifacts(projectDir, {
|
|
378
|
+
slug,
|
|
379
|
+
sourceMarkdown: markdown,
|
|
380
|
+
sections: parsed.sections,
|
|
381
|
+
sourceHash: parsed.source_hash,
|
|
382
|
+
findings,
|
|
383
|
+
round,
|
|
384
|
+
locale
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// Best-effort registry pointer so the dashboard/agents see the active review.
|
|
388
|
+
try {
|
|
389
|
+
const registry = await readBriefingRegistry(projectDir);
|
|
390
|
+
markRefinementState(registry, slug, {
|
|
391
|
+
refinement_status: 'review_generated',
|
|
392
|
+
review_html: `.aioson/briefings/${slug}/review.html`,
|
|
393
|
+
refinement_report: `.aioson/briefings/${slug}/refinement-report.md`
|
|
394
|
+
});
|
|
395
|
+
await writeBriefingRegistry(projectDir, registry);
|
|
396
|
+
} catch { /* registry pointer is advisory */ }
|
|
397
|
+
|
|
398
|
+
const reviewRel = `.aioson/briefings/${slug}/review.html`;
|
|
399
|
+
logger.log(`✓ Review generated for "${slug}" (round ${round}): ${parsed.sections.length} sections, ${findings.length} findings.`);
|
|
400
|
+
logger.log(` Open ${reviewRel} in a real browser (not an editor preview), review, then export the JSON.`);
|
|
401
|
+
if (!findingsRead.exists) {
|
|
402
|
+
logger.log(' No findings file was provided — the review carries sections only.');
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
ok: true,
|
|
406
|
+
slug,
|
|
407
|
+
round,
|
|
408
|
+
sections: parsed.sections.length,
|
|
409
|
+
findings: findings.length,
|
|
410
|
+
source_hash: parsed.source_hash,
|
|
411
|
+
review: reviewRel,
|
|
412
|
+
feedback: `.aioson/briefings/${slug}/refinement-feedback.json`,
|
|
413
|
+
report: `.aioson/briefings/${slug}/refinement-report.md`
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function summarizeFeedback(feedback) {
|
|
418
|
+
const sections = feedback.sections || [];
|
|
419
|
+
const findings = feedback.findings || [];
|
|
420
|
+
const changed = sections.filter((s) => s.status !== 'unchanged' || s.current_text !== s.original_text);
|
|
421
|
+
const findingsByStatus = {};
|
|
422
|
+
for (const finding of findings) {
|
|
423
|
+
findingsByStatus[finding.status] = (findingsByStatus[finding.status] || 0) + 1;
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
changed_sections: changed.map((s) => ({ id: s.id, status: s.status, text_changed: s.current_text !== s.original_text })),
|
|
427
|
+
blocked_sections: sections.filter((s) => s.status === 'blocked').map((s) => s.id),
|
|
428
|
+
comments: (feedback.comments || []).length,
|
|
429
|
+
findings_total: findings.length,
|
|
430
|
+
findings_by_status: findingsByStatus,
|
|
431
|
+
pending_blocking_findings: findings.filter((f) => f.blocking && f.status === 'pending').map((f) => f.id),
|
|
432
|
+
blocking_items: (feedback.blocking_items || []).length
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function runBriefingApplyFeedback({ args, options = {}, logger }) {
|
|
437
|
+
const projectDir = path.resolve(process.cwd(), args[0] || '.');
|
|
438
|
+
const resolved = await resolveRefinableSlug(projectDir, options.slug);
|
|
439
|
+
if (!resolved.ok) {
|
|
440
|
+
logSlugResolutionError(resolved, logger);
|
|
441
|
+
return resolved;
|
|
442
|
+
}
|
|
443
|
+
const slug = resolved.slug;
|
|
444
|
+
const allowStale = Boolean(options['allow-stale'] || options.allowStale);
|
|
445
|
+
|
|
446
|
+
const feedbackRel = options.feedback
|
|
447
|
+
? String(options.feedback)
|
|
448
|
+
: `.aioson/briefings/${slug}/refinement-feedback.json`;
|
|
449
|
+
try {
|
|
450
|
+
assertFeedbackPath(projectDir, slug, feedbackRel);
|
|
451
|
+
} catch (error) {
|
|
452
|
+
logger.error(error.message);
|
|
453
|
+
return { ok: false, error: 'invalid_feedback_path', slug };
|
|
454
|
+
}
|
|
455
|
+
const feedbackPath = path.resolve(projectDir, feedbackRel);
|
|
456
|
+
const feedbackRead = await readOptionalJson(feedbackPath);
|
|
457
|
+
if (!feedbackRead.exists || feedbackRead.error) {
|
|
458
|
+
logger.error(`cannot read feedback (${feedbackRel}): ${feedbackRead.error || 'file not found'}`);
|
|
459
|
+
return { ok: false, error: 'feedback_not_found', slug };
|
|
460
|
+
}
|
|
461
|
+
const feedback = feedbackRead.value;
|
|
462
|
+
|
|
463
|
+
if (options.declined) {
|
|
464
|
+
const result = await applyDeclinedFeedback(projectDir, slug, feedback, { allowStale: true });
|
|
465
|
+
if (!result.ok) {
|
|
466
|
+
for (const err of (result.validation && result.validation.errors) || []) logger.error(`feedback: ${err}`);
|
|
467
|
+
return { ...result, slug, mode: 'declined' };
|
|
468
|
+
}
|
|
469
|
+
// Archive the declined feedback so "file present = pending" stays true and
|
|
470
|
+
// the next briefing:review does not dead-end on pending_feedback. Findings
|
|
471
|
+
// are NOT archived: the briefing text is unchanged, so they stay valid.
|
|
472
|
+
const canonicalPath = resolveBriefingPath(projectDir, slug, 'refinement-feedback.json');
|
|
473
|
+
if (feedbackPath === canonicalPath) {
|
|
474
|
+
const round = feedback.round || 1;
|
|
475
|
+
let archiveName = `refinement-feedback.declined-round${round}.json`;
|
|
476
|
+
try {
|
|
477
|
+
await fsp.access(resolveBriefingPath(projectDir, slug, archiveName));
|
|
478
|
+
archiveName = `refinement-feedback.declined-round${round}-${Date.now()}.json`;
|
|
479
|
+
} catch { /* target free */ }
|
|
480
|
+
try {
|
|
481
|
+
await fsp.rename(canonicalPath, resolveBriefingPath(projectDir, slug, archiveName));
|
|
482
|
+
result.archived = `.aioson/briefings/${slug}/${archiveName}`;
|
|
483
|
+
} catch { /* archive is best-effort */ }
|
|
484
|
+
}
|
|
485
|
+
logger.log(`✓ Feedback declined for "${slug}" — briefings.md unchanged, ${result.skippedChanges.length} change(s) recorded as skipped.`);
|
|
486
|
+
return { ...result, slug, mode: 'declined' };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (!options.confirm) {
|
|
490
|
+
// Dry-run: validate + summarize so the agent can present the plan and ask
|
|
491
|
+
// for the human go-ahead. Never touches briefings.md.
|
|
492
|
+
let current;
|
|
493
|
+
try {
|
|
494
|
+
current = await fsp.readFile(resolveBriefingPath(projectDir, slug, 'briefings.md'), 'utf8');
|
|
495
|
+
} catch {
|
|
496
|
+
logger.error(`briefings.md not found for "${slug}".`);
|
|
497
|
+
return { ok: false, error: 'briefing_not_found', slug };
|
|
498
|
+
}
|
|
499
|
+
const validation = validateFeedback(feedback, { slug, currentSourceHash: hashText(current), allowStale });
|
|
500
|
+
const summary = summarizeFeedback(feedback);
|
|
501
|
+
if (!validation.ok) {
|
|
502
|
+
for (const err of validation.errors) logger.error(`feedback: ${err}`);
|
|
503
|
+
return { ok: false, error: 'invalid_feedback', slug, mode: 'dry-run', validation, summary };
|
|
504
|
+
}
|
|
505
|
+
logger.log(`Dry-run for "${slug}" (feedback round ${feedback.round || 1}):`);
|
|
506
|
+
logger.log(` changed sections: ${summary.changed_sections.map((c) => c.id).join(', ') || 'none'}`);
|
|
507
|
+
logger.log(` blocked sections: ${summary.blocked_sections.join(', ') || 'none'}`);
|
|
508
|
+
logger.log(` findings: ${summary.findings_total} (${Object.entries(summary.findings_by_status).map(([k, v]) => `${k}: ${v}`).join(', ') || '-'})`);
|
|
509
|
+
logger.log(` pending blocking findings: ${summary.pending_blocking_findings.join(', ') || 'none'}`);
|
|
510
|
+
for (const warning of validation.warnings) logger.log(` warning: ${warning}`);
|
|
511
|
+
logger.log('Re-run with --confirm to apply.');
|
|
512
|
+
return { ok: true, slug, mode: 'dry-run', pending_confirmation: true, validation, summary };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const result = await applyConfirmedFeedback(projectDir, slug, feedback, { confirmed: true, allowStale });
|
|
516
|
+
if (!result.ok) {
|
|
517
|
+
for (const err of (result.validation && result.validation.errors) || []) logger.error(`feedback: ${err}`);
|
|
518
|
+
if (result.error === 'invalid_feedback' && result.validation && result.validation.stale && !allowStale) {
|
|
519
|
+
logger.error('Feedback is stale (briefings.md changed since the review). Regenerate the review or pass --allow-stale.');
|
|
520
|
+
}
|
|
521
|
+
return { ...result, slug, mode: 'apply' };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Archive the consumed canonical feedback so "pending feedback" stays a
|
|
525
|
+
// deterministic signal (file present = not yet applied).
|
|
526
|
+
const canonicalPath = resolveBriefingPath(projectDir, slug, 'refinement-feedback.json');
|
|
527
|
+
if (feedbackPath === canonicalPath) {
|
|
528
|
+
const round = feedback.round || 1;
|
|
529
|
+
let archiveName = `refinement-feedback.applied-round${round}.json`;
|
|
530
|
+
try {
|
|
531
|
+
await fsp.access(resolveBriefingPath(projectDir, slug, archiveName));
|
|
532
|
+
archiveName = `refinement-feedback.applied-round${round}-${Date.now()}.json`;
|
|
533
|
+
} catch { /* target free */ }
|
|
534
|
+
try {
|
|
535
|
+
await fsp.rename(canonicalPath, resolveBriefingPath(projectDir, slug, archiveName));
|
|
536
|
+
result.archived = `.aioson/briefings/${slug}/${archiveName}`;
|
|
537
|
+
} catch { /* archive is best-effort */ }
|
|
538
|
+
// Retire the consumed findings too — the next round must audit the UPDATED
|
|
539
|
+
// briefing, not silently reuse last round's findings.
|
|
540
|
+
try {
|
|
541
|
+
await fsp.rename(
|
|
542
|
+
resolveBriefingPath(projectDir, slug, 'refinement-findings.json'),
|
|
543
|
+
resolveBriefingPath(projectDir, slug, `refinement-findings.applied-round${round}.json`)
|
|
544
|
+
);
|
|
545
|
+
} catch { /* no findings file, or archive failed — best-effort */ }
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
logger.log(`✓ Applied ${result.appliedChanges.length} change(s) to "${slug}".`);
|
|
549
|
+
if (result.returnedToDraft) logger.log(' Briefing returned from approved to draft.');
|
|
550
|
+
if (result.nextAction === 'resolve_blockers') {
|
|
551
|
+
logger.log(' Blockers remain — resolve them and regenerate the review (aioson briefing:review).');
|
|
552
|
+
} else {
|
|
553
|
+
logger.log(' No blockers — approve with `aioson briefing:approve` and hand off to @product, or regenerate the review for another round.');
|
|
554
|
+
}
|
|
555
|
+
return { ...result, slug, mode: 'apply' };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
module.exports = { runBriefingApprove, runBriefingUnapprove, runBriefingReview, runBriefingApplyFeedback };
|
|
@@ -16,20 +16,21 @@ const fs = require('node:fs/promises');
|
|
|
16
16
|
const path = require('node:path');
|
|
17
17
|
const { contextDir, readFileSafe, parseFrontmatter } = require('../preflight-engine');
|
|
18
18
|
const { runFeatureArchive } = require('./feature-archive');
|
|
19
|
+
const { runStateReset } = require('./state-save');
|
|
19
20
|
const dossierBootstrap = require('../dossier/dossier-bootstrap');
|
|
20
21
|
const dossierStore = require('../dossier/store');
|
|
21
22
|
const { emitDossierEvent } = require('../lib/dossier-telemetry');
|
|
22
23
|
const { appendScoutToFeatureDossier } = require('../dossier/scout-section');
|
|
23
24
|
const { emitSubTaskEvent } = require('../sub-task-telemetry');
|
|
24
25
|
const { loadConfig } = require('../sub-task-engine');
|
|
25
|
-
const { runDistillation, readFeatureClassification } = require('../learning-loop-engine');
|
|
26
|
-
const { openRuntimeDb } = require('../runtime-store');
|
|
27
|
-
const { runNotify } = require('./notify');
|
|
28
|
-
const { splitCurrentState, buildArchiveContent, parseActiveSlugs } = require('../current-state-trim');
|
|
29
|
-
const {
|
|
30
|
-
evaluateContractIntegrityGate,
|
|
31
|
-
formatContractIntegrityGateError
|
|
32
|
-
} = require('../harness/contract-integrity-gate');
|
|
26
|
+
const { runDistillation, readFeatureClassification } = require('../learning-loop-engine');
|
|
27
|
+
const { openRuntimeDb } = require('../runtime-store');
|
|
28
|
+
const { runNotify } = require('./notify');
|
|
29
|
+
const { splitCurrentState, buildArchiveContent, parseActiveSlugs } = require('../current-state-trim');
|
|
30
|
+
const {
|
|
31
|
+
evaluateContractIntegrityGate,
|
|
32
|
+
formatContractIntegrityGateError
|
|
33
|
+
} = require('../harness/contract-integrity-gate');
|
|
33
34
|
|
|
34
35
|
// P0 agent-loading-contract: a feature closing is the natural cadence to roll
|
|
35
36
|
// aged-out current-state.md entries into the cold archive. Conservative window
|
|
@@ -302,6 +303,72 @@ async function archiveScoutsForFeature(targetDir, slug) {
|
|
|
302
303
|
return result;
|
|
303
304
|
}
|
|
304
305
|
|
|
306
|
+
// retireDevStateForClosedFeature — clear the @dev cold-start pointer when it
|
|
307
|
+
// still points at the feature being closed. @dev reads dev-state.md DIRECTLY on
|
|
308
|
+
// cold start (its session-start protocol "starts on next_step immediately"),
|
|
309
|
+
// bypassing the dev:resume-data guard that already checks features.md status —
|
|
310
|
+
// so a stale pointer to a done feature would make a future @dev session try to
|
|
311
|
+
// "resume" work that is already closed (e.g. next_step: aioson feature:close).
|
|
312
|
+
// Only retires when active_feature matches this slug; a pointer to a DIFFERENT
|
|
313
|
+
// active feature is left untouched. Archived (not blind-deleted) for audit via
|
|
314
|
+
// the existing state:reset --archive path. Idempotent and best-effort.
|
|
315
|
+
async function retireDevStateForClosedFeature(targetDir, ctxDir, slug) {
|
|
316
|
+
const statePath = path.join(ctxDir, 'dev-state.md');
|
|
317
|
+
const content = await readFileSafe(statePath);
|
|
318
|
+
if (!content) return { retired: false, reason: 'no_dev_state' };
|
|
319
|
+
const fm = parseFrontmatter(content);
|
|
320
|
+
const active = String(fm.active_feature || '').trim();
|
|
321
|
+
if (active !== slug) {
|
|
322
|
+
return { retired: false, reason: 'points_to_other', active_feature: active || null };
|
|
323
|
+
}
|
|
324
|
+
const reset = await runStateReset({
|
|
325
|
+
args: [targetDir],
|
|
326
|
+
options: { archive: true, json: true },
|
|
327
|
+
logger: { log() {} }
|
|
328
|
+
});
|
|
329
|
+
return {
|
|
330
|
+
retired: Boolean(reset && reset.removed),
|
|
331
|
+
archived: reset && reset.archived ? reset.archived : null
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// retireWorkflowStateForClosedFeature — clear the per-feature workflow runtime
|
|
336
|
+
// state when it still references the feature being closed. Both files live in
|
|
337
|
+
// .aioson/context/, are not slug-named, and are excluded from feature:archive —
|
|
338
|
+
// so without this they linger and break the NEXT feature:
|
|
339
|
+
// - workflow.state.json: seedFeatureWorkflowState refuses to seed a new slug
|
|
340
|
+
// while a stale feature state is present ("different_active_feature").
|
|
341
|
+
// - workflow-execute.json: its enabled agentic_policy is the autopilot signal
|
|
342
|
+
// downstream agents read — a stale one would auto-run the next feature.
|
|
343
|
+
// Only clears when the file references THIS slug (feature mode); a project-mode
|
|
344
|
+
// state or a pointer to another feature is left untouched. Best-effort.
|
|
345
|
+
async function retireWorkflowStateForClosedFeature(ctxDir, slug) {
|
|
346
|
+
const retired = [];
|
|
347
|
+
const statePath = path.join(ctxDir, 'workflow.state.json');
|
|
348
|
+
const stateRaw = await readFileSafe(statePath);
|
|
349
|
+
if (stateRaw) {
|
|
350
|
+
try {
|
|
351
|
+
const st = JSON.parse(stateRaw);
|
|
352
|
+
if (st && st.mode === 'feature' && st.featureSlug === slug) {
|
|
353
|
+
await fs.unlink(statePath);
|
|
354
|
+
retired.push('workflow.state.json');
|
|
355
|
+
}
|
|
356
|
+
} catch { /* malformed — leave it for manual inspection */ }
|
|
357
|
+
}
|
|
358
|
+
const execPath = path.join(ctxDir, 'workflow-execute.json');
|
|
359
|
+
const execRaw = await readFileSafe(execPath);
|
|
360
|
+
if (execRaw) {
|
|
361
|
+
try {
|
|
362
|
+
const ex = JSON.parse(execRaw);
|
|
363
|
+
if (ex && ex.feature === slug) {
|
|
364
|
+
await fs.unlink(execPath);
|
|
365
|
+
retired.push('workflow-execute.json');
|
|
366
|
+
}
|
|
367
|
+
} catch { /* malformed — leave it */ }
|
|
368
|
+
}
|
|
369
|
+
return retired;
|
|
370
|
+
}
|
|
371
|
+
|
|
305
372
|
async function runFeatureClose({ args, options = {}, logger }) {
|
|
306
373
|
const targetDir = path.resolve(process.cwd(), args[0] || '.');
|
|
307
374
|
const slug = options.feature ? String(options.feature) : null;
|
|
@@ -326,40 +393,40 @@ async function runFeatureClose({ args, options = {}, logger }) {
|
|
|
326
393
|
const updates = [];
|
|
327
394
|
|
|
328
395
|
// 0a. Harness Done Gate (AC-HD-11 refined)
|
|
329
|
-
// Only enforced on PASS — FAIL means QA already rejected and we want the
|
|
330
|
-
// closure to record that. Runtime features the CLI can detect (prototype or
|
|
331
|
-
// migration/Prisma evidence) must have a valid contract even on MICRO/SMALL;
|
|
332
|
-
// non-runtime features without a contract keep the historical lightweight path.
|
|
396
|
+
// Only enforced on PASS — FAIL means QA already rejected and we want the
|
|
397
|
+
// closure to record that. Runtime features the CLI can detect (prototype or
|
|
398
|
+
// migration/Prisma evidence) must have a valid contract even on MICRO/SMALL;
|
|
399
|
+
// non-runtime features without a contract keep the historical lightweight path.
|
|
333
400
|
if (verdict === 'PASS') {
|
|
334
401
|
const planDir = path.join(targetDir, '.aioson', 'plans', slug);
|
|
335
|
-
const contractPath = path.join(planDir, 'harness-contract.json');
|
|
336
|
-
const progressPath = path.join(planDir, 'progress.json');
|
|
337
|
-
const contractContent = await readFileSafe(contractPath);
|
|
338
|
-
const progressContent = await readFileSafe(progressPath);
|
|
339
|
-
const force = options.force === true;
|
|
340
|
-
|
|
341
|
-
const integrityGate = await evaluateContractIntegrityGate(targetDir, slug, {
|
|
342
|
-
runChecks: Boolean(contractContent)
|
|
343
|
-
});
|
|
344
|
-
if (!integrityGate.ok) {
|
|
345
|
-
if (!force) {
|
|
346
|
-
const errMsg = formatContractIntegrityGateError(integrityGate);
|
|
347
|
-
if (options.json) {
|
|
348
|
-
return {
|
|
349
|
-
ok: false,
|
|
350
|
-
reason: 'harness_contract_gate_blocked',
|
|
351
|
-
feature: slug,
|
|
352
|
-
error: errMsg,
|
|
353
|
-
errors: integrityGate.errors
|
|
354
|
-
};
|
|
355
|
-
}
|
|
356
|
-
logger.log(errMsg);
|
|
357
|
-
return { ok: false, reason: 'harness_contract_gate_blocked' };
|
|
358
|
-
}
|
|
359
|
-
updates.push('harness contract gate: BYPASSED via --force');
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// REQ-13 (loop-guardrails): tema `publish` é gate de COMANDO — intercepta
|
|
402
|
+
const contractPath = path.join(planDir, 'harness-contract.json');
|
|
403
|
+
const progressPath = path.join(planDir, 'progress.json');
|
|
404
|
+
const contractContent = await readFileSafe(contractPath);
|
|
405
|
+
const progressContent = await readFileSafe(progressPath);
|
|
406
|
+
const force = options.force === true;
|
|
407
|
+
|
|
408
|
+
const integrityGate = await evaluateContractIntegrityGate(targetDir, slug, {
|
|
409
|
+
runChecks: Boolean(contractContent)
|
|
410
|
+
});
|
|
411
|
+
if (!integrityGate.ok) {
|
|
412
|
+
if (!force) {
|
|
413
|
+
const errMsg = formatContractIntegrityGateError(integrityGate);
|
|
414
|
+
if (options.json) {
|
|
415
|
+
return {
|
|
416
|
+
ok: false,
|
|
417
|
+
reason: 'harness_contract_gate_blocked',
|
|
418
|
+
feature: slug,
|
|
419
|
+
error: errMsg,
|
|
420
|
+
errors: integrityGate.errors
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
logger.log(errMsg);
|
|
424
|
+
return { ok: false, reason: 'harness_contract_gate_blocked' };
|
|
425
|
+
}
|
|
426
|
+
updates.push('harness contract gate: BYPASSED via --force');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// REQ-13 (loop-guardrails): tema `publish` é gate de COMANDO — intercepta
|
|
363
430
|
// o feature:close quando o contrato ativo o exige e não há gate publish
|
|
364
431
|
// aprovado. Nunca detectado por diff. `--force` NÃO bypassa: a aprovação
|
|
365
432
|
// humana é o propósito do gate (decisão registrada no spec da feature).
|
|
@@ -398,9 +465,9 @@ async function runFeatureClose({ args, options = {}, logger }) {
|
|
|
398
465
|
}
|
|
399
466
|
} catch { /* contrato ilegível — o done gate abaixo lida com o estado */ }
|
|
400
467
|
}
|
|
401
|
-
|
|
402
|
-
if (contractContent && progressContent) {
|
|
403
|
-
let progress = null;
|
|
468
|
+
|
|
469
|
+
if (contractContent && progressContent) {
|
|
470
|
+
let progress = null;
|
|
404
471
|
try {
|
|
405
472
|
progress = JSON.parse(progressContent);
|
|
406
473
|
} catch (err) {
|
|
@@ -471,6 +538,32 @@ async function runFeatureClose({ args, options = {}, logger }) {
|
|
|
471
538
|
updates.push('features.md: not found (skipped)');
|
|
472
539
|
}
|
|
473
540
|
|
|
541
|
+
// 2.5. Retire the @dev cold-start pointer if it still points at this feature.
|
|
542
|
+
// On PASS the feature is done; a lingering dev-state.md would make a future
|
|
543
|
+
// @dev cold-start try to resume it (it reads the file directly). FAIL leaves
|
|
544
|
+
// it — @dev keeps working the qa_failed feature. Never touches a pointer to a
|
|
545
|
+
// different active feature. Best-effort; never blocks the close.
|
|
546
|
+
if (verdict === 'PASS') {
|
|
547
|
+
try {
|
|
548
|
+
const ds = await retireDevStateForClosedFeature(targetDir, dir, slug);
|
|
549
|
+
if (ds.retired) {
|
|
550
|
+
updates.push(`dev-state.md: retired closed-feature pointer${ds.archived ? ` (archived to ${ds.archived})` : ''}`);
|
|
551
|
+
} else if (ds.reason === 'points_to_other') {
|
|
552
|
+
updates.push(`dev-state.md: left intact (points at ${ds.active_feature}, not ${slug})`);
|
|
553
|
+
}
|
|
554
|
+
} catch (err) {
|
|
555
|
+
updates.push(`dev-state.md: retire hook error (${(err && err.message) || err})`);
|
|
556
|
+
}
|
|
557
|
+
try {
|
|
558
|
+
const wf = await retireWorkflowStateForClosedFeature(dir, slug);
|
|
559
|
+
if (wf.length > 0) {
|
|
560
|
+
updates.push(`workflow state: retired ${wf.join(', ')} (closed-feature runtime state)`);
|
|
561
|
+
}
|
|
562
|
+
} catch (err) {
|
|
563
|
+
updates.push(`workflow state: retire hook error (${(err && err.message) || err})`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
474
567
|
// 3. Update project-pulse.md
|
|
475
568
|
const pulsePath = path.join(dir, 'project-pulse.md');
|
|
476
569
|
const pulseUpdated = await updateProjectPulseFile(
|