@godv61/dsh-task-engine 0.29.3 → 0.30.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 (85) hide show
  1. package/.adaptive-test.mjs +380 -185
  2. package/.codex-project-test.mjs +18 -18
  3. package/.evidence-test.mjs +14 -1
  4. package/.hook-test.mjs +19 -19
  5. package/.resource-test.mjs +17 -0
  6. package/.roundtrip-test.mjs +8 -3
  7. package/.sonar-credential-test.mjs +38 -0
  8. package/.sonarlint-local-test.mjs +71 -0
  9. package/.workflow-test.mjs +56 -9
  10. package/README.md +35 -112
  11. package/docs/development.md +45 -53
  12. package/docs/manual.html +140 -84
  13. package/hooks/commit-msg +19 -1
  14. package/lib/adaptive.js +1 -1
  15. package/lib/adaptive.js.map +1 -1
  16. package/lib/client.js +297 -9
  17. package/lib/client.js.map +1 -1
  18. package/lib/controller.d.ts +44 -0
  19. package/lib/controller.js +73 -0
  20. package/lib/controller.js.map +1 -1
  21. package/lib/dev-task.d.ts +2 -0
  22. package/lib/dev-task.js +342 -42
  23. package/lib/dev-task.js.map +1 -1
  24. package/lib/engine.d.ts +5 -1
  25. package/lib/engine.js +6 -0
  26. package/lib/engine.js.map +1 -1
  27. package/lib/hook.js +3 -3
  28. package/lib/hook.js.map +1 -1
  29. package/lib/project-init.d.ts +4 -0
  30. package/lib/project-init.js +21 -2
  31. package/lib/project-init.js.map +1 -1
  32. package/lib/skill-audit.js +9 -1
  33. package/lib/skill-audit.js.map +1 -1
  34. package/lib/sonar-credential.d.ts +17 -0
  35. package/lib/sonar-credential.js +17 -0
  36. package/lib/sonar-credential.js.map +1 -0
  37. package/lib/sonar-report.d.ts +4 -0
  38. package/lib/sonar-report.js +53 -0
  39. package/lib/sonar-report.js.map +1 -0
  40. package/lib/sonar.d.ts +35 -2
  41. package/lib/sonar.js +63 -4
  42. package/lib/sonar.js.map +1 -1
  43. package/lib/sonarlint-local.d.ts +17 -0
  44. package/lib/sonarlint-local.js +317 -0
  45. package/lib/sonarlint-local.js.map +1 -0
  46. package/lib/verification-tests.d.ts +10 -0
  47. package/lib/verification-tests.js +15 -0
  48. package/lib/verification-tests.js.map +1 -0
  49. package/package.json +6 -4
  50. package/preset/enable.mjs +2 -2
  51. package/preset/persona.md +4 -4
  52. package/scripts/verify-dsh-compat.mjs +14 -14
  53. package/scripts/verify-package.mjs +10 -8
  54. package/skills/architecture-design/SKILL.md +11 -11
  55. package/skills/code-development/SKILL.md +11 -11
  56. package/skills/code-review/SKILL.md +12 -10
  57. package/skills/eng-delivery/SKILL.md +10 -7
  58. package/skills/requirements-analysis/SKILL.md +11 -11
  59. package/skills/task-orchestration/SKILL.md +11 -11
  60. package/skills/test-validation/SKILL.md +11 -9
  61. package/docs/BRIEF-FOR-REVIEW.md +0 -163
  62. package/docs/CHANGELOG.md +0 -407
  63. package/docs/README.md +0 -42
  64. package/docs/adaptive-workflows.md +0 -76
  65. package/docs/assets/workflow-banner.svg +0 -29
  66. package/docs/configuration.md +0 -80
  67. package/docs/faq.md +0 -59
  68. package/docs/getting-started.md +0 -55
  69. package/docs/listing/godv61__dsh-task-engine.yml +0 -6
  70. package/docs/listing/submission.md +0 -84
  71. package/docs/manual-legacy.html +0 -380
  72. package/docs/releases/0.23.0.md +0 -32
  73. package/docs/releases/0.23.1.md +0 -58
  74. package/docs/releases/0.23.2.md +0 -21
  75. package/docs/resource-install.md +0 -64
  76. package/docs/roadmap.md +0 -33
  77. package/docs/testing/0.23.0//346/265/213/350/257/225/346/211/247/350/241/214/350/256/260/345/275/225.md +0 -189
  78. package/docs/testing/0.23.0//346/265/213/350/257/225/346/212/245/345/221/212.md +0 -42
  79. package/docs/testing/0.23.1//346/265/213/350/257/225/346/212/245/345/221/212.md +0 -34
  80. package/docs/testing/0.23.1//350/207/252/345/212/250/345/214/226/346/265/213/350/257/225/346/230/216/347/273/206.md +0 -41
  81. package/docs/testing/0.23.2/R02/344/270/232/345/212/241/346/265/213/350/257/225/346/230/216/347/273/206.md +0 -56
  82. package/docs/testing/0.23.2/R03/344/270/232/345/212/241/346/265/213/350/257/225/346/230/216/347/273/206.md +0 -38
  83. package/docs/testing/0.23.2//346/265/213/350/257/225/346/212/245/345/221/212.md +0 -65
  84. package/docs/testing/0.23.2//350/207/252/345/212/250/345/214/226/346/265/213/350/257/225/346/230/216/347/273/206.md +0 -43
  85. package/docs/workflow-regression.md +0 -36
package/lib/dev-task.js CHANGED
@@ -10,18 +10,22 @@
10
10
  *
11
11
  * @module dsh-task-engine/dev-task
12
12
  */
13
- import { readFileSync } from 'node:fs';
14
- import { homedir } from 'node:os';
13
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
14
+ import { homedir, tmpdir } from 'node:os';
15
15
  import { isAbsolute, join, relative, resolve } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { defineTool } from '@deepseek-ai/dsh-tools';
