@godv61/dsh-task-engine 0.26.1 → 0.27.1

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 (57) hide show
  1. package/.acceptance.mjs +11 -6
  2. package/.assessment-batch1.mjs +14 -11
  3. package/.codex-project-test.mjs +80 -0
  4. package/.enforce-test.mjs +12 -17
  5. package/.evidence-test.mjs +8 -8
  6. package/.filter-test.mjs +6 -8
  7. package/.freeze-test.mjs +44 -40
  8. package/.p0-test.mjs +21 -24
  9. package/.revision-test.mjs +16 -16
  10. package/.roundtrip-test.mjs +247 -120
  11. package/.workflow-test.mjs +119 -102
  12. package/README.md +14 -12
  13. package/defaults/eng.json +1 -97
  14. package/docs/CHANGELOG.md +33 -19
  15. package/docs/configuration.md +22 -21
  16. package/docs/development.md +1 -1
  17. package/docs/faq.md +5 -5
  18. package/docs/manual.html +41 -59
  19. package/docs/resource-install.md +5 -3
  20. package/docs/roadmap.md +2 -2
  21. package/hooks/commit-msg +4 -33
  22. package/lib/client.js +332 -226
  23. package/lib/client.js.map +3 -3
  24. package/lib/controller.d.ts +19 -3
  25. package/lib/controller.js +132 -28
  26. package/lib/controller.js.map +1 -1
  27. package/lib/dev-task.js +130 -79
  28. package/lib/dev-task.js.map +1 -1
  29. package/lib/engine.d.ts +10 -13
  30. package/lib/engine.js +3 -3
  31. package/lib/engine.js.map +1 -1
  32. package/lib/shipped-skills.d.ts +2 -4
  33. package/lib/shipped-skills.js +2 -4
  34. package/lib/shipped-skills.js.map +1 -1
  35. package/lib/skill-audit.d.ts +4 -4
  36. package/lib/skill-audit.js +19 -14
  37. package/lib/skill-audit.js.map +1 -1
  38. package/lib/user-skill-profiles.d.ts +7 -0
  39. package/lib/user-skill-profiles.js +69 -0
  40. package/lib/user-skill-profiles.js.map +1 -0
  41. package/lib/workflows.d.ts +24 -4
  42. package/lib/workflows.js +57 -41
  43. package/lib/workflows.js.map +1 -1
  44. package/package.json +4 -4
  45. package/preset/agent.cordis.yml +3 -3
  46. package/preset/persona.md +2 -2
  47. package/scripts/verify-package.mjs +3 -1
  48. package/skills/eng-delivery/SKILL.md +17 -17
  49. package/rules/coding-conventions.md +0 -7
  50. package/rules/commit-conventions.md +0 -6
  51. package/rules/security-redlines.md +0 -6
  52. package/skills/code-commit/SKILL.md +0 -13
  53. package/skills/code-implement/SKILL.md +0 -24
  54. package/skills/code-review/SKILL.md +0 -13
  55. package/skills/code-verify/SKILL.md +0 -20
  56. package/skills/requirement-analysis/SKILL.md +0 -16
  57. package/skills/solution-design/SKILL.md +0 -19
package/lib/dev-task.js CHANGED
@@ -18,6 +18,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
18
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
+ import { withUserSkillProfiles } from "./user-skill-profiles.js";
21
22
  import { loadedSkills, needsSkillReceipt, obligationStages, skillBlockers } from "./skill-audit.js";
22
23
  import { detectRoot, detectType, defaultVerifyCommand, listProjectRules, listProjectSkills, presentGovernanceFiles, } from "./project.js";
23
24
  /** Bundled commit-msg hook, copied into `.git/hooks/` by the `install_hook` operation. */
@@ -184,15 +185,23 @@ async function resolveWorkflow(fs, cwd) {
184
185
  source: 'invalid',
185
186
  };
