@godv61/dsh-task-engine 0.26.0 → 0.27.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
@@ -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,12 +346,11 @@ function readAbsRule(file) {
337
346
  return undefined;
338
347
  }
339
348
  }
340
- /** The bundled rule files whose contents the frozen tasks' bindings reference. */
349
+ /** The bundled rule files whose changes can be reported to existing tasks. */
341
350
  const BUILTIN_RULE_FILES = ['commit-conventions', 'coding-conventions', 'security-redlines'];
342
351
  /**
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.
352
+ * Fingerprint of the bundled rules this package ships. Existing tasks record
353
+ * the creation-time value so changes can be reported while live bodies apply.
346
354
  */
347
355
  function builtinRulesFingerprint() {
348
356
  const contents = BUILTIN_RULE_FILES
@@ -400,12 +408,11 @@ async function readSkillAt(ref, fs, cwd) {
400
408
  }
401
409
  }
402
410
  /**
403
- * Freeze every skill and rule body the config resolves to.
411
+ * Capture every skill and rule body the config resolves to at task creation.
404
412
  *
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.
413
+ * The copy is an audit baseline, not the runtime authority: task interactions
414
+ * always read the latest body at the same source-qualified reference. Capturing
415
+ * hashes makes changes visible and rejects an unreadable binding at creation.
409
416
  * @param config - the frozen workflow config.
410
417
  * @param fs - sandboxed filesystem for project-level lookups.
411
418
  * @returns the frozen resources plus the refs that could not be read.
@@ -480,10 +487,8 @@ async function resolveLegacyRuleSource(name, fs, cwd, frozen) {
480
487
  async function resolveRules(refs, fs, cwd, frozen) {
481
488
  const resolved = [];
482
489
  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.
490
+ // Creation-time copies are an audit baseline. The source-qualified reference
491
+ // remains stable, while its body is read afresh on each interaction.
487
492
  const frozenByRef = new Map();
488
493
  // Pre-0.26 snapshots have no kind. If such a snapshot reused a skill's ref
489
494
  // for a rule, its rule was never frozen; fail closed instead of reading the
@@ -499,25 +504,15 @@ async function resolveRules(refs, fs, cwd, frozen) {
499
504
  for (const ref of refs) {
500
505
  const key = formatResourceRef(ref);
501
506
  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
507
  const content = await readRuleAt(ref.source, ref.name, fs, cwd);
517
508
  if (content === undefined) {
518
509
  missing.push(key);
510
+ if (snapshot !== undefined)
511
+ stale.push(key + ' (source deleted)');
519
512
  continue;
520
513
  }
514
+ if (snapshot !== undefined && hashText(content) !== snapshot.hash)
515
+ stale.push(key + ' (source edited)');
521
516
  resolved.push({ name: ref.name, content, source: ref.source });
522
517
  }
523
518
  return { resolved, missing, stale };
@@ -573,20 +568,45 @@ async function rulesForBinding(binding, fs, cwd, frozen) {
573
568
  stale: [...stale, ...legacyResult.stale],
574
569
  };
575
570
  }
576
- /** Return the task's frozen skill instructions, falling back to live bodies for old tasks. */
577
- async function skillBodiesForBinding(binding, fs, cwd, frozen) {
571
+ /** Return current skill instructions at each stable source-qualified reference. */
572
+ async function skillBodiesForBinding(binding, fs, cwd) {
578
573
  const result = [];
579
574
  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);
575
+ const content = await readSkillAt(entry.skill, fs, cwd);
585
576
  if (content !== undefined)
586
577
  result.push({ name: entry.skill.name, source: entry.skill.source, content });
587
578
  }
588
579
  return result;
589
580
  }
