@godv61/dsh-task-engine 0.23.0 → 0.23.2

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 (53) hide show
  1. package/.p0-test.mjs +739 -734
  2. package/.workflow-test.mjs +290 -0
  3. package/README.md +77 -181
  4. package/defaults/eng.json +10 -10
  5. package/docs/CHANGELOG.md +192 -0
  6. package/docs/README.md +29 -0
  7. package/docs/assets/workflow-banner.svg +29 -0
  8. package/docs/configuration.md +49 -0
  9. package/docs/development.md +53 -0
  10. package/docs/faq.md +47 -0
  11. package/docs/getting-started.md +51 -0
  12. package/docs/manual.html +18 -11
  13. package/docs/release-0.23.0.md +3 -5
  14. package/docs/release-0.23.1.md +58 -0
  15. package/docs/release-0.23.2.md +21 -0
  16. package/docs/resource-install.md +55 -11
  17. package/docs/roadmap.md +33 -0
  18. package/docs/testing/0.23.1//346/265/213/350/257/225/346/212/245/345/221/212.md +34 -0
  19. 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 +41 -0
  20. package/docs/testing/0.23.2/R02/344/270/232/345/212/241/346/265/213/350/257/225/346/230/216/347/273/206.md +56 -0
  21. package/docs/testing/0.23.2/R03/344/270/232/345/212/241/346/265/213/350/257/225/346/230/216/347/273/206.md +38 -0
  22. package/docs/testing/0.23.2//346/265/213/350/257/225/346/212/245/345/221/212.md +65 -0
  23. 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 +43 -0
  24. package/docs/workflow-regression.md +36 -0
  25. package/hooks/commit-msg +4 -4
  26. package/lib/client.js +7 -6
  27. package/lib/client.js.map +2 -2
  28. package/lib/controller.js +5 -15
  29. package/lib/controller.js.map +1 -1
  30. package/lib/custom-flow.d.ts +212 -0
  31. package/lib/custom-flow.js +50 -0
  32. package/lib/custom-flow.js.map +1 -0
  33. package/lib/dev-task.js +202 -40
  34. package/lib/dev-task.js.map +1 -1
  35. package/lib/engine.d.ts +17 -1
  36. package/lib/engine.js +5 -0
  37. package/lib/engine.js.map +1 -1
  38. package/lib/shipped-skills.js +2 -1
  39. package/lib/shipped-skills.js.map +1 -1
  40. package/lib/skill-audit.d.ts +16 -0
  41. package/lib/skill-audit.js +60 -0
  42. package/lib/skill-audit.js.map +1 -0
  43. package/lib/workflows.js +4 -4
  44. package/lib/workflows.js.map +1 -1
  45. package/package.json +4 -3
  46. package/rules/commit-conventions.md +2 -2
  47. package/skills/code-commit/SKILL.md +13 -13
  48. package/skills/code-implement/SKILL.md +24 -23
  49. package/skills/code-review/SKILL.md +13 -13
  50. package/skills/code-verify/SKILL.md +19 -12
  51. package/skills/eng-delivery/SKILL.md +38 -34
  52. package/skills/requirement-analysis/SKILL.md +16 -16
  53. package/skills/solution-design/SKILL.md +19 -16
package/lib/dev-task.js CHANGED
@@ -18,6 +18,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
18
18
  import { assertAdvance, bindingsForStage, checkFileScope, commitCheckpoint, legalTargets, newTask, taskIdFromMessage, unmetGuards, validateCommitMessage, validateWorkflow, } 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 { loadedSkills, needsSkillReceipt, obligationStages, skillBlockers } from "./skill-audit.js";
21
22
  import { detectRoot, detectType, defaultVerifyCommand, listProjectRules, listProjectSkills, presentGovernanceFiles, } from "./project.js";
22
23
  /** Bundled commit-msg hook, copied into `.git/hooks/` by the `install_hook` operation. */
23
24
  const HOOK_TEMPLATE = readFileSync(fileURLToPath(new URL('../hooks/commit-msg', import.meta.url)), 'utf8');
@@ -101,15 +102,22 @@ const VERIFY_TIMEOUT_MS = 10 * 60 * 1000;
101
102
  * objective {@link VerificationReceipt}; `passed` derives from the exit code,
