@godv61/dsh-task-engine 0.24.0 → 0.26.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.
Files changed (60) hide show
  1. package/.acceptance.mjs +101 -39
  2. package/.assessment-batch1.mjs +32 -12
  3. package/.e2e-presets.mjs +40 -5
  4. package/.enforce-test.mjs +139 -0
  5. package/.evidence-test.mjs +118 -0
  6. package/.filter-test.mjs +93 -0
  7. package/.freeze-test.mjs +110 -1
  8. package/.hook-test.mjs +195 -190
  9. package/.p0-test.mjs +130 -46
  10. package/.preset-test.mjs +160 -160
  11. package/.revision-test.mjs +32 -3
  12. package/.roundtrip-test.mjs +178 -0
  13. package/.workflow-test.mjs +117 -4
  14. package/README.md +10 -8
  15. package/cordis.patch.yml +10 -10
  16. package/defaults/eng.json +70 -22
  17. package/docs/BRIEF-FOR-REVIEW.md +162 -162
  18. package/docs/CHANGELOG.md +49 -0
  19. package/docs/README.md +40 -40
  20. package/docs/configuration.md +14 -11
  21. package/docs/development.md +1 -1
  22. package/docs/faq.md +4 -4
  23. package/docs/listing/godv61__dsh-task-engine.yml +5 -5
  24. package/docs/listing/submission.md +83 -83
  25. package/docs/manual.html +31 -26
  26. package/docs/releases/0.23.2.md +21 -21
  27. package/docs/roadmap.md +34 -34
  28. package/docs/testing/0.23.2/R02/344/270/232/345/212/241/346/265/213/350/257/225/346/230/216/347/273/206.md +56 -56
  29. package/docs/testing/0.23.2/R03/344/270/232/345/212/241/346/265/213/350/257/225/346/230/216/347/273/206.md +38 -38
  30. package/docs/testing/0.23.2//346/265/213/350/257/225/346/212/245/345/221/212.md +65 -65
  31. package/hooks/commit-msg +159 -72
  32. package/lib/client.js +963 -571
  33. package/lib/client.js.map +3 -3
  34. package/lib/controller.d.ts +11 -2
  35. package/lib/controller.js +33 -5
  36. package/lib/controller.js.map +1 -1
  37. package/lib/dev-task.js +170 -28
  38. package/lib/dev-task.js.map +1 -1
  39. package/lib/engine.d.ts +105 -0
  40. package/lib/engine.js +93 -5
  41. package/lib/engine.js.map +1 -1
  42. package/lib/hook.js +17 -4
  43. package/lib/hook.js.map +1 -1
  44. package/lib/skill-audit.d.ts +14 -3
  45. package/lib/skill-audit.js +66 -3
  46. package/lib/skill-audit.js.map +1 -1
  47. package/lib/workflows.d.ts +78 -12
  48. package/lib/workflows.js +258 -94
  49. package/lib/workflows.js.map +1 -1
  50. package/package.json +9 -5
  51. package/preset/agent.cordis.yml +21 -21
  52. package/preset/enable.mjs +87 -87
  53. package/preset/persona.md +4 -4
  54. package/preset/preset.yml +1 -1
  55. package/rules/coding-conventions.md +6 -6
  56. package/rules/commit-conventions.md +6 -6
  57. package/rules/security-redlines.md +5 -5
  58. package/scripts/verify-dsh-compat.mjs +144 -144
  59. package/scripts/verify-package.mjs +1 -1
  60. package/skills/code-verify/SKILL.md +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, applyRevision, completionBlockers, formatResourceRef, invalidatedBy, legalTargets, newTask, taskIdFromMessage, unmetGuards, validateCommitMessage, validateWorkflow, verificationBlockers, } 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,15 @@ 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.skill_profiles !== undefined ? { skill_profiles: parsed.skill_profiles } : {}),