581
+ /** Missing live skill files cannot be satisfied by the creation-time archive. */
582
+ async function missingSkillRefs(binding, fs, cwd) {
583
+ const missing = [];
584
+ for (const entry of binding?.skills ?? []) {
585
+ if ((await readSkillAt(entry.skill, fs, cwd)) === undefined)
586
+ missing.push(formatResourceRef(entry.skill));
587
+ }
588
+ return missing;
589
+ }
590
+ function skillResourceBlockers(missing, stage) {
591
+ return missing.map(ref => `${stage}: bound skill ${ref} resolves nowhere; restore the file or remove the binding for a new task`);
592
+ }
593
+ async function changedSkillRefs(binding, fs, cwd, baseline) {
594
+ const changed = [];
595
+ for (const entry of binding?.skills ?? []) {
596
+ const key = formatResourceRef(entry.skill);
597
+ const original = baseline?.find(resource => (resource.kind === 'skill'
598
+ || (resource.kind === undefined && resource.content.startsWith('---')))
599
+ && formatResourceRef(resource.ref) === key);
600
+ if (original === undefined)
601
+ continue;
602
+ const live = await readSkillAt(entry.skill, fs, cwd);
603
+ if (live === undefined)
604
+ changed.push(key + ' (source deleted)');
605
+ else if (hashText(live) !== original.hash)
606
+ changed.push(key + ' (source edited)');
607
+ }
608
+ return changed;
609
+ }
590
610
  /**
591
611
  * Render a stage's progressive-disclosure payload: skill names to load via the
592
612
  * `skill` tool, plus the resolved rule bodies to follow. Empty text means the
@@ -605,9 +625,9 @@ async function renderBindings(stage, workflow, fs, cwd, frozen) {
605
625
  const skillPart = skills.length > 0
606
626
  ? `skills to load: ${skills.map(skill => formatResourceRef(skill.skill)).join(', ')} (use the skill tool by name)`
607
627
  : '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')}`
628
+ const currentSkills = await skillBodiesForBinding(binding, fs, cwd);
629
+ const skillInstructions = currentSkills.length > 0
630
+ ? `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
631
  : '';
612
632
  const rulePart = rules.length > 0
613
633
  ? `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,12 +637,15 @@ async function renderBindings(stage, workflow, fs, cwd, frozen) {
617
637
  const missingPart = missing.length > 0
618
638
  ? `rules in force at this stage but NOT FOUND in their declared layer: ${missing.join(', ')}`
619
639
  : '';
640
+ const missingSkillPart = skills.length > currentSkills.length
641
+ ? `bound skills NOT FOUND in their declared layer: ${(await missingSkillRefs(binding, fs, cwd)).join(', ')}`
642
+ : '';
620
643
  // Legacy stage-level rules are disclosed (so nothing is lost) but named as
621
644
  // unassigned, because a stage-level list never recorded which skill they were
622
645
  // meant for and guessing would invent an answer the config did not contain.
623
646
  const legacyPart = legacy.length > 0
624
647
  ? `UNASSIGNED legacy stage rules: ${legacy.join(', ')} — these belong to the stage, not to any skill. ` +
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.'
648
+ 'Assign each to the skill that should carry it (or copy the skill and give each copy its own rules) and record it in skill_profiles.'
626
649
  : '';
627
650
  // The disclosure names the skills that owe a command receipt, honouring each
628
651
  // binding's declared evidence kind — a document-producing skill owes an
@@ -634,8 +657,8 @@ async function renderBindings(stage, workflow, fs, cwd, frozen) {
634
657
  .filter(name => needsSkillReceipt(name));
635
658
  const receiptPart = additional.length
636
659
  ? `additional skills requiring skill_result command receipts: ${additional.join(', ')}`
637
- : 'skill_result not required for this stage: core skills use record/verify/review/commit gates';
638
- return [skillPart, skillInstructions, receiptPart, rulePart, missingPart, legacyPart].filter(Boolean).join('\n');
660
+ : 'no command skill_result receipt required for this stage; other evidence kinds follow their configured stage operations';
661
+ return [skillPart, skillInstructions, receiptPart, rulePart, missingPart, missingSkillPart, legacyPart].filter(Boolean).join('\n');
639
662
  }
640
663
  /** Normalize the tool's `items` argument into complete TaskItem records. */
