@godv61/dsh-task-engine 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.acceptance.mjs +101 -39
- package/.assessment-batch1.mjs +32 -12
- package/.e2e-presets.mjs +40 -5
- package/.enforce-test.mjs +139 -0
- package/.evidence-test.mjs +118 -0
- package/.filter-test.mjs +93 -0
- package/.freeze-test.mjs +110 -1
- package/.hook-test.mjs +195 -190
- package/.p0-test.mjs +130 -46
- package/.preset-test.mjs +160 -160
- package/.revision-test.mjs +32 -3
- package/.roundtrip-test.mjs +178 -0
- package/.workflow-test.mjs +117 -4
- package/README.md +10 -8
- package/cordis.patch.yml +10 -10
- package/defaults/eng.json +70 -22
- package/docs/BRIEF-FOR-REVIEW.md +162 -162
- package/docs/CHANGELOG.md +49 -0
- package/docs/README.md +40 -40
- package/docs/configuration.md +14 -11
- package/docs/development.md +1 -1
- package/docs/faq.md +4 -4
- package/docs/listing/godv61__dsh-task-engine.yml +5 -5
- package/docs/listing/submission.md +83 -83
- package/docs/manual.html +31 -26
- package/docs/releases/0.23.2.md +21 -21
- package/docs/roadmap.md +34 -34
- 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 -56
- 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 -38
- package/docs/testing/0.23.2//346/265/213/350/257/225/346/212/245/345/221/212.md +65 -65
- package/hooks/commit-msg +159 -72
- package/lib/client.js +963 -571
- package/lib/client.js.map +3 -3
- package/lib/controller.d.ts +11 -2
- package/lib/controller.js +33 -5
- package/lib/controller.js.map +1 -1
- package/lib/dev-task.js +170 -28
- package/lib/dev-task.js.map +1 -1
- package/lib/engine.d.ts +105 -0
- package/lib/engine.js +93 -5
- package/lib/engine.js.map +1 -1
- package/lib/hook.js +17 -4
- package/lib/hook.js.map +1 -1
- package/lib/skill-audit.d.ts +14 -3
- package/lib/skill-audit.js +66 -3
- package/lib/skill-audit.js.map +1 -1
- package/lib/workflows.d.ts +78 -12
- package/lib/workflows.js +258 -94
- package/lib/workflows.js.map +1 -1
- package/package.json +9 -5
- package/preset/agent.cordis.yml +21 -21
- package/preset/enable.mjs +87 -87
- package/preset/persona.md +4 -4
- package/preset/preset.yml +1 -1
- package/rules/coding-conventions.md +6 -6
- package/rules/commit-conventions.md +6 -6
- package/rules/security-redlines.md +5 -5
- package/scripts/verify-dsh-compat.mjs +144 -144
- package/scripts/verify-package.mjs +1 -1
- package/skills/code-verify/SKILL.md +3 -3
package/lib/dev-task.js
CHANGED
|
@@ -15,7 +15,7 @@ import { homedir } 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
|
-
import { assertAdvance, bindingsForStage, checkFileScope, commitCheckpoint, applyRevision, completionBlockers, formatResourceRef, invalidatedBy, legalTargets, newTask, taskIdFromMessage, unmetGuards, validateCommitMessage, validateWorkflow, verificationBlockers, } from "./engine.js";
|
|
18
|
+
import { assertAdvance, bindingsForStage, checkFileScope, commitCheckpoint, applyRevision, completionBlockers, formatResourceRef, invalidatedBy, legalTargets, resourceBlockers, newTask, taskIdFromMessage, unmetGuards, validateCommitMessage, validateWorkflow, verificationBlockers, } from "./engine.js";
|
|
19
19
|
import { HIGH_RISK_REQUIRED_CAPABILITIES, flowSatisfies, resolveFlow, } from "./workflows.js";
|
|
20
20
|
import { hashConfig, hashText } from "./snapshot.js";
|
|
21
21
|
import { loadedSkills, needsSkillReceipt, obligationStages, skillBlockers } from "./skill-audit.js";
|
|
@@ -184,7 +184,15 @@ async function resolveWorkflow(fs, cwd) {
|
|
|
184
184
|
source: 'invalid',
|
|
185
185
|
};
|
|
186
186
|
}
|
|
187
|
-
const resolved = resolveFlow(parsed.flow,
|
|
187
|
+
const resolved = resolveFlow(parsed.flow, {
|
|
188
|
+
flow: parsed.flow,
|
|
189
|
+
...(parsed.stage_bindings !== undefined ? { stage_bindings: parsed.stage_bindings } : {}),
|
|
190
|
+
...(parsed.skill_profiles !== undefined ? { skill_profiles: parsed.skill_profiles } : {}),
|
|
191
|
+
...(parsed.commit !== undefined ? { commit: parsed.commit } : {}),
|
|
192
|
+
...(parsed.artifacts !== undefined ? { artifacts: parsed.artifacts } : {}),
|
|
193
|
+
...(parsed.review_depth !== undefined ? { review_depth: parsed.review_depth } : {}),
|
|
194
|
+
...(parsed.commit_required !== undefined ? { commit_required: parsed.commit_required } : {}),
|
|
195
|
+
});
|
|
188
196
|
if (!resolved.ok) {
|
|
189
197
|
const standard = standardWorkflow();
|
|
190
198
|
return {
|
|
@@ -408,18 +416,18 @@ async function freezeResources(config, fs, cwd) {
|
|
|
408
416
|
const unreadable = [];
|
|
409
417
|
for (const binding of Object.values(config.stage_bindings ?? {})) {
|
|
410
418
|
for (const entry of binding.skills ?? []) {
|
|
411
|
-
const skillKey = formatResourceRef(entry.skill);
|
|
419
|
+
const skillKey = 'skill:' + formatResourceRef(entry.skill);
|
|
412
420
|
if (!seen.has(skillKey)) {
|
|
413
421
|
const body = await readSkillAt(entry.skill, fs, cwd);
|
|
414
422
|
if (body === undefined)
|
|
415
423
|
unreadable.push(skillKey);
|
|
416
424
|
else {
|
|
417
425
|
seen.add(skillKey);
|
|
418
|
-
resources.push({ ref: entry.skill, hash: hashText(body), content: body });
|
|
426
|
+
resources.push({ kind: 'skill', ref: entry.skill, hash: hashText(body), content: body });
|
|
419
427
|
}
|
|
420
428
|
}
|
|
421
429
|
for (const rule of entry.rules) {
|
|
422
|
-
const key = formatResourceRef(rule);
|
|
430
|
+
const key = 'rule:' + formatResourceRef(rule);
|
|
423
431
|
if (seen.has(key))
|
|
424
432
|
continue;
|
|
425
433
|
const body = await readRuleAt(rule.source, rule.name, fs, cwd);
|
|
@@ -428,7 +436,7 @@ async function freezeResources(config, fs, cwd) {
|
|
|
428
436
|
continue;
|
|
429
437
|
}
|
|
430
438
|
seen.add(key);
|
|
431
|
-
resources.push({ ref: rule, hash: hashText(body), content: body });
|
|
439
|
+
resources.push({ kind: 'rule', ref: rule, hash: hashText(body), content: body });
|
|
432
440
|
}
|
|
433
441
|
}
|
|
434
442
|
}
|
|
@@ -444,7 +452,14 @@ async function freezeResources(config, fs, cwd) {
|
|
|
444
452
|
* @param fs - sandboxed filesystem for the project-level lookup.
|
|
445
453
|
* @returns the layer that holds this name, or undefined when none does.
|
|
446
454
|
*/
|
|
447
|
-
async function resolveLegacyRuleSource(name, fs, cwd) {
|
|
455
|
+
async function resolveLegacyRuleSource(name, fs, cwd, frozen) {
|
|
456
|
+
// A frozen snapshot answers this without touching the filesystem: the layer a
|
|
457
|
+
// legacy name resolved to at creation is part of what the task froze, so a
|
|
458
|
+
// source deleted afterwards cannot change where it resolves.
|
|
459
|
+
for (const resource of frozen ?? []) {
|
|
460
|
+
if (resource.kind !== 'skill' && resource.ref.name === name)
|
|
461
|
+
return resource.ref.source;
|
|
462
|
+
}
|
|
448
463
|
for (const source of ['bundled', 'project', 'user']) {
|
|
449
464
|
if ((await readRuleAt(source, name, fs, cwd)) !== undefined)
|
|
450
465
|
return source;
|
|
@@ -462,18 +477,50 @@ async function resolveLegacyRuleSource(name, fs, cwd) {
|
|
|
462
477
|
* @param fs - sandboxed filesystem for the project-level lookup.
|
|
463
478
|
* @returns the resolved rules with bodies and sources, plus the refs not found.
|
|
464
479
|
*/
|
|
465
|
-
async function resolveRules(refs, fs, cwd) {
|
|
480
|
+
async function resolveRules(refs, fs, cwd, frozen) {
|
|
466
481
|
const resolved = [];
|
|
467
482
|
const missing = [];
|
|
483
|
+
// A frozen body is the authority for a task that has one. Freezing used to only
|
|
484
|
+
// ARCHIVE the content: status and the stage disclosure still read the live files,
|
|
485
|
+
// so editing a rule silently changed what a running task was told to follow —
|
|
486
|
+
// the exact instability the snapshot exists to prevent.
|
|
487
|
+
const frozenByRef = new Map();
|
|
488
|
+
// Pre-0.26 snapshots have no kind. If such a snapshot reused a skill's ref
|
|
489
|
+
// for a rule, its rule was never frozen; fail closed instead of reading the
|
|
490
|
+
// skill body as a rule.
|
|
491
|
+
for (const resource of frozen ?? []) {
|
|
492
|
+
if (resource.kind === 'rule')
|
|
493
|
+
frozenByRef.set(formatResourceRef(resource.ref), resource);
|
|
494
|
+
else if (resource.kind === undefined && !resource.content.startsWith('---')) {
|
|
495
|
+
frozenByRef.set(formatResourceRef(resource.ref), resource);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const stale = [];
|
|
468
499
|
for (const ref of refs) {
|
|
500
|
+
const key = formatResourceRef(ref);
|
|
501
|
+
const snapshot = frozenByRef.get(key);
|
|
502
|
+
if (snapshot !== undefined) {
|
|
503
|
+
resolved.push({ name: ref.name, content: snapshot.content, source: ref.source });
|
|
504
|
+
// Report when the source no longer matches what was frozen, so drift is
|
|
505
|
+
// visible instead of looking like the frozen text was simply current.
|
|
506
|
+
const live = await readRuleAt(ref.source, ref.name, fs, cwd);
|
|
507
|
+
if (live === undefined)
|
|
508
|
+
stale.push(key + ' (source deleted)');
|
|
509
|
+
else if (hashText(live) !== snapshot.hash)
|
|
510
|
+
stale.push(key + ' (source edited)');
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
// No snapshot (a pre-freezing task) or this ref was not frozen: read live, and
|
|
514
|
+
// report a rule that resolves nowhere rather than dropping it, because omitting
|
|
515
|
+
// it made a deleted file indistinguishable from an unbound one.
|
|
469
516
|
const content = await readRuleAt(ref.source, ref.name, fs, cwd);
|
|
470
517
|
if (content === undefined) {
|
|
471
|
-
missing.push(
|
|
518
|
+
missing.push(key);
|
|
472
519
|
continue;
|
|
473
520
|
}
|
|
474
521
|
resolved.push({ name: ref.name, content, source: ref.source });
|
|
475
522
|
}
|
|
476
|
-
return { resolved, missing };
|
|
523
|
+
return { resolved, missing, stale };
|
|
477
524
|
}
|
|
478
525
|
/**
|
|
479
526
|
* Every rule that applies at a stage, gathered from the skills bound to it.
|
|
@@ -487,7 +534,7 @@ async function resolveRules(refs, fs, cwd) {
|
|
|
487
534
|
* @param fs - sandboxed filesystem for project-level lookups.
|
|
488
535
|
* @returns resolved rules, missing refs, and the unassigned legacy names.
|
|
489
536
|
*/
|
|
490
|
-
async function rulesForBinding(binding, fs, cwd) {
|
|
537
|
+
async function rulesForBinding(binding, fs, cwd, frozen) {
|
|
491
538
|
const refs = [];
|
|
492
539
|
// Deduplicated by reference: one rule shared by two skills on the same stage is
|
|
493
540
|
// disclosed once, because it is one constraint being followed.
|
|
@@ -501,11 +548,11 @@ async function rulesForBinding(binding, fs, cwd) {
|
|
|
501
548
|
refs.push(rule);
|
|
502
549
|
}
|
|
503
550
|
}
|
|
504
|
-
const { resolved, missing } = await resolveRules(refs, fs, cwd);
|
|
551
|
+
const { resolved, missing, stale } = await resolveRules(refs, fs, cwd, frozen);
|
|
505
552
|
const legacy = binding?.legacy_rules ?? [];
|
|
506
553
|
const legacyRefs = [];
|
|
507
554
|
for (const name of legacy) {
|
|
508
|
-
const source = await resolveLegacyRuleSource(name, fs, cwd);
|
|
555
|
+
const source = await resolveLegacyRuleSource(name, fs, cwd, frozen);
|
|
509
556
|
// An unresolvable legacy name is reported through `missing` so it is visible
|
|
510
557
|
// rather than quietly absent.
|
|
511
558
|
if (source === undefined) {
|
|
@@ -518,8 +565,27 @@ async function rulesForBinding(binding, fs, cwd) {
|
|
|
518
565
|
seen.add(key);
|
|
519
566
|
legacyRefs.push({ source, name });
|
|
520
567
|
}
|
|
521
|
-
const
|
|
522
|
-
return {
|
|
568
|
+
const legacyResult = await resolveRules(legacyRefs, fs, cwd, frozen);
|
|
569
|
+
return {
|
|
570
|
+
resolved: [...resolved, ...legacyResult.resolved],
|
|
571
|
+
missing,
|
|
572
|
+
legacy,
|
|
573
|
+
stale: [...stale, ...legacyResult.stale],
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
/** Return the task's frozen skill instructions, falling back to live bodies for old tasks. */
|
|
577
|
+
async function skillBodiesForBinding(binding, fs, cwd, frozen) {
|
|
578
|
+
const result = [];
|
|
579
|
+
for (const entry of binding?.skills ?? []) {
|
|
580
|
+
const key = formatResourceRef(entry.skill);
|
|
581
|
+
const archived = frozen?.find(resource => (resource.kind === 'skill'
|
|
582
|
+
|| (resource.kind === undefined && resource.content.startsWith('---')))
|
|
583
|
+
&& formatResourceRef(resource.ref) === key);
|
|
584
|
+
const content = archived?.content ?? await readSkillAt(entry.skill, fs, cwd);
|
|
585
|
+
if (content !== undefined)
|
|
586
|
+
result.push({ name: entry.skill.name, source: entry.skill.source, content });
|
|
587
|
+
}
|
|
588
|
+
return result;
|
|
523
589
|
}
|
|
524
590
|
/**
|
|
525
591
|
* Render a stage's progressive-disclosure payload: skill names to load via the
|
|
@@ -530,15 +596,19 @@ async function rulesForBinding(binding, fs, cwd) {
|
|
|
530
596
|
* @param fs - host filesystem for project-rule resolution.
|
|
531
597
|
* @returns the disclosure text, or an empty string.
|
|
532
598
|
*/
|
|
533
|
-
async function renderBindings(stage, workflow, fs, cwd) {
|
|
599
|
+
async function renderBindings(stage, workflow, fs, cwd, frozen) {
|
|
534
600
|
const binding = bindingsForStage(stage, workflow);
|
|
535
601
|
const skills = binding?.skills ?? [];
|
|
536
602
|
if (binding === undefined || (skills.length === 0 && (binding.legacy_rules ?? []).length === 0))
|
|
537
603
|
return '';
|
|
538
|
-
const { resolved: rules, missing, legacy } = await rulesForBinding(binding, fs, cwd);
|
|
604
|
+
const { resolved: rules, missing, legacy } = await rulesForBinding(binding, fs, cwd, frozen);
|
|
539
605
|
const skillPart = skills.length > 0
|
|
540
606
|
? `skills to load: ${skills.map(skill => formatResourceRef(skill.skill)).join(', ')} (use the skill tool by name)`
|
|
541
607
|
: 'skills to load: none';
|
|
608
|
+
const frozenSkills = await skillBodiesForBinding(binding, fs, cwd, frozen);
|
|
609
|
+
const skillInstructions = frozenSkills.length > 0
|
|
610
|
+
? `skill instructions for this task (frozen at creation when available; these are authoritative if the live skill changed):\n${frozenSkills.map(skill => `### ${skill.source}:${skill.name}\n${skill.content}`).join('\n\n')}`
|
|
611
|
+
: '';
|
|
542
612
|
const rulePart = rules.length > 0
|
|
543
613
|
? `rules in force at this stage (each belongs to the skill shown by the binding):\n${rules.map(r => `### ${r.source}:${r.name}\n${r.content}`).join('\n\n')}`
|
|
544
614
|
: '';
|
|
@@ -554,11 +624,18 @@ async function renderBindings(stage, workflow, fs, cwd) {
|
|
|
554
624
|
? `UNASSIGNED legacy stage rules: ${legacy.join(', ')} — these belong to the stage, not to any skill. ` +
|
|
555
625
|
'Assign each to the skill that should carry it (or copy the skill and give each copy its own rules) and record it in stage_bindings.'
|
|
556
626
|
: '';
|
|
557
|
-
|
|
627
|
+
// The disclosure names the skills that owe a command receipt, honouring each
|
|
628
|
+
// binding's declared evidence kind — a document-producing skill owes an
|
|
629
|
+
// artifact, not a shell command, and saying otherwise would tell the model to
|
|
630
|
+
// run something irrelevant.
|
|
631
|
+
const additional = skills
|
|
632
|
+
.filter(entry => entry.evidence === undefined || entry.evidence === 'command')
|
|
633
|
+
.map(entry => entry.skill.name)
|
|
634
|
+
.filter(name => needsSkillReceipt(name));
|
|
558
635
|
const receiptPart = additional.length
|
|
559
636
|
? `additional skills requiring skill_result command receipts: ${additional.join(', ')}`
|
|
560
637
|
: 'skill_result not required for this stage: core skills use record/verify/review/commit gates';
|
|
561
|
-
return [skillPart, receiptPart, rulePart, missingPart, legacyPart].filter(Boolean).join('\n');
|
|
638
|
+
return [skillPart, skillInstructions, receiptPart, rulePart, missingPart, legacyPart].filter(Boolean).join('\n');
|
|
562
639
|
}
|
|
563
640
|
/** Normalize the tool's `items` argument into complete TaskItem records. */
|
|
564
641
|
function normalizeItems(items, previous = []) {
|
|
@@ -899,6 +976,9 @@ export function registerDevTask(ctx) {
|
|
|
899
976
|
// in-flight task stable: editing a rule the task uses would otherwise change
|
|
900
977
|
// what the task is doing without the task saying so.
|
|
901
978
|
const frozen = await freezeResources(resolved.config, fs, cwd);
|
|
979
|
+
if (frozen.unreadable.length > 0) {
|
|
980
|
+
throw new Error(`cannot create task with unreadable bound resources: ${frozen.unreadable.join(', ')}`);
|
|
981
|
+
}
|
|
902
982
|
const flow = {
|
|
903
983
|
flow: resolved.flow,
|
|
904
984
|
version: resolved.version,
|
|
@@ -923,15 +1003,21 @@ export function registerDevTask(ctx) {
|
|
|
923
1003
|
state.bindings_fingerprint = builtinRulesFingerprint();
|
|
924
1004
|
assertInsideRoot(root, cwd ?? '', taskPath(state.id));
|
|
925
1005
|
await writeTask(fs, state, cwd, await resolveWriteMode(ctx, a, exec));
|
|
926
|
-
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.
|
|
1006
|
+
return `created ${state.id} at stage ${state.stage}; legal next: ${legalTargets(state.stage, flow.config).join(', ') || 'none'}\n${await renderBindings(state.stage, flow.config, fs, cwd, flow.resources)}\nBefore advance, load all bound skills. Record each skill's declared evidence. Terminal bindings must finish before entering the terminal stage.`;
|
|
927
1007
|
}
|
|
928
1008
|
if (!a.task_id)
|
|
929
1009
|
throw new Error('task_id is required for this operation');
|
|
930
1010
|
const state = await loadTask(fs, a.task_id, cwd);
|
|
931
1011
|
const workflow = await workflowFor(state, fs, cwd);
|
|
1012
|
+
if (state.completed !== undefined && a.operation === 'complete') {
|
|
1013
|
+
return `task already completed at ${state.completed.at}`;
|
|
1014
|
+
}
|
|
1015
|
+
if (state.completed !== undefined && a.operation !== 'status' && a.operation !== 'revise') {
|
|
1016
|
+
throw new Error('task is completed; use revise to reopen it before changing task state');
|
|
1017
|
+
}
|
|
932
1018
|
if (a.operation === 'status') {
|
|
933
1019
|
const binding = bindingsForStage(state.stage, workflow);
|
|
934
|
-
const { resolved: rules, missing: missingRules, legacy: legacyRules, } = await rulesForBinding(binding, fs, cwd);
|
|
1020
|
+
const { resolved: rules, missing: missingRules, legacy: legacyRules, stale: staleRules, } = await rulesForBinding(binding, fs, cwd, state.flow?.resources);
|
|
935
1021
|
const missingSkills = skillBlockers(state, workflow, session);
|
|
936
1022
|
const staleEvidence = await evidenceBlockers(fs, state, workflow, cwd);
|
|
937
1023
|
const checkpoint = commitCheckpoint(state, workflow);
|
|
@@ -940,19 +1026,31 @@ export function registerDevTask(ctx) {
|
|
|
940
1026
|
// needs a passing verification and does not have one. Callers act on both.
|
|
941
1027
|
const verification = verificationBlockers(state, workflow);
|
|
942
1028
|
const commitBlockers = [...staleEvidence, ...missingSkills, ...verification];
|
|
1029
|
+
const completionIssues = state.completed === undefined
|
|
1030
|
+
? [...completionBlockers(state, workflow), ...missingSkills, ...resourceBlockers(missingRules, state.stage), ...staleEvidence]
|
|
1031
|
+
: [];
|
|
943
1032
|
return JSON.stringify({
|
|
944
1033
|
id: state.id,
|
|
945
1034
|
stage: state.stage,
|
|
946
1035
|
flow: state.flow !== undefined ? { flow: state.flow.flow, version: state.flow.version } : null,
|
|
947
1036
|
risk_level: state.risk_level,
|
|
1037
|
+
completed: state.completed ?? null,
|
|
1038
|
+
completion_blockers: completionIssues,
|
|
948
1039
|
work_size: state.work_size,
|
|
949
1040
|
requirement_confirmed: state.requirement_confirmed,
|
|
950
1041
|
solution_confirmed: state.solution_confirmed,
|
|
951
1042
|
items_done: `${state.items.filter(i => i.status === 'done').length}/${state.items.length}`,
|
|
952
1043
|
items: state.items,
|
|
953
1044
|
skill_obligations: obligationStages(state, workflow).map(stage => {
|
|
954
|
-
const
|
|
955
|
-
|
|
1045
|
+
const bindings = workflow.stage_bindings?.[stage]?.skills ?? [];
|
|
1046
|
+
const skills = bindings.map(entry => entry.skill.name);
|
|
1047
|
+
// Only bindings whose evidence is a command owe a command receipt, so a
|
|
1048
|
+
// document-producing skill is not told to run something irrelevant.
|
|
1049
|
+
const command_receipts_required = bindings
|
|
1050
|
+
.filter(entry => entry.evidence === undefined || entry.evidence === 'command')
|
|
1051
|
+
.map(entry => entry.skill.name)
|
|
1052
|
+
.filter(name => needsSkillReceipt(name));
|
|
1053
|
+
return { stage, skills, command_receipts_required };
|
|
956
1054
|
}),
|
|
957
1055
|
skill_blockers: missingSkills,
|
|
958
1056
|
evidence_blockers: staleEvidence,
|
|
@@ -963,6 +1061,11 @@ export function registerDevTask(ctx) {
|
|
|
963
1061
|
// runs under, and this mirrors that list for the current stage.
|
|
964
1062
|
missing_rules: missingRules,
|
|
965
1063
|
rules: rules.map(rule => ({ name: rule.name, source: rule.source })),
|
|
1064
|
+
// Rules whose SOURCE no longer matches what the task froze. The task keeps
|
|
1065
|
+
// following the frozen text — that is what freezing is for — and this reports
|
|
1066
|
+
// the drift, so a rule edited or deleted underneath a running task is visible
|
|
1067
|
+
// instead of silently authoritative.
|
|
1068
|
+
stale_source_rules: staleRules,
|
|
966
1069
|
unassigned_legacy_rules: legacyRules,
|
|
967
1070
|
skill_results: state.skill_results ?? {},
|
|
968
1071
|
commits: state.commits,
|
|
@@ -980,7 +1083,12 @@ export function registerDevTask(ctx) {
|
|
|
980
1083
|
: checkpoint,
|
|
981
1084
|
bindings: {
|
|
982
1085
|
skills: binding?.skills ?? [],
|
|
983
|
-
|
|
1086
|
+
skill_contents: await skillBodiesForBinding(binding, fs, cwd, state.flow?.resources),
|
|
1087
|
+
// The layer is disclosed with each rule, because two same-named rules in
|
|
1088
|
+
// different layers are distinct resources and the caller needs to know
|
|
1089
|
+
// which one is actually in force. Reporting only the name made them
|
|
1090
|
+
// indistinguishable in exactly the case where the distinction matters.
|
|
1091
|
+
rules: rules.map(r => ({ name: r.name, source: r.source, content: r.content })),
|
|
984
1092
|
},
|
|
985
1093
|
bindings_drift: state.bindings_fingerprint !== undefined && state.bindings_fingerprint !== builtinRulesFingerprint()
|
|
986
1094
|
? 'bundled rules changed since this task froze — the frozen fingerprint no longer matches the shipped rules'
|
|
@@ -1043,7 +1151,13 @@ export function registerDevTask(ctx) {
|
|
|
1043
1151
|
// leaving a checkpoint" rule fires on the way OUT of a stage and a
|
|
1044
1152
|
// terminal stage has no way out.
|
|
1045
1153
|
await assertFreshEvidence(fs, state, workflow, cwd);
|
|
1046
|
-
const
|
|
1154
|
+
const missingSkills = skillBlockers(state, workflow, session);
|
|
1155
|
+
const unresolvedRules = (await rulesForBinding(bindingsForStage(state.stage, workflow), fs, cwd, state.flow?.resources)).missing;
|
|
1156
|
+
const blockers = [
|
|
1157
|
+
...completionBlockers(state, workflow),
|
|
1158
|
+
...missingSkills,
|
|
1159
|
+
...resourceBlockers(unresolvedRules, state.stage),
|
|
1160
|
+
];
|
|
1047
1161
|
if (blockers.length > 0)
|
|
1048
1162
|
throw new Error(`cannot complete: ${blockers.join('; ')}`);
|
|
1049
1163
|
const hash = state.commits.find(commit => commit.hash !== undefined)?.hash;
|
|
@@ -1150,6 +1264,14 @@ export function registerDevTask(ctx) {
|
|
|
1150
1264
|
const missingSkills = skillBlockers(state, workflow, session);
|
|
1151
1265
|
if (missingSkills.length)
|
|
1152
1266
|
throw new Error(missingSkills.join('; '));
|
|
1267
|
+
// A stage whose rules cannot be resolved must not be passable. This was
|
|
1268
|
+
// reported in status and never enforced, so deleting a bound rule still let the
|
|
1269
|
+
// task advance — the stage ran without the constraints it was configured with
|
|
1270
|
+
// while the record looked fine.
|
|
1271
|
+
const unresolvedRules = (await rulesForBinding(bindingsForStage(state.stage, workflow), fs, cwd, state.flow?.resources)).missing;
|
|
1272
|
+
const resourceIssues = resourceBlockers(unresolvedRules, state.stage);
|
|
1273
|
+
if (resourceIssues.length)
|
|
1274
|
+
throw new Error(resourceIssues.join('; '));
|
|
1153
1275
|
let result = assertAdvance(state, target, workflow);
|
|
1154
1276
|
if (!result.ok) {
|
|
1155
1277
|
const unmet = unmetGuards(state, target, workflow);
|
|
@@ -1163,9 +1285,9 @@ export function registerDevTask(ctx) {
|
|
|
1163
1285
|
if (!result.ok)
|
|
1164
1286
|
throw new Error(result.errors.join('; '));
|
|
1165
1287
|
state.stage = target;
|
|
1166
|
-
const disclosure = await renderBindings(state.stage, workflow, fs, cwd);
|
|
1288
|
+
const disclosure = await renderBindings(state.stage, workflow, fs, cwd, state.flow?.resources);
|
|
1167
1289
|
const terminalObligations = obligationStages(state, workflow).filter(stage => stage !== state.stage);
|
|
1168
|
-
const terminalDisclosure = await Promise.all(terminalObligations.map(async (stage) => `Before entering ${stage}, execute its bindings now:\n${await renderBindings(stage, workflow, fs, cwd)}`));
|
|
1290
|
+
const terminalDisclosure = await Promise.all(terminalObligations.map(async (stage) => `Before entering ${stage}, execute its bindings now:\n${await renderBindings(stage, workflow, fs, cwd, state.flow?.resources)}`));
|
|
1169
1291
|
note = [`advanced to ${state.stage}`, disclosure, ...terminalDisclosure].filter(Boolean).join('\n');
|
|
1170
1292
|
break;
|
|
1171
1293
|
}
|
|
@@ -1243,11 +1365,31 @@ export function registerDevTask(ctx) {
|
|
|
1243
1365
|
}
|
|
1244
1366
|
case 'skill_result': {
|
|
1245
1367
|
const stage = a.target_stage ?? state.stage;
|
|
1246
|
-
|
|
1368
|
+
const entry = (workflow.stage_bindings?.[stage]?.skills ?? []).find(binding => binding.skill.name === a.skill_name);
|
|
1369
|
+
if (!obligationStages(state, workflow).includes(stage) || !entry)
|
|
1247
1370
|
throw new Error('skill_result requires a skill bound to this stage or its upcoming terminal stage');
|
|
1248
1371
|
const loadCall = loadedSkills(session).get(a.skill_name);
|
|
1249
1372
|
if (!loadCall)
|
|
1250
1373
|
throw new Error(`load skill "${a.skill_name}" successfully before recording execution`);
|
|
1374
|
+
if (entry.evidence === 'manual') {
|
|
1375
|
+
if (!a.evidence?.length || a.evidence.some(value => !value.trim()))
|
|
1376
|
+
throw new Error('manual skill_result requires non-empty evidence');
|
|
1377
|
+
const approval = ctx.get('approval');
|
|
1378
|
+
if (approval === undefined || exec.agent === undefined)
|
|
1379
|
+
throw new Error('manual skill_result requires the human approval service');
|
|
1380
|
+
const outcome = await approval.request({ agent: exec.agent, toolName: 'dev_task', callId: exec.callId,
|
|
1381
|
+
reason: `请确认技能 ${a.skill_name} 在 ${stage} 的人工结果:${a.evidence.join(';')}`, signal: exec.signal });
|
|
1382
|
+
if (outcome !== 'allowed-once')
|
|
1383
|
+
throw new Error(`manual skill_result was not approved (${outcome})`);
|
|
1384
|
+
state.skill_results ??= {};
|
|
1385
|
+
state.skill_results[stage] ??= {};
|
|
1386
|
+
state.skill_results[stage][a.skill_name] = { session_id: session?.id ?? '', load_call_id: loadCall, evidence: a.evidence, approved: true };
|
|
1387
|
+
note = JSON.stringify({ skill: a.skill_name, stage, approved: true }, null, 2);
|
|
1388
|
+
break;
|
|
1389
|
+
}
|
|
1390
|
+
if (entry.evidence === 'artifact' || entry.evidence === 'review' || entry.evidence === 'none') {
|
|
1391
|
+
throw new Error(`${entry.evidence} skill evidence is recorded by its node operation; skill_result is only for command or manual evidence`);
|
|
1392
|
+
}
|
|
1251
1393
|
if (!a.command?.trim() || !a.evidence?.length || a.evidence.some(value => !value.trim()))
|
|
1252
1394
|
throw new Error('skill_result requires a real validation command and non-empty evidence describing executed scenarios and results');
|
|
1253
1395
|
approvedWriteMode = await resolveWriteMode(ctx, a, exec);
|