18
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 { 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";
21
+ import { assertLocalSonarReady, inspectSonar, localReviewGate, unresolvedBlockingFindings, validAuditIncludePaths, validLocalScanCommand } from "./sonar.js";
22
+ import { inspectLocalRules } from "./sonarlint-local.js";
23
+ import { renderSonarReport, sonarReportPath } from "./sonar-report.js";
24
+ import { resolveSonarToken } from "./sonar-credential.js";
25
+ import { projectMapCoverageGaps, projectMapCoveragePaths, scanProject } from "./project-init.js";
23
26
  import { hashConfig, hashText } from "./snapshot.js";
24
27
  import { withUserSkillProfiles } from "./user-skill-profiles.js";
28
+ import { mavenTestEvidence } from "./verification-tests.js";
25
29
  import { loadedSkills, needsSkillReceipt, obligationStages, skillBlockers } from "./skill-audit.js";
26
30
  import { detectRoot, detectType, defaultVerifyCommand, listProjectRules, listProjectSkills, presentGovernanceFiles, } from "./project.js";
27
31
  /** Bundled commit-msg hook, copied into `.git/hooks/` by the `install_hook` operation. */
@@ -101,6 +105,13 @@ async function resolveVerifyCommand(fs, cwd) {
101
105
  }
102
106
  /** Hard cap on a verification command's run time; a timeout marks the receipt non-passing. */
103
107
  const VERIFY_TIMEOUT_MS = 10 * 60 * 1000;
108
+ async function runShell(shell, spec) {
109
+ if (typeof shell.execute === 'function')
110
+ return (await shell.execute(spec)).result();
111
+ if (typeof shell.run === 'function')
112
+ return shell.run(spec);
113
+ throw new Error('host shell service has neither execute nor run');
114
+ }
104
115
  /**
105
116
  * Run a verification command through the host shell service and return an
106
117
  * objective {@link VerificationReceipt}; `passed` derives from the exit code,
@@ -122,7 +133,7 @@ async function runVerificationCommand(ctx, command, cwd, exec, approvedMode) {
122
133
  timeoutMs: VERIFY_TIMEOUT_MS, signal: exec.signal,
123
134
  ...(sandboxPolicy ? { sandboxPolicy } : {}),
124
135
  });
125
- const outcome = await shell.run(spec);
136
+ const outcome = await runShell(shell, spec);
126
137
  return {
127
138
  command,
128
139
  exit_code: outcome.exitCode ?? -1,
@@ -136,6 +147,55 @@ async function runVerificationCommand(ctx, command, cwd, exec, approvedMode) {
136
147
  ...(outcome.sandbox ? { sandbox: outcome.sandbox } : {}),
137
148
  };
138
149
  }
150
+ /** Run a local scanner without putting the credential on a command line or in the task record. */
151
+ async function runLocalSonar(ctx, policy, token, root, branch, exec, defaultCommand, approvedMode) {
152
+ if (!token)
153
+ throw new Error('SonarQube Token is not configured for this project');
154
+ if (!/^[A-Za-z0-9._/-]+$/u.test(branch) || !policy.reference_branch
155
+ || branch === policy.reference_branch || !/^[A-Za-z0-9._/-]+$/u.test(policy.reference_branch)) {
156
+ throw new Error('local SonarQube scan needs a safe current branch and a different reference branch');
157
+ }
158
+ if (!/^[A-Za-z0-9_.:-]+$/u.test(policy.project_key))
159
+ throw new Error('invalid SonarQube project key');
160
+ const shell = ctx.get('shell');
161
+ if (!shell)
162
+ throw new Error('local SonarQube scan needs the host shell service');
163
+ const temporary = mkdtempSync(join(tmpdir(), 'dsh-sonar-'));
164
+ try {
165
+ const metadata = join(temporary, 'report-task.txt');
166
+ const quote = (value) => `'${value.replace(/'/gu, "''")}'`;
167
+ const command = [policy.scan_command?.trim() || defaultCommand,
168
+ `-Dsonar.projectKey=${quote(policy.project_key)}`,
169
+ `-Dsonar.branch.name=${quote(branch)}`,
170
+ `-Dsonar.newCode.referenceBranch=${quote(policy.reference_branch)}`,
171
+ `-Dsonar.scanner.metadataFilePath=${quote(metadata)}`,
172
+ '-Dsonar.qualitygate.wait=false'].join(' ');
173
+ const policyService = ctx.get('sandboxPolicy');
174
+ const standingPolicy = policyService?.resolve({ session: exec.agent?.session });
175
+ const sandboxPolicy = approvedMode === undefined ? standingPolicy : { ...standingPolicy, mode: approvedMode };
176
+ const spec = shell.resolve({ command, workdir: root, timeoutMs: 30 * 60 * 1000, signal: exec.signal,
177
+ env: { SONAR_TOKEN: token, SONAR_HOST_URL: policy.host_url },
178
+ ...(sandboxPolicy ? { sandboxPolicy } : {}) });
179
+ const outcome = await runShell(shell, spec);
180
+ if (outcome.exitCode !== 0 || outcome.aborted || outcome.timedOut || outcome.sandbox?.denied || outcome.sandbox?.runnerFailed) {
181
+ throw new Error(`local SonarQube scanner did not complete (exit ${outcome.exitCode ?? 'unknown'}); inspect scanner output and retry`);
182
+ }
183
+ let report;
184
+ try {
185
+ report = readFileSync(metadata, 'utf8');
186
+ }
187
+ catch {
188
+ throw new Error('local SonarQube scanner did not write report-task.txt; check scanner metadataFilePath support');
189
+ }
190
+ const id = /^ceTaskId=(.+)$/mu.exec(report)?.[1]?.trim();
191
+ if (!id || !/^[\w-]{8,120}$/u.test(id))
192
+ throw new Error('local SonarQube scanner returned an invalid ceTaskId');
193
+ return id;
194
+ }
195
+ finally {
196
+ rmSync(temporary, { recursive: true, force: true });
197
+ }
198
+ }
139
199
  function sanitize(id) {
140
200
  return id.replace(/[^0-9a-zA-Z_-]/g, '-');
141
201
  }
@@ -462,7 +522,26 @@ function sonarPolicyFor(project) {
462
522
  const mode = sonar.mode ?? 'branch';
463
523
  if (mode !== 'branch' && mode !== 'pull-request')
464
524
  throw new Error('sonar.mode must be branch or pull-request');
465
- return { enabled: true, host_url, project_key, token_env, mode };
525
+ const source = sonar.source ?? 'ci';
526
+ if (source !== 'ci' && source !== 'local' && source !== 'ide-local')
527
+ throw new Error('sonar.source must be ci, local, or ide-local');
528
+ if ((source === 'local' || source === 'ide-local') && (mode !== 'branch' || !sonar.reference_branch?.trim())) {
529
+ throw new Error('local Sonar review needs branch mode and a reference_branch for new code');
530
+ }
531
+ if (sonar.reference_branch && !/^[A-Za-z0-9._/-]+$/u.test(sonar.reference_branch))
532
+ throw new Error('invalid SonarQube reference_branch');
533
+ if (sonar.scan_command && !validLocalScanCommand(sonar.scan_command)) {
534
+ throw new Error('scan_command must be a single Maven or SonarScanner command without shell operators or Token arguments');
535
+ }
536
+ if (sonar.include_paths !== undefined && !validAuditIncludePaths(sonar.include_paths)) {
537
+ throw new Error('sonar.include_paths must contain safe project-relative paths');
538
+ }
539
+ if (sonar.include_paths?.length && source !== 'ide-local')
540
+ throw new Error('sonar.include_paths only applies to ide-local reviews');
541
+ return { enabled: true, host_url, project_key, token_env, mode, source,
542
+ ...(sonar.reference_branch ? { reference_branch: sonar.reference_branch } : {}),
543
+ ...(sonar.scan_command ? { scan_command: sonar.scan_command } : {}),
544
+ ...(sonar.include_paths?.length ? { include_paths: sonar.include_paths } : {}) };
466
545
  }
467
546
  async function resolveLogicalSkill(name, fs, cwd) {
468
547
  if (!RESOURCE_NAME.test(name))
@@ -788,7 +867,7 @@ async function renderBindings(stage, workflow, fs, cwd, frozen) {
788
867
  return [skillPart, skillInstructions, receiptPart, rulePart, missingPart, missingSkillPart, legacyPart].filter(Boolean).join('\n');
789
868
  }
790
869
  /** Normalize the tool's `items` argument into complete TaskItem records. */
791
- function normalizeItems(items, previous = []) {
870
+ function normalizeItems(items, previous = [], mode = 'merge') {
792
871
  const ids = new Set();
793
872
  const result = (items ?? []).map(item => {
794
873
  const id = String(item.id ?? '').trim();
@@ -804,9 +883,16 @@ function normalizeItems(items, previous = []) {
804
883
  const unchanged = old?.title === title && !(old.status === 'done' && status !== 'done');
805
884
  return { ...(unchanged ? old : {}), id, title, status };
806
885
  });
807
- if (result.filter(item => item.status === 'doing').length > 1)
886
+ // Incremental updates retain all prior items; an explicit replacement may remove only untouched work.
887
+ const merged = mode === 'merge'
888
+ ? [...previous.map(item => result.find(next => next.id === item.id) ?? item),
889
+ ...result.filter(item => !previous.some(old => old.id === item.id))]
890
+ : [...result, ...previous.filter(item => !ids.has(item.id)
891
+ && (item.status === 'done' || item.dispatch !== undefined || item.review !== undefined))];
892
+ if (merged.filter(item => item.status === 'doing').length > 1) {
808
893
  throw new Error('only one implementation item may be doing');
809
- return result;
894
+ }
895
+ return merged;
810
896
  }
811
897
  /** Hash declared deliverables through host FS; task bookkeeping is excluded to avoid self-invalidation. */
812
898
  async function scopeFingerprint(fs, state, cwd) {
@@ -843,7 +929,7 @@ async function evidenceBlockers(fs, state, workflow, cwd) {
843
929
  if (receipt !== undefined && receipt.scope_hash !== current) {
844
930
  blockers.push('verification is stale after file/scope changes; rerun verify with a real command');
845
931
  }
846
- else if (!state.verification.passed && verificationBlockers(state, workflow).length > 0) {
932
+ else if (verificationBlockers(state, workflow).length > 0) {
847
933
  // Only where the flow actually holds a verification requirement: a task that
848
934
  // has not reached a verification gate yet has nothing to re-verify, and
849
935
  // reporting it there would block the flow from starting at all.
@@ -899,16 +985,101 @@ async function sonarBlockers(fs, state, cwd) {
899
985
  const current = await scopeFingerprint(fs, state, cwd);
900
986
  const problems = [];
901
987
  if (state.sonar_audit.scope_hash !== current)
902
- problems.push('SonarQube audit is stale after file changes; rerun CI scan and sonar_check');
988
+ problems.push('SonarQube audit is stale after file changes; rerun the scan and sonar_check');
903
989
  const commit = state.commits.findLast(entry => entry.hash !== undefined)?.hash;
904
- if (!commit || state.sonar_audit.commit_hash !== commit)
990
+ if (state.sonar_policy.source !== 'ide-local' && (!commit || state.sonar_audit.commit_hash !== commit)) {
905
991
  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`);
992
+ }
993
+ if (state.sonar_policy.source === 'ide-local') {
994
+ if (localReviewGate(state.sonar_audit) !== 'OK')
995
+ problems.push('本地规则审核尚有未解决问题或未覆盖文件');
996
+ const unresolved = unresolvedBlockingFindings(state.sonar_audit);
997
+ if (unresolved.length)
998
+ problems.push(`${unresolved.length} medium/high SonarQube new-code findings remain`);
999
+ }
1000
+ else {
1001
+ if (state.sonar_audit.gate !== 'OK')
1002
+ problems.push(`SonarQube Quality Gate is ${state.sonar_audit.gate}`);
1003
+ if (state.sonar_audit.blocking.length)
1004
+ problems.push(`${state.sonar_audit.blocking.length} medium/high SonarQube new-code findings remain`);
1005
+ }
1006
+ if (state.sonar_audit.uncovered_files?.length)
1007
+ problems.push(`本地规则审核未覆盖 ${state.sonar_audit.uncovered_files.length} 个新增代码文件`);
910
1008
  return problems;
911
1009
  }
1010
+ /** Explicit item headings in a task plan, for checking plan/ledger consistency. */
1011
+ export function plannedItemIds(steps) {
1012
+ return [...new Set(steps.split(/\r?\n/u).flatMap(line => {
1013
+ const match = /^\s*(?:[-*+]\s+|#{1,6}\s+)?([A-Za-z][A-Za-z0-9_-]*\d+)\b/u.exec(line);
1014
+ return match === null ? [] : [match[1]];
1015
+ }))];
1016
+ }
1017
+ function taskPlanItemBlockers(state) {
1018
+ if (state.stage !== '任务编排' || state.complexity === undefined)
1019
+ return [];
1020
+ const ids = plannedItemIds(state.artifacts['task-plan']?.steps ?? '');
1021
+ if (!ids.length)
1022
+ return ['task-plan.steps must list implementation items on separate lines with stable IDs (for example, - I1 ...); use the same IDs in dev_task items'];
1023
+ const recorded = new Set(state.items.map(item => item.id));
1024
+ const missing = ids.filter(id => !recorded.has(id));
1025
+ const unplanned = state.items.filter(item => !ids.includes(item.id)).map(item => item.id);
1026
+ return [...(missing.length ? [`task plan items missing from ledger: ${missing.join(', ')}`] : []),
1027
+ ...(unplanned.length ? [`ledger items absent from task plan: ${unplanned.join(', ')}`] : [])];
1028
+ }
1029
+ /** One optional, reviewable learning candidate per server rule, never an automatic Rule. */
1030
+ function ruleLearningCandidates(state) {
1031
+ const audits = [...(state.sonar_history ?? []), state.sonar_audit].filter((audit) => audit !== undefined);
1032
+ const learnedKeys = new Set((state.learned_rules ?? []).map(entry => entry.issue_key));
1033
+ const eligible = audits.flatMap(audit => unresolvedBlockingFindings(audit));
1034
+ const learnedRules = new Set(eligible
1035
+ .filter(issue => learnedKeys.has(issue.key)).map(issue => issue.rule));
1036
+ const byRule = new Map();
1037
+ for (const issue of eligible) {
1038
+ if (learnedRules.has(issue.rule))
1039
+ continue;
1040
+ const candidate = byRule.get(issue.rule) ?? { rule: issue.rule, count: 0, example: issue, keys: new Set() };
1041
+ if (!candidate.keys.has(issue.key)) {
1042
+ candidate.keys.add(issue.key);
1043
+ candidate.count++;
1044
+ }
1045
+ byRule.set(issue.rule, candidate);
1046
+ }
1047
+ return [...byRule.values()].map(({ rule, count, example }) => ({ rule, count, example }));
1048
+ }
1049
+ async function writeSonarReport(fs, state, audit, cwd, mode) {
1050
+ if (!state.sonar_policy)
1051
+ throw new Error('SonarQube is not enabled for this task');
1052
+ const path = sonarReportPath(state.id, audit);
1053
+ await writeText(fs, path, renderSonarReport(state.id, state.sonar_policy, audit), cwd, mode, { kind: 'createIfAbsent' });
1054
+ audit.report_path = path;
1055
+ }
1056
+ async function updateSonarReport(fs, state, audit, cwd, mode) {
1057
+ if (!state.sonar_policy || !audit.report_path)
1058
+ throw new Error('the current Sonar audit has no report to update');
1059
+ const info = await fs.lstat(audit.report_path, cwd === undefined ? undefined : { cwd });
1060
+ if (!info)
1061
+ throw new Error('the current Sonar report is missing; rerun sonar_check');
1062
+ await writeText(fs, audit.report_path, renderSonarReport(state.id, state.sonar_policy, audit), cwd, mode, { kind: 'replaceIfVersion', version: info.version });
1063
+ }
1064
+ async function inspectCompletedSonar(policy, token, ceTaskId, target, scopeHash, signal) {
1065
+ for (let attempt = 0; attempt < 60; attempt++) {
1066
+ if (signal.aborted)
1067
+ throw new Error('SonarQube audit was cancelled');
1068
+ try {
1069
+ return await inspectSonar(policy, token, ceTaskId, target, scopeHash, fetch, true);
1070
+ }
1071
+ catch (error) {
1072
+ if (!/Compute Engine task is (PENDING|IN_PROGRESS)/u.test(String(error)) || attempt === 59)
1073
+ throw error;
1074
+ await new Promise((resolve, reject) => {
1075
+ const timer = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, 5000);
1076
+ const onAbort = () => { clearTimeout(timer); reject(new Error('SonarQube audit was cancelled')); };
1077
+ signal.addEventListener('abort', onAbort, { once: true });
1078
+ });
1079
+ }
1080
+ }
1081
+ throw new Error('SonarQube analysis did not finish within five minutes');
1082
+ }
912
1083
  /** Resolve one item by id for the item-scoped audit operations. */
913
1084
  function requireItem(state, id) {
914
1085
  if (id === undefined || id === '')
@@ -954,9 +1125,9 @@ export async function approveAdvance(ctx, state, target, workflow, confirmations
954
1125
  state.solution_confirmed = true;
955
1126
  return assertAdvance(state, target, workflow);
956
1127
  }
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'];
1128
+ const OPERATIONS = ['status', 'assess', 'create', 'load_skill', 'record', 'items', 'scope', 'dispatch', 'review_item', 'advance', 'verify', 'sonar_check', 'sonar_disposition', 'learn_rule', 'review', 'commit', 'complete', 'revise', 'config', 'install_hook', 'verify_hook', 'init', 'init_project', 'set_risk', 'skill_result'];
958
1129
  const TOOL_DESCRIPTION = 'Own the engineering delivery workflow as hard state. Read or create the task record, record a ' +
959
- 'stage artifact, record each implementation item\'s subagent dispatch and two-stage (spec + quality) ' +
1130
+ 'stage artifact, record each implementation item\'s start and two-stage (spec + quality) ' +
960
1131
  'review audit (the flow decides how many verdicts per item), advance one stage (rejected unless every configured guard already holds, including ' +
961
1132
  'the stage artifacts; a requirement/solution confirmation guard asks a human to approve instead of ' +
962
1133
  'being satisfied by the model), record verification or review, and gate a commit on ' +
@@ -985,9 +1156,10 @@ export function registerDevTask(ctx) {
985
1156
  work_size: { type: 'string', enum: ['tiny', 'standard', 'complex'], description: 'Workload tier (create).' },
986
1157
  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
1158
  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).' },
1159
+ ce_task_id: { type: 'string', description: 'SonarQube Compute Engine task id from a CI scan (sonar_check in CI mode). Omit for local mode: the plugin runs the scanner and reads its task id.' },
989
1160
  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).' },
1161
+ issue_key: { type: 'string', description: 'A SonarQube finding key: current local audit for sonar_disposition, or audit history for learn_rule.' },
1162
+ disposition_reason: { type: 'string', description: 'Specific explanation of why the current local finding is a false positive (sonar_disposition). Requires human approval and supporting evidence.' },
991
1163
  rule_name: { type: 'string', description: 'New project Rule name (learn_rule).' },
992
1164
  learning_reason: { type: 'string', description: 'Why this failure generalizes beyond the current line of code (learn_rule).' },
993
1165
  resources: { type: 'array', description: 'Project Skill/Rule drafts for init_project propose/apply; content is reviewed before creation.',
@@ -999,7 +1171,7 @@ export function registerDevTask(ctx) {
999
1171
  risk_level: { type: 'string', enum: ['standard', 'high_risk'], description: 'Risk tier (create).' },
1000
1172
  items: {
1001
1173
  type: 'array',
1002
- description: 'Full implementation item list (create/replace). For an existing id, omit title to preserve its title and audit. Changing a reviewed title requires reopening as todo/doing and reviewing again. Include every item to retain.',
1174
+ description: 'Implementation items (create/items). items defaults to merge by id, retaining omitted entries. Use items_mode=replace only for deliberate replanning; completed or audited items are still retained. For an existing id, omit title to preserve its title and audit.',
1003
1175
  items: {
1004
1176
  type: 'object',
1005
1177
  additionalProperties: false,
@@ -1010,8 +1182,9 @@ export function registerDevTask(ctx) {
1010
1182
  },
1011
1183
  },
1012
1184
  },
1013
- item_id: { type: 'string', description: 'Item id to attach the dispatch/review audit to (dispatch/review_item).' },
1014
- description: { type: 'string', description: 'Dispatched subagent task description (dispatch).' },
1185
+ items_mode: { type: 'string', enum: ['merge', 'replace'], description: 'items operation: merge by id (default) or explicitly replace untouched items and reorder the supplied list.' },
1186
+ item_id: { type: 'string', description: 'Item id to start or review (dispatch/review_item).' },
1187
+ description: { type: 'string', description: 'Implementation intent (dispatch); the current agent may execute it directly.' },
1015
1188
  spec_outcome: { type: 'string', enum: ['pass', 'fail'], description: 'Specification-conformance verdict (review_item).' },
1016
1189
  quality_outcome: { type: 'string', enum: ['pass', 'fail'], description: 'Code-quality verdict (review_item).' },
1017
1190
  notes: { type: 'array', items: { type: 'string' }, description: 'Findings or defects (review_item).' },
@@ -1039,7 +1212,7 @@ export function registerDevTask(ctx) {
1039
1212
  overwrite: { type: 'boolean', description: 'Allow replacing an existing AGENTS.md (init apply); triggers human approval.' },
1040
1213
  expected_hash: { type: 'string', description: 'The content hash returned by init phase=propose; apply is rejected without it, or with a different hash (init apply).' },
1041
1214
  existing_hash: { type: 'string', description: 'The existing-file hash returned by init phase=propose; when the current AGENTS.md exists, a mismatching existing_hash proves it changed during review (init apply).' },
1042
- sandbox_permissions: { type: 'string', enum: ['workspace-write', 'danger-full-access'], description: 'Sandbox mode for this call\'s file writes and verify/skill_result command. Commands otherwise use the session policy. Retry the exact denied command with justification; danger-full-access requires approval before execution and applies only to this call.' },
1215
+ sandbox_permissions: { type: 'string', enum: ['workspace-write', 'danger-full-access'], description: 'Sandbox mode for this call\'s file writes and verify/skill_result/local Sonar scanner command. Commands otherwise use the session policy. Retry a denied command with justification; danger-full-access requires approval before execution and applies only to this call.' },
1043
1216
  justification: { type: 'string', description: 'Required with sandbox_permissions: one sentence for the user explaining why this exact operation needs the wider access.' },
1044
1217
  phase: { type: 'string', enum: ['inspect', 'propose', 'apply'], description: 'init phase: inspect (read-only), propose (preview draft, no write), apply (write; overwriting an existing file requires human approval).' },
1045
1218
  },
@@ -1119,6 +1292,13 @@ export function registerDevTask(ctx) {
1119
1292
  if (phase !== 'propose' && phase !== 'apply')
1120
1293
  throw new Error('init_project phase must be inspect, propose, or apply');
1121
1294
  const resources = validateInitResources(a.resources);
1295
+ for (const resource of resources) {
1296
+ if (resource.kind !== 'skill' || resource.name !== `${inventory.project_name}-project-map`)
1297
+ continue;
1298
+ const gaps = projectMapCoverageGaps(resource.content, inventory);
1299
+ if (gaps.length)
1300
+ throw new Error(`project map omits repository-wide evidence: ${gaps.join(', ')}. Describe each discovered module or manifest in the reusable project map; keep feature-specific findings in task artifacts.`);
1301
+ }
1122
1302
  const metaRaw = await readText(fs, '.dsh/meta.json', cwd);
1123
1303
  const meta = await readAdaptiveConfig(fs, cwd);
1124
1304
  const createdRules = new Set(resources.filter(resource => resource.kind === 'rule').map(resource => resource.name));
@@ -1142,11 +1322,13 @@ export function registerDevTask(ctx) {
1142
1322
  }
1143
1323
  }
1144
1324
  const metaHash = hashText(metaRaw ?? '');
1145
- const proposalHash = hashText(JSON.stringify(resources) + '\n' + metaHash);
1325
+ const inventoryHash = hashText(JSON.stringify(inventory));
1326
+ const proposalHash = hashText(JSON.stringify(resources) + '\n' + metaHash + '\n' + inventoryHash);
1146
1327
  if (phase === 'propose')
1147
1328
  return JSON.stringify({ inventory, resources, files_to_create: paths,
1329
+ project_map_coverage: projectMapCoveragePaths(inventory),
1148
1330
  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);
1331
+ instruction: 'Review each project-specific claim against the listed evidence. A *-project-map Skill must describe the whole repository and remain useful across unrelated requirements; move current-feature details to task artifacts or a separately named feature Skill. Apply with identical resources and expected_hash; existing files are protected.' }, null, 2);
1150
1332
  if (a.expected_hash !== proposalHash || a.existing_hash !== metaHash) {
1151
1333
  throw new Error('init_project draft or meta config changed since propose; inspect and propose again');
1152
1334
  }
@@ -1171,7 +1353,9 @@ export function registerDevTask(ctx) {
1171
1353
  }
1172
1354
  const info = await fs.lstat('.dsh/meta.json', { cwd });
1173
1355
  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);
1356
+ return JSON.stringify({ created: [...paths, '.dsh/meta.json'], bindings,
1357
+ team_files: [...paths, '.dsh/meta.json'],
1358
+ next_action: 'Review and commit the generated .dsh/skills, .dsh/rules and .dsh/meta.json as team configuration. Call assess to inspect the effective project Skills before creating a task.' }, null, 2);
1175
1359
  }
1176
1360
  if (a.operation === 'init') {
1177
1361
  const phase = a.phase ?? 'inspect';
@@ -1270,6 +1454,10 @@ export function registerDevTask(ctx) {
1270
1454
  throw new Error(`high_risk task cannot run on flow "${resolved.flow}" — it lacks the required capabilities ` +
1271
1455
  `(${HIGH_RISK_REQUIRED_CAPABILITIES.join(', ')}). Use the standard flow or lower the task risk.`);
1272
1456
  }
1457
+ const sonarPolicy = adaptiveConfig === undefined ? undefined : sonarPolicyFor(adaptiveConfig);
1458
+ if (sonarPolicy?.source === 'ide-local' && resolved.config.stages.includes('代码审核')) {
1459
+ resolved.config.commit = { ...resolved.config.commit, checkpoints: ['代码审核'] };
1460
+ }
1273
1461
  // Freeze the resolved skill and rule bodies now. A name alone cannot keep an
1274
1462
  // in-flight task stable: editing a rule the task uses would otherwise change
1275
1463
  // what the task is doing without the task saying so.
@@ -1285,7 +1473,6 @@ export function registerDevTask(ctx) {
1285
1473
  resources: frozen.resources,
1286
1474
  };
1287
1475
  const root = await detectRoot(projectProbe(fs), cwd ?? '');
1288
- const sonarPolicy = adaptiveConfig === undefined ? undefined : sonarPolicyFor(adaptiveConfig);
1289
1476
  const state = newTask({
1290
1477
  id: a.task_id,
1291
1478
  title: a.title,
@@ -1351,6 +1538,7 @@ export function registerDevTask(ctx) {
1351
1538
  ...skillResourceBlockers(missingSkillFiles, state.stage),
1352
1539
  ...resourceBlockers(missingRules, state.stage), ...staleEvidence, ...await sonarBlockers(fs, state, cwd)]
1353
1540
  : [];
1541
+ const planItemBlockers = taskPlanItemBlockers(state);
1354
1542
  return JSON.stringify({
1355
1543
  id: state.id,
1356
1544
  stage: state.stage,
@@ -1365,6 +1553,7 @@ export function registerDevTask(ctx) {
1365
1553
  solution_confirmed: state.solution_confirmed,
1366
1554
  items_done: `${state.items.filter(i => i.status === 'done').length}/${state.items.length}`,
1367
1555
  items: state.items,
1556
+ task_plan_item_blockers: planItemBlockers,
1368
1557
  skill_obligations: obligationStages(state, workflow).map(stage => {
1369
1558
  const bindings = workflow.stage_bindings?.[stage]?.skills ?? [];
1370
1559
  const skills = bindings.map(entry => state.complexity !== undefined || entry.skill.source === 'codex-project'
@@ -1402,10 +1591,12 @@ export function registerDevTask(ctx) {
1402
1591
  review: state.review,
1403
1592
  sonar: state.sonar_policy === undefined ? { enabled: false } : {
1404
1593
  enabled: true, mode: state.sonar_policy.mode, project_key: state.sonar_policy.project_key,
1594
+ source: state.sonar_policy.source ?? 'ci', reference_branch: state.sonar_policy.reference_branch,
1405
1595
  audit: state.sonar_audit ?? null, blockers: await sonarBlockers(fs, state, cwd),
1406
1596
  prior_audits: state.sonar_history?.length ?? 0,
1407
1597
  },
1408
1598
  learned_rules: state.learned_rules ?? [],
1599
+ rule_learning_candidates: ruleLearningCandidates(state),
1409
1600
  artifact_requirements: workflow.artifacts.filter(def => def.stage === state.stage).map(def => ({
1410
1601
  id: def.id, name: def.name, fields: def.fields,
1411
1602
  missing_fields: def.fields.filter(field => !(state.artifacts[def.id]?.[field] ?? '').trim()),
@@ -1436,29 +1627,131 @@ export function registerDevTask(ctx) {
1436
1627
  if (!state.verification.passed || (await evidenceBlockers(fs, state, workflow, cwd)).length) {
1437
1628
  throw new Error('functional verification must pass on the current files before sonar_check');
1438
1629
  }
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');
1630
+ if (state.sonar_policy.source !== 'ide-local' && !state.commits.some(commit => commit.hash !== undefined)) {
1631
+ throw new Error('sonar_check requires a recorded Git commit at the 测试 checkpoint; CI mode also requires a push');
1441
1632
  }
1442
- if (!a.ce_task_id)
1443
- throw new Error('sonar_check requires ce_task_id from this task\'s completed CI scan');
1444
1633
  const target = state.sonar_policy.mode === 'branch' ? state.branch : a.pull_request;
1445
1634
  if (!target)
1446
1635
  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));
1636
+ const credentialProvider = ctx.get('credentials');
1637
+ const token = await resolveSonarToken(credentialProvider, state.root, process.env[state.sonar_policy.token_env]);
1638
+ const scopeBefore = await scopeFingerprint(fs, state, cwd);
1639
+ if (state.sonar_policy.source === 'ide-local') {
1640
+ if (a.ce_task_id)
1641
+ throw new Error('本地规则审核不使用 CI ce_task_id');
1642
+ if (!state.files.length)
1643
+ throw new Error('本地规则审核需要先登记当前任务的文件范围');
1644
+ const root = state.root ?? cwd;
1645
+ if (!root)
1646
+ throw new Error('本地规则审核需要项目工作区');
1647
+ const audit = await inspectLocalRules(state.sonar_policy, token, root, scopeBefore, exec.signal, state.files);
1648
+ if (await scopeFingerprint(fs, state, cwd) !== scopeBefore) {
1649
+ throw new Error('本地规则审核期间代码发生变化,请重新测试和审核');
1650
+ }
1651
+ if (state.sonar_audit !== undefined)
1652
+ state.sonar_history = [...(state.sonar_history ?? []), state.sonar_audit].slice(-20);
1653
+ state.sonar_audit = audit;
1654
+ state.review.outcome = 'pending';
1655
+ const mode = await resolveWriteMode(ctx, a, exec);
1656
+ await writeSonarReport(fs, state, audit, cwd, mode);
1657
+ await writeTask(fs, state, cwd, mode);
1658
+ return JSON.stringify({ gate: audit.gate, blocking: audit.blocking, findings: audit.findings,
1659
+ uncovered_files: audit.uncovered_files ?? [], report_path: audit.report_path,
1660
+ rule_learning_candidates: ruleLearningCandidates(state),
1661
+ next_action: audit.gate === 'OK' ? '继续代码审核;检查历史阻断问题是否有可复用的修复经验,再决定是否通过 learn_rule 提议项目 Rule。误报或一次性问题不要沉淀。'
1662
+ : '修复新增代码问题,并处理未覆盖文件;重新测试后再次运行 sonar_check。' }, null, 2);
1663
+ }
1664
+ let ceTaskId = a.ce_task_id;
1665
+ if (state.sonar_policy.source === 'local') {
1666
+ if (ceTaskId)
1667
+ throw new Error('local sonar_check runs a fresh scan; do not provide a CI ce_task_id');
1668
+ const root = state.root ?? cwd;
1669
+ if (!root)
1670
+ throw new Error('local SonarQube scan needs a project workspace');
1671
+ const head = await runVerificationCommand(ctx, 'git rev-parse HEAD', root, exec);
1672
+ const branch = await runVerificationCommand(ctx, 'git branch --show-current', root, exec);
1673
+ const status = await runVerificationCommand(ctx, 'git status --porcelain --untracked-files=all', root, exec);
1674
+ const lastCommit = state.commits.findLast(entry => entry.hash !== undefined)?.hash;
1675
+ if (head.exit_code !== 0 || branch.exit_code !== 0 || status.exit_code !== 0 || !lastCommit
1676
+ || !head.stdout.trim().startsWith(lastCommit) || branch.stdout.trim() !== state.branch) {
1677
+ throw new Error('local SonarQube scan requires the recorded commit checked out on the task branch');
1678
+ }
1679
+ const dirty = status.stdout.split(/\r?\n/u).filter(line => line.trim() && !/^..\s+\.dsh[\\/]/u.test(line));
1680
+ if (dirty.length)
1681
+ throw new Error('local SonarQube scan requires a clean working tree outside .dsh; commit or remove unrecorded changes first');
1682
+ await assertLocalSonarReady(state.sonar_policy, token);
1683
+ const defaultCommand = await readText(fs, 'pom.xml', root) === undefined ? 'sonar-scanner'
1684
+ : 'mvn org.sonarsource.scanner.maven:sonar-maven-plugin:5.5.0.6356:sonar';
1685
+ const approvedMode = a.sandbox_permissions === undefined ? undefined : await resolveWriteMode(ctx, { ...a, command: state.sonar_policy.scan_command?.trim() || defaultCommand }, exec);
1686
+ ceTaskId = await runLocalSonar(ctx, state.sonar_policy, token, root, state.branch, exec, defaultCommand, approvedMode);
1687
+ }
1688
+ if (!ceTaskId)
1689
+ throw new Error('sonar_check requires ce_task_id from this task\'s completed CI scan');
1690
+ if (state.sonar_policy.source === 'local' && await scopeFingerprint(fs, state, cwd) !== scopeBefore) {
1691
+ throw new Error('local SonarQube scan changed task files; rerun functional verification before auditing');
1692
+ }
1693
+ const audit = await inspectCompletedSonar(state.sonar_policy, token, ceTaskId, target, scopeBefore, exec.signal);
1448
1694
  audit.commit_hash = state.commits.findLast(entry => entry.hash !== undefined).hash;
1449
1695
  if (state.sonar_audit !== undefined)
1450
1696
  state.sonar_history = [...(state.sonar_history ?? []), state.sonar_audit].slice(-20);
1451
1697
  state.sonar_audit = audit;
1452
1698
  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,
1699
+ const mode = await resolveWriteMode(ctx, a, exec);
1700
+ await writeSonarReport(fs, state, audit, cwd, mode);
1701
+ await writeTask(fs, state, cwd, mode);
1702
+ return JSON.stringify({ gate: audit.gate, ...(audit.server_gate ? { server_gate: audit.server_gate } : {}),
1703
+ blocking: audit.blocking, findings: audit.findings, report_path: audit.report_path,
1704
+ rule_learning_candidates: ruleLearningCandidates(state),
1455
1705
  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);
1706
+ ? 'Review prior blocking issues for reusable fixes with learn_rule; do not learn false positives. Continue code review, then record review pass.'
1707
+ : 'Record review blocked, fix findings, rerun tests and the scan, then call sonar_check again.' }, null, 2);
1708
+ }
1709
+ if (a.operation === 'sonar_disposition') {
1710
+ if (state.stage !== '代码审核' || state.sonar_policy?.source !== 'ide-local' || !state.sonar_audit) {
1711
+ throw new Error('sonar_disposition only applies to the current ide-local audit in the 代码审核 stage');
1712
+ }
1713
+ if (state.sonar_audit.scope_hash !== await scopeFingerprint(fs, state, cwd)) {
1714
+ throw new Error('the local audit is stale after code changes; rerun verification and sonar_check');
1715
+ }
1716
+ const issue = state.sonar_audit.blocking.find(entry => entry.key === a.issue_key);
1717
+ if (!issue)
1718
+ throw new Error('issue_key must identify a blocking finding in the current local audit');
1719
+ if (state.sonar_audit.dispositions?.some(entry => entry.issue_key === issue.key)) {
1720
+ throw new Error('this finding already has an approved disposition');
1721
+ }
1722
+ if (!a.disposition_reason?.trim() || a.disposition_reason.trim().length < 30
1723
+ || a.disposition_reason.length > 2000 || !a.evidence?.length || a.evidence.length > 6
1724
+ || a.evidence.some(item => !item.trim() || item.length > 500)) {
1725
+ throw new Error('false-positive disposition needs a specific reason of at least 30 characters and non-empty supporting evidence');
1726
+ }
1727
+ const approval = ctx.get('approval');
1728
+ if (!approval || !exec.agent)
1729
+ throw new Error('false-positive disposition requires the human approval service');
1730
+ const outcome = await approval.request({ agent: exec.agent, toolName: 'dev_task', callId: exec.callId,
1731
+ reason: `本地 Sonar 误报复核:${issue.rule} · ${issue.file}${issue.line === undefined ? '' : `:${issue.line}`}(${issue.key})。\n原因:${a.disposition_reason.trim()}\n证据:${a.evidence.join(';')}\n仅对此次未变化的本地审核结果有效,不修改服务端规则或原始结果。是否批准?`,
1732
+ signal: exec.signal });
1733
+ if (outcome !== 'allowed-once')
1734
+ throw new Error(`false-positive disposition was not approved (${outcome})`);
1735
+ if (state.sonar_audit.scope_hash !== await scopeFingerprint(fs, state, cwd)) {
1736
+ throw new Error('code changed during false-positive approval; rerun verification and sonar_check');
1737
+ }
1738
+ state.sonar_audit.dispositions = [...(state.sonar_audit.dispositions ?? []), {
1739
+ issue_key: issue.key, kind: 'false_positive', reason: a.disposition_reason.trim(),
1740
+ evidence: a.evidence, approved_at: new Date().toISOString(),
1741
+ }];
1742
+ state.review.outcome = 'pending';
1743
+ const mode = await resolveWriteMode(ctx, a, exec);
1744
+ await updateSonarReport(fs, state, state.sonar_audit, cwd, mode);
1745
+ await writeTask(fs, state, cwd, mode);
1746
+ return JSON.stringify({ approved_issue: issue.key, raw_gate: state.sonar_audit.gate,
1747
+ review_gate: localReviewGate(state.sonar_audit),
1748
+ unresolved_blocking: unresolvedBlockingFindings(state.sonar_audit).length,
1749
+ report_path: state.sonar_audit.report_path,
1750
+ next_action: '其余问题仍需修复或逐项复核;代码或规则变化后重新测试并运行 sonar_check。' }, null, 2);
1458
1751
  }
1459
1752
  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);
1753
+ const issue = [state.sonar_audit, ...(state.sonar_history ?? [])].filter((entry) => entry !== undefined)
1754
+ .flatMap(entry => unresolvedBlockingFindings(entry)).find(entry => entry.key === a.issue_key);
1462
1755
  if (!issue)
1463
1756
  throw new Error('learn_rule requires a blocking issue_key recorded by this task\'s SonarQube audit');
1464
1757
  if (!a.rule_name || !RESOURCE_NAME.test(a.rule_name) || !a.content?.trim() || a.content.trim().length < 40
@@ -1655,7 +1948,7 @@ export function registerDevTask(ctx) {
1655
1948
  note = `file scope set to ${state.files.length} files${state.files.length > 0 ? ': ' + state.files.join(', ') : ' (empty — commits are blocked until files are declared)'}`;
1656
1949
  break;
1657
1950
  case 'items':
1658
- state.items = normalizeItems(a.items, state.items);
1951
+ state.items = normalizeItems(a.items, state.items, a.items_mode ?? 'merge');
1659
1952
  note = `items set: ${state.items.map(i => `${i.id}:${i.status}`).join(', ') || 'none'}`;
1660
1953
  break;
1661
1954
  case 'dispatch': {
@@ -1665,7 +1958,7 @@ export function registerDevTask(ctx) {
1665
1958
  item.status = 'doing';
1666
1959
  delete item.review;
1667
1960
  item.dispatch = { description: a.description ?? '', at: new Date().toISOString() };
1668
- note = `item "${item.id}" is doing; dispatch intent recorded: ${item.dispatch.description || '(no description)'}. Now invoke the subagent; after it returns, review_item then items(done).`;
1961
+ note = `item "${item.id}" is doing; implementation intent recorded: ${item.dispatch.description || '(no description)'}. Implement it directly or with help, then review_item and mark it done.`;
1669
1962
  break;
1670
1963
  }
1671
1964
  case 'review_item': {
@@ -1684,6 +1977,9 @@ export function registerDevTask(ctx) {
1684
1977
  }
1685
1978
  case 'advance': {
1686
1979
  const target = a.target_stage ?? '';
1980
+ const planItemBlockers = taskPlanItemBlockers(state);
1981
+ if (planItemBlockers.length)
1982
+ throw new Error(planItemBlockers.join('; '));
1687
1983
  await assertFreshEvidence(fs, state, workflow, cwd);
1688
1984
  const missingSkills = skillBlockers(state, workflow, session);
1689
1985
  if (missingSkills.length)
@@ -1777,8 +2073,12 @@ export function registerDevTask(ctx) {
1777
2073
  if (receipt.root !== undefined && receipt.root !== verifyRoot) {
1778
2074
  throw new Error(`verification receipt root mismatch: ${receipt.root} vs task root ${String(verifyRoot)}`);
1779
2075
  }
2076
+ const testSummary = mavenTestEvidence(command, `${receipt.stdout}\n${receipt.stderr}`);
2077
+ if (testSummary)
2078
+ receipt.test_summary = testSummary;
1780
2079
  state.verification = {
1781
- passed: receipt.exit_code === 0 && !receipt.timed_out && !receipt.aborted && !receipt.sandbox?.denied && !receipt.sandbox?.runnerFailed,
2080
+ passed: receipt.exit_code === 0 && !receipt.timed_out && !receipt.aborted && !receipt.sandbox?.denied
2081
+ && !receipt.sandbox?.runnerFailed && (testSummary === null || (testSummary.count ?? 0) > 0),
1782
2082
  evidence: a.evidence ?? [],
1783
2083
  receipt,
1784
2084
  };