@godv61/dsh-task-engine 0.23.8 → 0.24.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/.acceptance.mjs +89 -0
- package/.assessment-batch1.mjs +403 -0
- package/.e2e-presets.mjs +195 -0
- package/.freeze-test.mjs +90 -0
- package/.hook-consistency.mjs +60 -0
- package/.p0-test.mjs +11 -4
- package/.preset-test.mjs +2 -2
- package/.revision-test.mjs +99 -0
- package/.workflow-test.mjs +64 -62
- package/defaults/eng.json +45 -6
- package/docs/BRIEF-FOR-REVIEW.md +163 -0
- package/docs/CHANGELOG.md +72 -0
- package/docs/configuration.md +31 -6
- package/hooks/commit-msg +104 -25
- package/lib/client.js +227 -106
- package/lib/client.js.map +2 -3
- package/lib/controller.d.ts +9 -0
- package/lib/controller.js +37 -3
- package/lib/controller.js.map +1 -1
- package/lib/dev-task.js +285 -43
- package/lib/dev-task.js.map +1 -1
- package/lib/engine.d.ts +278 -6
- package/lib/engine.js +264 -13
- package/lib/engine.js.map +1 -1
- package/lib/seed-preset.d.ts +5 -5
- package/lib/seed-preset.js +5 -5
- package/lib/skill-audit.js +5 -1
- package/lib/skill-audit.js.map +1 -1
- package/lib/workflows.js +87 -27
- package/lib/workflows.js.map +1 -1
- package/package.json +9 -3
- package/scripts/verify-package.mjs +3 -3
package/lib/dev-task.js
CHANGED
|
@@ -15,7 +15,7 @@ import { homedir } from 'node:os';
|
|
|
15
15
|
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
17
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
18
|
-
import { assertAdvance, bindingsForStage, checkFileScope, commitCheckpoint, legalTargets, newTask, taskIdFromMessage, unmetGuards, validateCommitMessage, validateWorkflow, } from "./engine.js";
|
|
18
|
+
import { assertAdvance, bindingsForStage, checkFileScope, commitCheckpoint, applyRevision, completionBlockers, formatResourceRef, invalidatedBy, legalTargets, newTask, taskIdFromMessage, unmetGuards, validateCommitMessage, validateWorkflow, verificationBlockers, } from "./engine.js";
|
|
19
19
|
import { HIGH_RISK_REQUIRED_CAPABILITIES, flowSatisfies, resolveFlow, } from "./workflows.js";
|
|
20
20
|
import { hashConfig, hashText } from "./snapshot.js";
|
|
21
21
|
import { loadedSkills, needsSkillReceipt, obligationStages, skillBlockers } from "./skill-audit.js";
|
|
@@ -342,36 +342,184 @@ function builtinRulesFingerprint() {
|
|
|
342
342
|
.join('\n');
|
|
343
343
|
return hashText(contents);
|
|
344
344
|
}
|
|
345
|
+
/** Read one rule body from a specific layer, so a reference decides where to look. */
|
|
346
|
+
async function readRuleAt(source, name, fs, cwd) {
|
|
347
|
+
const safe = sanitize(name);
|
|
348
|
+
if (safe === '')
|
|
349
|
+
return undefined;
|
|
350
|
+
switch (source) {
|
|
351
|
+
case 'bundled':
|
|
352
|
+
return readAbsRule(fileURLToPath(new URL(`../rules/${safe}.md`, import.meta.url)));
|
|
353
|
+
case 'project':
|
|
354
|
+
return await readText(fs, `.dsh/rules/${safe}.md`, cwd);
|
|
355
|
+
case 'user':
|
|
356
|
+
return readAbsRule(join(dshHome(), 'rules', `${safe}.md`));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/** Bundled skill directory, the only layer whose bodies this process can read without the host registry. */
|
|
360
|
+
const BUNDLED_SKILLS_DIR = fileURLToPath(new URL('../skills/', import.meta.url));
|
|
345
361
|
/**
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
362
|
+
* Read one skill body from a layer this process can reach.
|
|
363
|
+
*
|
|
364
|
+
* Project-level skills live under the workspace and are readable through the
|
|
365
|
+
* sandboxed fs; user-level ones sit under the harness home. A skill the host
|
|
366
|
+
* registry resolves but this process cannot read yields undefined, which is
|
|
367
|
+
* reported as unfreezable rather than silently omitted — the task must not claim
|
|
368
|
+
* to have frozen something it did not read.
|
|
369
|
+
* @param ref - the skill reference from a stage binding.
|
|
351
370
|
* @param fs - sandboxed filesystem for the project-level lookup.
|
|
352
|
-
* @returns the
|
|
371
|
+
* @returns the SKILL.md body, or undefined when it cannot be read here.
|
|
353
372
|
*/
|
|
354
|
-
async function
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
373
|
+
async function readSkillAt(ref, fs, cwd) {
|
|
374
|
+
const safe = sanitize(ref.name);
|
|
375
|
+
if (safe === '')
|
|
376
|
+
return undefined;
|
|
377
|
+
const read = (path) => {
|
|
378
|
+
try {
|
|
379
|
+
return readFileSync(path, 'utf8');
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
switch (ref.source) {
|
|
386
|
+
case 'bundled':
|
|
387
|
+
return read(join(BUNDLED_SKILLS_DIR, safe, 'SKILL.md'));
|
|
388
|
+
case 'project':
|
|
389
|
+
return await readText(fs, '.dsh/skills/' + safe + '/SKILL.md', cwd);
|
|
390
|
+
case 'user':
|
|
391
|
+
return read(join(dshHome(), 'skills', safe, 'SKILL.md'));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Freeze every skill and rule body the config resolves to.
|
|
396
|
+
*
|
|
397
|
+
* Names alone cannot keep an in-flight task stable: editing a rule that a running
|
|
398
|
+
* task uses would silently change what that task is doing. Each resolved body is
|
|
399
|
+
* copied with its content hash, so a deleted or edited source cannot change a
|
|
400
|
+
* running task, and identical bodies are stored once per hash.
|
|
401
|
+
* @param config - the frozen workflow config.
|
|
402
|
+
* @param fs - sandboxed filesystem for project-level lookups.
|
|
403
|
+
* @returns the frozen resources plus the refs that could not be read.
|
|
404
|
+
*/
|
|
405
|
+
async function freezeResources(config, fs, cwd) {
|
|
406
|
+
const seen = new Set();
|
|
407
|
+
const resources = [];
|
|
408
|
+
const unreadable = [];
|
|
409
|
+
for (const binding of Object.values(config.stage_bindings ?? {})) {
|
|
410
|
+
for (const entry of binding.skills ?? []) {
|
|
411
|
+
const skillKey = formatResourceRef(entry.skill);
|
|
412
|
+
if (!seen.has(skillKey)) {
|
|
413
|
+
const body = await readSkillAt(entry.skill, fs, cwd);
|
|
414
|
+
if (body === undefined)
|
|
415
|
+
unreadable.push(skillKey);
|
|
416
|
+
else {
|
|
417
|
+
seen.add(skillKey);
|
|
418
|
+
resources.push({ ref: entry.skill, hash: hashText(body), content: body });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
for (const rule of entry.rules) {
|
|
422
|
+
const key = formatResourceRef(rule);
|
|
423
|
+
if (seen.has(key))
|
|
424
|
+
continue;
|
|
425
|
+
const body = await readRuleAt(rule.source, rule.name, fs, cwd);
|
|
426
|
+
if (body === undefined) {
|
|
427
|
+
unreadable.push(key);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
seen.add(key);
|
|
431
|
+
resources.push({ ref: rule, hash: hashText(body), content: body });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return { resources, unreadable };
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Resolve a bare legacy rule name to the layer that precedence selects.
|
|
439
|
+
*
|
|
440
|
+
* Precedence, unchanged from before: bundled wins over project, which wins over
|
|
441
|
+
* user. A project cannot shadow a shipped rule like `security-redlines` with a
|
|
442
|
+
* weaker local copy; local additions use distinct names instead.
|
|
443
|
+
* @param name - the bare rule name from a legacy config.
|
|
444
|
+
* @param fs - sandboxed filesystem for the project-level lookup.
|
|
445
|
+
* @returns the layer that holds this name, or undefined when none does.
|
|
446
|
+
*/
|
|
447
|
+
async function resolveLegacyRuleSource(name, fs, cwd) {
|
|
448
|
+
for (const source of ['bundled', 'project', 'user']) {
|
|
449
|
+
if ((await readRuleAt(source, name, fs, cwd)) !== undefined)
|
|
450
|
+
return source;
|
|
451
|
+
}
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Resolve rule references to their bodies.
|
|
456
|
+
*
|
|
457
|
+
* Each reference names its own layer, so the lookup is exact: no precedence walk
|
|
458
|
+
* is involved and two same-named rules in different layers are distinct
|
|
459
|
+
* resources. A reference that resolves nowhere is reported rather than dropped —
|
|
460
|
+
* omitting it made a deleted file indistinguishable from an unbound one.
|
|
461
|
+
* @param refs - rule references, each with its source layer.
|
|
462
|
+
* @param fs - sandboxed filesystem for the project-level lookup.
|
|
463
|
+
* @returns the resolved rules with bodies and sources, plus the refs not found.
|
|
464
|
+
*/
|
|
465
|
+
async function resolveRules(refs, fs, cwd) {
|
|
466
|
+
const resolved = [];
|
|
467
|
+
const missing = [];
|
|
468
|
+
for (const ref of refs) {
|
|
469
|
+
const content = await readRuleAt(ref.source, ref.name, fs, cwd);
|
|
470
|
+
if (content === undefined) {
|
|
471
|
+
missing.push(formatResourceRef(ref));
|
|
363
472
|
continue;
|
|
364
473
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
474
|
+
resolved.push({ name: ref.name, content, source: ref.source });
|
|
475
|
+
}
|
|
476
|
+
return { resolved, missing };
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Every rule that applies at a stage, gathered from the skills bound to it.
|
|
480
|
+
*
|
|
481
|
+
* A stage has no rule list of its own: the rules in force are the ones its skills
|
|
482
|
+
* carry. Both shapes are collected here so a migrated config is not silently
|
|
483
|
+
* weaker than the one it came from — `legacy_rules` still apply until a human
|
|
484
|
+
* assigns them — and the two groups stay distinguishable in the return value so
|
|
485
|
+
* the caller can say which rules still need an owner.
|
|
486
|
+
* @param binding - the stage's binding, if any.
|
|
487
|
+
* @param fs - sandboxed filesystem for project-level lookups.
|
|
488
|
+
* @returns resolved rules, missing refs, and the unassigned legacy names.
|
|
489
|
+
*/
|
|
490
|
+
async function rulesForBinding(binding, fs, cwd) {
|
|
491
|
+
const refs = [];
|
|
492
|
+
// Deduplicated by reference: one rule shared by two skills on the same stage is
|
|
493
|
+
// disclosed once, because it is one constraint being followed.
|
|
494
|
+
const seen = new Set();
|
|
495
|
+
for (const skill of binding?.skills ?? []) {
|
|
496
|
+
for (const rule of skill.rules) {
|
|
497
|
+
const key = formatResourceRef(rule);
|
|
498
|
+
if (seen.has(key))
|
|
499
|
+
continue;
|
|
500
|
+
seen.add(key);
|
|
501
|
+
refs.push(rule);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const { resolved, missing } = await resolveRules(refs, fs, cwd);
|
|
505
|
+
const legacy = binding?.legacy_rules ?? [];
|
|
506
|
+
const legacyRefs = [];
|
|
507
|
+
for (const name of legacy) {
|
|
508
|
+
const source = await resolveLegacyRuleSource(name, fs, cwd);
|
|
509
|
+
// An unresolvable legacy name is reported through `missing` so it is visible
|
|
510
|
+
// rather than quietly absent.
|
|
511
|
+
if (source === undefined) {
|
|
512
|
+
missing.push(name);
|
|
368
513
|
continue;
|
|
369
514
|
}
|
|
370
|
-
const
|
|
371
|
-
if (
|
|
372
|
-
|
|
515
|
+
const key = formatResourceRef({ source, name });
|
|
516
|
+
if (seen.has(key))
|
|
517
|
+
continue;
|
|
518
|
+
seen.add(key);
|
|
519
|
+
legacyRefs.push({ source, name });
|
|
373
520
|
}
|
|
374
|
-
|
|
521
|
+
const legacyResolved = (await resolveRules(legacyRefs, fs, cwd)).resolved;
|
|
522
|
+
return { resolved: [...resolved, ...legacyResolved], missing, legacy };
|
|
375
523
|
}
|
|
376
524
|
/**
|
|
377
525
|
* Render a stage's progressive-disclosure payload: skill names to load via the
|
|
@@ -385,21 +533,32 @@ async function resolveRules(names, fs, cwd) {
|
|
|
385
533
|
async function renderBindings(stage, workflow, fs, cwd) {
|
|
386
534
|
const binding = bindingsForStage(stage, workflow);
|
|
387
535
|
const skills = binding?.skills ?? [];
|
|
388
|
-
|
|
389
|
-
if (binding === undefined || (skills.length === 0 && ruleNames.length === 0))
|
|
536
|
+
if (binding === undefined || (skills.length === 0 && (binding.legacy_rules ?? []).length === 0))
|
|
390
537
|
return '';
|
|
391
|
-
const rules = await
|
|
538
|
+
const { resolved: rules, missing, legacy } = await rulesForBinding(binding, fs, cwd);
|
|
392
539
|
const skillPart = skills.length > 0
|
|
393
|
-
? `skills to load: ${skills.join(', ')} (use the skill tool by name)`
|
|
540
|
+
? `skills to load: ${skills.map(skill => formatResourceRef(skill.skill)).join(', ')} (use the skill tool by name)`
|
|
394
541
|
: 'skills to load: none';
|
|
395
542
|
const rulePart = rules.length > 0
|
|
396
|
-
? `rules
|
|
543
|
+
? `rules in force at this stage (each belongs to the skill shown by the binding):\n${rules.map(r => `### ${r.source}:${r.name}\n${r.content}`).join('\n\n')}`
|
|
544
|
+
: '';
|
|
545
|
+
// A bound rule that resolves nowhere is stated explicitly. Silently dropping it
|
|
546
|
+
// left the stage looking as if the constraint had never been configured.
|
|
547
|
+
const missingPart = missing.length > 0
|
|
548
|
+
? `rules in force at this stage but NOT FOUND in their declared layer: ${missing.join(', ')}`
|
|
397
549
|
: '';
|
|
398
|
-
|
|
550
|
+
// Legacy stage-level rules are disclosed (so nothing is lost) but named as
|
|
551
|
+
// unassigned, because a stage-level list never recorded which skill they were
|
|
552
|
+
// meant for and guessing would invent an answer the config did not contain.
|
|
553
|
+
const legacyPart = legacy.length > 0
|
|
554
|
+
? `UNASSIGNED legacy stage rules: ${legacy.join(', ')} — these belong to the stage, not to any skill. ` +
|
|
555
|
+
'Assign each to the skill that should carry it (or copy the skill and give each copy its own rules) and record it in stage_bindings.'
|
|
556
|
+
: '';
|
|
557
|
+
const additional = skills.map(skill => skill.skill.name).filter(needsSkillReceipt);
|
|
399
558
|
const receiptPart = additional.length
|
|
400
559
|
? `additional skills requiring skill_result command receipts: ${additional.join(', ')}`
|
|
401
560
|
: 'skill_result not required for this stage: core skills use record/verify/review/commit gates';
|
|
402
|
-
return [skillPart, receiptPart, rulePart].filter(Boolean).join('\n');
|
|
561
|
+
return [skillPart, receiptPart, rulePart, missingPart, legacyPart].filter(Boolean).join('\n');
|
|
403
562
|
}
|
|
404
563
|
/** Normalize the tool's `items` argument into complete TaskItem records. */
|
|
405
564
|
function normalizeItems(items, previous = []) {
|
|
@@ -447,8 +606,21 @@ async function evidenceBlockers(fs, state, workflow, cwd) {
|
|
|
447
606
|
return [];
|
|
448
607
|
const blockers = [];
|
|
449
608
|
const current = await scopeFingerprint(fs, state, cwd);
|
|
450
|
-
|
|
451
|
-
|
|
609
|
+
// Staleness is judged on the RECORDED verification, not only on a passing one.
|
|
610
|
+
// Gating this on `passed` meant a re-verification that FAILED removed its own
|
|
611
|
+
// receipt from consideration, so the failure produced no blocker at all —
|
|
612
|
+
// failing verification was strictly weaker than never verifying. A receipt that
|
|
613
|
+
// exists but no longer matches the current tree, or a verification that is not
|
|
614
|
+
// passing while the flow requires it, both belong here.
|
|
615
|
+
const receipt = state.verification.receipt;
|
|
616
|
+
if (receipt !== undefined && receipt.scope_hash !== current) {
|
|
617
|
+
blockers.push('verification is stale after file/scope changes; rerun verify with a real command');
|
|
618
|
+
}
|
|
619
|
+
else if (!state.verification.passed && verificationBlockers(state, workflow).length > 0) {
|
|
620
|
+
// Only where the flow actually holds a verification requirement: a task that
|
|
621
|
+
// has not reached a verification gate yet has nothing to re-verify, and
|
|
622
|
+
// reporting it there would block the flow from starting at all.
|
|
623
|
+
blockers.push('verification is not passing; rerun verify with a real command');
|
|
452
624
|
}
|
|
453
625
|
for (const stage of obligationStages(state, workflow)) {
|
|
454
626
|
for (const [name, result] of Object.entries(state.skill_results?.[stage] ?? {})) {
|
|
@@ -510,13 +682,15 @@ export async function approveAdvance(ctx, state, target, workflow, confirmations
|
|
|
510
682
|
state.solution_confirmed = true;
|
|
511
683
|
return assertAdvance(state, target, workflow);
|
|
512
684
|
}
|
|
513
|
-
const OPERATIONS = ['status', 'create', 'record', 'items', 'scope', 'dispatch', 'review_item', 'advance', 'verify', 'review', 'commit', 'config', 'install_hook', 'verify_hook', 'init', 'set_risk', 'skill_result'];
|
|
685
|
+
const OPERATIONS = ['status', 'create', 'record', 'items', 'scope', 'dispatch', 'review_item', 'advance', 'verify', 'review', 'commit', 'complete', 'revise', 'config', 'install_hook', 'verify_hook', 'init', 'set_risk', 'skill_result'];
|
|
514
686
|
const TOOL_DESCRIPTION = 'Own the engineering delivery workflow as hard state. Read or create the task record, record a ' +
|
|
515
687
|
'stage artifact, record each implementation item\'s subagent dispatch and two-stage (spec + quality) ' +
|
|
516
|
-
'review audit, advance one stage (rejected unless every configured guard already holds, including ' +
|
|
688
|
+
'review audit (the flow decides how many verdicts per item), advance one stage (rejected unless every configured guard already holds, including ' +
|
|
517
689
|
'the stage artifacts; a requirement/solution confirmation guard asks a human to approve instead of ' +
|
|
518
690
|
'being satisfied by the model), record verification or review, and gate a commit on ' +
|
|
519
|
-
'stage/scope/message
|
|
691
|
+
'stage/scope/message, and mark the task complete only once its configured finish conditions ' +
|
|
692
|
+
'hold — reaching the last stage is not itself completion. The init operation manages the ' +
|
|
693
|
+
'protected AGENTS.md in three phases ' +
|
|
520
694
|
'(inspect → propose → apply; overwriting an existing file requires human approval) because DSH ' +
|
|
521
695
|
'injects that file into every session. The tool refuses illegal moves ' +
|
|
522
696
|
'instead of degrading — treat a rejection as a fact to fix, not a prompt to retry another way.';
|
|
@@ -556,7 +730,7 @@ export function registerDevTask(ctx) {
|
|
|
556
730
|
spec_outcome: { type: 'string', enum: ['pass', 'fail'], description: 'Specification-conformance verdict (review_item).' },
|
|
557
731
|
quality_outcome: { type: 'string', enum: ['pass', 'fail'], description: 'Code-quality verdict (review_item).' },
|
|
558
732
|
notes: { type: 'array', items: { type: 'string' }, description: 'Findings or defects (review_item).' },
|
|
559
|
-
target_stage: { type: 'string', description: 'Stage to advance to, or
|
|
733
|
+
target_stage: { type: 'string', description: 'Stage to advance to (advance), a bound stage for skill_result, or the stage to return to (revise). For revise it must be the stage the changed decision belongs to. Current/upcoming terminal skill binding stage for skill_result.' },
|
|
560
734
|
command: { type: 'string', description: 'Real acceptance command for verify/skill_result. New tasks require command receipts. Propagate failures when composing shell commands.' },
|
|
561
735
|
passed: { type: 'boolean', description: 'Legacy tasks only: verification claim when no command can be resolved. New tasks require real command receipts.' },
|
|
562
736
|
evidence: { type: 'array', items: { type: 'string' }, description: 'Supplementary verification evidence (verify).' },
|
|
@@ -573,6 +747,8 @@ export function registerDevTask(ctx) {
|
|
|
573
747
|
description: 'Repo-relative paths in scope (create/scope) or being committed (commit).',
|
|
574
748
|
},
|
|
575
749
|
message: { type: 'string', description: 'Commit summary to validate (commit).' },
|
|
750
|
+
revision_kind: { type: 'string', enum: ['requirement', 'solution', 'defect'], description: 'Why the task is going back (revise). Decides which conclusions are invalidated: requirement clears its confirmation and everything downstream; solution keeps the requirement agreed; defect clears only the evidence about the old implementation.' },
|
|
751
|
+
revision_reason: { type: 'string', description: 'What changed, in the author\'s words (revise).' },
|
|
576
752
|
hash: { type: 'string', description: 'Commit hash to record after the git commit (commit).' },
|
|
577
753
|
content: { type: 'string', description: 'Full AGENTS.md body (init propose/apply). Inspect the existing file first via init phase=inspect.' },
|
|
578
754
|
overwrite: { type: 'boolean', description: 'Allow replacing an existing AGENTS.md (init apply); triggers human approval.' },
|
|
@@ -719,11 +895,16 @@ export function registerDevTask(ctx) {
|
|
|
719
895
|
throw new Error(`high_risk task cannot run on flow "${resolved.flow}" — it lacks the required capabilities ` +
|
|
720
896
|
`(${HIGH_RISK_REQUIRED_CAPABILITIES.join(', ')}). Use the standard flow or lower the task risk.`);
|
|
721
897
|
}
|
|
898
|
+
// Freeze the resolved skill and rule bodies now. A name alone cannot keep an
|
|
899
|
+
// in-flight task stable: editing a rule the task uses would otherwise change
|
|
900
|
+
// what the task is doing without the task saying so.
|
|
901
|
+
const frozen = await freezeResources(resolved.config, fs, cwd);
|
|
722
902
|
const flow = {
|
|
723
903
|
flow: resolved.flow,
|
|
724
904
|
version: resolved.version,
|
|
725
905
|
config: resolved.config,
|
|
726
906
|
hash: hashConfig(resolved.config),
|
|
907
|
+
resources: frozen.resources,
|
|
727
908
|
};
|
|
728
909
|
const root = await detectRoot(projectProbe(fs), cwd ?? '');
|
|
729
910
|
const state = newTask({
|
|
@@ -750,11 +931,15 @@ export function registerDevTask(ctx) {
|
|
|
750
931
|
const workflow = await workflowFor(state, fs, cwd);
|
|
751
932
|
if (a.operation === 'status') {
|
|
752
933
|
const binding = bindingsForStage(state.stage, workflow);
|
|
753
|
-
const rules = await
|
|
934
|
+
const { resolved: rules, missing: missingRules, legacy: legacyRules, } = await rulesForBinding(binding, fs, cwd);
|
|
754
935
|
const missingSkills = skillBlockers(state, workflow, session);
|
|
755
936
|
const staleEvidence = await evidenceBlockers(fs, state, workflow, cwd);
|
|
756
937
|
const checkpoint = commitCheckpoint(state, workflow);
|
|
757
|
-
|
|
938
|
+
// The flow's verification requirement is reported separately from staleness:
|
|
939
|
+
// one says the receipt no longer matches the tree, the other says the flow
|
|
940
|
+
// needs a passing verification and does not have one. Callers act on both.
|
|
941
|
+
const verification = verificationBlockers(state, workflow);
|
|
942
|
+
const commitBlockers = [...staleEvidence, ...missingSkills, ...verification];
|
|
758
943
|
return JSON.stringify({
|
|
759
944
|
id: state.id,
|
|
760
945
|
stage: state.stage,
|
|
@@ -765,12 +950,20 @@ export function registerDevTask(ctx) {
|
|
|
765
950
|
solution_confirmed: state.solution_confirmed,
|
|
766
951
|
items_done: `${state.items.filter(i => i.status === 'done').length}/${state.items.length}`,
|
|
767
952
|
items: state.items,
|
|
768
|
-
skill_obligations: obligationStages(state, workflow).map(stage =>
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
})
|
|
953
|
+
skill_obligations: obligationStages(state, workflow).map(stage => {
|
|
954
|
+
const skills = (workflow.stage_bindings?.[stage]?.skills ?? []).map(skill => skill.skill.name);
|
|
955
|
+
return { stage, skills, command_receipts_required: skills.filter(needsSkillReceipt) };
|
|
956
|
+
}),
|
|
772
957
|
skill_blockers: missingSkills,
|
|
773
958
|
evidence_blockers: staleEvidence,
|
|
959
|
+
verification_blockers: verification,
|
|
960
|
+
// Rules in force here, each named with the layer it came from, plus the
|
|
961
|
+
// ones that could not be found and the legacy stage-level names still
|
|
962
|
+
// awaiting an owner. A skill's own list is the complete answer to what it
|
|
963
|
+
// runs under, and this mirrors that list for the current stage.
|
|
964
|
+
missing_rules: missingRules,
|
|
965
|
+
rules: rules.map(rule => ({ name: rule.name, source: rule.source })),
|
|
966
|
+
unassigned_legacy_rules: legacyRules,
|
|
774
967
|
skill_results: state.skill_results ?? {},
|
|
775
968
|
commits: state.commits,
|
|
776
969
|
verification: state.verification,
|
|
@@ -843,6 +1036,55 @@ export function registerDevTask(ctx) {
|
|
|
843
1036
|
return `commit recorded (${checkpoint.label ?? ''}): ${a.hash}; use status to inspect remaining gates, then advance`;
|
|
844
1037
|
return `commit approved (${checkpoint.label ?? ''}) — git add ${(a.files ?? []).join(' ')}; git commit -m "${a.message ?? ''}"`;
|
|
845
1038
|
}
|
|
1039
|
+
if (a.operation === 'complete') {
|
|
1040
|
+
// Completion is recorded, not inferred from standing on the last stage.
|
|
1041
|
+
// A flow whose final stage is also its commit checkpoint used to reach
|
|
1042
|
+
// that stage with no delivery record at all, because the "commit before
|
|
1043
|
+
// leaving a checkpoint" rule fires on the way OUT of a stage and a
|
|
1044
|
+
// terminal stage has no way out.
|
|
1045
|
+
await assertFreshEvidence(fs, state, workflow, cwd);
|
|
1046
|
+
const blockers = completionBlockers(state, workflow);
|
|
1047
|
+
if (blockers.length > 0)
|
|
1048
|
+
throw new Error(`cannot complete: ${blockers.join('; ')}`);
|
|
1049
|
+
const hash = state.commits.find(commit => commit.hash !== undefined)?.hash;
|
|
1050
|
+
state.completed = hash === undefined
|
|
1051
|
+
? { at: new Date().toISOString() }
|
|
1052
|
+
: { at: new Date().toISOString(), commit_hash: hash };
|
|
1053
|
+
await writeTask(fs, state, cwd, await resolveWriteMode(ctx, a, exec));
|
|
1054
|
+
return hash === undefined
|
|
1055
|
+
? `task completed at "${state.stage}"; the record stays readable for audit`
|
|
1056
|
+
: `task completed at "${state.stage}" (commit ${hash}); the record stays readable for audit`;
|
|
1057
|
+
}
|
|
1058
|
+
if (a.operation === 'revise') {
|
|
1059
|
+
// The graph has only forward edges, but the work does not: a requirement can
|
|
1060
|
+
// change after implementation started. Before this, going back meant hand-
|
|
1061
|
+
// editing the record, which left every downstream conclusion looking intact
|
|
1062
|
+
// even though it described a superseded tree.
|
|
1063
|
+
const kind = a.revision_kind;
|
|
1064
|
+
if (kind !== 'requirement' && kind !== 'solution' && kind !== 'defect') {
|
|
1065
|
+
throw new Error('revise requires revision_kind: one of requirement, solution, defect');
|
|
1066
|
+
}
|
|
1067
|
+
if (!a.revision_reason?.trim())
|
|
1068
|
+
throw new Error('revise requires revision_reason describing what changed');
|
|
1069
|
+
const target = a.target_stage;
|
|
1070
|
+
if (target === undefined || !workflow.stages.includes(target)) {
|
|
1071
|
+
throw new Error('revise requires target_stage naming a stage of this flow: ' + workflow.stages.join(', '));
|
|
1072
|
+
}
|
|
1073
|
+
if (target === state.stage)
|
|
1074
|
+
throw new Error('revise target_stage is the current stage; nothing to return to');
|
|
1075
|
+
// Which stages are reachable backwards is a property of the flow, not of the
|
|
1076
|
+
// tool: any earlier stage is a legal place to resume from.
|
|
1077
|
+
if (workflow.stages.indexOf(target) > workflow.stages.indexOf(state.stage)) {
|
|
1078
|
+
throw new Error('revise goes backwards only; "' + target + '" comes after "' + state.stage + '" in this flow');
|
|
1079
|
+
}
|
|
1080
|
+
const outcome = applyRevision(state, {
|
|
1081
|
+
kind, reason: a.revision_reason.trim(), to: target, from: state.stage,
|
|
1082
|
+
at: new Date().toISOString(), invalidated: invalidatedBy(kind),
|
|
1083
|
+
});
|
|
1084
|
+
await writeTask(fs, state, cwd, await resolveWriteMode(ctx, a, exec));
|
|
1085
|
+
return 'revised (' + kind + ') back to "' + outcome.stage + '"; invalidated: ' + outcome.invalidated.join(', ')
|
|
1086
|
+
+ '. Re-establish these before advancing again; the history is kept in the task record.';
|
|
1087
|
+
}
|
|
846
1088
|
let note;
|
|
847
1089
|
let approvedWriteMode;
|
|
848
1090
|
switch (a.operation) {
|
|
@@ -1001,7 +1243,7 @@ export function registerDevTask(ctx) {
|
|
|
1001
1243
|
}
|
|
1002
1244
|
case 'skill_result': {
|
|
1003
1245
|
const stage = a.target_stage ?? state.stage;
|
|
1004
|
-
if (!obligationStages(state, workflow).includes(stage) || !workflow.stage_bindings?.[stage]?.skills
|
|
1246
|
+
if (!obligationStages(state, workflow).includes(stage) || !(workflow.stage_bindings?.[stage]?.skills ?? []).some(entry => entry.skill.name === a.skill_name))
|
|
1005
1247
|
throw new Error('skill_result requires a skill bound to this stage or its upcoming terminal stage');
|
|
1006
1248
|
const loadCall = loadedSkills(session).get(a.skill_name);
|
|
1007
1249
|
if (!loadCall)
|