186
187
  }
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
+ let project;
189
+ try {
190
+ project = withUserSkillProfiles({
191
+ flow: parsed.flow,
192
+ ...(parsed.stage_bindings !== undefined ? { stage_bindings: parsed.stage_bindings } : {}),
193
+ ...(parsed.skill_profiles !== undefined ? { skill_profiles: parsed.skill_profiles } : {}),
194
+ ...(parsed.commit !== undefined ? { commit: parsed.commit } : {}),
195
+ ...(parsed.artifacts !== undefined ? { artifacts: parsed.artifacts } : {}),
196
+ ...(parsed.review_depth !== undefined ? { review_depth: parsed.review_depth } : {}),
197
+ ...(parsed.commit_required !== undefined ? { commit_required: parsed.commit_required } : {}),
198
+ });
199
+ }
200
+ catch (error) {
201
+ const standard = standardWorkflow();
202
+ return { ...standard, problems: [`用户级技能规则档案无法读取:${error instanceof Error ? error.message : String(error)}`], source: 'invalid' };
203
+ }
204
+ const resolved = resolveFlow(parsed.flow, project);
196
205
  if (!resolved.ok) {
197
206
  const standard = standardWorkflow();
198
207
  return {
@@ -337,19 +346,6 @@ function readAbsRule(file) {
337
346
  return undefined;
338
347
  }
339
348
  }
340
- /** The bundled rule files whose contents the frozen tasks' bindings reference. */
341
- const BUILTIN_RULE_FILES = ['commit-conventions', 'coding-conventions', 'security-redlines'];
342
- /**
343
- * Fingerprint of the bundled rules this package ships. A task created now
344
- * freezes this value; when a later package version ships changed rules, the
345
- * disclosure reports the drift instead of silently applying new content.
346
- */
347
- function builtinRulesFingerprint() {
348
- const contents = BUILTIN_RULE_FILES
349
- .map(name => readAbsRule(fileURLToPath(new URL(`../rules/${name}.md`, import.meta.url))) ?? '')
350
- .join('\n');
351
- return hashText(contents);
352
- }
353
349
  /** Read one rule body from a specific layer, so a reference decides where to look. */
354
350
  async function readRuleAt(source, name, fs, cwd) {
355
351
  const safe = sanitize(name);
@@ -395,17 +391,18 @@ async function readSkillAt(ref, fs, cwd) {
395
391
  return read(join(BUNDLED_SKILLS_DIR, safe, 'SKILL.md'));
396
392
  case 'project':
397
393
  return await readText(fs, '.dsh/skills/' + safe + '/SKILL.md', cwd);
394
+ case 'codex-project':
395
+ return await readText(fs, '.agents/skills/' + safe + '/SKILL.md', cwd);
398
396
  case 'user':
399
397
  return read(join(dshHome(), 'skills', safe, 'SKILL.md'));
400
398
  }
401
399
  }
402
400
  /**
403
- * Freeze every skill and rule body the config resolves to.
401
+ * Capture every skill and rule body the config resolves to at task creation.
404
402
  *
405
- * Names alone cannot keep an in-flight task stable: editing a rule that a running
406
- * task uses would silently change what that task is doing. Each resolved body is
407
- * copied with its content hash, so a deleted or edited source cannot change a
408
- * running task, and identical bodies are stored once per hash.
403
+ * The copy is an audit baseline, not the runtime authority: task interactions
404
+ * always read the latest body at the same source-qualified reference. Capturing
405
+ * hashes makes changes visible and rejects an unreadable binding at creation.
409
406
  * @param config - the frozen workflow config.
410
407
  * @param fs - sandboxed filesystem for project-level lookups.
411
408
  * @returns the frozen resources plus the refs that could not be read.
@@ -446,8 +443,8 @@ async function freezeResources(config, fs, cwd) {
446
443
  * Resolve a bare legacy rule name to the layer that precedence selects.
447
444
  *
448
445
  * Precedence, unchanged from before: bundled wins over project, which wins over
449
- * user. A project cannot shadow a shipped rule like `security-redlines` with a
450
- * weaker local copy; local additions use distinct names instead.
446
+ * user. A source-qualified reference identifies the intended body even when
447
+ * two resource layers use the same name.
451
448
  * @param name - the bare rule name from a legacy config.
452
449
  * @param fs - sandboxed filesystem for the project-level lookup.
453
450
  * @returns the layer that holds this name, or undefined when none does.
@@ -480,10 +477,8 @@ async function resolveLegacyRuleSource(name, fs, cwd, frozen) {
480
477
  async function resolveRules(refs, fs, cwd, frozen) {
481
478
  const resolved = [];
482
479
  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.
480
+ // Creation-time copies are an audit baseline. The source-qualified reference
481
+ // remains stable, while its body is read afresh on each interaction.
487
482
  const frozenByRef = new Map();
488
483
  // Pre-0.26 snapshots have no kind. If such a snapshot reused a skill's ref
489
484
  // for a rule, its rule was never frozen; fail closed instead of reading the
@@ -499,25 +494,15 @@ async function resolveRules(refs, fs, cwd, frozen) {
499
494
  for (const ref of refs) {
500
495
  const key = formatResourceRef(ref);
501
496
  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.
516
497
  const content = await readRuleAt(ref.source, ref.name, fs, cwd);
517
498
  if (content === undefined) {
518
499
  missing.push(key);
500
+ if (snapshot !== undefined)
501
+ stale.push(key + ' (source deleted)');
519
502
  continue;
520
503
  }
504
+ if (snapshot !== undefined && hashText(content) !== snapshot.hash)
505
+ stale.push(key + ' (source edited)');
521
506
  resolved.push({ name: ref.name, content, source: ref.source });
522
507
  }
523
508
  return { resolved, missing, stale };
@@ -573,20 +558,45 @@ async function rulesForBinding(binding, fs, cwd, frozen) {
573
558
  stale: [...stale, ...legacyResult.stale],
574
559
  };
575
560
  }
576
- /** Return the task's frozen skill instructions, falling back to live bodies for old tasks. */
577
- async function skillBodiesForBinding(binding, fs, cwd, frozen) {
561
+ /** Return current skill instructions at each stable source-qualified reference. */
562
+ async function skillBodiesForBinding(binding, fs, cwd) {
578
563
  const result = [];
579
564
  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);
565
+ const content = await readSkillAt(entry.skill, fs, cwd);
585
566
  if (content !== undefined)
586
567
  result.push({ name: entry.skill.name, source: entry.skill.source, content });
587
568
  }
588
569
  return result;
589
570
  }