102
103
  * never from the model's claim.
103
104
  */
104
- async function runVerificationCommand(ctx, command, cwd) {
105
+ async function runVerificationCommand(ctx, command, cwd, exec, approvedMode) {
105
106
  const shell = ctx.get('shell');
106
107
  if (shell === undefined) {
107
108
  throw new Error('verify 带 command 需要 host 提供 shell 服务(用于跑真实验证命令),但当前未挂载');
108
109
  }
109
110
  const started_at = new Date().toISOString();
110
- const spec = shell.resolve(cwd !== undefined
111
- ? { command, workdir: cwd, timeoutMs: VERIFY_TIMEOUT_MS }
112
- : { command, timeoutMs: VERIFY_TIMEOUT_MS });
111
+ const policyService = ctx.get('sandboxPolicy');
112
+ if (approvedMode !== undefined && policyService === undefined) {
113
+ throw new Error('verification sandbox override requires the host sandboxPolicy service');
114
+ }
115
+ const standingPolicy = policyService?.resolve({ session: exec.agent?.session });
116
+ const sandboxPolicy = approvedMode === undefined ? standingPolicy : { ...standingPolicy, mode: approvedMode };
117
+ const spec = shell.resolve({ command, ...(cwd !== undefined ? { workdir: cwd } : {}),
118
+ timeoutMs: VERIFY_TIMEOUT_MS, signal: exec.signal,
119
+ ...(sandboxPolicy ? { sandboxPolicy } : {}),
120
+ });
113
121
  const outcome = await shell.run(spec);
114
122
  return {
115
123
  command,
@@ -121,6 +129,7 @@ async function runVerificationCommand(ctx, command, cwd) {
121
129
  ...(cwd !== undefined ? { root: cwd } : {}),
122
130
  stdout: outcome.stdout?.text ?? '',
123
131
  stderr: outcome.stderr?.text ?? '',
132
+ ...(outcome.sandbox ? { sandbox: outcome.sandbox } : {}),
124
133
  };
125
134
  }
126
135
  function sanitize(id) {
@@ -254,7 +263,7 @@ async function writeTask(fs, state, cwd, mode) {
254
263
  }
255
264
  }
256
265
  /**
257
- * Resolve the sandbox mode this call's writes run under. No escalation args:
266
+ * Resolve the sandbox mode before this call's writes or verification command. No escalation args:
258
267
  * workspace-write. Escalation args must travel as the `sandbox_permissions` ⇔
259
268
  * `justification` pair (mirroring the harness escalation contract), and a
260
269
  * `danger-full-access` request must earn a one-shot human approval; without an
@@ -283,7 +292,9 @@ async function resolveWriteMode(ctx, args, exec) {
283
292
  agent: exec.agent,
284
293
  toolName: 'dev_task',
285
294
  callId: exec.callId,
286
- reason: `沙箱升级:请确认允许 dev_task 以 ${mode} 模式写任务文件(workspace: ${workspace})。理由:${args.justification}`,
295
+ reason: args.command !== undefined
296
+ ? `沙箱升级:请确认允许 dev_task 以 ${mode} 模式执行本次验证命令并保存回执(workspace: ${workspace})。命令:${args.command}\n理由:${args.justification}`
297
+ : `沙箱升级:请确认允许 dev_task 以 ${mode} 模式写任务文件(workspace: ${workspace})。理由:${args.justification}`,
287
298
  signal: exec.signal,
288
299
  });
289
300
  if (outcome !== 'allowed-once') {
@@ -351,14 +362,14 @@ async function resolveRules(names, fs, cwd) {
351
362
  result.push({ name: rawName, content: bundled });
352
363
  continue;
353
364
  }
354
- const user = readAbsRule(join(dshHome(), 'rules', `${name}.md`));
355
- if (user !== undefined) {
356
- result.push({ name: rawName, content: user });
357
- continue;
358
- }
359
365
  const project = await readText(fs, `.dsh/rules/${name}.md`, cwd);
360
- if (project !== undefined)
366
+ if (project !== undefined) {
361
367
  result.push({ name: rawName, content: project });
368
+ continue;
369
+ }
370
+ const user = readAbsRule(join(dshHome(), 'rules', `${name}.md`));
371
+ if (user !== undefined)
372
+ result.push({ name: rawName, content: user });
362
373
  }
363
374
  return result;
364
375
  }
@@ -384,15 +395,75 @@ async function renderBindings(stage, workflow, fs, cwd) {
384
395
  const rulePart = rules.length > 0
385
396
  ? `rules for this stage:\n${rules.map(r => `### ${r.name}\n${r.content}`).join('\n\n')}`
386
397
  : '';
387
- return [skillPart, rulePart].filter(Boolean).join('\n');
398
+ const additional = skills.filter(needsSkillReceipt);
399
+ const receiptPart = additional.length
400
+ ? `additional skills requiring skill_result command receipts: ${additional.join(', ')}`
401
+ : 'skill_result not required for this stage: core skills use record/verify/review/commit gates';
402
+ return [skillPart, receiptPart, rulePart].filter(Boolean).join('\n');
388
403
  }
389
404
  /** Normalize the tool's `items` argument into complete TaskItem records. */
390
- function normalizeItems(items) {
391
- return (items ?? []).map(item => ({
392
- id: String(item.id ?? ''),
393
- title: String(item.title ?? ''),
394
- status: item.status ?? 'todo',
395
- }));
405
+ function normalizeItems(items, previous = []) {
406
+ const ids = new Set();
407
+ const result = (items ?? []).map(item => {
408
+ const id = String(item.id ?? '').trim();
409
+ const old = previous.find(entry => entry.id === id);
410
+ const title = String(item.title ?? old?.title ?? '').trim();
411
+ if (!id || !title || ids.has(id))
412
+ throw new Error('items require non-empty unique id and title');
413
+ ids.add(id);
414
+ const status = item.status ?? old?.status ?? 'todo';
415
+ if (old?.review && old.title !== title && status === 'done') {
416
+ throw new Error(`item "${id}" has a review: omit title to preserve it, or reopen as todo/doing before changing its title and reviewing again`);
417
+ }
418
+ const unchanged = old?.title === title && !(old.status === 'done' && status !== 'done');
419
+ return { ...(unchanged ? old : {}), id, title, status };
420
+ });
421
+ if (result.filter(item => item.status === 'doing').length > 1)
422
+ throw new Error('only one implementation item may be doing');
423
+ return result;
424
+ }
425
+ /** Hash declared deliverables through host FS; task bookkeeping is excluded to avoid self-invalidation. */
426
+ async function scopeFingerprint(fs, state, cwd) {
427
+ const entries = [];
428
+ for (const path of [...new Set(state.files)].filter(path => !/^\.dsh\/(task-[^/]+\.json|eng\.json)$/.test(path)).sort()) {
429
+ assertInsideRoot(state.root ?? cwd ?? '', state.root ?? cwd ?? '', path);
430
+ const root = state.root ?? cwd;
431
+ const target = await fs.resolve(path, root === undefined ? undefined : { cwd: root });
432
+ let content;
433
+ try {
434
+ content = await fs.readText(target);
435
+ }
436
+ catch (error) {
437
+ if (!['ENOENT', 'FS_NOT_FOUND'].includes(error.code ?? ''))
438
+ throw error;
439
+ }
440
+ entries.push([path, content === undefined ? null : hashText(content)]);
441
+ }
442
+ return hashText(JSON.stringify(entries));
443
+ }
444
+ /** A previous pass cannot authorize a changed tree. Commands still need meaningful human-reviewed coverage. */
445
+ async function evidenceBlockers(fs, state, workflow, cwd) {
446
+ if (state.execution_version !== 1)
447
+ return [];
448
+ const blockers = [];
449
+ const current = await scopeFingerprint(fs, state, cwd);
450
+ if (state.verification.passed && state.verification.receipt?.scope_hash !== current) {
451
+ blockers.push('verification is missing or stale after file/scope changes; rerun verify with a real command');
452
+ }
453
+ for (const stage of obligationStages(state, workflow)) {
454
+ for (const [name, result] of Object.entries(state.skill_results?.[stage] ?? {})) {
455
+ if (!needsSkillReceipt(name))
456
+ continue;
457
+ if (result.receipt?.scope_hash !== current)
458
+ blockers.push(`skill_result for ${name} is stale after file/scope changes; rerun its validation command`);
459
+ }
460
+ }
461
+ return blockers;
462
+ }
463
+ async function assertFreshEvidence(fs, state, workflow, cwd) {
464
+ const blockers = await evidenceBlockers(fs, state, workflow, cwd);
465
+ if (blockers.length)
466
+ throw new Error(blockers.join('; '));
396
467
  }
397
468
  /** Resolve one item by id for the item-scoped audit operations. */
398
469
  function requireItem(state, id) {
@@ -431,7 +502,7 @@ export async function approveAdvance(ctx, state, target, workflow, confirmations
431
502
  });
432
503
  if (outcome !== 'allowed-once') {
433
504
  const verb = outcome === 'rejected' ? '被驳回' : outcome === 'cancelled' ? '已取消' : '无人批准';
434
- return { ok: false, errors: [`${labels}确认${verb},流转被拒绝`] };
505
+ return { ok: false, errors: [`${labels}确认${verb},流转被拒绝。保持当前阶段;先获取用户的修改意见并修订,不要假定误操作或直接重复申请。`] };
435
506
  }
436
507
  if (confirmations.includes('requirement_confirmation'))
437
508
  state.requirement_confirmed = true;
@@ -439,7 +510,7 @@ export async function approveAdvance(ctx, state, target, workflow, confirmations
439
510
  state.solution_confirmed = true;
440
511
  return assertAdvance(state, target, workflow);
441
512
  }
442
- const OPERATIONS = ['status', 'create', 'record', 'items', 'scope', 'dispatch', 'review_item', 'advance', 'verify', 'review', 'commit', 'config', 'install_hook', 'verify_hook', 'init', 'set_risk'];
513
+ const OPERATIONS = ['status', 'create', 'record', 'items', 'scope', 'dispatch', 'review_item', 'advance', 'verify', 'review', 'commit', 'config', 'install_hook', 'verify_hook', 'init', 'set_risk', 'skill_result'];
443
514
  const TOOL_DESCRIPTION = 'Own the engineering delivery workflow as hard state. Read or create the task record, record a ' +
444
515
  'stage artifact, record each implementation item\'s subagent dispatch and two-stage (spec + quality) ' +
445
516
  'review audit, advance one stage (rejected unless every configured guard already holds, including ' +
@@ -461,14 +532,15 @@ export function registerDevTask(ctx) {
461
532
  description: TOOL_DESCRIPTION,
462
533
  parameters: {
463
534
  operation: { type: 'string', enum: [...OPERATIONS], required: true, description: 'Which task-record action to perform.' },
464
- task_id: { type: 'string', description: 'Task id (short stable id like GREET-001); required on create and every other operation.' },
535
+ task_id: { type: 'string', description: 'Task id. Omit on status to discover tasks in the current workspace; optionally filter by branch.' },
536
+ 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.' },
465
537
  branch: { type: 'string', description: 'Current git branch (recorded on create).' },
466
538
  title: { type: 'string', description: 'Task title (create).' },
467
539
  work_size: { type: 'string', enum: ['tiny', 'standard', 'complex'], description: 'Workload tier (create).' },
468
540
  risk_level: { type: 'string', enum: ['standard', 'high_risk'], description: 'Risk tier (create).' },
469
541
  items: {
470
542
  type: 'array',
471
- description: 'Implementation items (create/update).',
543
+ 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.',
472
544
  items: {
473
545
  type: 'object',
474
546
  additionalProperties: false,
@@ -484,15 +556,15 @@ export function registerDevTask(ctx) {
484
556
  spec_outcome: { type: 'string', enum: ['pass', 'fail'], description: 'Specification-conformance verdict (review_item).' },
485
557
  quality_outcome: { type: 'string', enum: ['pass', 'fail'], description: 'Code-quality verdict (review_item).' },
486
558
  notes: { type: 'array', items: { type: 'string' }, description: 'Findings or defects (review_item).' },
487
- target_stage: { type: 'string', description: 'Stage to advance to (advance).' },
488
- command: { type: 'string', description: 'Verification command to run for a real receipt (verify); when set, passed derives from its exit code instead of the model claim.' },
489
- passed: { type: 'boolean', description: 'Verification passed (verify), used only when no command is given.' },
559
+ target_stage: { type: 'string', description: 'Stage to advance to, or current/upcoming terminal skill binding stage for skill_result.' },
560
+ command: { type: 'string', description: 'Real acceptance command for verify/skill_result. New tasks require command receipts. Propagate failures when composing shell commands.' },
561
+ passed: { type: 'boolean', description: 'Legacy tasks only: verification claim when no command can be resolved. New tasks require real command receipts.' },
490
562
  evidence: { type: 'array', items: { type: 'string' }, description: 'Supplementary verification evidence (verify).' },
491
563
  outcome: { type: 'string', enum: ['pass', 'blocked'], description: 'Review outcome (review).' },
492
- artifact: { type: 'string', description: 'Artifact id to record fields for (record).' },
564
+ artifact: { type: 'string', description: 'Artifact id from status.artifact_requirements for the current stage (record).' },
493
565
  fields: {
494
566
  type: 'object',
495
- description: 'Field values for the artifact (record).',
567
+ description: 'Field values using only keys listed by status.artifact_requirements (record). Put questions and tradeoffs inside an allowed text field, not new keys. Allowed fields come from the frozen workflow and may differ between presets.',
496
568
  additionalProperties: true,
497
569
  },
498
570
  files: {
@@ -506,7 +578,7 @@ export function registerDevTask(ctx) {
506
578
  overwrite: { type: 'boolean', description: 'Allow replacing an existing AGENTS.md (init apply); triggers human approval.' },
507
579
  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).' },
508
580
  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).' },
509
- sandbox_permissions: { type: 'string', enum: ['workspace-write', 'danger-full-access'], description: 'Wider sandbox mode for this call\'s file writes; default is workspace-write. Use only as a retry after a sandbox denial of the same call; requires justification and human approval.' },
581
+ 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.' },
510
582
  justification: { type: 'string', description: 'Required with sandbox_permissions: one sentence for the user explaining why this exact operation needs the wider access.' },
511
583
  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).' },
512
584
  },
@@ -522,6 +594,20 @@ export function registerDevTask(ctx) {
522
594
  // The session workspace drives every relative path; the backend's own base
523
595
  // (its config.cwd) is the harness process directory, never the workspace.
524
596
  const cwd = exec.agent?.session.header.cwd;
597
+ const session = exec.agent?.session;
598
+ if (a.operation === 'status' && !a.task_id) {
599
+ const tasks = [];
600
+ const names = await projectProbe(fs).list?.(cwd ?? '', '.dsh') ?? [];
601
+ for (const name of names.filter(name => /^task-.+\.json$/.test(name))) {
602
+ const raw = await readText(fs, `.dsh/${name}`, cwd);
603
+ if (!raw)
604
+ continue;
605
+ const task = JSON.parse(raw);
606
+ if (!a.branch || task.branch === a.branch)
607
+ tasks.push({ id: task.id, title: task.title, branch: task.branch, stage: task.stage });
608
+ }
609
+ 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);
610
+ }
525
611
  if (a.operation === 'config') {
526
612
  const resolved = await resolveWorkflow(fs, cwd);
527
613
  return JSON.stringify({
@@ -651,11 +737,12 @@ export function registerDevTask(ctx) {
651
737
  project_type: await detectType(projectProbe(fs), root),
652
738
  });
653
739
  state.items = normalizeItems(a.items);
740
+ state.execution_version = 1;
654
741
  state.files = a.files ?? [];
655
742
  state.bindings_fingerprint = builtinRulesFingerprint();
656
743
  assertInsideRoot(root, cwd ?? '', taskPath(state.id));
657
744
  await writeTask(fs, state, cwd, await resolveWriteMode(ctx, a, exec));
658
- return `created ${state.id} at stage ${state.stage}; legal next: ${legalTargets(state.stage, flow.config).join(', ') || 'none'}`;
745
+ return `created ${state.id} at stage ${state.stage}; legal next: ${legalTargets(state.stage, flow.config).join(', ') || 'none'}\n${await renderBindings(state.stage, flow.config, fs, cwd)}\nBefore advance, load all bound skills. For additional skills, execute their instructions then use skill_result with evidence and command. Terminal bindings must finish before entering the terminal stage.`;
659
746
  }
660
747
  if (!a.task_id)
661
748
  throw new Error('task_id is required for this operation');
@@ -664,6 +751,10 @@ export function registerDevTask(ctx) {
664
751
  if (a.operation === 'status') {
665
752
  const binding = bindingsForStage(state.stage, workflow);
666
753
  const rules = await resolveRules(binding?.rules ?? [], fs, cwd);
754
+ const missingSkills = skillBlockers(state, workflow, session);
755
+ const staleEvidence = await evidenceBlockers(fs, state, workflow, cwd);
756
+ const checkpoint = commitCheckpoint(state, workflow);
757
+ const commitBlockers = [...staleEvidence, ...missingSkills];
667
758
  return JSON.stringify({
668
759
  id: state.id,
669
760
  stage: state.stage,
@@ -673,12 +764,27 @@ export function registerDevTask(ctx) {
673
764
  requirement_confirmed: state.requirement_confirmed,
674
765
  solution_confirmed: state.solution_confirmed,
675
766
  items_done: `${state.items.filter(i => i.status === 'done').length}/${state.items.length}`,
767
+ items: state.items,
768
+ skill_obligations: obligationStages(state, workflow).map(stage => ({
769
+ stage, skills: workflow.stage_bindings?.[stage]?.skills ?? [],
770
+ command_receipts_required: (workflow.stage_bindings?.[stage]?.skills ?? []).filter(needsSkillReceipt),
771
+ })),
772
+ skill_blockers: missingSkills,
773
+ evidence_blockers: staleEvidence,
774
+ skill_results: state.skill_results ?? {},
775
+ commits: state.commits,
676
776
  verification: state.verification,
677
777
  review: state.review,
778
+ artifact_requirements: workflow.artifacts.filter(def => def.stage === state.stage).map(def => ({
779
+ id: def.id, name: def.name, fields: def.fields,
780
+ missing_fields: def.fields.filter(field => !(state.artifacts[def.id]?.[field] ?? '').trim()),
781
+ })),
678
782
  artifacts: state.artifacts,
679
783
  files: state.files,
680
784
  legal_next: legalTargets(state.stage, workflow),
681
- commit: commitCheckpoint(state, workflow),
785
+ commit: checkpoint.allowed && commitBlockers.length
786
+ ? { ...checkpoint, allowed: false, reason: commitBlockers.join('; ') }
787
+ : checkpoint,
682
788
  bindings: {
683
789
  skills: binding?.skills ?? [],
684
790
  rules: rules.map(r => ({ name: r.name, content: r.content })),
@@ -690,6 +796,10 @@ export function registerDevTask(ctx) {
690
796
  }, null, 2);
691
797
  }
692
798
  if (a.operation === 'commit') {
799
+ await assertFreshEvidence(fs, state, workflow, cwd);
800
+ const missingSkills = skillBlockers(state, workflow, session);
801
+ if (missingSkills.length)
802
+ throw new Error(missingSkills.join('; '));
693
803
  const checkpoint = commitCheckpoint(state, workflow);
694
804
  if (!checkpoint.allowed)
695
805
  throw new Error(checkpoint.reason ?? 'no commit is due');
@@ -711,12 +821,30 @@ export function registerDevTask(ctx) {
711
821
  if (committedId !== undefined && committedId !== state.id) {
712
822
  throw new Error(`commit summary names task "${committedId}" but this operation targets "${state.id}" — put the target task id in the summary`);
713
823
  }
714
- if (a.hash)
715
- state.commits.push({ label: checkpoint.label, hash: a.hash });
824
+ if (a.hash) {
825
+ if (!/^[0-9a-f]{7,40}$/i.test(a.hash))
826
+ throw new Error('commit hash must be a Git object id');
827
+ if (state.execution_version === 1) {
828
+ const receipt = await runVerificationCommand(ctx, 'git -c core.quotepath=false log -1 --format=%H%n%s%n --name-only HEAD', state.root ?? cwd, exec);
829
+ if (receipt.exit_code !== 0 || receipt.aborted || receipt.timed_out || receipt.sandbox?.denied || receipt.sandbox?.runnerFailed)
830
+ throw new Error('cannot verify the recorded Git commit');
831
+ const lines = receipt.stdout.trim().split(/\r?\n/);
832
+ if (!lines[0]?.startsWith(a.hash) || lines[1] !== a.message)
833
+ throw new Error('Git commit does not match the approved hash and summary');
834
+ const paths = lines.slice(2).map(line => line.trim()).filter(Boolean);
835
+ if (!paths.length || (workflow.commit.file_scope && !checkFileScope(state, paths, workflow).ok))
836
+ throw new Error('actual committed files do not match the task scope');
837
+ }
838
+ if (!state.commits.some(entry => entry.hash === a.hash))
839
+ state.commits.push({ label: checkpoint.label, hash: a.hash });
840
+ }
716
841
  await writeTask(fs, state, cwd, await resolveWriteMode(ctx, a, exec));
842
+ if (a.hash)
843
+ return `commit recorded (${checkpoint.label ?? ''}): ${a.hash}; use status to inspect remaining gates, then advance`;
717
844
  return `commit approved (${checkpoint.label ?? ''}) — git add ${(a.files ?? []).join(' ')}; git commit -m "${a.message ?? ''}"`;
718
845
  }
719
846
  let note;
847
+ let approvedWriteMode;
720
848
  switch (a.operation) {
721
849
  case 'record': {
722
850
  if (!a.artifact)
@@ -731,7 +859,7 @@ export function registerDevTask(ctx) {
731
859
  const merged = { ...(state.artifacts[def.id] ?? {}) };
732
860
  for (const [field, value] of Object.entries(a.fields ?? {})) {
733
861
  if (!def.fields.includes(field)) {
734
- throw new Error(`artifact "${def.id}" has no field "${field}"; fields: ${def.fields.join(', ')}`);
862
+ throw new Error(`artifact "${def.id}" has no field "${field}"; fields: ${def.fields.join(', ')}. Read status.artifact_requirements and put additional questions or tradeoffs inside an allowed text field, not a new key. No changes were saved.`);
735
863
  }
736
864
  merged[field] = value === undefined || value === null ? '' : String(value);
737
865
  }
@@ -747,13 +875,17 @@ export function registerDevTask(ctx) {
747
875
  note = `file scope set to ${state.files.length} files${state.files.length > 0 ? ': ' + state.files.join(', ') : ' (empty — commits are blocked until files are declared)'}`;
748
876
  break;
749
877
  case 'items':
750
- state.items = normalizeItems(a.items);
878
+ state.items = normalizeItems(a.items, state.items);
751
879
  note = `items set: ${state.items.map(i => `${i.id}:${i.status}`).join(', ') || 'none'}`;
752
880
  break;
753
881
  case 'dispatch': {
754
882
  const item = requireItem(state, a.item_id);
883
+ if (state.items.some(other => other.id !== item.id && other.status === 'doing'))
884
+ throw new Error('finish or pause the current doing item before dispatching another item');
885
+ item.status = 'doing';
886
+ delete item.review;
755
887
  item.dispatch = { description: a.description ?? '', at: new Date().toISOString() };
756
- note = `dispatched item "${item.id}" to a subagent: ${item.dispatch.description || '(no description)'}`;
888
+ 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).`;
757
889
  break;
758
890
  }
759
891
  case 'review_item': {
@@ -772,6 +904,10 @@ export function registerDevTask(ctx) {
772
904
  }
773
905
  case 'advance': {
774
906
  const target = a.target_stage ?? '';
907
+ await assertFreshEvidence(fs, state, workflow, cwd);
908
+ const missingSkills = skillBlockers(state, workflow, session);
909
+ if (missingSkills.length)
910
+ throw new Error(missingSkills.join('; '));
775
911
  let result = assertAdvance(state, target, workflow);
776
912
  if (!result.ok) {
777
913
  const unmet = unmetGuards(state, target, workflow);
@@ -786,7 +922,9 @@ export function registerDevTask(ctx) {
786
922
  throw new Error(result.errors.join('; '));
787
923
  state.stage = target;
788
924
  const disclosure = await renderBindings(state.stage, workflow, fs, cwd);
789
- note = disclosure === '' ? `advanced to ${state.stage}` : `advanced to ${state.stage}\n${disclosure}`;
925
+ const terminalObligations = obligationStages(state, workflow).filter(stage => stage !== state.stage);
926
+ const terminalDisclosure = await Promise.all(terminalObligations.map(async (stage) => `Before entering ${stage}, execute its bindings now:\n${await renderBindings(stage, workflow, fs, cwd)}`));
927
+ note = [`advanced to ${state.stage}`, disclosure, ...terminalDisclosure].filter(Boolean).join('\n');
790
928
  break;
791
929
  }
792
930
  case 'set_risk': {
@@ -840,19 +978,43 @@ export function registerDevTask(ctx) {
840
978
  const verifyRoot = state.root ?? cwd;
841
979
  const command = explicit ? a.command.trim() : await resolveVerifyCommand(fs, verifyRoot);
842
980
  if (command !== undefined) {
843
- const receipt = await runVerificationCommand(ctx, command, verifyRoot);
981
+ approvedWriteMode = await resolveWriteMode(ctx, { ...a, command }, exec);
982
+ const receipt = await runVerificationCommand(ctx, command, verifyRoot, exec, a.sandbox_permissions === undefined ? undefined : approvedWriteMode);
983
+ if (state.execution_version === 1)
984
+ receipt.scope_hash = await scopeFingerprint(fs, state, cwd);
844
985
  if (receipt.root !== undefined && receipt.root !== verifyRoot) {
845
986
  throw new Error(`verification receipt root mismatch: ${receipt.root} vs task root ${String(verifyRoot)}`);
846
987
  }
847
988
  state.verification = {
848
- passed: receipt.exit_code === 0 && !receipt.timed_out && !receipt.aborted,
989
+ passed: receipt.exit_code === 0 && !receipt.timed_out && !receipt.aborted && !receipt.sandbox?.denied && !receipt.sandbox?.runnerFailed,
849
990
  evidence: a.evidence ?? [],
850
991
  receipt,
851
992
  };
852
993
  }
853
994
  else {
995
+ if (state.execution_version === 1)
996
+ throw new Error('verify requires a real validation command; pass command explicitly for this project');
854
997
  state.verification = { passed: a.passed === true, evidence: a.evidence ?? [] };
855
998
  }
999
+ note = JSON.stringify({ ok: state.verification.passed, stage: state.stage, verification: state.verification }, null, 2);
1000
+ break;
1001
+ }
1002
+ case 'skill_result': {
1003
+ const stage = a.target_stage ?? state.stage;
1004
+ if (!obligationStages(state, workflow).includes(stage) || !workflow.stage_bindings?.[stage]?.skills?.includes(a.skill_name ?? ''))
1005
+ throw new Error('skill_result requires a skill bound to this stage or its upcoming terminal stage');
1006
+ const loadCall = loadedSkills(session).get(a.skill_name);
1007
+ if (!loadCall)
1008
+ throw new Error(`load skill "${a.skill_name}" successfully before recording execution`);
1009
+ if (!a.command?.trim() || !a.evidence?.length || a.evidence.some(value => !value.trim()))
1010
+ throw new Error('skill_result requires a real validation command and non-empty evidence describing executed scenarios and results');
1011
+ approvedWriteMode = await resolveWriteMode(ctx, a, exec);
1012
+ const receipt = await runVerificationCommand(ctx, a.command, state.root ?? cwd, exec, a.sandbox_permissions === undefined ? undefined : approvedWriteMode);
1013
+ receipt.scope_hash = await scopeFingerprint(fs, state, cwd);
1014
+ state.skill_results ??= {};
1015
+ state.skill_results[stage] ??= {};
1016
+ state.skill_results[stage][a.skill_name] = { session_id: session?.id ?? '', load_call_id: loadCall, evidence: a.evidence, receipt };
1017
+ note = JSON.stringify({ skill: a.skill_name, stage, receipt }, null, 2);
856
1018
  break;
857
1019
  }
858
1020
  case 'review':
@@ -863,7 +1025,7 @@ export function registerDevTask(ctx) {
863
1025
  default:
864
1026
  throw new Error(`unknown operation ${a.operation}`);
865
1027
  }
866
- await writeTask(fs, state, cwd, await resolveWriteMode(ctx, a, exec));
1028
+ await writeTask(fs, state, cwd, approvedWriteMode ?? await resolveWriteMode(ctx, a, exec));
867
1029
  return note ?? `ok (stage ${state.stage})`;
868
1030
  },
869
1031
  }));