@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.
- package/.p0-test.mjs +739 -734
- package/.workflow-test.mjs +290 -0
- package/README.md +77 -181
- package/defaults/eng.json +10 -10
- package/docs/CHANGELOG.md +192 -0
- package/docs/README.md +29 -0
- package/docs/assets/workflow-banner.svg +29 -0
- package/docs/configuration.md +49 -0
- package/docs/development.md +53 -0
- package/docs/faq.md +47 -0
- package/docs/getting-started.md +51 -0
- package/docs/manual.html +18 -11
- package/docs/release-0.23.0.md +3 -5
- package/docs/release-0.23.1.md +58 -0
- package/docs/release-0.23.2.md +21 -0
- package/docs/resource-install.md +55 -11
- package/docs/roadmap.md +33 -0
- package/docs/testing/0.23.1//346/265/213/350/257/225/346/212/245/345/221/212.md +34 -0
- 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
- 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
- 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
- package/docs/testing/0.23.2//346/265/213/350/257/225/346/212/245/345/221/212.md +65 -0
- 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
- package/docs/workflow-regression.md +36 -0
- package/hooks/commit-msg +4 -4
- package/lib/client.js +7 -6
- package/lib/client.js.map +2 -2
- package/lib/controller.js +5 -15
- package/lib/controller.js.map +1 -1
- package/lib/custom-flow.d.ts +212 -0
- package/lib/custom-flow.js +50 -0
- package/lib/custom-flow.js.map +1 -0
- package/lib/dev-task.js +202 -40
- package/lib/dev-task.js.map +1 -1
- package/lib/engine.d.ts +17 -1
- package/lib/engine.js +5 -0
- package/lib/engine.js.map +1 -1
- package/lib/shipped-skills.js +2 -1
- package/lib/shipped-skills.js.map +1 -1
- package/lib/skill-audit.d.ts +16 -0
- package/lib/skill-audit.js +60 -0
- package/lib/skill-audit.js.map +1 -0
- package/lib/workflows.js +4 -4
- package/lib/workflows.js.map +1 -1
- package/package.json +4 -3
- package/rules/commit-conventions.md +2 -2
- package/skills/code-commit/SKILL.md +13 -13
- package/skills/code-implement/SKILL.md +24 -23
- package/skills/code-review/SKILL.md +13 -13
- package/skills/code-verify/SKILL.md +19 -12
- package/skills/eng-delivery/SKILL.md +38 -34
- package/skills/requirement-analysis/SKILL.md +16 -16
- 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
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
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: '
|
|
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
|
|
488
|
-
command: { type: 'string', description: '
|
|
489
|
-
passed: { type: 'boolean', description: '
|
|
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
|
|
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
|
|
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: '
|
|
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:
|
|
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
|
-
|
|
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 = `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}));
|