571
+ /** Missing live skill files cannot be satisfied by the creation-time archive. */
572
+ async function missingSkillRefs(binding, fs, cwd) {
573
+ const missing = [];
574
+ for (const entry of binding?.skills ?? []) {
575
+ if ((await readSkillAt(entry.skill, fs, cwd)) === undefined)
576
+ missing.push(formatResourceRef(entry.skill));
577
+ }
578
+ return missing;
579
+ }
580
+ function skillResourceBlockers(missing, stage) {
581
+ return missing.map(ref => `${stage}: bound skill ${ref} resolves nowhere; restore the file or remove the binding for a new task`);
582
+ }
583
+ async function changedSkillRefs(binding, fs, cwd, baseline) {
584
+ const changed = [];
585
+ for (const entry of binding?.skills ?? []) {
586
+ const key = formatResourceRef(entry.skill);
587
+ const original = baseline?.find(resource => (resource.kind === 'skill'
588
+ || (resource.kind === undefined && resource.content.startsWith('---')))
589
+ && formatResourceRef(resource.ref) === key);
590
+ if (original === undefined)
591
+ continue;
592
+ const live = await readSkillAt(entry.skill, fs, cwd);
593
+ if (live === undefined)
594
+ changed.push(key + ' (source deleted)');
595
+ else if (hashText(live) !== original.hash)
596
+ changed.push(key + ' (source edited)');
597
+ }
598
+ return changed;
599
+ }
590
600
  /**
591
601
  * Render a stage's progressive-disclosure payload: skill names to load via the
592
602
  * `skill` tool, plus the resolved rule bodies to follow. Empty text means the
@@ -603,11 +613,11 @@ async function renderBindings(stage, workflow, fs, cwd, frozen) {
603
613
  return '';
604
614
  const { resolved: rules, missing, legacy } = await rulesForBinding(binding, fs, cwd, frozen);
605
615
  const skillPart = skills.length > 0
606
- ? `skills to load: ${skills.map(skill => formatResourceRef(skill.skill)).join(', ')} (use the skill tool by name)`
616
+ ? `skills to load: ${skills.map(skill => formatResourceRef(skill.skill)).join(', ')} (use the skill tool by name; for codex-project skills use dev_task operation=load_skill with the qualified skill_name)`
607
617
  : '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')}`
618
+ const currentSkills = await skillBodiesForBinding(binding, fs, cwd);
619
+ const skillInstructions = currentSkills.length > 0
620
+ ? `current skill instructions for this task (read at this interaction):\n${currentSkills.map(skill => `### ${skill.source}:${skill.name}\n${skill.content}`).join('\n\n')}`
611
621
  : '';
612
622
  const rulePart = rules.length > 0
613
623
  ? `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')}`
@@ -617,6 +627,9 @@ async function renderBindings(stage, workflow, fs, cwd, frozen) {
617
627
  const missingPart = missing.length > 0
618
628
  ? `rules in force at this stage but NOT FOUND in their declared layer: ${missing.join(', ')}`
619
629
  : '';
630
+ const missingSkillPart = skills.length > currentSkills.length
631
+ ? `bound skills NOT FOUND in their declared layer: ${(await missingSkillRefs(binding, fs, cwd)).join(', ')}`
632
+ : '';
620
633
  // Legacy stage-level rules are disclosed (so nothing is lost) but named as
621
634
  // unassigned, because a stage-level list never recorded which skill they were
622
635
  // meant for and guessing would invent an answer the config did not contain.
@@ -630,12 +643,12 @@ async function renderBindings(stage, workflow, fs, cwd, frozen) {
630
643
  // run something irrelevant.
631
644
  const additional = skills
632
645
  .filter(entry => entry.evidence === undefined || entry.evidence === 'command')
633
- .map(entry => entry.skill.name)
646
+ .map(entry => entry.skill.source === 'codex-project' ? formatResourceRef(entry.skill) : entry.skill.name)
634
647
  .filter(name => needsSkillReceipt(name));
635
648
  const receiptPart = additional.length
636
649
  ? `additional skills requiring skill_result command receipts: ${additional.join(', ')}`
637
650
  : 'no command skill_result receipt required for this stage; other evidence kinds follow their configured stage operations';
638
- return [skillPart, skillInstructions, receiptPart, rulePart, missingPart, legacyPart].filter(Boolean).join('\n');
651
+ return [skillPart, skillInstructions, receiptPart, rulePart, missingPart, missingSkillPart, legacyPart].filter(Boolean).join('\n');
639
652
  }
640
653
  /** Normalize the tool's `items` argument into complete TaskItem records. */
