@godv61/dsh-task-engine 0.27.1 → 0.29.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 (63) hide show
  1. package/.acceptance.mjs +11 -11
  2. package/.adaptive-test.mjs +189 -0
  3. package/.assessment-batch1.mjs +14 -14
  4. package/.codex-project-test.mjs +80 -80
  5. package/.enforce-test.mjs +12 -12
  6. package/.evidence-test.mjs +8 -8
  7. package/.filter-test.mjs +6 -6
  8. package/.freeze-test.mjs +44 -44
  9. package/.hook-test.mjs +19 -2
  10. package/.p0-test.mjs +21 -21
  11. package/.preset-test.mjs +11 -1
  12. package/.revision-test.mjs +16 -16
  13. package/.roundtrip-test.mjs +247 -247
  14. package/.workflow-test.mjs +122 -120
  15. package/README.md +33 -27
  16. package/cordis.patch.yml +158 -9
  17. package/docs/CHANGELOG.md +44 -30
  18. package/docs/README.md +6 -5
  19. package/docs/adaptive-workflows.md +72 -0
  20. package/docs/configuration.md +25 -25
  21. package/docs/development.md +1 -1
  22. package/docs/faq.md +14 -6
  23. package/docs/getting-started.md +8 -5
  24. package/docs/manual-legacy.html +380 -0
  25. package/docs/manual.html +124 -378
  26. package/docs/resource-install.md +5 -5
  27. package/docs/roadmap.md +8 -9
  28. package/hooks/commit-msg +39 -22
  29. package/lib/adaptive.d.ts +15 -0
  30. package/lib/adaptive.js +54 -0
  31. package/lib/adaptive.js.map +1 -0
  32. package/lib/client.js +719 -433
  33. package/lib/client.js.map +3 -3
  34. package/lib/controller.d.ts +50 -0
  35. package/lib/controller.js +132 -0
  36. package/lib/controller.js.map +1 -1
  37. package/lib/dev-task.js +413 -18
  38. package/lib/dev-task.js.map +1 -1
  39. package/lib/engine.d.ts +20 -0
  40. package/lib/engine.js +4 -1
  41. package/lib/engine.js.map +1 -1
  42. package/lib/hook.js +50 -29
  43. package/lib/hook.js.map +1 -1
  44. package/lib/project-init.d.ts +23 -0
  45. package/lib/project-init.js +94 -0
  46. package/lib/project-init.js.map +1 -0
  47. package/lib/skill-audit.js +8 -7
  48. package/lib/skill-audit.js.map +1 -1
  49. package/lib/sonar.d.ts +33 -0
  50. package/lib/sonar.js +80 -0
  51. package/lib/sonar.js.map +1 -0
  52. package/package.json +15 -16
  53. package/preset/agent.cordis.yml +3 -3
  54. package/preset/enable.mjs +2 -2
  55. package/preset/persona.md +4 -2
  56. package/scripts/verify-package.mjs +9 -5
  57. package/skills/architecture-design/SKILL.md +11 -0
  58. package/skills/code-development/SKILL.md +11 -0
  59. package/skills/code-review/SKILL.md +11 -0
  60. package/skills/eng-delivery/SKILL.md +19 -15
  61. package/skills/requirements-analysis/SKILL.md +11 -0
  62. package/skills/task-orchestration/SKILL.md +11 -0
  63. package/skills/test-validation/SKILL.md +11 -0
package/lib/dev-task.js CHANGED
@@ -17,6 +17,9 @@ import { fileURLToPath } from 'node:url';
17
17
  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
+ import { ADAPTIVE_VERSION, COMPLEXITY_OPTIONS, META_STAGES, adaptiveWorkflow, isComplexity, metaForStage } from "./adaptive.js";
21
+ import { inspectSonar } from "./sonar.js";
22
+ import { scanProject } from "./project-init.js";
20
23
  import { hashConfig, hashText } from "./snapshot.js";
21
24
  import { withUserSkillProfiles } from "./user-skill-profiles.js";
22
25
  import { loadedSkills, needsSkillReceipt, obligationStages, skillBlockers } from "./skill-audit.js";
@@ -397,6 +400,140 @@ async function readSkillAt(ref, fs, cwd) {
397
400
  return read(join(dshHome(), 'skills', safe, 'SKILL.md'));
398
401
  }
399
402
  }