191
+ ...(parsed.commit !== undefined ? { commit: parsed.commit } : {}),
192
+ ...(parsed.artifacts !== undefined ? { artifacts: parsed.artifacts } : {}),
193
+ ...(parsed.review_depth !== undefined ? { review_depth: parsed.review_depth } : {}),
194
+ ...(parsed.commit_required !== undefined ? { commit_required: parsed.commit_required } : {}),
195
+ });
188
196
  if (!resolved.ok) {
189
197
  const standard = standardWorkflow();
190
198
  return {
@@ -408,18 +416,18 @@ async function freezeResources(config, fs, cwd) {
408
416
  const unreadable = [];
409
417
  for (const binding of Object.values(config.stage_bindings ?? {})) {
410
418
  for (const entry of binding.skills ?? []) {
411
- const skillKey = formatResourceRef(entry.skill);
419
+ const skillKey = 'skill:' + formatResourceRef(entry.skill);
412
420
  if (!seen.has(skillKey)) {
413
421
  const body = await readSkillAt(entry.skill, fs, cwd);
414
422
  if (body === undefined)
415
423
  unreadable.push(skillKey);
416
424
  else {
417
425
  seen.add(skillKey);
418
- resources.push({ ref: entry.skill, hash: hashText(body), content: body });
426
+ resources.push({ kind: 'skill', ref: entry.skill, hash: hashText(body), content: body });
419
427
  }
420
428
  }
421
429
  for (const rule of entry.rules) {
422
- const key = formatResourceRef(rule);
430
+ const key = 'rule:' + formatResourceRef(rule);
423
431
  if (seen.has(key))
424
432
  continue;
425
433
  const body = await readRuleAt(rule.source, rule.name, fs, cwd);
@@ -428,7 +436,7 @@ async function freezeResources(config, fs, cwd) {
428
436
  continue;
429
437
  }
430
438
  seen.add(key);
431
- resources.push({ ref: rule, hash: hashText(body), content: body });
439
+ resources.push({ kind: 'rule', ref: rule, hash: hashText(body), content: body });
432
440
  }
433
441
  }
434
442
  }
@@ -444,7 +452,14 @@ async function freezeResources(config, fs, cwd) {
444
452
  * @param fs - sandboxed filesystem for the project-level lookup.
445
453
  * @returns the layer that holds this name, or undefined when none does.
446
454
  */
447
- async function resolveLegacyRuleSource(name, fs, cwd) {
455
+ async function resolveLegacyRuleSource(name, fs, cwd, frozen) {
456
+ // A frozen snapshot answers this without touching the filesystem: the layer a
457
+ // legacy name resolved to at creation is part of what the task froze, so a
458
+ // source deleted afterwards cannot change where it resolves.
459
+ for (const resource of frozen ?? []) {
460
+ if (resource.kind !== 'skill' && resource.ref.name === name)
461
+ return resource.ref.source;
462
+ }
448
463
  for (const source of ['bundled', 'project', 'user']) {
449
464
  if ((await readRuleAt(source, name, fs, cwd)) !== undefined)
450
465
  return source;
@@ -462,18 +477,50 @@ async function resolveLegacyRuleSource(name, fs, cwd) {
462
477
  * @param fs - sandboxed filesystem for the project-level lookup.
463
478
  * @returns the resolved rules with bodies and sources, plus the refs not found.
464
479
  */
465
- async function resolveRules(refs, fs, cwd) {
480
+ async function resolveRules(refs, fs, cwd, frozen) {
466
481
  const resolved = [];
467
482
  const missing = [];
483
+ // A frozen body is the authority for a task that has one. Freezing used to only
484
+ // ARCHIVE the content: status and the stage disclosure still read the live files,
485
+ // so editing a rule silently changed what a running task was told to follow —
486
+ // the exact instability the snapshot exists to prevent.
487
+ const frozenByRef = new Map();
488
+ // Pre-0.26 snapshots have no kind. If such a snapshot reused a skill's ref
489
+ // for a rule, its rule was never frozen; fail closed instead of reading the
490
+ // skill body as a rule.
491
+ for (const resource of frozen ?? []) {
492
+ if (resource.kind === 'rule')
493
+ frozenByRef.set(formatResourceRef(resource.ref), resource);
494
+ else if (resource.kind === undefined && !resource.content.startsWith('---')) {
495
+ frozenByRef.set(formatResourceRef(resource.ref), resource);
496
+ }
497
+ }
498
+ const stale = [];
468
499
  for (const ref of refs) {
500
+ const key = formatResourceRef(ref);
501
+ const snapshot = frozenByRef.get(key);
502
+ if (snapshot !== undefined) {
503
+ resolved.push({ name: ref.name, content: snapshot.content, source: ref.source });
504
+ // Report when the source no longer matches what was frozen, so drift is
505
+ // visible instead of looking like the frozen text was simply current.
506
+ const live = await readRuleAt(ref.source, ref.name, fs, cwd);
507
+ if (live === undefined)
508
+ stale.push(key + ' (source deleted)');
509
+ else if (hashText(live) !== snapshot.hash)
510
+ stale.push(key + ' (source edited)');
511
+ continue;
512
+ }
513
+ // No snapshot (a pre-freezing task) or this ref was not frozen: read live, and
514
+ // report a rule that resolves nowhere rather than dropping it, because omitting
515
+ // it made a deleted file indistinguishable from an unbound one.
469
516
  const content = await readRuleAt(ref.source, ref.name, fs, cwd);
470
517
  if (content === undefined) {
471
- missing.push(formatResourceRef(ref));
518
+ missing.push(key);
472
519
  continue;
473
520
  }
474
521
  resolved.push({ name: ref.name, content, source: ref.source });
475
522
  }
476
- return { resolved, missing };
523
+ return { resolved, missing, stale };
477
524
  }
478
525
  /**
479
526
  * Every rule that applies at a stage, gathered from the skills bound to it.
@@ -487,7 +534,7 @@ async function resolveRules(refs, fs, cwd) {
487
534
  * @param fs - sandboxed filesystem for project-level lookups.
488
535
  * @returns resolved rules, missing refs, and the unassigned legacy names.
489
536
  */
490
- async function rulesForBinding(binding, fs, cwd) {
537
+ async function rulesForBinding(binding, fs, cwd, frozen) {
491
538
  const refs = [];
492
539
  // Deduplicated by reference: one rule shared by two skills on the same stage is
493
540
  // disclosed once, because it is one constraint being followed.
@@ -501,11 +548,11 @@ async function rulesForBinding(binding, fs, cwd) {
501
548
  refs.push(rule);
502
549
  }
503
550
  }
504
- const { resolved, missing } = await resolveRules(refs, fs, cwd);
551
+ const { resolved, missing, stale } = await resolveRules(refs, fs, cwd, frozen);
505
552
  const legacy = binding?.legacy_rules ?? [];
506
553
  const legacyRefs = [];
507
554
  for (const name of legacy) {
508
- const source = await resolveLegacyRuleSource(name, fs, cwd);
555
+ const source = await resolveLegacyRuleSource(name, fs, cwd, frozen);
509
556
  // An unresolvable legacy name is reported through `missing` so it is visible
510
557
  // rather than quietly absent.
511
558
  if (source === undefined) {
@@ -518,8 +565,27 @@ async function rulesForBinding(binding, fs, cwd) {
518
565
  seen.add(key);
519
566
  legacyRefs.push({ source, name });
520
567
  }
521
- const legacyResolved = (await resolveRules(legacyRefs, fs, cwd)).resolved;
522
- return { resolved: [...resolved, ...legacyResolved], missing, legacy };
568
+ const legacyResult = await resolveRules(legacyRefs, fs, cwd, frozen);
569
+ return {
570
+ resolved: [...resolved, ...legacyResult.resolved],
571
+ missing,
572
+ legacy,
573
+ stale: [...stale, ...legacyResult.stale],
574
+ };
575
+ }
576
+ /** Return the task's frozen skill instructions, falling back to live bodies for old tasks. */
577
+ async function skillBodiesForBinding(binding, fs, cwd, frozen) {
578
+ const result = [];
579
+ for (const entry of binding?.skills ?? []) {
580
+ const key = formatResourceRef(entry.skill);
581
+ const archived = frozen?.find(resource => (resource.kind === 'skill'
582
+ || (resource.kind === undefined && resource.content.startsWith('---')))
583
+ && formatResourceRef(resource.ref) === key);
584
+ const content = archived?.content ?? await readSkillAt(entry.skill, fs, cwd);
585
+ if (content !== undefined)
586
+ result.push({ name: entry.skill.name, source: entry.skill.source, content });
587
+ }
588
+ return result;
523
589
  }
524
590
  /**
525
591
  * Render a stage's progressive-disclosure payload: skill names to load via the
@@ -530,15 +596,19 @@ async function rulesForBinding(binding, fs, cwd) {
530
596
  * @param fs - host filesystem for project-rule resolution.
531
597
  * @returns the disclosure text, or an empty string.
532
598
  */
533
- async function renderBindings(stage, workflow, fs, cwd) {
599
+ async function renderBindings(stage, workflow, fs, cwd, frozen) {
534
600
  const binding = bindingsForStage(stage, workflow);
535
601
  const skills = binding?.skills ?? [];
536
602
  if (binding === undefined || (skills.length === 0 && (binding.legacy_rules ?? []).length === 0))
537
603
  return '';
538
- const { resolved: rules, missing, legacy } = await rulesForBinding(binding, fs, cwd);
604
+ const { resolved: rules, missing, legacy } = await rulesForBinding(binding, fs, cwd, frozen);
539
605
  const skillPart = skills.length > 0
540
606
  ? `skills to load: ${skills.map(skill => formatResourceRef(skill.skill)).join(', ')} (use the skill tool by name)`
541
607
  : 'skills to load: none';
608
+ const frozenSkills = await skillBodiesForBinding(binding, fs, cwd, frozen);
609
+ const skillInstructions = frozenSkills.length > 0
610
+ ? `skill instructions for this task (frozen at creation when available; these are authoritative if the live skill changed):\n${frozenSkills.map(skill => `### ${skill.source}:${skill.name}\n${skill.content}`).join('\n\n')}`
611
+ : '';
542
612
  const rulePart = rules.length > 0
543
613
  ? `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
614
  : '';
@@ -554,11 +624,18 @@ async function renderBindings(stage, workflow, fs, cwd) {
554
624
  ? `UNASSIGNED legacy stage rules: ${legacy.join(', ')} — these belong to the stage, not to any skill. ` +
555
625
  '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
626
  : '';
557
- const additional = skills.map(skill => skill.skill.name).filter(needsSkillReceipt);
627
+ // The disclosure names the skills that owe a command receipt, honouring each
628
+ // binding's declared evidence kind — a document-producing skill owes an
629
+ // artifact, not a shell command, and saying otherwise would tell the model to
630
+ // run something irrelevant.
631
+ const additional = skills
632
+ .filter(entry => entry.evidence === undefined || entry.evidence === 'command')
633
+ .map(entry => entry.skill.name)
634
+ .filter(name => needsSkillReceipt(name));
558
635
  const receiptPart = additional.length
559
636
  ? `additional skills requiring skill_result command receipts: ${additional.join(', ')}`
560
637
  : 'skill_result not required for this stage: core skills use record/verify/review/commit gates';
561
- return [skillPart, receiptPart, rulePart, missingPart, legacyPart].filter(Boolean).join('\n');
638
+ return [skillPart, skillInstructions, receiptPart, rulePart, missingPart, legacyPart].filter(Boolean).join('\n');
562
639
  }
563
640
  /** Normalize the tool's `items` argument into complete TaskItem records. */
564
641
  function normalizeItems(items, previous = []) {
@@ -899,6 +976,9 @@ export function registerDevTask(ctx) {
899
976
  // in-flight task stable: editing a rule the task uses would otherwise change
900
977
  // what the task is doing without the task saying so.
901
978
  const frozen = await freezeResources(resolved.config, fs, cwd);
979
+ if (frozen.unreadable.length > 0) {
980
+ throw new Error(`cannot create task with unreadable bound resources: ${frozen.unreadable.join(', ')}`);
981
+ }
902
982
  const flow = {
903
983
  flow: resolved.flow,
904
984
  version: resolved.version,
@@ -923,15 +1003,21 @@ export function registerDevTask(ctx) {
923
1003
  state.bindings_fingerprint = builtinRulesFingerprint();
924
1004
  assertInsideRoot(root, cwd ?? '', taskPath(state.id));
925
1005
  await writeTask(fs, state, cwd, await resolveWriteMode(ctx, a, exec));
926
- return `created ${state.id} at stage ${state.stage}; legal next: ${legalTargets(state.stage, flow.config).join(', ') || 'none'}\n${await renderBindings(state.stage, flow.config, fs, cwd)}\nBefore advance, load all bound skills. For additional skills, execute their instructions then use skill_result with evidence and command. Terminal bindings must finish before entering the terminal stage.`;
1006
+ return `created ${state.id} at stage ${state.stage}; legal next: ${legalTargets(state.stage, flow.config).join(', ') || 'none'}\n${await renderBindings(state.stage, flow.config, fs, cwd, flow.resources)}\nBefore advance, load all bound skills. Record each skill's declared evidence. Terminal bindings must finish before entering the terminal stage.`;
927
1007
  }
928
1008
  if (!a.task_id)
929
1009
  throw new Error('task_id is required for this operation');
930
1010
  const state = await loadTask(fs, a.task_id, cwd);
931
1011
  const workflow = await workflowFor(state, fs, cwd);
1012
+ if (state.completed !== undefined && a.operation === 'complete') {
1013
+ return `task already completed at ${state.completed.at}`;
1014
+ }
1015
+ if (state.completed !== undefined && a.operation !== 'status' && a.operation !== 'revise') {
1016
+ throw new Error('task is completed; use revise to reopen it before changing task state');
1017
+ }
932
1018
  if (a.operation === 'status') {
933
1019
  const binding = bindingsForStage(state.stage, workflow);
934
- const { resolved: rules, missing: missingRules, legacy: legacyRules, } = await rulesForBinding(binding, fs, cwd);
1020
+ const { resolved: rules, missing: missingRules, legacy: legacyRules, stale: staleRules, } = await rulesForBinding(binding, fs, cwd, state.flow?.resources);
935
1021
  const missingSkills = skillBlockers(state, workflow, session);
936
1022
  const staleEvidence = await evidenceBlockers(fs, state, workflow, cwd);
937
1023
  const checkpoint = commitCheckpoint(state, workflow);
@@ -940,19 +1026,31 @@ export function registerDevTask(ctx) {
940
1026
  // needs a passing verification and does not have one. Callers act on both.
941
1027
  const verification = verificationBlockers(state, workflow);
942
1028
  const commitBlockers = [...staleEvidence, ...missingSkills, ...verification];
1029
+ const completionIssues = state.completed === undefined
1030
+ ? [...completionBlockers(state, workflow), ...missingSkills, ...resourceBlockers(missingRules, state.stage), ...staleEvidence]
1031
+ : [];
943
1032
  return JSON.stringify({
944
1033
  id: state.id,
945
1034
  stage: state.stage,
946
1035
  flow: state.flow !== undefined ? { flow: state.flow.flow, version: state.flow.version } : null,
947
1036
  risk_level: state.risk_level,
1037
+ completed: state.completed ?? null,
1038
+ completion_blockers: completionIssues,
948
1039
  work_size: state.work_size,
949
1040
  requirement_confirmed: state.requirement_confirmed,
950
1041
  solution_confirmed: state.solution_confirmed,
951
1042
  items_done: `${state.items.filter(i => i.status === 'done').length}/${state.items.length}`,
952
1043
  items: state.items,
953
1044
  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) };
1045
+ const bindings = workflow.stage_bindings?.[stage]?.skills ?? [];
1046
+ const skills = bindings.map(entry => entry.skill.name);
1047
+ // Only bindings whose evidence is a command owe a command receipt, so a
1048
+ // document-producing skill is not told to run something irrelevant.
1049
+ const command_receipts_required = bindings
1050
+ .filter(entry => entry.evidence === undefined || entry.evidence === 'command')
1051
+ .map(entry => entry.skill.name)
1052
+ .filter(name => needsSkillReceipt(name));
1053
+ return { stage, skills, command_receipts_required };
956
1054
  }),
957
1055
  skill_blockers: missingSkills,
958
1056
  evidence_blockers: staleEvidence,
@@ -963,6 +1061,11 @@ export function registerDevTask(ctx) {
963
1061
  // runs under, and this mirrors that list for the current stage.
964
1062
  missing_rules: missingRules,
965
1063
  rules: rules.map(rule => ({ name: rule.name, source: rule.source })),
1064
+ // Rules whose SOURCE no longer matches what the task froze. The task keeps
1065
+ // following the frozen text — that is what freezing is for — and this reports
1066
+ // the drift, so a rule edited or deleted underneath a running task is visible
1067
+ // instead of silently authoritative.
1068
+ stale_source_rules: staleRules,
966
1069
  unassigned_legacy_rules: legacyRules,
967
1070
  skill_results: state.skill_results ?? {},
968
1071
  commits: state.commits,
@@ -980,7 +1083,12 @@ export function registerDevTask(ctx) {
980
1083
  : checkpoint,
981
1084
  bindings: {
982
1085
  skills: binding?.skills ?? [],
983
- rules: rules.map(r => ({ name: r.name, content: r.content })),
1086
+ skill_contents: await skillBodiesForBinding(binding, fs, cwd, state.flow?.resources),
1087
+ // The layer is disclosed with each rule, because two same-named rules in
1088
+ // different layers are distinct resources and the caller needs to know
1089
+ // which one is actually in force. Reporting only the name made them
1090
+ // indistinguishable in exactly the case where the distinction matters.
1091
+ rules: rules.map(r => ({ name: r.name, source: r.source, content: r.content })),
984
1092
  },
985
1093
  bindings_drift: state.bindings_fingerprint !== undefined && state.bindings_fingerprint !== builtinRulesFingerprint()
986
1094
  ? 'bundled rules changed since this task froze — the frozen fingerprint no longer matches the shipped rules'
@@ -1043,7 +1151,13 @@ export function registerDevTask(ctx) {
1043
1151
  // leaving a checkpoint" rule fires on the way OUT of a stage and a
1044
1152
  // terminal stage has no way out.
1045
1153
  await assertFreshEvidence(fs, state, workflow, cwd);
1046
- const blockers = completionBlockers(state, workflow);
1154
+ const missingSkills = skillBlockers(state, workflow, session);
1155
+ const unresolvedRules = (await rulesForBinding(bindingsForStage(state.stage, workflow), fs, cwd, state.flow?.resources)).missing;
1156
+ const blockers = [
1157
+ ...completionBlockers(state, workflow),
1158
+ ...missingSkills,
1159
+ ...resourceBlockers(unresolvedRules, state.stage),
1160
+ ];
1047
1161
  if (blockers.length > 0)
1048
1162
  throw new Error(`cannot complete: ${blockers.join('; ')}`);
1049
1163
  const hash = state.commits.find(commit => commit.hash !== undefined)?.hash;
@@ -1150,6 +1264,14 @@ export function registerDevTask(ctx) {
1150
1264
  const missingSkills = skillBlockers(state, workflow, session);
1151
1265
  if (missingSkills.length)
1152
1266
  throw new Error(missingSkills.join('; '));
1267
+ // A stage whose rules cannot be resolved must not be passable. This was
1268
+ // reported in status and never enforced, so deleting a bound rule still let the
1269
+ // task advance — the stage ran without the constraints it was configured with
1270
+ // while the record looked fine.
1271
+ const unresolvedRules = (await rulesForBinding(bindingsForStage(state.stage, workflow), fs, cwd, state.flow?.resources)).missing;
1272
+ const resourceIssues = resourceBlockers(unresolvedRules, state.stage);
1273
+ if (resourceIssues.length)
1274
+ throw new Error(resourceIssues.join('; '));
1153
1275
  let result = assertAdvance(state, target, workflow);
1154
1276
  if (!result.ok) {
1155
1277
  const unmet = unmetGuards(state, target, workflow);
@@ -1163,9 +1285,9 @@ export function registerDevTask(ctx) {
1163
1285
  if (!result.ok)
1164
1286
  throw new Error(result.errors.join('; '));
1165
1287
  state.stage = target;
1166
- const disclosure = await renderBindings(state.stage, workflow, fs, cwd);
1288
+ const disclosure = await renderBindings(state.stage, workflow, fs, cwd, state.flow?.resources);
1167
1289
  const terminalObligations = obligationStages(state, workflow).filter(stage => stage !== state.stage);
1168
- const terminalDisclosure = await Promise.all(terminalObligations.map(async (stage) => `Before entering ${stage}, execute its bindings now:\n${await renderBindings(stage, workflow, fs, cwd)}`));
1290
+ 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)}`));
1169
1291
  note = [`advanced to ${state.stage}`, disclosure, ...terminalDisclosure].filter(Boolean).join('\n');
1170
1292
  break;
1171
1293
  }
@@ -1243,11 +1365,31 @@ export function registerDevTask(ctx) {
1243
1365
  }
1244
1366
  case 'skill_result': {
1245
1367
  const stage = a.target_stage ?? state.stage;
1246
- if (!obligationStages(state, workflow).includes(stage) || !(workflow.stage_bindings?.[stage]?.skills ?? []).some(entry => entry.skill.name === a.skill_name))
1368
+ const entry = (workflow.stage_bindings?.[stage]?.skills ?? []).find(binding => binding.skill.name === a.skill_name);
1369
+ if (!obligationStages(state, workflow).includes(stage) || !entry)
1247
1370
  throw new Error('skill_result requires a skill bound to this stage or its upcoming terminal stage');
1248
1371
  const loadCall = loadedSkills(session).get(a.skill_name);
1249
1372
  if (!loadCall)
1250
1373
  throw new Error(`load skill "${a.skill_name}" successfully before recording execution`);
1374
+ if (entry.evidence === 'manual') {
1375
+ if (!a.evidence?.length || a.evidence.some(value => !value.trim()))
1376
+ throw new Error('manual skill_result requires non-empty evidence');
1377
+ const approval = ctx.get('approval');
1378
+ if (approval === undefined || exec.agent === undefined)
1379
+ throw new Error('manual skill_result requires the human approval service');
1380
+ const outcome = await approval.request({ agent: exec.agent, toolName: 'dev_task', callId: exec.callId,
1381
+ reason: `请确认技能 ${a.skill_name} 在 ${stage} 的人工结果:${a.evidence.join(';')}`, signal: exec.signal });
1382
+ if (outcome !== 'allowed-once')
1383
+ throw new Error(`manual skill_result was not approved (${outcome})`);
1384
+ state.skill_results ??= {};
1385
+ state.skill_results[stage] ??= {};
1386
+ state.skill_results[stage][a.skill_name] = { session_id: session?.id ?? '', load_call_id: loadCall, evidence: a.evidence, approved: true };
1387
+ note = JSON.stringify({ skill: a.skill_name, stage, approved: true }, null, 2);
1388
+ break;
1389
+ }
1390
+ if (entry.evidence === 'artifact' || entry.evidence === 'review' || entry.evidence === 'none') {
1391
+ throw new Error(`${entry.evidence} skill evidence is recorded by its node operation; skill_result is only for command or manual evidence`);
1392
+ }
1251
1393
  if (!a.command?.trim() || !a.evidence?.length || a.evidence.some(value => !value.trim()))
1252
1394
  throw new Error('skill_result requires a real validation command and non-empty evidence describing executed scenarios and results');
1253
1395
  approvedWriteMode = await resolveWriteMode(ctx, a, exec);