641
654
  function normalizeItems(items, previous = []) {
@@ -759,7 +772,7 @@ export async function approveAdvance(ctx, state, target, workflow, confirmations
759
772
  state.solution_confirmed = true;
760
773
  return assertAdvance(state, target, workflow);
761
774
  }
762
- 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'];
775
+ const OPERATIONS = ['status', 'create', 'load_skill', 'record', 'items', 'scope', 'dispatch', 'review_item', 'advance', 'verify', 'review', 'commit', 'complete', 'revise', 'config', 'install_hook', 'verify_hook', 'init', 'set_risk', 'skill_result'];
763
776
  const TOOL_DESCRIPTION = 'Own the engineering delivery workflow as hard state. Read or create the task record, record a ' +
764
777
  'stage artifact, record each implementation item\'s subagent dispatch and two-stage (spec + quality) ' +
765
778
  'review audit (the flow decides how many verdicts per item), advance one stage (rejected unless every configured guard already holds, including ' +
@@ -784,7 +797,7 @@ export function registerDevTask(ctx) {
784
797
  parameters: {
785
798
  operation: { type: 'string', enum: [...OPERATIONS], required: true, description: 'Which task-record action to perform.' },
786
799
  task_id: { type: 'string', description: 'Task id. Omit on status to discover tasks in the current workspace; optionally filter by branch.' },
787
- skill_name: { type: 'string', description: 'Bound skill to record after executing it (skill_result): requires prior successful skill load. Command evidence needs a real validation command; manual evidence needs human approval. target_stage may name the upcoming terminal stage.' },
800
+ skill_name: { type: 'string', description: 'Bound skill to load or record. For a codex-project skill, pass codex-project:name to load_skill and skill_result. Command evidence needs a real validation command; manual evidence needs human approval.' },
788
801
  branch: { type: 'string', description: 'Current git branch (recorded on create).' },
789
802
  title: { type: 'string', description: 'Task title (create).' },
790
803
  work_size: { type: 'string', enum: ['tiny', 'standard', 'complex'], description: 'Workload tier (create).' },
@@ -1000,7 +1013,6 @@ export function registerDevTask(ctx) {
1000
1013
  state.items = normalizeItems(a.items);
1001
1014
  state.execution_version = 1;
1002
1015
  state.files = a.files ?? [];
1003
- state.bindings_fingerprint = builtinRulesFingerprint();
1004
1016
  assertInsideRoot(root, cwd ?? '', taskPath(state.id));
1005
1017
  await writeTask(fs, state, cwd, await resolveWriteMode(ctx, a, exec));
1006
1018
  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.`;
@@ -1015,9 +1027,28 @@ export function registerDevTask(ctx) {
1015
1027
  if (state.completed !== undefined && a.operation !== 'status' && a.operation !== 'revise') {
1016
1028
  throw new Error('task is completed; use revise to reopen it before changing task state');
1017
1029
  }
1030
+ if (a.operation === 'load_skill') {
1031
+ const stage = a.target_stage ?? state.stage;
1032
+ if (!obligationStages(state, workflow).includes(stage))
1033
+ throw new Error(`stage "${stage}" is not currently actionable`);
1034
+ const entry = (workflow.stage_bindings?.[stage]?.skills ?? [])
1035
+ .find(binding => formatResourceRef(binding.skill) === a.skill_name);
1036
+ if (!entry || entry.skill.source !== 'codex-project')
1037
+ throw new Error('load_skill requires a bound codex-project:name at the current or upcoming terminal stage');
1038
+ const content = await readSkillAt(entry.skill, fs, cwd);
1039
+ if (content === undefined)
1040
+ throw new Error(`bound skill ${a.skill_name} cannot be read`);
1041
+ const { resolved, missing } = await resolveRules(entry.rules, fs, cwd, state.flow?.resources);
1042
+ if (missing.length)
1043
+ throw new Error(`bound rule for ${a.skill_name} cannot be read: ${missing.join(', ')}`);
1044
+ return [`skill: ${a.skill_name}`, `current skill instructions:\n${content}`,
1045
+ resolved.length ? `rules attached to this skill:\n${resolved.map(rule => `### ${rule.source}:${rule.name}\n${rule.content}`).join('\n\n')}` : 'rules attached to this skill: none'].join('\n\n');
1046
+ }
1018
1047
  if (a.operation === 'status') {
1019
1048
  const binding = bindingsForStage(state.stage, workflow);
1020
1049
  const { resolved: rules, missing: missingRules, legacy: legacyRules, stale: staleRules, } = await rulesForBinding(binding, fs, cwd, state.flow?.resources);
1050
+ const missingSkillFiles = await missingSkillRefs(binding, fs, cwd);
1051
+ const changedSkillFiles = await changedSkillRefs(binding, fs, cwd, state.flow?.resources);
1021
1052
  const missingSkills = skillBlockers(state, workflow, session);
1022
1053
  const staleEvidence = await evidenceBlockers(fs, state, workflow, cwd);
1023
1054
  const checkpoint = commitCheckpoint(state, workflow);
@@ -1025,9 +1056,12 @@ export function registerDevTask(ctx) {
1025
1056
  // one says the receipt no longer matches the tree, the other says the flow
1026
1057
  // needs a passing verification and does not have one. Callers act on both.
1027
1058
  const verification = verificationBlockers(state, workflow);
1028
- const commitBlockers = [...staleEvidence, ...missingSkills, ...verification];
1059
+ const commitBlockers = [...staleEvidence, ...missingSkills,
1060
+ ...skillResourceBlockers(missingSkillFiles, state.stage), ...verification];
1029
1061
  const completionIssues = state.completed === undefined
1030
- ? [...completionBlockers(state, workflow), ...missingSkills, ...resourceBlockers(missingRules, state.stage), ...staleEvidence]
1062
+ ? [...completionBlockers(state, workflow), ...missingSkills,
1063
+ ...skillResourceBlockers(missingSkillFiles, state.stage),
1064
+ ...resourceBlockers(missingRules, state.stage), ...staleEvidence]
1031
1065
  : [];
1032
1066
  return JSON.stringify({
1033
1067
  id: state.id,
@@ -1043,12 +1077,12 @@ export function registerDevTask(ctx) {
1043
1077
  items: state.items,
1044
1078
  skill_obligations: obligationStages(state, workflow).map(stage => {
1045
1079
  const bindings = workflow.stage_bindings?.[stage]?.skills ?? [];
1046
- const skills = bindings.map(entry => entry.skill.name);
1080
+ const skills = bindings.map(entry => entry.skill.source === 'codex-project' ? formatResourceRef(entry.skill) : entry.skill.name);
1047
1081
  // Only bindings whose evidence is a command owe a command receipt, so a
1048
1082
  // document-producing skill is not told to run something irrelevant.
1049
1083
  const command_receipts_required = bindings
1050
1084
  .filter(entry => entry.evidence === undefined || entry.evidence === 'command')
1051
- .map(entry => entry.skill.name)
1085
+ .map(entry => entry.skill.source === 'codex-project' ? formatResourceRef(entry.skill) : entry.skill.name)
1052
1086
  .filter(name => needsSkillReceipt(name));
1053
1087
  return { stage, skills, command_receipts_required };
1054
1088
  }),
@@ -1060,12 +1094,15 @@ export function registerDevTask(ctx) {
1060
1094
  // awaiting an owner. A skill's own list is the complete answer to what it
1061
1095
  // runs under, and this mirrors that list for the current stage.
1062
1096
  missing_rules: missingRules,
1097
+ missing_skills: missingSkillFiles,
1098
+ changed_source_skills: changedSkillFiles,
1063
1099
  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.
1100
+ // Rules that differ from the creation-time audit copy. Edited bodies
1101
+ // are already in force; deleted sources are reported as missing.
1068
1102
  stale_source_rules: staleRules,
1103
+ resource_update_notice: changedSkillFiles.length || staleRules.length
1104
+ ? 'Skill/Rule source changed since task creation; the current source body is in force. Recheck prior evidence against the updated instructions.'
1105
+ : undefined,
1069
1106
  unassigned_legacy_rules: legacyRules,
1070
1107
  skill_results: state.skill_results ?? {},
1071
1108
  commits: state.commits,
@@ -1083,16 +1120,13 @@ export function registerDevTask(ctx) {
1083
1120
  : checkpoint,
1084
1121
  bindings: {
1085
1122
  skills: binding?.skills ?? [],
1086
- skill_contents: await skillBodiesForBinding(binding, fs, cwd, state.flow?.resources),
1123
+ skill_contents: await skillBodiesForBinding(binding, fs, cwd),
1087
1124
  // The layer is disclosed with each rule, because two same-named rules in
1088
1125
  // different layers are distinct resources and the caller needs to know
1089
1126
  // which one is actually in force. Reporting only the name made them
1090
1127
  // indistinguishable in exactly the case where the distinction matters.
1091
1128
  rules: rules.map(r => ({ name: r.name, source: r.source, content: r.content })),
1092
1129
  },
1093
- bindings_drift: state.bindings_fingerprint !== undefined && state.bindings_fingerprint !== builtinRulesFingerprint()
1094
- ? 'bundled rules changed since this task froze — the frozen fingerprint no longer matches the shipped rules'
1095
- : undefined,
1096
1130
  risk_downgrades: state.risk_downgrades ?? [],
1097
1131
  }, null, 2);
1098
1132
  }
@@ -1152,10 +1186,13 @@ export function registerDevTask(ctx) {
1152
1186
  // terminal stage has no way out.
1153
1187
  await assertFreshEvidence(fs, state, workflow, cwd);
1154
1188
  const missingSkills = skillBlockers(state, workflow, session);
1155
- const unresolvedRules = (await rulesForBinding(bindingsForStage(state.stage, workflow), fs, cwd, state.flow?.resources)).missing;
1189
+ const currentBinding = bindingsForStage(state.stage, workflow);
1190
+ const missingSkillFiles = await missingSkillRefs(currentBinding, fs, cwd);
1191
+ const unresolvedRules = (await rulesForBinding(currentBinding, fs, cwd, state.flow?.resources)).missing;
1156
1192
  const blockers = [
1157
1193
  ...completionBlockers(state, workflow),
1158
1194
  ...missingSkills,
1195
+ ...skillResourceBlockers(missingSkillFiles, state.stage),
1159
1196
  ...resourceBlockers(unresolvedRules, state.stage),
1160
1197
  ];
1161
1198
  if (blockers.length > 0)
@@ -1264,6 +1301,10 @@ export function registerDevTask(ctx) {
1264
1301
  const missingSkills = skillBlockers(state, workflow, session);
1265
1302
  if (missingSkills.length)
1266
1303
  throw new Error(missingSkills.join('; '));
1304
+ const missingSkillFiles = await missingSkillRefs(bindingsForStage(state.stage, workflow), fs, cwd);
1305
+ const missingSkillIssues = skillResourceBlockers(missingSkillFiles, state.stage);
1306
+ if (missingSkillIssues.length)
1307
+ throw new Error(missingSkillIssues.join('; '));
1267
1308
  // A stage whose rules cannot be resolved must not be passable. This was
1268
1309
  // reported in status and never enforced, so deleting a bound rule still let the
1269
1310
  // task advance — the stage ran without the constraints it was configured with
@@ -1365,12 +1406,22 @@ export function registerDevTask(ctx) {
1365
1406
  }
1366
1407
  case 'skill_result': {
1367
1408
  const stage = a.target_stage ?? state.stage;
1368
- const entry = (workflow.stage_bindings?.[stage]?.skills ?? []).find(binding => binding.skill.name === a.skill_name);
1409
+ const entry = (workflow.stage_bindings?.[stage]?.skills ?? []).find(binding => (binding.skill.source === 'codex-project' ? formatResourceRef(binding.skill) : binding.skill.name) === a.skill_name);
1369
1410
  if (!obligationStages(state, workflow).includes(stage) || !entry)
1370
1411
  throw new Error('skill_result requires a skill bound to this stage or its upcoming terminal stage');
1371
- const loadCall = loadedSkills(session).get(a.skill_name);
1412
+ if ((await readSkillAt(entry.skill, fs, cwd)) === undefined) {
1413
+ throw new Error(`bound skill ${formatResourceRef(entry.skill)} resolves nowhere; restore its source before recording a result`);
1414
+ }
1415
+ const unresolvedForSkill = (await resolveRules(entry.rules, fs, cwd, state.flow?.resources)).missing;
1416
+ if (unresolvedForSkill.length > 0) {
1417
+ throw new Error(`bound rule for ${formatResourceRef(entry.skill)} resolves nowhere: ${unresolvedForSkill.join(', ')}`);
1418
+ }
1419
+ const loadKey = entry.skill.source === 'codex-project' ? `${state.id}#${a.skill_name}` : a.skill_name;
1420
+ const loadCall = loadedSkills(session).get(loadKey);
1372
1421
  if (!loadCall)
1373
- throw new Error(`load skill "${a.skill_name}" successfully before recording execution`);
1422
+ throw new Error(entry.skill.source === 'codex-project'
1423
+ ? `load skill "${a.skill_name}" with dev_task operation=load_skill before recording execution`
1424
+ : `load skill "${a.skill_name}" successfully before recording execution`);
1374
1425
  if (entry.evidence === 'manual') {
1375
1426
  if (!a.evidence?.length || a.evidence.some(value => !value.trim()))
1376
1427
  throw new Error('manual skill_result requires non-empty evidence');