@godv61/dsh-task-engine 0.23.9 → 0.25.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, resourceBlockers, 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";
@@ -184,7 +184,14 @@ async function resolveWorkflow(fs, cwd) {
184
184
  source: 'invalid',
185
185
  };
186
186
  }
187
- const resolved = resolveFlow(parsed.flow, parsed.stage_bindings !== undefined ? { stage_bindings: parsed.stage_bindings } : undefined);
187
+ const resolved = resolveFlow(parsed.flow, {
188
+ flow: parsed.flow,
189
+ ...(parsed.stage_bindings !== undefined ? { stage_bindings: parsed.stage_bindings } : {}),
190
+ ...(parsed.commit !== undefined ? { commit: parsed.commit } : {}),
191
+ ...(parsed.artifacts !== undefined ? { artifacts: parsed.artifacts } : {}),
192
+ ...(parsed.review_depth !== undefined ? { review_depth: parsed.review_depth } : {}),
193
+ ...(parsed.commit_required !== undefined ? { commit_required: parsed.commit_required } : {}),
194
+ });
188
195
  if (!resolved.ok) {
189
196
  const standard = standardWorkflow();
190
197
  return {
@@ -342,36 +349,220 @@ function builtinRulesFingerprint() {
342
349
  .join('\n');
343
350
  return hashText(contents);
344
351
  }
352
+ /** Read one rule body from a specific layer, so a reference decides where to look. */
353
+ async function readRuleAt(source, name, fs, cwd) {
354
+ const safe = sanitize(name);
355
+ if (safe === '')
356
+ return undefined;
357
+ switch (source) {
358
+ case 'bundled':
359
+ return readAbsRule(fileURLToPath(new URL(`../rules/${safe}.md`, import.meta.url)));
360
+ case 'project':
361
+ return await readText(fs, `.dsh/rules/${safe}.md`, cwd);
362
+ case 'user':
363
+ return readAbsRule(join(dshHome(), 'rules', `${safe}.md`));
364
+ }
365
+ }
366
+ /** Bundled skill directory, the only layer whose bodies this process can read without the host registry. */
367
+ const BUNDLED_SKILLS_DIR = fileURLToPath(new URL('../skills/', import.meta.url));
368
+ /**
369
+ * Read one skill body from a layer this process can reach.
370
+ *
371
+ * Project-level skills live under the workspace and are readable through the
372
+ * sandboxed fs; user-level ones sit under the harness home. A skill the host
373
+ * registry resolves but this process cannot read yields undefined, which is
374
+ * reported as unfreezable rather than silently omitted — the task must not claim
375
+ * to have frozen something it did not read.
376
+ * @param ref - the skill reference from a stage binding.
377
+ * @param fs - sandboxed filesystem for the project-level lookup.
378
+ * @returns the SKILL.md body, or undefined when it cannot be read here.
379
+ */
380
+ async function readSkillAt(ref, fs, cwd) {
381
+ const safe = sanitize(ref.name);
382
+ if (safe === '')
383
+ return undefined;
384
+ const read = (path) => {
385
+ try {
386
+ return readFileSync(path, 'utf8');
387
+ }
388
+ catch {
389
+ return undefined;
390
+ }
391
+ };
392
+ switch (ref.source) {
393
+ case 'bundled':
394
+ return read(join(BUNDLED_SKILLS_DIR, safe, 'SKILL.md'));
395
+ case 'project':
396
+ return await readText(fs, '.dsh/skills/' + safe + '/SKILL.md', cwd);
397
+ case 'user':
398
+ return read(join(dshHome(), 'skills', safe, 'SKILL.md'));
399
+ }
400
+ }
401
+ /**
402
+ * Freeze every skill and rule body the config resolves to.
403
+ *
404
+ * Names alone cannot keep an in-flight task stable: editing a rule that a running
405
+ * task uses would silently change what that task is doing. Each resolved body is
406
+ * copied with its content hash, so a deleted or edited source cannot change a
407
+ * running task, and identical bodies are stored once per hash.
408
+ * @param config - the frozen workflow config.
409
+ * @param fs - sandboxed filesystem for project-level lookups.
410
+ * @returns the frozen resources plus the refs that could not be read.
411
+ */
412
+ async function freezeResources(config, fs, cwd) {
413
+ const seen = new Set();
414
+ const resources = [];
415
+ const unreadable = [];
416
+ for (const binding of Object.values(config.stage_bindings ?? {})) {
417
+ for (const entry of binding.skills ?? []) {
418
+ const skillKey = formatResourceRef(entry.skill);
419
+ if (!seen.has(skillKey)) {
420
+ const body = await readSkillAt(entry.skill, fs, cwd);
421
+ if (body === undefined)
422
+ unreadable.push(skillKey);
423
+ else {
424
+ seen.add(skillKey);
425
+ resources.push({ ref: entry.skill, hash: hashText(body), content: body });
426
+ }
427
+ }
428
+ for (const rule of entry.rules) {
429
+ const key = formatResourceRef(rule);
430
+ if (seen.has(key))
431
+ continue;
432
+ const body = await readRuleAt(rule.source, rule.name, fs, cwd);
433
+ if (body === undefined) {
434
+ unreadable.push(key);
435
+ continue;
436
+ }
437
+ seen.add(key);
438
+ resources.push({ ref: rule, hash: hashText(body), content: body });
439
+ }
440
+ }
441
+ }
442
+ return { resources, unreadable };
443
+ }
444
+ /**
445
+ * Resolve a bare legacy rule name to the layer that precedence selects.
446
+ *
447
+ * Precedence, unchanged from before: bundled wins over project, which wins over
448
+ * user. A project cannot shadow a shipped rule like `security-redlines` with a
449
+ * weaker local copy; local additions use distinct names instead.
450
+ * @param name - the bare rule name from a legacy config.
451
+ * @param fs - sandboxed filesystem for the project-level lookup.
452
+ * @returns the layer that holds this name, or undefined when none does.
453
+ */
454
+ async function resolveLegacyRuleSource(name, fs, cwd, frozen) {
455
+ // A frozen snapshot answers this without touching the filesystem: the layer a
456
+ // legacy name resolved to at creation is part of what the task froze, so a
457
+ // source deleted afterwards cannot change where it resolves.
458
+ for (const resource of frozen ?? []) {
459
+ if (resource.ref.name === name)
460
+ return resource.ref.source;
461
+ }
462
+ for (const source of ['bundled', 'project', 'user']) {
463
+ if ((await readRuleAt(source, name, fs, cwd)) !== undefined)
464
+ return source;
465
+ }
466
+ return undefined;
467
+ }
345
468
  /**
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.
469
+ * Resolve rule references to their bodies.
470
+ *
471
+ * Each reference names its own layer, so the lookup is exact: no precedence walk
472
+ * is involved and two same-named rules in different layers are distinct
473
+ * resources. A reference that resolves nowhere is reported rather than dropped —
474
+ * omitting it made a deleted file indistinguishable from an unbound one.
475
+ * @param refs - rule references, each with its source layer.
351
476
  * @param fs - sandboxed filesystem for the project-level lookup.
352
- * @returns the rules that resolved, in name order, with their bodies.
477
+ * @returns the resolved rules with bodies and sources, plus the refs not found.
353
478
  */
354
- async function resolveRules(names, fs, cwd) {
355
- const result = [];
356
- for (const rawName of names) {
357
- const name = sanitize(rawName);
358
- if (name === '')
479
+ async function resolveRules(refs, fs, cwd, frozen) {
480
+ const resolved = [];
481
+ const missing = [];
482
+ // A frozen body is the authority for a task that has one. Freezing used to only
483
+ // ARCHIVE the content: status and the stage disclosure still read the live files,
484
+ // so editing a rule silently changed what a running task was told to follow —
485
+ // the exact instability the snapshot exists to prevent.
486
+ const frozenByRef = new Map();
487
+ for (const resource of frozen ?? [])
488
+ frozenByRef.set(formatResourceRef(resource.ref), resource);
489
+ const stale = [];
490
+ for (const ref of refs) {
491
+ const key = formatResourceRef(ref);
492
+ const snapshot = frozenByRef.get(key);
493
+ if (snapshot !== undefined) {
494
+ resolved.push({ name: ref.name, content: snapshot.content, source: ref.source });
495
+ // Report when the source no longer matches what was frozen, so drift is
496
+ // visible instead of looking like the frozen text was simply current.
497
+ const live = await readRuleAt(ref.source, ref.name, fs, cwd);
498
+ if (live === undefined)
499
+ stale.push(key + ' (source deleted)');
500
+ else if (hashText(live) !== snapshot.hash)
501
+ stale.push(key + ' (source edited)');
359
502
  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 });
503
+ }
504
+ // No snapshot (a pre-freezing task) or this ref was not frozen: read live, and
505
+ // report a rule that resolves nowhere rather than dropping it, because omitting
506
+ // it made a deleted file indistinguishable from an unbound one.
507
+ const content = await readRuleAt(ref.source, ref.name, fs, cwd);
508
+ if (content === undefined) {
509
+ missing.push(key);
363
510
  continue;
364
511
  }
365
- const project = await readText(fs, `.dsh/rules/${name}.md`, cwd);
366
- if (project !== undefined) {
367
- result.push({ name: rawName, content: project });
512
+ resolved.push({ name: ref.name, content, source: ref.source });
513
+ }
514
+ return { resolved, missing, stale };
515
+ }
516
+ /**
517
+ * Every rule that applies at a stage, gathered from the skills bound to it.
518
+ *
519
+ * A stage has no rule list of its own: the rules in force are the ones its skills
520
+ * carry. Both shapes are collected here so a migrated config is not silently
521
+ * weaker than the one it came from — `legacy_rules` still apply until a human
522
+ * assigns them — and the two groups stay distinguishable in the return value so
523
+ * the caller can say which rules still need an owner.
524
+ * @param binding - the stage's binding, if any.
525
+ * @param fs - sandboxed filesystem for project-level lookups.
526
+ * @returns resolved rules, missing refs, and the unassigned legacy names.
527
+ */
528
+ async function rulesForBinding(binding, fs, cwd, frozen) {
529
+ const refs = [];
530
+ // Deduplicated by reference: one rule shared by two skills on the same stage is
531
+ // disclosed once, because it is one constraint being followed.
532
+ const seen = new Set();
533
+ for (const skill of binding?.skills ?? []) {
534
+ for (const rule of skill.rules) {
535
+ const key = formatResourceRef(rule);
536
+ if (seen.has(key))
537
+ continue;
538
+ seen.add(key);
539
+ refs.push(rule);
540
+ }
541
+ }
542
+ const { resolved, missing, stale } = await resolveRules(refs, fs, cwd, frozen);
543
+ const legacy = binding?.legacy_rules ?? [];
544
+ const legacyRefs = [];
545
+ for (const name of legacy) {
546
+ const source = await resolveLegacyRuleSource(name, fs, cwd, frozen);
547
+ // An unresolvable legacy name is reported through `missing` so it is visible
548
+ // rather than quietly absent.
549
+ if (source === undefined) {
550
+ missing.push(name);
368
551
  continue;
369
552
  }
370
- const user = readAbsRule(join(dshHome(), 'rules', `${name}.md`));
371
- if (user !== undefined)
372
- result.push({ name: rawName, content: user });
553
+ const key = formatResourceRef({ source, name });
554
+ if (seen.has(key))
555
+ continue;
556
+ seen.add(key);
557
+ legacyRefs.push({ source, name });
373
558
  }
374
- return result;
559
+ const legacyResult = await resolveRules(legacyRefs, fs, cwd, frozen);
560
+ return {
561
+ resolved: [...resolved, ...legacyResult.resolved],
562
+ missing,
563
+ legacy,
564
+ stale: [...stale, ...legacyResult.stale],
565
+ };
375
566
  }
376
567
  /**
377
568
  * Render a stage's progressive-disclosure payload: skill names to load via the
@@ -382,24 +573,42 @@ async function resolveRules(names, fs, cwd) {
382
573
  * @param fs - host filesystem for project-rule resolution.
383
574
  * @returns the disclosure text, or an empty string.
384
575
  */
385
- async function renderBindings(stage, workflow, fs, cwd) {
576
+ async function renderBindings(stage, workflow, fs, cwd, frozen) {
386
577
  const binding = bindingsForStage(stage, workflow);
387
578
  const skills = binding?.skills ?? [];
388
- const ruleNames = binding?.rules ?? [];
389
- if (binding === undefined || (skills.length === 0 && ruleNames.length === 0))
579
+ if (binding === undefined || (skills.length === 0 && (binding.legacy_rules ?? []).length === 0))
390
580
  return '';
391
- const rules = await resolveRules(ruleNames, fs, cwd);
581
+ const { resolved: rules, missing, legacy } = await rulesForBinding(binding, fs, cwd, frozen);
392
582
  const skillPart = skills.length > 0
393
- ? `skills to load: ${skills.join(', ')} (use the skill tool by name)`
583
+ ? `skills to load: ${skills.map(skill => formatResourceRef(skill.skill)).join(', ')} (use the skill tool by name)`
394
584
  : 'skills to load: none';
395
585
  const rulePart = rules.length > 0
396
- ? `rules for this stage:\n${rules.map(r => `### ${r.name}\n${r.content}`).join('\n\n')}`
586
+ ? `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')}`
587
+ : '';
588
+ // A bound rule that resolves nowhere is stated explicitly. Silently dropping it
589
+ // left the stage looking as if the constraint had never been configured.
590
+ const missingPart = missing.length > 0
591
+ ? `rules in force at this stage but NOT FOUND in their declared layer: ${missing.join(', ')}`
592
+ : '';
593
+ // Legacy stage-level rules are disclosed (so nothing is lost) but named as
594
+ // unassigned, because a stage-level list never recorded which skill they were
595
+ // meant for and guessing would invent an answer the config did not contain.
596
+ const legacyPart = legacy.length > 0
597
+ ? `UNASSIGNED legacy stage rules: ${legacy.join(', ')} — these belong to the stage, not to any skill. ` +
598
+ '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.'
397
599
  : '';
398
- const additional = skills.filter(needsSkillReceipt);
600
+ // The disclosure names the skills that owe a command receipt, honouring each
601
+ // binding's declared evidence kind — a document-producing skill owes an
602
+ // artifact, not a shell command, and saying otherwise would tell the model to
603
+ // run something irrelevant.
604
+ const additional = skills
605
+ .filter(entry => entry.evidence === undefined || entry.evidence === 'command')
606
+ .map(entry => entry.skill.name)
607
+ .filter(name => needsSkillReceipt(name));
399
608
  const receiptPart = additional.length
400
609
  ? `additional skills requiring skill_result command receipts: ${additional.join(', ')}`
401
610
  : 'skill_result not required for this stage: core skills use record/verify/review/commit gates';
402
- return [skillPart, receiptPart, rulePart].filter(Boolean).join('\n');
611
+ return [skillPart, receiptPart, rulePart, missingPart, legacyPart].filter(Boolean).join('\n');
403
612
  }
404
613
  /** Normalize the tool's `items` argument into complete TaskItem records. */
405
614
  function normalizeItems(items, previous = []) {
@@ -447,8 +656,21 @@ async function evidenceBlockers(fs, state, workflow, cwd) {
447
656
  return [];
448
657
  const blockers = [];
449
658
  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');
659
+ // Staleness is judged on the RECORDED verification, not only on a passing one.
660
+ // Gating this on `passed` meant a re-verification that FAILED removed its own
661
+ // receipt from consideration, so the failure produced no blocker at all —
662
+ // failing verification was strictly weaker than never verifying. A receipt that
663
+ // exists but no longer matches the current tree, or a verification that is not
664
+ // passing while the flow requires it, both belong here.
665
+ const receipt = state.verification.receipt;
666
+ if (receipt !== undefined && receipt.scope_hash !== current) {
667
+ blockers.push('verification is stale after file/scope changes; rerun verify with a real command');
668
+ }
669
+ else if (!state.verification.passed && verificationBlockers(state, workflow).length > 0) {
670
+ // Only where the flow actually holds a verification requirement: a task that
671
+ // has not reached a verification gate yet has nothing to re-verify, and
672
+ // reporting it there would block the flow from starting at all.
673
+ blockers.push('verification is not passing; rerun verify with a real command');
452
674
  }
453
675
  for (const stage of obligationStages(state, workflow)) {
454
676
  for (const [name, result] of Object.entries(state.skill_results?.[stage] ?? {})) {
@@ -510,13 +732,15 @@ export async function approveAdvance(ctx, state, target, workflow, confirmations
510
732
  state.solution_confirmed = true;
511
733
  return assertAdvance(state, target, workflow);
512
734
  }
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'];
735
+ 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
736
  const TOOL_DESCRIPTION = 'Own the engineering delivery workflow as hard state. Read or create the task record, record a ' +
515
737
  '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 ' +
738
+ 'review audit (the flow decides how many verdicts per item), advance one stage (rejected unless every configured guard already holds, including ' +
517
739
  'the stage artifacts; a requirement/solution confirmation guard asks a human to approve instead of ' +
518
740
  '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 ' +
741
+ 'stage/scope/message, and mark the task complete only once its configured finish conditions ' +
742
+ 'hold — reaching the last stage is not itself completion. The init operation manages the ' +
743
+ 'protected AGENTS.md in three phases ' +
520
744
  '(inspect → propose → apply; overwriting an existing file requires human approval) because DSH ' +
521
745
  'injects that file into every session. The tool refuses illegal moves ' +
522
746
  'instead of degrading — treat a rejection as a fact to fix, not a prompt to retry another way.';
@@ -556,7 +780,7 @@ export function registerDevTask(ctx) {
556
780
  spec_outcome: { type: 'string', enum: ['pass', 'fail'], description: 'Specification-conformance verdict (review_item).' },
557
781
  quality_outcome: { type: 'string', enum: ['pass', 'fail'], description: 'Code-quality verdict (review_item).' },
558
782
  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.' },
783
+ 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
784
  command: { type: 'string', description: 'Real acceptance command for verify/skill_result. New tasks require command receipts. Propagate failures when composing shell commands.' },
561
785
  passed: { type: 'boolean', description: 'Legacy tasks only: verification claim when no command can be resolved. New tasks require real command receipts.' },
562
786
  evidence: { type: 'array', items: { type: 'string' }, description: 'Supplementary verification evidence (verify).' },
@@ -573,6 +797,8 @@ export function registerDevTask(ctx) {
573
797
  description: 'Repo-relative paths in scope (create/scope) or being committed (commit).',
574
798
  },
575
799
  message: { type: 'string', description: 'Commit summary to validate (commit).' },
800
+ 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.' },
801
+ revision_reason: { type: 'string', description: 'What changed, in the author\'s words (revise).' },
576
802
  hash: { type: 'string', description: 'Commit hash to record after the git commit (commit).' },
577
803
  content: { type: 'string', description: 'Full AGENTS.md body (init propose/apply). Inspect the existing file first via init phase=inspect.' },
578
804
  overwrite: { type: 'boolean', description: 'Allow replacing an existing AGENTS.md (init apply); triggers human approval.' },
@@ -719,11 +945,16 @@ export function registerDevTask(ctx) {
719
945
  throw new Error(`high_risk task cannot run on flow "${resolved.flow}" — it lacks the required capabilities ` +
720
946
  `(${HIGH_RISK_REQUIRED_CAPABILITIES.join(', ')}). Use the standard flow or lower the task risk.`);
721
947
  }
948
+ // Freeze the resolved skill and rule bodies now. A name alone cannot keep an
949
+ // in-flight task stable: editing a rule the task uses would otherwise change
950
+ // what the task is doing without the task saying so.
951
+ const frozen = await freezeResources(resolved.config, fs, cwd);
722
952
  const flow = {
723
953
  flow: resolved.flow,
724
954
  version: resolved.version,
725
955
  config: resolved.config,
726
956
  hash: hashConfig(resolved.config),
957
+ resources: frozen.resources,
727
958
  };
728
959
  const root = await detectRoot(projectProbe(fs), cwd ?? '');
729
960
  const state = newTask({
@@ -750,11 +981,15 @@ export function registerDevTask(ctx) {
750
981
  const workflow = await workflowFor(state, fs, cwd);
751
982
  if (a.operation === 'status') {
752
983
  const binding = bindingsForStage(state.stage, workflow);
753
- const rules = await resolveRules(binding?.rules ?? [], fs, cwd);
984
+ const { resolved: rules, missing: missingRules, legacy: legacyRules, stale: staleRules, } = await rulesForBinding(binding, fs, cwd, state.flow?.resources);
754
985
  const missingSkills = skillBlockers(state, workflow, session);
755
986
  const staleEvidence = await evidenceBlockers(fs, state, workflow, cwd);
756
987
  const checkpoint = commitCheckpoint(state, workflow);
757
- const commitBlockers = [...staleEvidence, ...missingSkills];
988
+ // The flow's verification requirement is reported separately from staleness:
989
+ // one says the receipt no longer matches the tree, the other says the flow
990
+ // needs a passing verification and does not have one. Callers act on both.
991
+ const verification = verificationBlockers(state, workflow);
992
+ const commitBlockers = [...staleEvidence, ...missingSkills, ...verification];
758
993
  return JSON.stringify({
759
994
  id: state.id,
760
995
  stage: state.stage,
@@ -765,12 +1000,32 @@ export function registerDevTask(ctx) {
765
1000
  solution_confirmed: state.solution_confirmed,
766
1001
  items_done: `${state.items.filter(i => i.status === 'done').length}/${state.items.length}`,
767
1002
  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
- })),
1003
+ skill_obligations: obligationStages(state, workflow).map(stage => {
1004
+ const bindings = workflow.stage_bindings?.[stage]?.skills ?? [];
1005
+ const skills = bindings.map(entry => entry.skill.name);
1006
+ // Only bindings whose evidence is a command owe a command receipt, so a
1007
+ // document-producing skill is not told to run something irrelevant.
1008
+ const command_receipts_required = bindings
1009
+ .filter(entry => entry.evidence === undefined || entry.evidence === 'command')
1010
+ .map(entry => entry.skill.name)
1011
+ .filter(name => needsSkillReceipt(name));
1012
+ return { stage, skills, command_receipts_required };
1013
+ }),
772
1014
  skill_blockers: missingSkills,
773
1015
  evidence_blockers: staleEvidence,
1016
+ verification_blockers: verification,
1017
+ // Rules in force here, each named with the layer it came from, plus the
1018
+ // ones that could not be found and the legacy stage-level names still
1019
+ // awaiting an owner. A skill's own list is the complete answer to what it
1020
+ // runs under, and this mirrors that list for the current stage.
1021
+ missing_rules: missingRules,
1022
+ rules: rules.map(rule => ({ name: rule.name, source: rule.source })),
1023
+ // Rules whose SOURCE no longer matches what the task froze. The task keeps
1024
+ // following the frozen text — that is what freezing is for — and this reports
1025
+ // the drift, so a rule edited or deleted underneath a running task is visible
1026
+ // instead of silently authoritative.
1027
+ stale_source_rules: staleRules,
1028
+ unassigned_legacy_rules: legacyRules,
774
1029
  skill_results: state.skill_results ?? {},
775
1030
  commits: state.commits,
776
1031
  verification: state.verification,
@@ -787,7 +1042,11 @@ export function registerDevTask(ctx) {
787
1042
  : checkpoint,
788
1043
  bindings: {
789
1044
  skills: binding?.skills ?? [],
790
- rules: rules.map(r => ({ name: r.name, content: r.content })),
1045
+ // The layer is disclosed with each rule, because two same-named rules in
1046
+ // different layers are distinct resources and the caller needs to know
1047
+ // which one is actually in force. Reporting only the name made them
1048
+ // indistinguishable in exactly the case where the distinction matters.
1049
+ rules: rules.map(r => ({ name: r.name, source: r.source, content: r.content })),
791
1050
  },
792
1051
  bindings_drift: state.bindings_fingerprint !== undefined && state.bindings_fingerprint !== builtinRulesFingerprint()
793
1052
  ? 'bundled rules changed since this task froze — the frozen fingerprint no longer matches the shipped rules'
@@ -843,6 +1102,55 @@ export function registerDevTask(ctx) {
843
1102
  return `commit recorded (${checkpoint.label ?? ''}): ${a.hash}; use status to inspect remaining gates, then advance`;
844
1103
  return `commit approved (${checkpoint.label ?? ''}) — git add ${(a.files ?? []).join(' ')}; git commit -m "${a.message ?? ''}"`;
845
1104
  }
1105
+ if (a.operation === 'complete') {
1106
+ // Completion is recorded, not inferred from standing on the last stage.
1107
+ // A flow whose final stage is also its commit checkpoint used to reach
1108
+ // that stage with no delivery record at all, because the "commit before
1109
+ // leaving a checkpoint" rule fires on the way OUT of a stage and a
1110
+ // terminal stage has no way out.
1111
+ await assertFreshEvidence(fs, state, workflow, cwd);
1112
+ const blockers = completionBlockers(state, workflow);
1113
+ if (blockers.length > 0)
1114
+ throw new Error(`cannot complete: ${blockers.join('; ')}`);
1115
+ const hash = state.commits.find(commit => commit.hash !== undefined)?.hash;
1116
+ state.completed = hash === undefined
1117
+ ? { at: new Date().toISOString() }
1118
+ : { at: new Date().toISOString(), commit_hash: hash };
1119
+ await writeTask(fs, state, cwd, await resolveWriteMode(ctx, a, exec));
1120
+ return hash === undefined
1121
+ ? `task completed at "${state.stage}"; the record stays readable for audit`
1122
+ : `task completed at "${state.stage}" (commit ${hash}); the record stays readable for audit`;
1123
+ }
1124
+ if (a.operation === 'revise') {
1125
+ // The graph has only forward edges, but the work does not: a requirement can
1126
+ // change after implementation started. Before this, going back meant hand-
1127
+ // editing the record, which left every downstream conclusion looking intact
1128
+ // even though it described a superseded tree.
1129
+ const kind = a.revision_kind;
1130
+ if (kind !== 'requirement' && kind !== 'solution' && kind !== 'defect') {
1131
+ throw new Error('revise requires revision_kind: one of requirement, solution, defect');
1132
+ }
1133
+ if (!a.revision_reason?.trim())
1134
+ throw new Error('revise requires revision_reason describing what changed');
1135
+ const target = a.target_stage;
1136
+ if (target === undefined || !workflow.stages.includes(target)) {
1137
+ throw new Error('revise requires target_stage naming a stage of this flow: ' + workflow.stages.join(', '));
1138
+ }
1139
+ if (target === state.stage)
1140
+ throw new Error('revise target_stage is the current stage; nothing to return to');
1141
+ // Which stages are reachable backwards is a property of the flow, not of the
1142
+ // tool: any earlier stage is a legal place to resume from.
1143
+ if (workflow.stages.indexOf(target) > workflow.stages.indexOf(state.stage)) {
1144
+ throw new Error('revise goes backwards only; "' + target + '" comes after "' + state.stage + '" in this flow');
1145
+ }
1146
+ const outcome = applyRevision(state, {
1147
+ kind, reason: a.revision_reason.trim(), to: target, from: state.stage,
1148
+ at: new Date().toISOString(), invalidated: invalidatedBy(kind),
1149
+ });
1150
+ await writeTask(fs, state, cwd, await resolveWriteMode(ctx, a, exec));
1151
+ return 'revised (' + kind + ') back to "' + outcome.stage + '"; invalidated: ' + outcome.invalidated.join(', ')
1152
+ + '. Re-establish these before advancing again; the history is kept in the task record.';
1153
+ }
846
1154
  let note;
847
1155
  let approvedWriteMode;
848
1156
  switch (a.operation) {
@@ -908,6 +1216,14 @@ export function registerDevTask(ctx) {
908
1216
  const missingSkills = skillBlockers(state, workflow, session);
909
1217
  if (missingSkills.length)
910
1218
  throw new Error(missingSkills.join('; '));
1219
+ // A stage whose rules cannot be resolved must not be passable. This was
1220
+ // reported in status and never enforced, so deleting a bound rule still let the
1221
+ // task advance — the stage ran without the constraints it was configured with
1222
+ // while the record looked fine.
1223
+ const unresolvedRules = (await rulesForBinding(bindingsForStage(state.stage, workflow), fs, cwd, state.flow?.resources)).missing;
1224
+ const resourceIssues = resourceBlockers(unresolvedRules, state.stage);
1225
+ if (resourceIssues.length)
1226
+ throw new Error(resourceIssues.join('; '));
911
1227
  let result = assertAdvance(state, target, workflow);
912
1228
  if (!result.ok) {
913
1229
  const unmet = unmetGuards(state, target, workflow);
@@ -921,9 +1237,9 @@ export function registerDevTask(ctx) {
921
1237
  if (!result.ok)
922
1238
  throw new Error(result.errors.join('; '));
923
1239
  state.stage = target;
924
- const disclosure = await renderBindings(state.stage, workflow, fs, cwd);
1240
+ const disclosure = await renderBindings(state.stage, workflow, fs, cwd, state.flow?.resources);
925
1241
  const terminalObligations = obligationStages(state, workflow).filter(stage => stage !== state.stage);
926
- const terminalDisclosure = await Promise.all(terminalObligations.map(async (stage) => `Before entering ${stage}, execute its bindings now:\n${await renderBindings(stage, workflow, fs, cwd)}`));
1242
+ const terminalDisclosure = await Promise.all(terminalObligations.map(async (stage) => `Before entering ${stage}, execute its bindings now:\n${await renderBindings(stage, workflow, fs, cwd, state.flow?.resources)}`));
927
1243
  note = [`advanced to ${state.stage}`, disclosure, ...terminalDisclosure].filter(Boolean).join('\n');
928
1244
  break;
929
1245
  }
@@ -1001,7 +1317,7 @@ export function registerDevTask(ctx) {
1001
1317
  }
1002
1318
  case 'skill_result': {
1003
1319
  const stage = a.target_stage ?? state.stage;
1004
- if (!obligationStages(state, workflow).includes(stage) || !workflow.stage_bindings?.[stage]?.skills?.includes(a.skill_name ?? ''))
1320
+ if (!obligationStages(state, workflow).includes(stage) || !(workflow.stage_bindings?.[stage]?.skills ?? []).some(entry => entry.skill.name === a.skill_name))
1005
1321
  throw new Error('skill_result requires a skill bound to this stage or its upcoming terminal stage');
1006
1322
  const loadCall = loadedSkills(session).get(a.skill_name);
1007
1323
  if (!loadCall)