403
+ const RESOURCE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
404
+ const META_EVIDENCE = {
405
+ 'requirements-analysis': 'artifact',
406
+ 'architecture-design': 'artifact',
407
+ 'task-orchestration': 'artifact',
408
+ 'code-development': 'none',
409
+ 'test-validation': 'none',
410
+ 'code-review': 'review',
411
+ };
412
+ async function readAdaptiveConfig(fs, cwd) {
413
+ const raw = await readText(fs, '.dsh/meta.json', cwd);
414
+ if (raw === undefined)
415
+ return {};
416
+ let config;
417
+ try {
418
+ config = JSON.parse(raw);
419
+ }
420
+ catch (error) {
421
+ throw new Error(`invalid .dsh/meta.json: ${error instanceof Error ? error.message : String(error)}`);
422
+ }
423
+ if (config === null || typeof config !== 'object' || Array.isArray(config))
424
+ throw new Error('.dsh/meta.json must be an object');
425
+ if (config.meta_bindings !== undefined) {
426
+ if (config.meta_bindings === null || typeof config.meta_bindings !== 'object' || Array.isArray(config.meta_bindings)) {
427
+ throw new Error('.dsh/meta.json meta_bindings must be an object');
428
+ }
429
+ for (const [meta, names] of Object.entries(config.meta_bindings)) {
430
+ if (!(meta in META_STAGES))
431
+ throw new Error(`unknown meta-skill "${meta}" in .dsh/meta.json`);
432
+ if (!Array.isArray(names) || !names.every(name => typeof name === 'string' && RESOURCE_NAME.test(name))) {
433
+ throw new Error(`meta_bindings.${meta} must be an array of simple Skill names`);
434
+ }
435
+ }
436
+ }
437
+ if (config.sonar !== undefined) {
438
+ if (config.sonar === null || typeof config.sonar !== 'object' || Array.isArray(config.sonar)) {
439
+ throw new Error('.dsh/meta.json sonar must be an object');
440
+ }
441
+ if (config.sonar.enabled !== undefined && typeof config.sonar.enabled !== 'boolean') {
442
+ throw new Error('.dsh/meta.json sonar.enabled must be boolean');
443
+ }
444
+ }
445
+ return config;
446
+ }
447
+ function sonarPolicyFor(project) {
448
+ const sonar = project.sonar;
449
+ if (sonar?.enabled !== true)
450
+ return undefined;
451
+ const host_url = sonar.host_url ?? process.env.SONAR_HOST_URL;
452
+ const project_key = sonar.project_key ?? process.env.SONAR_PROJECT_KEY;
453
+ const token_env = sonar.token_env ?? 'SONAR_TOKEN';
454
+ if (!host_url || !/^https?:\/\//u.test(host_url) || !project_key?.trim()
455
+ || !/^[A-Z][A-Z0-9_]*$/u.test(token_env)) {
456
+ throw new Error('enabled SonarQube needs host_url, project_key, and a valid token_env in .dsh/meta.json or environment');
457
+ }
458
+ const parsed = new URL(host_url);
459
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) {
460
+ throw new Error('SonarQube host_url must not contain credentials, a query, or a fragment');
461
+ }
462
+ const mode = sonar.mode ?? 'branch';
463
+ if (mode !== 'branch' && mode !== 'pull-request')
464
+ throw new Error('sonar.mode must be branch or pull-request');
465
+ return { enabled: true, host_url, project_key, token_env, mode };
466
+ }
467
+ async function resolveLogicalSkill(name, fs, cwd) {
468
+ if (!RESOURCE_NAME.test(name))
469
+ throw new Error(`invalid Skill name "${name}"`);
470
+ for (const source of ['project', 'codex-project', 'user', 'bundled']) {
471
+ const ref = { source, name };
472
+ if (await readSkillAt(ref, fs, cwd) !== undefined)
473
+ return ref;
474
+ }
475
+ throw new Error(`Skill "${name}" was not found in project, user or bundled sources`);
476
+ }
477
+ async function profileFor(ref, fs, cwd) {
478
+ let raw;
479
+ switch (ref.source) {
480
+ case 'project':
481
+ raw = await readText(fs, `.dsh/skills/${ref.name}/profile.json`, cwd);
482
+ break;
483
+ case 'codex-project':
484
+ raw = await readText(fs, `.agents/skills/${ref.name}/profile.json`, cwd);
485
+ break;
486
+ case 'user':
487
+ raw = readAbsRule(join(dshHome(), 'skills', ref.name, 'profile.json'));
488
+ break;
489
+ case 'bundled':
490
+ raw = readAbsRule(join(BUNDLED_SKILLS_DIR, ref.name, 'profile.json'));
491
+ break;
492
+ }
493
+ if (raw === undefined)
494
+ return undefined;
495
+ let value;
496
+ try {
497
+ value = JSON.parse(raw);
498
+ }
499
+ catch (error) {
500
+ throw new Error(`invalid profile for ${formatResourceRef(ref)}: ${error instanceof Error ? error.message : String(error)}`);
501
+ }
502
+ if (!value || !Array.isArray(value.rules) || !value.rules.every(rule => rule && RESOURCE_NAME.test(rule.name) && ['bundled', 'project', 'user'].includes(rule.source))
503
+ || (value.evidence !== undefined && !['command', 'artifact', 'review', 'manual', 'none'].includes(value.evidence))) {
504
+ throw new Error(`invalid profile for ${formatResourceRef(ref)}`);
505
+ }
506
+ return value;
507
+ }
508
+ /** Resolve all bound methods once at task creation; references stay source-qualified afterwards. */
509
+ async function resolveAdaptiveWorkflow(grade, fs, cwd, selected) {
510
+ const config = adaptiveWorkflow(grade);
511
+ const project = selected ?? await readAdaptiveConfig(fs, cwd);
512
+ const stage_bindings = {};
513
+ const skill_profiles = {};
514
+ for (const stage of config.stages) {
515
+ const meta = metaForStage(stage);
516
+ if (meta === undefined)
517
+ continue;
518
+ const names = [...new Set([meta, ...(project.meta_bindings?.[meta] ?? [])])];
519
+ const skills = [];
520
+ for (const name of names) {
521
+ const ref = await resolveLogicalSkill(name, fs, cwd);
522
+ const profile = await profileFor(ref, fs, cwd) ?? {
523
+ rules: [], evidence: name === meta ? META_EVIDENCE[meta] : 'none',
524
+ };
525
+ skill_profiles[formatResourceRef(ref)] = profile;
526
+ skills.push({ skill: ref, rules: profile.rules, evidence: profile.evidence ?? 'none' });
527
+ }
528
+ stage_bindings[stage] = { skills };
529
+ }
530
+ config.stage_bindings = stage_bindings;
531
+ config.skill_profiles = skill_profiles;
532
+ const problems = validateWorkflow(config);
533
+ if (problems.length > 0)
534
+ throw new Error(`invalid adaptive workflow:\n- ${problems.join('\n- ')}`);
535
+ return config;
536
+ }
400
537
  /**
401
538
  * Capture every skill and rule body the config resolves to at task creation.
402
539
  *
@@ -613,7 +750,7 @@ async function renderBindings(stage, workflow, fs, cwd, frozen) {
613
750
  return '';
614
751
  const { resolved: rules, missing, legacy } = await rulesForBinding(binding, fs, cwd, frozen);
615
752
  const skillPart = skills.length > 0
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)`
753
+ ? `skills to load: ${skills.map(skill => formatResourceRef(skill.skill)).join(', ')} (use dev_task operation=load_skill with the exact source:name)`
617
754
  : 'skills to load: none';
618
755
  const currentSkills = await skillBodiesForBinding(binding, fs, cwd);
619
756
  const skillInstructions = currentSkills.length > 0
@@ -674,7 +811,7 @@ function normalizeItems(items, previous = []) {
674
811
  /** Hash declared deliverables through host FS; task bookkeeping is excluded to avoid self-invalidation. */
