@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/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
- * Resolve rule contents by name: bundled first, then user home, then project.
347
- * Bundled (core) rules win over user and project files of the same name, so a
348
- * project cannot shadow a shipped rule like `security-redlines` with a weaker
349
- * local copy; local additions use distinct names instead.
350
- * @param names - rule names from the current stage binding.
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 rules that resolved, in name order, with their bodies.
371
+ * @returns the SKILL.md body, or undefined when it cannot be read here.
353
372
  */
354
- async function resolveRules(names, fs, cwd) {
355
- const result = [];
356
- for (const rawName of names) {
357
- const name = sanitize(rawName);
358
- if (name === '')
359
- continue;
360
- const bundled = readAbsRule(fileURLToPath(new URL(`../rules/${name}.md`, import.meta.url)));
361
- if (bundled !== undefined) {
362
- result.push({ name: rawName, content: bundled });
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
- const project = await readText(fs, `.dsh/rules/${name}.md`, cwd);
366
- if (project !== undefined) {
367
- result.push({ name: rawName, content: project });
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 user = readAbsRule(join(dshHome(), 'rules', `${name}.md`));
371
- if (user !== undefined)
372
- result.push({ name: rawName, content: user });
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
- return result;
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
- const ruleNames = binding?.rules ?? [];
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 resolveRules(ruleNames, fs, cwd);
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 for this stage:\n${rules.map(r => `### ${r.name}\n${r.content}`).join('\n\n')}`
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
- const additional = skills.filter(needsSkillReceipt);
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
- if (state.verification.passed && state.verification.receipt?.scope_hash !== current) {
451
- blockers.push('verification is missing or stale after file/scope changes; rerun verify with a real command');
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. The init operation manages the protected AGENTS.md in three phases ' +
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 current/upcoming terminal skill binding stage for skill_result.' },
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 resolveRules(binding?.rules ?? [], fs, cwd);
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
- const commitBlockers = [...staleEvidence, ...missingSkills];
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
- stage, skills: workflow.stage_bindings?.[stage]?.skills ?? [],
770
- command_receipts_required: (workflow.stage_bindings?.[stage]?.skills ?? []).filter(needsSkillReceipt),
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?.includes(a.skill_name ?? ''))
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)