641
664
  function normalizeItems(items, previous = []) {
@@ -784,7 +807,7 @@ export function registerDevTask(ctx) {
784
807
  parameters: {
785
808
  operation: { type: 'string', enum: [...OPERATIONS], required: true, description: 'Which task-record action to perform.' },
786
809
  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, evidence and a real validation command. target_stage may name the upcoming terminal stage.' },
810
+ 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.' },
788
811
  branch: { type: 'string', description: 'Current git branch (recorded on create).' },
789
812
  title: { type: 'string', description: 'Task title (create).' },
790
813
  work_size: { type: 'string', enum: ['tiny', 'standard', 'complex'], description: 'Workload tier (create).' },
@@ -808,7 +831,7 @@ export function registerDevTask(ctx) {
808
831
  quality_outcome: { type: 'string', enum: ['pass', 'fail'], description: 'Code-quality verdict (review_item).' },
809
832
  notes: { type: 'array', items: { type: 'string' }, description: 'Findings or defects (review_item).' },
810
833
  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.' },
811
- command: { type: 'string', description: 'Real acceptance command for verify/skill_result. New tasks require command receipts. Propagate failures when composing shell commands.' },
834
+ command: { type: 'string', description: 'Real acceptance command for verify or a command-evidence skill_result. New task verification requires a command receipt; other skills follow their configured evidence kind. Propagate failures when composing shell commands.' },
812
835
  passed: { type: 'boolean', description: 'Legacy tasks only: verification claim when no command can be resolved. New tasks require real command receipts.' },
813
836
  evidence: { type: 'array', items: { type: 'string' }, description: 'Supplementary verification evidence (verify).' },
814
837
  outcome: { type: 'string', enum: ['pass', 'blocked'], description: 'Review outcome (review).' },
@@ -1018,6 +1041,8 @@ export function registerDevTask(ctx) {
1018
1041
  if (a.operation === 'status') {
1019
1042
  const binding = bindingsForStage(state.stage, workflow);
1020
1043
  const { resolved: rules, missing: missingRules, legacy: legacyRules, stale: staleRules, } = await rulesForBinding(binding, fs, cwd, state.flow?.resources);
1044
+ const missingSkillFiles = await missingSkillRefs(binding, fs, cwd);
1045
+ const changedSkillFiles = await changedSkillRefs(binding, fs, cwd, state.flow?.resources);
1021
1046
  const missingSkills = skillBlockers(state, workflow, session);
1022
1047
  const staleEvidence = await evidenceBlockers(fs, state, workflow, cwd);
1023
1048
  const checkpoint = commitCheckpoint(state, workflow);
@@ -1025,9 +1050,12 @@ export function registerDevTask(ctx) {
1025
1050
  // one says the receipt no longer matches the tree, the other says the flow
1026
1051
  // needs a passing verification and does not have one. Callers act on both.
1027
1052
  const verification = verificationBlockers(state, workflow);
1028
- const commitBlockers = [...staleEvidence, ...missingSkills, ...verification];
1053
+ const commitBlockers = [...staleEvidence, ...missingSkills,
1054
+ ...skillResourceBlockers(missingSkillFiles, state.stage), ...verification];
1029
1055
  const completionIssues = state.completed === undefined
1030
- ? [...completionBlockers(state, workflow), ...missingSkills, ...resourceBlockers(missingRules, state.stage), ...staleEvidence]
1056
+ ? [...completionBlockers(state, workflow), ...missingSkills,
1057
+ ...skillResourceBlockers(missingSkillFiles, state.stage),
1058
+ ...resourceBlockers(missingRules, state.stage), ...staleEvidence]
1031
1059
  : [];
1032
1060
  return JSON.stringify({
1033
1061
  id: state.id,
@@ -1060,12 +1088,15 @@ export function registerDevTask(ctx) {
1060
1088
  // awaiting an owner. A skill's own list is the complete answer to what it
1061
1089
  // runs under, and this mirrors that list for the current stage.
1062
1090
  missing_rules: missingRules,
1091
+ missing_skills: missingSkillFiles,
1092
+ changed_source_skills: changedSkillFiles,
1063
1093
  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.
1094
+ // Rules that differ from the creation-time audit copy. Edited bodies
1095
+ // are already in force; deleted sources are reported as missing.
1068
1096
  stale_source_rules: staleRules,
1097
+ resource_update_notice: changedSkillFiles.length || staleRules.length
1098
+ ? 'Skill/Rule source changed since task creation; the current source body is in force. Recheck prior evidence against the updated instructions.'
1099
+ : undefined,
1069
1100
  unassigned_legacy_rules: legacyRules,
1070
1101
  skill_results: state.skill_results ?? {},
1071
1102
  commits: state.commits,
@@ -1083,7 +1114,7 @@ export function registerDevTask(ctx) {
1083
1114
  : checkpoint,
1084
1115
  bindings: {
1085
1116
  skills: binding?.skills ?? [],
1086
- skill_contents: await skillBodiesForBinding(binding, fs, cwd, state.flow?.resources),
1117
+ skill_contents: await skillBodiesForBinding(binding, fs, cwd),
1087
1118
  // The layer is disclosed with each rule, because two same-named rules in
1088
1119
  // different layers are distinct resources and the caller needs to know
1089
1120
  // which one is actually in force. Reporting only the name made them
@@ -1091,7 +1122,7 @@ export function registerDevTask(ctx) {
1091
1122
  rules: rules.map(r => ({ name: r.name, source: r.source, content: r.content })),
1092
1123
  },
1093
1124
  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'
1125
+ ? 'bundled rules changed since task creation — current bundled bodies are now in force'
1095
1126
  : undefined,
1096
1127
  risk_downgrades: state.risk_downgrades ?? [],
1097
1128
  }, null, 2);
@@ -1152,10 +1183,13 @@ export function registerDevTask(ctx) {
1152
1183
  // terminal stage has no way out.
1153
1184
  await assertFreshEvidence(fs, state, workflow, cwd);
1154
1185
  const missingSkills = skillBlockers(state, workflow, session);
1155
- const unresolvedRules = (await rulesForBinding(bindingsForStage(state.stage, workflow), fs, cwd, state.flow?.resources)).missing;
1186
+ const currentBinding = bindingsForStage(state.stage, workflow);
1187
+ const missingSkillFiles = await missingSkillRefs(currentBinding, fs, cwd);
1188
+ const unresolvedRules = (await rulesForBinding(currentBinding, fs, cwd, state.flow?.resources)).missing;
1156
1189
  const blockers = [
1157
1190
  ...completionBlockers(state, workflow),
1158
1191
  ...missingSkills,
1192
+ ...skillResourceBlockers(missingSkillFiles, state.stage),
1159
1193
  ...resourceBlockers(unresolvedRules, state.stage),
1160
1194
  ];
1161
1195
  if (blockers.length > 0)
@@ -1264,6 +1298,10 @@ export function registerDevTask(ctx) {
1264
1298
  const missingSkills = skillBlockers(state, workflow, session);
1265
1299
  if (missingSkills.length)
1266
1300
  throw new Error(missingSkills.join('; '));
1301
+ const missingSkillFiles = await missingSkillRefs(bindingsForStage(state.stage, workflow), fs, cwd);
1302
+ const missingSkillIssues = skillResourceBlockers(missingSkillFiles, state.stage);
1303
+ if (missingSkillIssues.length)
1304
+ throw new Error(missingSkillIssues.join('; '));
1267
1305
  // A stage whose rules cannot be resolved must not be passable. This was
1268
1306
  // reported in status and never enforced, so deleting a bound rule still let the
1269
1307
  // task advance — the stage ran without the constraints it was configured with
@@ -1368,6 +1406,13 @@ export function registerDevTask(ctx) {
1368
1406
  const entry = (workflow.stage_bindings?.[stage]?.skills ?? []).find(binding => binding.skill.name === a.skill_name);
1369
1407
  if (!obligationStages(state, workflow).includes(stage) || !entry)
1370
1408
  throw new Error('skill_result requires a skill bound to this stage or its upcoming terminal stage');
1409
+ if ((await readSkillAt(entry.skill, fs, cwd)) === undefined) {
1410
+ throw new Error(`bound skill ${formatResourceRef(entry.skill)} resolves nowhere; restore its source before recording a result`);
1411
+ }
1412
+ const unresolvedForSkill = (await resolveRules(entry.rules, fs, cwd, state.flow?.resources)).missing;
1413
+ if (unresolvedForSkill.length > 0) {
1414
+ throw new Error(`bound rule for ${formatResourceRef(entry.skill)} resolves nowhere: ${unresolvedForSkill.join(', ')}`);
1415
+ }
1371
1416
  const loadCall = loadedSkills(session).get(a.skill_name);
1372
1417
  if (!loadCall)
1373
1418
  throw new Error(`load skill "${a.skill_name}" successfully before recording execution`);