675
812
  async function scopeFingerprint(fs, state, cwd) {
676
813
  const entries = [];
677
- for (const path of [...new Set(state.files)].filter(path => !/^\.dsh\/(task-[^/]+\.json|eng\.json)$/.test(path)).sort()) {
814
+ for (const path of [...new Set(state.files)].filter(path => !/^\.dsh\/(task-[^/]+\.json|eng\.json|meta\.json)$/.test(path)).sort()) {
678
815
  assertInsideRoot(state.root ?? cwd ?? '', state.root ?? cwd ?? '', path);
679
816
  const root = state.root ?? cwd;
680
817
  const target = await fs.resolve(path, root === undefined ? undefined : { cwd: root });
@@ -723,10 +860,55 @@ async function evidenceBlockers(fs, state, workflow, cwd) {
723
860
  return blockers;
724
861
  }
725
862
  async function assertFreshEvidence(fs, state, workflow, cwd) {
726
- const blockers = await evidenceBlockers(fs, state, workflow, cwd);
863
+ const blockers = [...await evidenceBlockers(fs, state, workflow, cwd), ...await sonarBlockers(fs, state, cwd)];
727
864
  if (blockers.length)
728
865
  throw new Error(blockers.join('; '));
729
866
  }
867
+ function validateInitResources(resources) {
868
+ if (!Array.isArray(resources) || resources.length === 0 || resources.length > 24) {
869
+ throw new Error('init_project requires 1–24 Skill/Rule resources');
870
+ }
871
+ const names = new Set();
872
+ for (const resource of resources) {
873
+ if (!resource || !['skill', 'rule'].includes(resource.kind) || !RESOURCE_NAME.test(resource.name)
874
+ || typeof resource.content !== 'string' || !resource.content.trim() || resource.content.length > 16_000) {
875
+ throw new Error('each init_project resource needs kind, kebab-case name, and nonempty content under 16,000 characters');
876
+ }
877
+ const key = `${resource.kind}:${resource.name}`;
878
+ if (names.has(key))
879
+ throw new Error(`duplicate init_project resource ${key}`);
880
+ names.add(key);
881
+ if (resource.kind === 'skill') {
882
+ if (typeof resource.description !== 'string' || !resource.description.trim())
883
+ throw new Error(`Skill ${resource.name} needs a description`);
884
+ if (!Array.isArray(resource.meta_skills) || resource.meta_skills.length === 0 ||
885
+ !resource.meta_skills.every(meta => meta in META_STAGES))
886
+ throw new Error(`Skill ${resource.name} needs valid meta_skills`);
887
+ if (resource.rules !== undefined && (!Array.isArray(resource.rules) || !resource.rules.every(rule => RESOURCE_NAME.test(rule)))) {
888
+ throw new Error(`Skill ${resource.name} has invalid rule names`);
889
+ }
890
+ }
891
+ }
892
+ return resources;
893
+ }
894
+ async function sonarBlockers(fs, state, cwd) {
895
+ if (state.sonar_policy === undefined || !['代码审核', '完成'].includes(state.stage))
896
+ return [];
897
+ if (state.sonar_audit === undefined)
898
+ return ['SonarQube audit is enabled but has not been run; call sonar_check in code review'];
899
+ const current = await scopeFingerprint(fs, state, cwd);
900
+ const problems = [];
901
+ if (state.sonar_audit.scope_hash !== current)
902
+ problems.push('SonarQube audit is stale after file changes; rerun CI scan and sonar_check');
903
+ const commit = state.commits.findLast(entry => entry.hash !== undefined)?.hash;
904
+ if (!commit || state.sonar_audit.commit_hash !== commit)
905
+ problems.push('SonarQube audit does not match the task\'s latest recorded commit');
906
+ if (state.sonar_audit.gate !== 'OK')
907
+ problems.push(`SonarQube Quality Gate is ${state.sonar_audit.gate}`);
908
+ if (state.sonar_audit.blocking.length)
909
+ problems.push(`${state.sonar_audit.blocking.length} medium/high SonarQube new-code findings remain`);
910
+ return problems;
911
+ }
730
912
  /** Resolve one item by id for the item-scoped audit operations. */
731
913
  function requireItem(state, id) {
732
914
  if (id === undefined || id === '')
@@ -772,7 +954,7 @@ export async function approveAdvance(ctx, state, target, workflow, confirmations
772
954
  state.solution_confirmed = true;
773
955
  return assertAdvance(state, target, workflow);
774
956
  }
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'];
957
+ const OPERATIONS = ['status', 'assess', 'create', 'load_skill', 'record', 'items', 'scope', 'dispatch', 'review_item', 'advance', 'verify', 'sonar_check', 'learn_rule', 'review', 'commit', 'complete', 'revise', 'config', 'install_hook', 'verify_hook', 'init', 'init_project', 'set_risk', 'skill_result'];
776
958
  const TOOL_DESCRIPTION = 'Own the engineering delivery workflow as hard state. Read or create the task record, record a ' +
777
959
  'stage artifact, record each implementation item\'s subagent dispatch and two-stage (spec + quality) ' +
778
960
  'review audit (the flow decides how many verdicts per item), advance one stage (rejected unless every configured guard already holds, including ' +
@@ -801,6 +983,19 @@ export function registerDevTask(ctx) {
801
983
  branch: { type: 'string', description: 'Current git branch (recorded on create).' },
802
984
  title: { type: 'string', description: 'Task title (create).' },
803
985
  work_size: { type: 'string', enum: ['tiny', 'standard', 'complex'], description: 'Workload tier (create).' },
986
+ complexity: { type: 'string', enum: ['low', 'medium', 'high', 'ultra'], description: 'Task-level complexity (assess/create). Select from requirement scope and implementation dependencies; independent of risk_level. Passing this on create selects the adaptive meta-skill flow.' },
987
+ complexity_reason: { type: 'string', description: 'Evidence-based reason for the selected complexity (create).' },
988
+ ce_task_id: { type: 'string', description: 'SonarQube Compute Engine task id from the completed CI scan report-task.txt (sonar_check).' },
989
+ pull_request: { type: 'string', description: 'GitLab merge request IID or PR number when SonarQube mode is pull-request (sonar_check).' },
990
+ issue_key: { type: 'string', description: 'A blocking SonarQube issue key from this task\'s audit history (learn_rule).' },
991
+ rule_name: { type: 'string', description: 'New project Rule name (learn_rule).' },
992
+ learning_reason: { type: 'string', description: 'Why this failure generalizes beyond the current line of code (learn_rule).' },
993
+ resources: { type: 'array', description: 'Project Skill/Rule drafts for init_project propose/apply; content is reviewed before creation.',
994
+ items: { type: 'object', additionalProperties: false, properties: {
995
+ kind: { type: 'string', enum: ['skill', 'rule'] }, name: { type: 'string' }, description: { type: 'string' },
996
+ content: { type: 'string' }, meta_skills: { type: 'array', items: { type: 'string' } },
997
+ rules: { type: 'array', items: { type: 'string' } },
998
+ } } },
804
999
  risk_level: { type: 'string', enum: ['standard', 'high_risk'], description: 'Risk tier (create).' },
805
1000
  items: {
806
1001
  type: 'array',
@@ -870,7 +1065,8 @@ export function registerDevTask(ctx) {
870
1065
  continue;
871
1066
  const task = JSON.parse(raw);
872
1067
  if (!a.branch || task.branch === a.branch)
873
- tasks.push({ id: task.id, title: task.title, branch: task.branch, stage: task.stage });
1068
+ tasks.push({ id: task.id, title: task.title, branch: task.branch, stage: task.stage,
1069
+ ...(task.complexity !== undefined ? { complexity: task.complexity } : {}) });
874
1070
  }
875
1071
  return JSON.stringify({ tasks, next_action: tasks.length ? 'Call status with the selected task_id; create a new task for a new requirement.' : 'No task found. Call create with task_id, title and current branch.' }, null, 2);
876
1072
  }
@@ -881,8 +1077,22 @@ export function registerDevTask(ctx) {
881
1077
  valid: resolved.problems.length === 0,
882
1078
  problems: resolved.problems,
883
1079
  workflow: resolved.config,
1080
+ adaptive: { grades: COMPLEXITY_OPTIONS, config_path: '.dsh/meta.json', task_selected: true },
884
1081
  }, null, 2);
885
1082
  }
1083
+ if (a.operation === 'assess') {
1084
+ if (a.complexity === undefined) {
1085
+ return JSON.stringify({ grades: COMPLEXITY_OPTIONS,
1086
+ instruction: 'Analyze the requirement, choose one grade, then call assess with complexity to preview its stages and effective Skill sources. Record the reason on create. Risk is assessed separately.' }, null, 2);
1087
+ }
1088
+ if (!isComplexity(a.complexity))
1089
+ throw new Error(`unknown complexity "${String(a.complexity)}"`);
1090
+ const config = await resolveAdaptiveWorkflow(a.complexity, fs, cwd);
1091
+ return JSON.stringify({ complexity: a.complexity, stages: config.stages,
1092
+ meta_skills: Object.fromEntries(Object.entries(config.stage_bindings ?? {}).map(([stage, binding]) => [stage, (binding.skills ?? []).map(entry => ({ logical_name: entry.skill.name, effective_source: entry.skill.source,
1093
+ rules: entry.rules }))])),
1094
+ artifacts: config.artifacts, review_depth: config.review_depth }, null, 2);
1095
+ }
886
1096
  if (a.operation === 'install_hook') {
887
1097
  await installCommitHook(fs, cwd, await resolveWriteMode(ctx, a, exec));
888
1098
  return 'installed commit-msg hook at .git/hooks/commit-msg — git commit is now gated by the task state';
@@ -897,6 +1107,72 @@ export function registerDevTask(ctx) {
897
1107
  }
898
1108
  throw new Error('hook integrity FAILED — .git/hooks/commit-msg differs from the bundled gate; reinstall with install_hook');
899
1109
  }
1110
+ if (a.operation === 'init_project') {
1111
+ const root = await detectRoot(projectProbe(fs), cwd ?? '');
1112
+ if (!cwd || resolve(root) !== resolve(cwd)) {
1113
+ throw new Error('init_project must run with the project root as the session workspace');
1114
+ }
1115
+ const phase = a.phase ?? 'inspect';
1116
+ const inventory = await scanProject(projectProbe(fs), root);
1117
+ if (phase === 'inspect')
1118
+ return JSON.stringify(inventory, null, 2);
1119
+ if (phase !== 'propose' && phase !== 'apply')
1120
+ throw new Error('init_project phase must be inspect, propose, or apply');
1121
+ const resources = validateInitResources(a.resources);
1122
+ const metaRaw = await readText(fs, '.dsh/meta.json', cwd);
1123
+ const meta = await readAdaptiveConfig(fs, cwd);
1124
+ const createdRules = new Set(resources.filter(resource => resource.kind === 'rule').map(resource => resource.name));
1125
+ const paths = [];
1126
+ for (const resource of resources) {
1127
+ const path = resource.kind === 'rule' ? `.dsh/rules/${resource.name}.md` : `.dsh/skills/${resource.name}/SKILL.md`;
1128
+ assertInsideRoot(root, cwd, path);
1129
+ if (await readText(fs, path, cwd) !== undefined)
1130
+ throw new Error(`init_project will not overwrite ${path}`);
1131
+ paths.push(path);
1132
+ if (resource.kind === 'skill') {
1133
+ const profilePath = `.dsh/skills/${resource.name}/profile.json`;
1134
+ if (await readText(fs, profilePath, cwd) !== undefined)
1135
+ throw new Error(`init_project will not overwrite ${profilePath}`);
1136
+ paths.push(profilePath);
1137
+ for (const rule of resource.rules ?? []) {
1138
+ if (!createdRules.has(rule) && await readText(fs, `.dsh/rules/${rule}.md`, cwd) === undefined) {
1139
+ throw new Error(`Skill ${resource.name} references missing project Rule ${rule}`);
1140
+ }
1141
+ }
1142
+ }
1143
+ }
1144
+ const metaHash = hashText(metaRaw ?? '');
1145
+ const proposalHash = hashText(JSON.stringify(resources) + '\n' + metaHash);
1146
+ if (phase === 'propose')
1147
+ return JSON.stringify({ inventory, resources, files_to_create: paths,
1148
+ meta_config: '.dsh/meta.json', existing_hash: metaHash, expected_hash: proposalHash,
1149
+ instruction: 'Review each project-specific claim against the listed evidence. Apply with identical resources and expected_hash; existing files are protected.' }, null, 2);
1150
+ if (a.expected_hash !== proposalHash || a.existing_hash !== metaHash) {
1151
+ throw new Error('init_project draft or meta config changed since propose; inspect and propose again');
1152
+ }
1153
+ const writeMode = await resolveWriteMode(ctx, a, exec);
1154
+ // Publish rules first, then Skills/profiles, then the binding map. A failed final
1155
+ // write can leave unbound resources, but never a live binding to an absent Rule.
1156
+ for (const resource of resources.filter(resource => resource.kind === 'rule')) {
1157
+ await writeText(fs, `.dsh/rules/${resource.name}.md`, resource.content.trimEnd() + '\n', cwd, writeMode, { kind: 'createIfAbsent' });
1158
+ }
1159
+ for (const resource of resources.filter(resource => resource.kind === 'skill')) {
1160
+ const description = resource.description.replace(/[\r\n]+/gu, ' ').trim();
1161
+ const body = `---\nname: ${resource.name}\ndescription: ${description}\n---\n\n${resource.content.trimEnd()}\n`;
1162
+ await writeText(fs, `.dsh/skills/${resource.name}/SKILL.md`, body, cwd, writeMode, { kind: 'createIfAbsent' });
1163
+ const profile = { rules: (resource.rules ?? []).map(name => ({ source: 'project', name })), evidence: 'none' };
1164
+ await writeText(fs, `.dsh/skills/${resource.name}/profile.json`, JSON.stringify(profile, null, 2) + '\n', cwd, writeMode, { kind: 'createIfAbsent' });
1165
+ }
1166
+ const bindings = { ...meta.meta_bindings };
1167
+ for (const resource of resources.filter(resource => resource.kind === 'skill')) {
1168
+ for (const skill of resource.meta_skills ?? []) {
1169
+ bindings[skill] = [...new Set([...(bindings[skill] ?? []), resource.name])];
1170
+ }
1171
+ }
1172
+ const info = await fs.lstat('.dsh/meta.json', { cwd });
1173
+ await writeText(fs, '.dsh/meta.json', JSON.stringify({ ...meta, meta_bindings: bindings }, null, 2) + '\n', cwd, writeMode, info === undefined ? { kind: 'createIfAbsent' } : { kind: 'replaceIfVersion', version: info.version });
1174
+ return JSON.stringify({ created: paths, bindings, next_action: 'Call assess to inspect the effective project Skills before creating a task.' }, null, 2);
1175
+ }
900
1176
  if (a.operation === 'init') {
901
1177
  const phase = a.phase ?? 'inspect';
902
1178
  const existing = await readText(fs, 'AGENTS.md', cwd);
@@ -976,12 +1252,21 @@ export function registerDevTask(ctx) {
976
1252
  if (!a.task_id || !a.title || !a.branch) {
977
1253
  throw new Error('create requires task_id, title, and branch');
978
1254
  }
979
- const resolved = await resolveWorkflow(fs, cwd);
1255
+ if (a.complexity !== undefined && !isComplexity(a.complexity))
1256
+ throw new Error(`unknown complexity "${String(a.complexity)}"`);
1257
+ if (a.complexity !== undefined && !a.complexity_reason?.trim()) {
1258
+ throw new Error('adaptive create requires complexity_reason explaining the requirement evidence for this grade');
1259
+ }
1260
+ const adaptiveConfig = a.complexity === undefined ? undefined : await readAdaptiveConfig(fs, cwd);
1261
+ const resolved = a.complexity !== undefined
1262
+ ? { flow: `adaptive-${a.complexity}`, version: ADAPTIVE_VERSION,
1263
+ config: await resolveAdaptiveWorkflow(a.complexity, fs, cwd, adaptiveConfig), problems: [] }
1264
+ : await resolveWorkflow(fs, cwd);
980
1265
  if (resolved.problems.length > 0) {
981
1266
  throw new Error(`invalid .dsh/eng.json — fix the project config first:\n- ${resolved.problems.join('\n- ')}`);
982
1267
  }
983
1268
  const risk = a.risk_level ?? 'standard';
984
- if (risk === 'high_risk' && !flowSatisfies(resolved.flow, HIGH_RISK_REQUIRED_CAPABILITIES)) {
1269
+ if (risk === 'high_risk' && a.complexity === undefined && !flowSatisfies(resolved.flow, HIGH_RISK_REQUIRED_CAPABILITIES)) {
985
1270
  throw new Error(`high_risk task cannot run on flow "${resolved.flow}" — it lacks the required capabilities ` +
986
1271
  `(${HIGH_RISK_REQUIRED_CAPABILITIES.join(', ')}). Use the standard flow or lower the task risk.`);
987
1272
  }
@@ -1000,12 +1285,15 @@ export function registerDevTask(ctx) {
1000
1285
  resources: frozen.resources,
1001
1286
  };
1002
1287
  const root = await detectRoot(projectProbe(fs), cwd ?? '');
1288
+ const sonarPolicy = adaptiveConfig === undefined ? undefined : sonarPolicyFor(adaptiveConfig);
1003
1289
  const state = newTask({
1004
1290
  id: a.task_id,
1005
1291
  title: a.title,
1006
1292
  branch: a.branch,
1007
1293
  work_size: a.work_size ?? 'standard',
1008
1294
  risk_level: risk,
1295
+ ...(a.complexity !== undefined ? { complexity: a.complexity, complexity_reason: a.complexity_reason.trim() } : {}),
1296
+ ...(sonarPolicy !== undefined ? { sonar_policy: sonarPolicy } : {}),
1009
1297
  flow,
1010
1298
  root,
1011
1299
  project_type: await detectType(projectProbe(fs), root),
@@ -1015,7 +1303,7 @@ export function registerDevTask(ctx) {
1015
1303
  state.files = a.files ?? [];
1016
1304
  assertInsideRoot(root, cwd ?? '', taskPath(state.id));
1017
1305
  await writeTask(fs, state, cwd, await resolveWriteMode(ctx, a, exec));
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.`;
1306
+ 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${state.complexity !== undefined ? ' with dev_task load_skill and exact source:name' : ''}. Record each skill's declared evidence. Terminal bindings must finish before entering the terminal stage.`;
1019
1307
  }
1020
1308
  if (!a.task_id)
1021
1309
  throw new Error('task_id is required for this operation');
@@ -1024,7 +1312,7 @@ export function registerDevTask(ctx) {
1024
1312
  if (state.completed !== undefined && a.operation === 'complete') {
1025
1313
  return `task already completed at ${state.completed.at}`;
1026
1314
  }
1027
- if (state.completed !== undefined && a.operation !== 'status' && a.operation !== 'revise') {
1315
+ if (state.completed !== undefined && a.operation !== 'status' && a.operation !== 'revise' && a.operation !== 'learn_rule') {
1028
1316
  throw new Error('task is completed; use revise to reopen it before changing task state');
1029
1317
  }
1030
1318
  if (a.operation === 'load_skill') {
@@ -1033,8 +1321,8 @@ export function registerDevTask(ctx) {
1033
1321
  throw new Error(`stage "${stage}" is not currently actionable`);
1034
1322
  const entry = (workflow.stage_bindings?.[stage]?.skills ?? [])
1035
1323
  .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');
1324
+ if (!entry)
1325
+ throw new Error('load_skill requires an exact source:name bound at the current or upcoming terminal stage');
1038
1326
  const content = await readSkillAt(entry.skill, fs, cwd);
1039
1327
  if (content === undefined)
1040
1328
  throw new Error(`bound skill ${a.skill_name} cannot be read`);
@@ -1061,13 +1349,15 @@ export function registerDevTask(ctx) {
1061
1349
  const completionIssues = state.completed === undefined
1062
1350
  ? [...completionBlockers(state, workflow), ...missingSkills,
1063
1351
  ...skillResourceBlockers(missingSkillFiles, state.stage),
1064
- ...resourceBlockers(missingRules, state.stage), ...staleEvidence]
1352
+ ...resourceBlockers(missingRules, state.stage), ...staleEvidence, ...await sonarBlockers(fs, state, cwd)]
1065
1353
  : [];
1066
1354
  return JSON.stringify({
1067
1355
  id: state.id,
1068
1356
  stage: state.stage,
1069
1357
  flow: state.flow !== undefined ? { flow: state.flow.flow, version: state.flow.version } : null,
1070
1358
  risk_level: state.risk_level,
1359
+ complexity: state.complexity ?? null,
1360
+ complexity_reason: state.complexity_reason ?? null,
1071
1361
  completed: state.completed ?? null,
1072
1362
  completion_blockers: completionIssues,
1073
1363
  work_size: state.work_size,
@@ -1077,12 +1367,14 @@ export function registerDevTask(ctx) {
1077
1367
  items: state.items,
1078
1368
  skill_obligations: obligationStages(state, workflow).map(stage => {
1079
1369
  const bindings = workflow.stage_bindings?.[stage]?.skills ?? [];
1080
- const skills = bindings.map(entry => entry.skill.source === 'codex-project' ? formatResourceRef(entry.skill) : entry.skill.name);
1370
+ const skills = bindings.map(entry => state.complexity !== undefined || entry.skill.source === 'codex-project'
1371
+ ? formatResourceRef(entry.skill) : entry.skill.name);
1081
1372
  // Only bindings whose evidence is a command owe a command receipt, so a
1082
1373
  // document-producing skill is not told to run something irrelevant.
1083
1374
  const command_receipts_required = bindings
1084
1375
  .filter(entry => entry.evidence === undefined || entry.evidence === 'command')
1085
- .map(entry => entry.skill.source === 'codex-project' ? formatResourceRef(entry.skill) : entry.skill.name)
1376
+ .map(entry => state.complexity !== undefined || entry.skill.source === 'codex-project'
1377
+ ? formatResourceRef(entry.skill) : entry.skill.name)
1086
1378
  .filter(name => needsSkillReceipt(name));
1087
1379
  return { stage, skills, command_receipts_required };
1088
1380
  }),
@@ -1108,6 +1400,12 @@ export function registerDevTask(ctx) {
1108
1400
  commits: state.commits,
1109
1401
  verification: state.verification,
1110
1402
  review: state.review,
1403
+ sonar: state.sonar_policy === undefined ? { enabled: false } : {
1404
+ enabled: true, mode: state.sonar_policy.mode, project_key: state.sonar_policy.project_key,
1405
+ audit: state.sonar_audit ?? null, blockers: await sonarBlockers(fs, state, cwd),
1406
+ prior_audits: state.sonar_history?.length ?? 0,
1407
+ },
1408
+ learned_rules: state.learned_rules ?? [],
1111
1409
  artifact_requirements: workflow.artifacts.filter(def => def.stage === state.stage).map(def => ({
1112
1410
  id: def.id, name: def.name, fields: def.fields,
1113
1411
  missing_fields: def.fields.filter(field => !(state.artifacts[def.id]?.[field] ?? '').trim()),
@@ -1130,6 +1428,91 @@ export function registerDevTask(ctx) {
1130
1428
  risk_downgrades: state.risk_downgrades ?? [],
1131
1429
  }, null, 2);
1132
1430
  }
1431
+ if (a.operation === 'sonar_check') {
1432
+ if (!state.sonar_policy)
1433
+ throw new Error('SonarQube is not enabled for this task');
1434
+ if (state.stage !== '代码审核')
1435
+ throw new Error('sonar_check runs only in the 代码审核 stage');
1436
+ if (!state.verification.passed || (await evidenceBlockers(fs, state, workflow, cwd)).length) {
1437
+ throw new Error('functional verification must pass on the current files before sonar_check');
1438
+ }
1439
+ if (!state.commits.some(commit => commit.hash !== undefined)) {
1440
+ throw new Error('sonar_check requires a recorded Git commit; commit at the 测试 checkpoint, push it, and use that CI scan');
1441
+ }
1442
+ if (!a.ce_task_id)
1443
+ throw new Error('sonar_check requires ce_task_id from this task\'s completed CI scan');
1444
+ const target = state.sonar_policy.mode === 'branch' ? state.branch : a.pull_request;
1445
+ if (!target)
1446
+ throw new Error('pull-request SonarQube audit requires pull_request');
1447
+ const audit = await inspectSonar(state.sonar_policy, process.env[state.sonar_policy.token_env] ?? '', a.ce_task_id, target, await scopeFingerprint(fs, state, cwd));
1448
+ audit.commit_hash = state.commits.findLast(entry => entry.hash !== undefined).hash;
1449
+ if (state.sonar_audit !== undefined)
1450
+ state.sonar_history = [...(state.sonar_history ?? []), state.sonar_audit].slice(-20);
1451
+ state.sonar_audit = audit;
1452
+ state.review.outcome = 'pending';
1453
+ await writeTask(fs, state, cwd, await resolveWriteMode(ctx, a, exec));
1454
+ return JSON.stringify({ gate: audit.gate, blocking: audit.blocking, findings: audit.findings,
1455
+ analysis_id: audit.analysis_id, next_action: audit.gate === 'OK' && audit.blocking.length === 0
1456
+ ? 'Continue code review, then record review pass.'
1457
+ : 'Record review blocked, fix findings, rerun tests and CI scan, then call sonar_check with the new ce_task_id.' }, null, 2);
1458
+ }
1459
+ if (a.operation === 'learn_rule') {
1460
+ const issue = [state.sonar_audit, ...(state.sonar_history ?? [])].filter(entry => entry !== undefined)
1461
+ .flatMap(entry => entry.blocking).find(entry => entry.key === a.issue_key);
1462
+ if (!issue)
1463
+ throw new Error('learn_rule requires a blocking issue_key recorded by this task\'s SonarQube audit');
1464
+ if (!a.rule_name || !RESOURCE_NAME.test(a.rule_name) || !a.content?.trim() || a.content.trim().length < 40
1465
+ || !a.learning_reason?.trim()) {
1466
+ throw new Error('learn_rule needs a new kebab-case rule_name, actionable content of at least 40 characters, and learning_reason');
1467
+ }
1468
+ const codeSkills = workflow.stage_bindings?.['代码开发']?.skills ?? [];
1469
+ const binding = codeSkills.find(entry => formatResourceRef(entry.skill) === a.skill_name || entry.skill.name === a.skill_name);
1470
+ if (!binding)
1471
+ throw new Error('learn_rule requires a Skill bound to the 代码开发 meta-skill');
1472
+ const rulePath = `.dsh/rules/${a.rule_name}.md`;
1473
+ const skillPath = `.dsh/skills/${binding.skill.name}/SKILL.md`;
1474
+ const profilePath = `.dsh/skills/${binding.skill.name}/profile.json`;
1475
+ const root = state.root ?? cwd ?? '';
1476
+ if (!cwd || resolve(root) !== resolve(cwd))
1477
+ throw new Error('learn_rule must run with the task project root as the session workspace');
1478
+ for (const path of [rulePath, skillPath, profilePath])
1479
+ assertInsideRoot(root, cwd ?? root, path);
1480
+ if (await readText(fs, rulePath, cwd) !== undefined)
1481
+ throw new Error(`project Rule ${a.rule_name} already exists; edit it through the Rule editor instead`);
1482
+ const projectSkill = await readText(fs, skillPath, cwd);
1483
+ const sourceSkill = projectSkill ?? await readSkillAt(binding.skill, fs, cwd);
1484
+ if (sourceSkill === undefined)
1485
+ throw new Error(`Skill ${formatResourceRef(binding.skill)} cannot be read`);
1486
+ const profileRaw = await readText(fs, profilePath, cwd);
1487
+ const baseProfile = projectSkill === undefined ? { rules: binding.rules, evidence: binding.evidence }
1488
+ : await profileFor({ source: 'project', name: binding.skill.name }, fs, cwd) ?? { rules: [], evidence: binding.evidence };
1489
+ const profile = { rules: [...baseProfile.rules, { source: 'project', name: a.rule_name }],
1490
+ ...(baseProfile.evidence !== undefined ? { evidence: baseProfile.evidence } : {}) };
1491
+ const ruleBody = `# ${a.rule_name}\n\n${a.content.trimEnd()}\n\n` +
1492
+ `> 来源:SonarQube 规则 ${issue.rule}、问题 ${issue.key}。可复用原因:${a.learning_reason.trim()}\n`;
1493
+ const proposalHash = hashText(JSON.stringify({ issue: issue.key, skill: binding.skill, name: a.rule_name,
1494
+ body: ruleBody, profile, profile_hash: hashText(profileRaw ?? '') }));
1495
+ const phase = a.phase ?? 'propose';
1496
+ if (phase === 'propose')
1497
+ return JSON.stringify({ issue, target_skill: binding.skill,
1498
+ project_override: projectSkill === undefined, rule_path: rulePath, rule_body: ruleBody,
1499
+ profile_path: profilePath, profile, expected_hash: proposalHash,
1500
+ instruction: 'Review the example and generality before apply. The project Rule will affect future tasks; current task bindings remain frozen.' }, null, 2);
1501
+ if (phase !== 'apply' || a.expected_hash !== proposalHash)
1502
+ throw new Error('learn_rule apply requires the unchanged expected_hash from propose');
1503
+ const mode = await resolveWriteMode(ctx, a, exec);
1504
+ await writeText(fs, rulePath, ruleBody, cwd, mode, { kind: 'createIfAbsent' });
1505
+ if (projectSkill === undefined) {
1506
+ await writeText(fs, skillPath, sourceSkill, cwd, mode, { kind: 'createIfAbsent' });
1507
+ }
1508
+ const info = await fs.lstat(profilePath, { cwd });
1509
+ await writeText(fs, profilePath, JSON.stringify(profile, null, 2) + '\n', cwd, mode, info === undefined ? { kind: 'createIfAbsent' } : { kind: 'replaceIfVersion', version: info.version });
1510
+ state.learned_rules = [...(state.learned_rules ?? []), { issue_key: issue.key, rule_name: a.rule_name,
1511
+ skill_name: binding.skill.name, at: new Date().toISOString() }];
1512
+ await writeTask(fs, state, cwd, mode);
1513
+ return JSON.stringify({ created_rule: rulePath, attached_to: `project:${binding.skill.name}`,
1514
+ next_action: 'Review this file in Git. New adaptive tasks resolve the project Skill and its Rule automatically.' }, null, 2);
1515
+ }
1133
1516
  if (a.operation === 'commit') {
1134
1517
  await assertFreshEvidence(fs, state, workflow, cwd);
1135
1518
  const missingSkills = skillBlockers(state, workflow, session);
@@ -1232,6 +1615,10 @@ export function registerDevTask(ctx) {
1232
1615
  kind, reason: a.revision_reason.trim(), to: target, from: state.stage,
1233
1616
  at: new Date().toISOString(), invalidated: invalidatedBy(kind),
1234
1617
  });
1618
+ if (state.sonar_audit !== undefined) {
1619
+ state.sonar_history = [...(state.sonar_history ?? []), state.sonar_audit].slice(-20);
1620
+ }
1621
+ delete state.sonar_audit;
1235
1622
  await writeTask(fs, state, cwd, await resolveWriteMode(ctx, a, exec));
1236
1623
  return 'revised (' + kind + ') back to "' + outcome.stage + '"; invalidated: ' + outcome.invalidated.join(', ')
1237
1624
  + '. Re-establish these before advancing again; the history is kept in the task record.';
@@ -1406,7 +1793,8 @@ export function registerDevTask(ctx) {
1406
1793
  }
1407
1794
  case 'skill_result': {
1408
1795
  const stage = a.target_stage ?? state.stage;
1409
- const entry = (workflow.stage_bindings?.[stage]?.skills ?? []).find(binding => (binding.skill.source === 'codex-project' ? formatResourceRef(binding.skill) : binding.skill.name) === a.skill_name);
1796
+ const entry = (workflow.stage_bindings?.[stage]?.skills ?? []).find(binding => (state.complexity !== undefined || binding.skill.source === 'codex-project'
1797
+ ? formatResourceRef(binding.skill) : binding.skill.name) === a.skill_name);
1410
1798
  if (!obligationStages(state, workflow).includes(stage) || !entry)
1411
1799
  throw new Error('skill_result requires a skill bound to this stage or its upcoming terminal stage');
1412
1800
  if ((await readSkillAt(entry.skill, fs, cwd)) === undefined) {
@@ -1416,10 +1804,12 @@ export function registerDevTask(ctx) {
1416
1804
  if (unresolvedForSkill.length > 0) {
1417
1805
  throw new Error(`bound rule for ${formatResourceRef(entry.skill)} resolves nowhere: ${unresolvedForSkill.join(', ')}`);
1418
1806
  }
1419
- const loadKey = entry.skill.source === 'codex-project' ? `${state.id}#${a.skill_name}` : a.skill_name;
1420
- const loadCall = loadedSkills(session).get(loadKey);
1807
+ const exactLoadKey = `${state.id}#${formatResourceRef(entry.skill)}`;
1808
+ const loaded = loadedSkills(session);
1809
+ const loadCall = loaded.get(exactLoadKey) ?? (state.complexity === undefined && entry.skill.source !== 'codex-project'
1810
+ ? loaded.get(entry.skill.name) : undefined);
1421
1811
  if (!loadCall)
1422
- throw new Error(entry.skill.source === 'codex-project'
1812
+ throw new Error(state.complexity !== undefined || entry.skill.source === 'codex-project'
1423
1813
  ? `load skill "${a.skill_name}" with dev_task operation=load_skill before recording execution`
1424
1814
  : `load skill "${a.skill_name}" successfully before recording execution`);
1425
1815
  if (entry.evidence === 'manual') {
@@ -1455,6 +1845,11 @@ export function registerDevTask(ctx) {
1455
1845
  case 'review':
1456
1846
  if (a.outcome !== 'pass' && a.outcome !== 'blocked')
1457
1847
  throw new Error('review requires outcome: pass|blocked');
1848
+ if (a.outcome === 'pass') {
1849
+ const sonar = await sonarBlockers(fs, state, cwd);
1850
+ if (sonar.length)
1851
+ throw new Error(sonar.join('; '));
1852
+ }
1458
1853
  state.review.outcome = a.outcome;
1459
1854
  break;
1460
1855
  default: