@nanmicoder/dsh-agent-teams 0.1.22 → 0.1.23
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/README.md +32 -10
- package/README_ZH.md +32 -10
- package/lib/artifacts.js +342 -0
- package/lib/capabilities.js +6 -4
- package/lib/client/ActivityPanel.js +5 -2
- package/lib/client/agent-teams-card-definition.js +100 -6
- package/lib/client.js +90 -7
- package/lib/client.js.map +1 -1
- package/lib/command.js +3 -2
- package/lib/git-artifact-stamp.json +2 -2
- package/lib/harness-compat.js +70 -21
- package/lib/index.js +26 -3
- package/lib/lineage.js +198 -0
- package/lib/mailbox.js +59 -34
- package/lib/members.js +301 -53
- package/lib/profiles.js +3 -2
- package/lib/quality-gates.js +850 -65
- package/lib/scheduler.js +22 -3
- package/lib/snapshot.js +22 -1
- package/lib/state.js +279 -13
- package/lib/tool-policies.js +187 -0
- package/lib/tools.js +370 -89
- package/lib/types/artifacts.d.ts +66 -0
- package/lib/types/client/activity-monitor.d.ts +6 -0
- package/lib/types/client/agent-teams-card-definition.d.ts +56 -0
- package/lib/types/harness-compat.d.ts +56 -14
- package/lib/types/index.d.ts +26 -1
- package/lib/types/lineage.d.ts +92 -0
- package/lib/types/members.d.ts +112 -1
- package/lib/types/profiles.d.ts +6 -0
- package/lib/types/quality-gates.d.ts +216 -10
- package/lib/types/scheduler.d.ts +2 -1
- package/lib/types/snapshot.d.ts +20 -0
- package/lib/types/state.d.ts +22 -5
- package/lib/types/tool-policies.d.ts +119 -0
- package/lib/types/tools.d.ts +12 -1
- package/lib/types/types.d.ts +95 -0
- package/lib/types/workspace-audit.d.ts +27 -0
- package/lib/workspace-audit.js +52 -0
- package/package.json +12 -5
- package/release-notes/v0.1.23.md +34 -0
- package/scripts/legacy-session-source-repair.mjs +344 -0
package/lib/quality-gates.js
CHANGED
|
@@ -111,13 +111,19 @@ export function pathMatchesScope(path, pattern) {
|
|
|
111
111
|
}
|
|
112
112
|
return normalizedPath === normalizedPattern;
|
|
113
113
|
}
|
|
114
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Paths no quality contract may claim: version control, harness/plugin state,
|
|
116
|
+
* secrets and private keys. Exported because the completion gate and contract
|
|
117
|
+
* generation (including the artifact reader that must not walk into
|
|
118
|
+
* `.agent-teams/`, `secrets/` or `.env*`) have to share exactly one predicate.
|
|
119
|
+
*/
|
|
120
|
+
export function isDefaultExcludedPath(path) {
|
|
115
121
|
const normalized = normalizeWorkspacePath(path);
|
|
116
122
|
if (normalized === undefined)
|
|
117
123
|
return false;
|
|
118
124
|
const segments = normalized.split('/');
|
|
119
125
|
const base = segments[segments.length - 1] ?? '';
|
|
120
|
-
if (segments[0] === '.git' || segments[0] === '.dsh')
|
|
126
|
+
if (segments[0] === '.git' || segments[0] === '.dsh' || segments[0] === '.agent-teams')
|
|
121
127
|
return true;
|
|
122
128
|
if (base === '.env' || base.startsWith('.env.'))
|
|
123
129
|
return true;
|
|
@@ -127,6 +133,9 @@ function isDefaultExcluded(path) {
|
|
|
127
133
|
return true;
|
|
128
134
|
return false;
|
|
129
135
|
}
|
|
136
|
+
function isDefaultExcluded(path) {
|
|
137
|
+
return isDefaultExcludedPath(path);
|
|
138
|
+
}
|
|
130
139
|
export function classifyChangedPath(path, inScope = [], outOfScope = []) {
|
|
131
140
|
if (normalizeWorkspacePath(path) === undefined)
|
|
132
141
|
return 'illegal';
|
|
@@ -430,29 +439,77 @@ const REPAIR_SCOPE_LINE_SUFFIX = /:\d+$/;
|
|
|
430
439
|
* forbids, so no honest completion exists and the repair dead-locks.
|
|
431
440
|
*
|
|
432
441
|
* Absolute and otherwise illegal paths are dropped (they can never match
|
|
433
|
-
* workspace-relative scope patterns anyway)
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
442
|
+
* workspace-relative scope patterns anyway), and so are paths the completion
|
|
443
|
+
* gate refuses by default (`secrets/` segments, `.env*`, `.git`, `.dsh`,
|
|
444
|
+
* `id_rsa*` — issue #183). `classifyChangedPath` checks that default-exclusion
|
|
445
|
+
* set BEFORE inScope, so collecting such a path here would generate a contract
|
|
446
|
+
* that demands an edit the gate calls `out_of_scope`: no honest completion
|
|
447
|
+
* exists and the repair burns its rounds. Contract generation and the
|
|
448
|
+
* completion gate must agree; the exemption channel stays the user-configured
|
|
449
|
+
* outOfScope list, never a reviewer's finding.
|
|
437
450
|
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
451
|
+
* When nothing legal remains, the source task's own inScope is kept as the
|
|
452
|
+
* fallback. Over-inclusion is accepted otherwise: inScope is an audit upper
|
|
453
|
+
* bound, and the requiredFix text still tells the implementer what to touch.
|
|
454
|
+
* Keep every remaining derived path until the generator resolves inherited
|
|
455
|
+
* exclusions; filtering those first would silently discard a required fix
|
|
456
|
+
* target.
|
|
440
457
|
*/
|
|
441
458
|
export function repairScopeFromFindings(findings, fallback) {
|
|
442
459
|
const derived = [];
|
|
460
|
+
for (const finding of findings) {
|
|
461
|
+
for (const path of findingDerivedPaths(finding)) {
|
|
462
|
+
if (isDefaultExcludedPath(path) || derived.includes(path))
|
|
463
|
+
continue;
|
|
464
|
+
derived.push(path);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return derived.length > 0 ? derived : fallback === undefined ? undefined : [...new Set(fallback)];
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Every workspace-relative path one finding names, before any exclusion rule:
|
|
471
|
+
* the observed file plus the fix targets referenced by `requiredFix`.
|
|
472
|
+
*/
|
|
473
|
+
export function findingDerivedPaths(finding) {
|
|
474
|
+
const paths = [];
|
|
443
475
|
const push = (raw) => {
|
|
444
476
|
const normalized = normalizeWorkspacePath(raw.replace(REPAIR_SCOPE_LINE_SUFFIX, ''));
|
|
445
|
-
if (normalized === undefined ||
|
|
477
|
+
if (normalized === undefined || paths.includes(normalized))
|
|
446
478
|
return;
|
|
447
|
-
|
|
479
|
+
paths.push(normalized);
|
|
448
480
|
};
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
481
|
+
if (nonemptyString(finding.file))
|
|
482
|
+
push(finding.file);
|
|
483
|
+
for (const match of finding.requiredFix.matchAll(REPAIR_SCOPE_PATH_PATTERN))
|
|
484
|
+
push(match[0]);
|
|
485
|
+
return paths;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Findings whose every cited path is default-excluded. They cannot become an
|
|
489
|
+
* honest repair contract (see {@link repairScopeFromFindings}), so they are
|
|
490
|
+
* surfaced to the captain instead of silently disappearing or dead-locking a
|
|
491
|
+
* member. A finding that names no path at all is NOT excluded: it simply falls
|
|
492
|
+
* back to the judged task's own scope, exactly as before.
|
|
493
|
+
*/
|
|
494
|
+
export function excludedFindingsOf(findings, pathExists) {
|
|
495
|
+
return findings.filter((finding) => {
|
|
496
|
+
const paths = findingDerivedPaths(finding);
|
|
497
|
+
if (paths.length === 0)
|
|
498
|
+
return false;
|
|
499
|
+
const own = normalizeWorkspacePath(finding.file ?? '');
|
|
500
|
+
return paths.every((path) => {
|
|
501
|
+
if (isDefaultExcludedPath(path))
|
|
502
|
+
return true;
|
|
503
|
+
// A path extracted from prose that does not exist is not something the
|
|
504
|
+
// workspace can act on (real run: "console/stream write" became a
|
|
505
|
+
// repair scope while the finding's actual target was excluded). The
|
|
506
|
+
// finding's own `file` always counts, so "create this new file" stays
|
|
507
|
+
// actionable.
|
|
508
|
+
if (pathExists === undefined || path === own)
|
|
509
|
+
return false;
|
|
510
|
+
return !pathExists(path);
|
|
511
|
+
});
|
|
512
|
+
});
|
|
456
513
|
}
|
|
457
514
|
const AMENDABLE_CONTRACT_FIELDS = ['objective', 'acceptance', 'verify', 'inScope', 'outOfScope'];
|
|
458
515
|
/**
|
|
@@ -576,21 +633,77 @@ function withoutScopeConflicts(outOfScope, inScope) {
|
|
|
576
633
|
const conflicting = new Set(inScopeOverlap(outOfScope, inScope ?? []));
|
|
577
634
|
return outOfScope.filter((pattern) => !conflicting.has(pattern));
|
|
578
635
|
}
|
|
579
|
-
|
|
636
|
+
/**
|
|
637
|
+
* Line anchors (`file.ts:42`, `deliver.go:503-505`) cannot survive an edit
|
|
638
|
+
* anywhere above them, so generated text never asserts one. Callers locate the
|
|
639
|
+
* cited code by content anchor instead. Fully anchored numbers (`12:30`, a
|
|
640
|
+
* version, a URL port) are left alone.
|
|
641
|
+
*/
|
|
642
|
+
const LINE_ANCHOR_PATTERN = /(?<!\/\/)(?<=[A-Za-z_./\\-][A-Za-z0-9_./\\-]*):\d+(?:-\d+)?\b/gu;
|
|
643
|
+
/** Remove drifting line anchors from generated contract text. */
|
|
644
|
+
export function stripLineAnchors(text) {
|
|
645
|
+
return text.replaceAll(LINE_ANCHOR_PATTERN, '');
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Acceptance items derived from a failed verification: the concrete failures
|
|
649
|
+
* the member must resolve. A verification carries no review findings, so the
|
|
650
|
+
* generator uses the evidence the task itself recorded.
|
|
651
|
+
*/
|
|
652
|
+
function verificationFailureAcceptance(closed) {
|
|
653
|
+
const items = [];
|
|
654
|
+
for (const result of closed.acceptanceResults ?? []) {
|
|
655
|
+
if (result.status === 'failed')
|
|
656
|
+
items.push(`Satisfy the failed acceptance item recorded by ${closed.id}: ${stripLineAnchors(result.criterion)}`);
|
|
657
|
+
}
|
|
658
|
+
for (const command of closed.commandsRun ?? []) {
|
|
659
|
+
if (command.status !== 'failed')
|
|
660
|
+
continue;
|
|
661
|
+
items.push(`Make \`${command.command}\` pass (it failed${command.exitCode === undefined ? '' : ` with exit code ${command.exitCode}`})`);
|
|
662
|
+
}
|
|
663
|
+
if (items.length === 0) {
|
|
664
|
+
items.push(`Resolve what failed in ${closed.id}${closed.output === undefined ? '' : `: ${closed.output.slice(0, 200)}`}`);
|
|
665
|
+
}
|
|
666
|
+
return items;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* What the generator tells the member about a work order it derived from
|
|
670
|
+
* someone else's findings. The plugin cannot verify a finding's claims (a
|
|
671
|
+
* target count, a symbol that still exists, an action the product supports),
|
|
672
|
+
* and says so instead of lending them authority.
|
|
673
|
+
*/
|
|
674
|
+
function generatedRepairDescription(closed, excluded) {
|
|
675
|
+
const lines = [
|
|
676
|
+
`Auto-generated repair round derived from the findings of ${closed.id} (${taskKindOf(closed)}).`,
|
|
677
|
+
'The plugin did not verify the finding text: line numbers were removed because an edit anywhere above them invalidates them, and claims such as a target count, a symbol that still exists, or an action the product supports are the reporter\'s, not confirmed facts. Locate the cited code by content anchor (symbol, string, call site) and check every acceptance item against the current workspace before editing; if an item is already satisfied or impossible as written, report that instead of forcing a change.',
|
|
678
|
+
];
|
|
679
|
+
if (excluded.length > 0) {
|
|
680
|
+
lines.push(`Excluded from this contract on purpose: ${excluded.map((finding) => finding.file ?? finding.id).join(', ')} — the completion gate refuses those paths by default, so they need a human decision rather than a member edit.`);
|
|
681
|
+
}
|
|
682
|
+
return lines.join(' ');
|
|
683
|
+
}
|
|
684
|
+
export function planQualityFollowUp(team, closed, options = {}) {
|
|
580
685
|
const empty = { created: [], tasks: [] };
|
|
686
|
+
const escalate = (reason) => ({ ...empty, escalated: true, status: 'escalated', reason });
|
|
581
687
|
const kind = taskKindOf(closed);
|
|
582
|
-
if (
|
|
688
|
+
if (closed.status !== 'failed')
|
|
583
689
|
return empty;
|
|
584
|
-
if (
|
|
585
|
-
|
|
586
|
-
|
|
690
|
+
if (kind === 'review' || kind === 'requirements') {
|
|
691
|
+
if (closed.verdict === 'reject')
|
|
692
|
+
return escalate(`${closed.id} returned verdict=reject; automatic repair is not a substitute for a human decision`);
|
|
693
|
+
if (closed.verdict !== 'needs_revision')
|
|
694
|
+
return empty;
|
|
695
|
+
}
|
|
696
|
+
else if (kind !== 'verification') {
|
|
697
|
+
// Both quality gates share the findings → repair → re-check contract, so
|
|
698
|
+
// a failed verification self-heals exactly like a failed review (#175B).
|
|
587
699
|
return empty;
|
|
700
|
+
}
|
|
588
701
|
const policy = resolveReviewPolicy(team.reviewPolicy);
|
|
589
702
|
const currentRound = closed.round ?? 1;
|
|
590
703
|
const nextRound = currentRound + 1;
|
|
591
704
|
const maxRounds = kind === 'requirements' ? policy.requirementsMaxRounds : policy.codeMaxRounds;
|
|
592
705
|
if (nextRound > maxRounds)
|
|
593
|
-
return {
|
|
706
|
+
return escalate(`${closed.id} reached the ${kind} round ceiling (${maxRounds}); the loop cannot open round ${nextRound}`);
|
|
594
707
|
if (kind === 'requirements') {
|
|
595
708
|
const next = {
|
|
596
709
|
kind: 'requirements',
|
|
@@ -603,49 +716,87 @@ export function planQualityFollowUp(team, closed) {
|
|
|
603
716
|
};
|
|
604
717
|
return { created: [next], tasks: [next] };
|
|
605
718
|
}
|
|
606
|
-
const
|
|
719
|
+
const target = reviewedTargetOf(team, closed);
|
|
720
|
+
const sourceId = closed.reviewedTaskId ?? closed.sourceTaskId ?? target?.id;
|
|
607
721
|
if (sourceId === undefined)
|
|
608
722
|
return empty;
|
|
609
723
|
const source = team.tasks.find((item) => item.id === sourceId);
|
|
610
724
|
const findings = unresolvedFindings(closed);
|
|
611
|
-
const
|
|
725
|
+
const excluded = excludedFindingsOf(findings, options.pathExists);
|
|
726
|
+
const actionable = findings.filter((finding) => !excluded.includes(finding));
|
|
727
|
+
if (findings.length > 0 && actionable.length === 0) {
|
|
728
|
+
return {
|
|
729
|
+
...empty,
|
|
730
|
+
escalated: true,
|
|
731
|
+
status: 'escalated',
|
|
732
|
+
reason: `every finding of ${closed.id} cites only default-excluded paths (${excluded.map((finding) => finding.file ?? finding.id).join(', ')}); the completion gate classifies those out_of_scope, so no honest repair contract exists — a human must decide`,
|
|
733
|
+
excludedFindings: excluded.map((finding) => finding.id),
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
const findingIds = actionable.map((finding) => finding.id);
|
|
612
737
|
if (hasOpenFollowUp(team, sourceId, findingIds))
|
|
613
738
|
return empty;
|
|
614
739
|
if (countRepairAttempts(team, sourceId, findingIds) >= policy.maxRepairAttempts) {
|
|
615
|
-
return {
|
|
740
|
+
return escalate(`${sourceId} already used ${policy.maxRepairAttempts} automatic repair attempts for these findings`);
|
|
616
741
|
}
|
|
617
|
-
// inScope is derived from the findings below: the observed file
|
|
618
|
-
// workspace-relative paths referenced by the requiredFix
|
|
619
|
-
|
|
742
|
+
// inScope is derived from the actionable findings below: the observed file
|
|
743
|
+
// plus any workspace-relative paths referenced by the requiredFix
|
|
744
|
+
// instructions, minus everything the completion gate refuses by default.
|
|
745
|
+
const repairScope = repairScopeFromFindings(actionable, source?.inScope);
|
|
746
|
+
const verifyCommands = source?.verify ?? closed.verify;
|
|
620
747
|
const implementer = schedulableAssignee(source?.assignee, team);
|
|
748
|
+
// The causal edge to the failed gate is recorded explicitly: it cannot be a
|
|
749
|
+
// `dependencies` entry, because a failed task never reaches `completed` and
|
|
750
|
+
// `unsatisfiedDependencies` would then make this repair unclaimable forever.
|
|
751
|
+
const repairDependencies = [sourceId];
|
|
621
752
|
const repair = {
|
|
622
753
|
id: `repair-round-${nextRound}`,
|
|
623
754
|
kind: 'repair',
|
|
624
755
|
subject: `repair-round-${nextRound}`,
|
|
756
|
+
description: generatedRepairDescription(closed, excluded),
|
|
625
757
|
assignee: implementer,
|
|
626
|
-
dependencies:
|
|
758
|
+
dependencies: repairDependencies,
|
|
627
759
|
round: nextRound,
|
|
628
760
|
objective: source?.objective ?? closed.objective ?? `Fix findings from ${sourceId}`,
|
|
629
761
|
inScope: repairScope,
|
|
630
762
|
outOfScope: withoutScopeConflicts(source?.outOfScope, repairScope),
|
|
631
|
-
verify:
|
|
632
|
-
acceptance:
|
|
763
|
+
verify: verifyCommands,
|
|
764
|
+
acceptance: actionable.length > 0
|
|
765
|
+
? actionable.map((finding) => stripLineAnchors(finding.requiredFix))
|
|
766
|
+
: verificationFailureAcceptance(closed),
|
|
633
767
|
sourceTaskId: sourceId,
|
|
634
768
|
sourceFindingIds: findingIds,
|
|
769
|
+
findingSourceTaskIds: [closed.id],
|
|
770
|
+
contractPaths: repairScope,
|
|
635
771
|
};
|
|
636
|
-
const
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
772
|
+
const checker = schedulableAssignee(closed.assignee !== implementer ? closed.assignee : undefined, team, implementer);
|
|
773
|
+
const recheck = kind === 'verification'
|
|
774
|
+
? {
|
|
775
|
+
id: `verification-round-${nextRound}`,
|
|
776
|
+
kind: 'verification',
|
|
777
|
+
subject: `verification-round-${nextRound}`,
|
|
778
|
+
assignee: checker,
|
|
779
|
+
dependencies: [repair.id ?? `repair-round-${nextRound}`],
|
|
780
|
+
round: nextRound,
|
|
781
|
+
objective: closed.objective ?? `Re-verify ${sourceId} after ${repair.id ?? `repair-round-${nextRound}`}`,
|
|
782
|
+
acceptance: sanitizeReviewAcceptance(closed.acceptance),
|
|
783
|
+
verify: verifyCommands,
|
|
784
|
+
}
|
|
785
|
+
: {
|
|
786
|
+
id: `review-round-${nextRound}`,
|
|
787
|
+
kind: 'review',
|
|
788
|
+
subject: `review-round-${nextRound}`,
|
|
789
|
+
assignee: checker,
|
|
790
|
+
dependencies: [repair.id ?? `repair-round-${nextRound}`],
|
|
791
|
+
round: nextRound,
|
|
792
|
+
objective: sanitizeReviewObjective(closed.objective, DEFAULT_REVIEW_OBJECTIVE),
|
|
793
|
+
acceptance: sanitizeReviewAcceptance(closed.acceptance),
|
|
794
|
+
reviewedTaskId: repair.id,
|
|
795
|
+
};
|
|
796
|
+
const result = { created: [repair, recheck], tasks: [repair, recheck] };
|
|
797
|
+
if (excluded.length > 0)
|
|
798
|
+
result.excludedFindings = excluded.map((finding) => finding.id);
|
|
799
|
+
return result;
|
|
649
800
|
}
|
|
650
801
|
export function buildCoverageMatrix(goalItems, tasks) {
|
|
651
802
|
return goalItems.map((goalItem) => {
|
|
@@ -662,8 +813,520 @@ export function buildCoverageMatrix(goalItems, tasks) {
|
|
|
662
813
|
return { goal_item: goalItem, task_ids: taskIds, status: 'in_progress' };
|
|
663
814
|
});
|
|
664
815
|
}
|
|
665
|
-
|
|
816
|
+
/* ------------------------------------------------------------------------ *
|
|
817
|
+
* Content-bound verdicts (issue #161) and generated work orders (issue #174) *
|
|
818
|
+
* ------------------------------------------------------------------------ */
|
|
819
|
+
const VERDICT_KINDS = ['review', 'requirements', 'verification'];
|
|
820
|
+
/** Kinds whose completion is a verdict about a revision. */
|
|
821
|
+
export function isVerdictKind(kind) {
|
|
822
|
+
return kind !== undefined && VERDICT_KINDS.includes(kind);
|
|
823
|
+
}
|
|
824
|
+
function shortDigest(value) {
|
|
825
|
+
return value === undefined || value === '' ? 'absent' : value.slice(0, 8);
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Compare recorded bytes against the bytes on disk now, keeping the path each
|
|
829
|
+
* reason belongs to. "Cannot be read" is reported as its own reason: a
|
|
830
|
+
* permission error is not a match, and a partial read is not proof of an
|
|
831
|
+
* unchanged file.
|
|
832
|
+
*/
|
|
833
|
+
export function artifactDriftDetails(recorded, current) {
|
|
834
|
+
if (recorded === undefined || recorded.length === 0 || current === undefined)
|
|
835
|
+
return [];
|
|
836
|
+
const now = new Map(current.map((item) => [item.path, item]));
|
|
837
|
+
const drift = [];
|
|
838
|
+
for (const before of recorded) {
|
|
839
|
+
const after = now.get(before.path);
|
|
840
|
+
if (after === undefined) {
|
|
841
|
+
drift.push({ path: before.path, message: `${before.path} was not in the current workspace snapshot` });
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
if (after.unreadable === true) {
|
|
845
|
+
drift.push({ path: after.path, message: `${after.path} cannot be read now (${after.reason ?? 'unknown error'}), so its recorded bytes cannot be confirmed` });
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
if (before.unreadable === true) {
|
|
849
|
+
drift.push({ path: before.path, message: `${before.path} was not readable when this evidence was recorded (${before.reason ?? 'unknown error'}), so it never covered those bytes` });
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
if (before.present !== after.present) {
|
|
853
|
+
drift.push({
|
|
854
|
+
path: before.path,
|
|
855
|
+
message: after.present
|
|
856
|
+
? `${before.path} was absent when recorded and now exists`
|
|
857
|
+
: `${before.path} was deleted after it was recorded`,
|
|
858
|
+
});
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
if (before.present && after.present && before.sha256 !== after.sha256) {
|
|
862
|
+
drift.push({ path: before.path, message: `${before.path} changed (sha256 ${shortDigest(before.sha256)} -> ${shortDigest(after.sha256)})` });
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return drift;
|
|
866
|
+
}
|
|
867
|
+
/** Message-only view of {@link artifactDriftDetails}. */
|
|
868
|
+
export function artifactDrift(recorded, current) {
|
|
869
|
+
return artifactDriftDetails(recorded, current).map((detail) => detail.message);
|
|
870
|
+
}
|
|
871
|
+
/** A partially enumerated scope is incomplete evidence, never a silent pass. */
|
|
872
|
+
export function gapDrift(gaps) {
|
|
873
|
+
return (gaps ?? []).map((gap) => `${gap.pattern}: ${gap.reason}`);
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* The task a verdict is about: an explicit `reviewedTaskId`/`sourceTaskId`
|
|
877
|
+
* first, otherwise the implementation/repair this gate depends on.
|
|
878
|
+
*/
|
|
879
|
+
export function reviewedTargetOf(team, task) {
|
|
880
|
+
const explicit = task.reviewedTaskId ?? task.sourceTaskId;
|
|
881
|
+
if (explicit !== undefined) {
|
|
882
|
+
const found = team.tasks.find((item) => item.id === explicit);
|
|
883
|
+
if (found !== undefined)
|
|
884
|
+
return found;
|
|
885
|
+
}
|
|
886
|
+
for (const dependency of task.dependencies) {
|
|
887
|
+
const candidate = team.tasks.find((item) => item.id === dependency);
|
|
888
|
+
if (candidate !== undefined && (taskKindOf(candidate) === 'implementation' || taskKindOf(candidate) === 'repair')) {
|
|
889
|
+
return candidate;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return undefined;
|
|
893
|
+
}
|
|
894
|
+
export function bindReviewToRevision(team, task, binding) {
|
|
895
|
+
const target = reviewedTargetOf(team, task);
|
|
896
|
+
if (target === undefined || binding.artifacts.length === 0)
|
|
897
|
+
return task;
|
|
898
|
+
const kind = taskKindOf(task);
|
|
899
|
+
return {
|
|
900
|
+
...task,
|
|
901
|
+
...kind === 'review' || kind === 'requirements' ? { reviewedTaskId: task.reviewedTaskId ?? target.id } : {},
|
|
902
|
+
reviewedAttempt: target.attempt ?? 0,
|
|
903
|
+
reviewedArtifacts: binding.artifacts.map((item) => ({ ...item })),
|
|
904
|
+
...binding.gaps === undefined || binding.gaps.length === 0 ? {} : { reviewedGaps: binding.gaps.map((gap) => ({ ...gap })) },
|
|
905
|
+
...binding.scope === undefined || binding.scope.length === 0 ? {} : { reviewedScope: [...binding.scope] },
|
|
906
|
+
...binding.duringDrift === undefined || binding.duringDrift.length === 0 ? {} : { reviewedDuringDrift: [...binding.duringDrift] },
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Scope a verdict must bind: the paths the judged task changed, otherwise the
|
|
911
|
+
* scope patterns of its contract (expanded by the caller).
|
|
912
|
+
*/
|
|
913
|
+
export function verdictScopePatterns(team, task) {
|
|
914
|
+
const lineage = verdictLineageTasks(team, task);
|
|
915
|
+
// Every implementation/repair this gate transitively judges: the original
|
|
916
|
+
// source AND the repairs that followed it. A pre-planned review whose edge
|
|
917
|
+
// was rewired to a re-verification must still bind the bytes the original
|
|
918
|
+
// source changed, otherwise a later edit to the repair's target would have
|
|
919
|
+
// no valid review evidence at all (#161 x #175).
|
|
920
|
+
const changed = new Set();
|
|
921
|
+
for (const item of lineage) {
|
|
922
|
+
const kind = taskKindOf(item);
|
|
923
|
+
if (kind !== 'implementation' && kind !== 'repair')
|
|
924
|
+
continue;
|
|
925
|
+
for (const path of item.changedPaths ?? []) {
|
|
926
|
+
const normalized = normalizeWorkspacePath(path);
|
|
927
|
+
if (normalized !== undefined)
|
|
928
|
+
changed.add(normalized);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
if (changed.size > 0)
|
|
932
|
+
return [...changed];
|
|
933
|
+
const scope = new Set([
|
|
934
|
+
...(task.inScope ?? []),
|
|
935
|
+
...lineage.flatMap((item) => item.inScope ?? []),
|
|
936
|
+
]);
|
|
937
|
+
if (scope.size > 0)
|
|
938
|
+
return [...scope];
|
|
939
|
+
return (reviewedTargetOf(team, task)?.deliverables ?? [])
|
|
940
|
+
.map((path) => normalizeWorkspacePath(path))
|
|
941
|
+
.filter((path) => path !== undefined);
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* The artifacts a gate transitively judges: follow explicit review/source
|
|
945
|
+
* links and dependency edges (a repair records the source it fixes, a
|
|
946
|
+
* verification depends on the repair, a review depends on the verification).
|
|
947
|
+
*/
|
|
948
|
+
function verdictLineageTasks(team, task) {
|
|
949
|
+
const byId = new Map(team.tasks.map((item) => [item.id, item]));
|
|
950
|
+
const visited = new Set();
|
|
951
|
+
const queue = [...task.dependencies];
|
|
952
|
+
const explicit = task.reviewedTaskId ?? task.sourceTaskId;
|
|
953
|
+
if (explicit !== undefined)
|
|
954
|
+
queue.push(explicit);
|
|
955
|
+
const target = reviewedTargetOf(team, task);
|
|
956
|
+
if (target !== undefined && target.id !== task.id)
|
|
957
|
+
queue.push(target.id);
|
|
958
|
+
const found = [];
|
|
959
|
+
while (queue.length > 0) {
|
|
960
|
+
const id = queue.shift();
|
|
961
|
+
if (visited.has(id))
|
|
962
|
+
continue;
|
|
963
|
+
visited.add(id);
|
|
964
|
+
const item = byId.get(id);
|
|
965
|
+
if (item === undefined)
|
|
966
|
+
continue;
|
|
967
|
+
found.push(item);
|
|
968
|
+
queue.push(...item.dependencies);
|
|
969
|
+
const link = item.reviewedTaskId ?? item.sourceTaskId;
|
|
970
|
+
if (link !== undefined)
|
|
971
|
+
queue.push(link);
|
|
972
|
+
}
|
|
973
|
+
return found;
|
|
974
|
+
}
|
|
975
|
+
/** Scope patterns recorded verdicts bound, for new-file detection. */
|
|
976
|
+
export function deliveryScopePatterns(team) {
|
|
977
|
+
const patterns = new Set();
|
|
978
|
+
for (const task of team.tasks) {
|
|
979
|
+
const kind = taskKindOf(task);
|
|
980
|
+
if (!VERDICT_KINDS.includes(kind) || task.status !== 'completed')
|
|
981
|
+
continue;
|
|
982
|
+
for (const pattern of task.reviewedScope ?? [])
|
|
983
|
+
patterns.add(pattern);
|
|
984
|
+
}
|
|
985
|
+
return [...patterns];
|
|
986
|
+
}
|
|
987
|
+
function isDirectoryPattern(pattern) {
|
|
988
|
+
const trimmed = pattern.trim();
|
|
989
|
+
return trimmed.endsWith('/') || trimmed === '.' || trimmed === './';
|
|
990
|
+
}
|
|
991
|
+
/** Root of the artifact lineage a gate judges (the implementation/repair chain). */
|
|
992
|
+
function lineageRoot(team, task) {
|
|
993
|
+
let current = task;
|
|
994
|
+
const visited = new Set();
|
|
995
|
+
while (!visited.has(current.id)) {
|
|
996
|
+
visited.add(current.id);
|
|
997
|
+
const nextId = current.reviewedTaskId ?? current.sourceTaskId ?? current.dependencies[0];
|
|
998
|
+
const next = nextId === undefined ? undefined : team.tasks.find((item) => item.id === nextId);
|
|
999
|
+
if (next === undefined)
|
|
1000
|
+
break;
|
|
1001
|
+
current = next;
|
|
1002
|
+
}
|
|
1003
|
+
if (current.id === task.id) {
|
|
1004
|
+
const kind = taskKindOf(task);
|
|
1005
|
+
return kind === 'implementation' || kind === 'repair' ? task.id : undefined;
|
|
1006
|
+
}
|
|
1007
|
+
return current.id;
|
|
1008
|
+
}
|
|
1009
|
+
const VERDICT_WEIGHT = { verification: 1, review: 2, requirements: 2 };
|
|
1010
|
+
/**
|
|
1011
|
+
* Does `candidate` cover `prior`'s evidence? A later sound verdict that either
|
|
1012
|
+
* depends on the prior gate or judges the same artifact lineage supersedes it,
|
|
1013
|
+
* so a legitimately changed revision does not block delivery forever behind an
|
|
1014
|
+
* old snapshot.
|
|
1015
|
+
*/
|
|
1016
|
+
function supersedes(team, candidate, prior) {
|
|
1017
|
+
if (candidate.id === prior.id || candidate.status !== 'completed')
|
|
1018
|
+
return false;
|
|
1019
|
+
if ((VERDICT_WEIGHT[taskKindOf(candidate)] ?? 0) < (VERDICT_WEIGHT[taskKindOf(prior)] ?? 0))
|
|
1020
|
+
return false;
|
|
1021
|
+
if (candidate.createdAt < prior.createdAt)
|
|
1022
|
+
return false;
|
|
1023
|
+
if (candidate.createdAt === prior.createdAt && (candidate.round ?? 0) <= (prior.round ?? 0))
|
|
1024
|
+
return false;
|
|
1025
|
+
if (dependencyClosureContains(team.tasks, candidate.dependencies, prior.id))
|
|
1026
|
+
return true;
|
|
1027
|
+
const candidateRoot = lineageRoot(team, candidate);
|
|
1028
|
+
return candidateRoot !== undefined && candidateRoot === lineageRoot(team, prior);
|
|
1029
|
+
}
|
|
1030
|
+
/** Paths whose current bytes decide whether recorded verdicts and orders hold. */
|
|
1031
|
+
export function deliveryArtifactPaths(team) {
|
|
1032
|
+
const paths = new Set();
|
|
1033
|
+
for (const task of team.tasks) {
|
|
1034
|
+
for (const artifact of task.reviewedArtifacts ?? [])
|
|
1035
|
+
paths.add(artifact.path);
|
|
1036
|
+
for (const artifact of task.contractSnapshot?.artifacts ?? [])
|
|
1037
|
+
paths.add(artifact.path);
|
|
1038
|
+
}
|
|
1039
|
+
// Legacy verdicts carry no digests; the changed paths of the task they
|
|
1040
|
+
// judged still let the delivery check detect a later edit by mtime.
|
|
1041
|
+
for (const task of team.tasks) {
|
|
1042
|
+
const kind = taskKindOf(task);
|
|
1043
|
+
if (!VERDICT_KINDS.includes(kind))
|
|
1044
|
+
continue;
|
|
1045
|
+
if (task.status !== 'completed')
|
|
1046
|
+
continue;
|
|
1047
|
+
if ((task.reviewedArtifacts?.length ?? 0) > 0)
|
|
1048
|
+
continue;
|
|
1049
|
+
for (const changed of reviewedTargetOf(team, task)?.changedPaths ?? []) {
|
|
1050
|
+
const normalized = normalizeWorkspacePath(changed);
|
|
1051
|
+
if (normalized !== undefined)
|
|
1052
|
+
paths.add(normalized);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
return [...paths];
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* File mtimes carry sub-millisecond precision while task stamps are whole
|
|
1059
|
+
* milliseconds, so a file written in the same millisecond as the verdict would
|
|
1060
|
+
* otherwise read as "modified after". Only a clearly later write counts;
|
|
1061
|
+
* content digests are exact and need no tolerance.
|
|
1062
|
+
*/
|
|
1063
|
+
const MTIME_TOLERANCE_MS = 1;
|
|
1064
|
+
function verdictDrift(team, item, audit) {
|
|
1065
|
+
const issues = [];
|
|
1066
|
+
const target = reviewedTargetOf(team, item);
|
|
1067
|
+
if (target !== undefined && item.reviewedAttempt !== undefined && (target.attempt ?? 0) !== item.reviewedAttempt) {
|
|
1068
|
+
issues.push({ message: `${target.id} was re-attempted after this verdict (attempt ${item.reviewedAttempt} -> ${target.attempt ?? 0})` });
|
|
1069
|
+
}
|
|
1070
|
+
for (const gap of gapDrift(item.reviewedGaps))
|
|
1071
|
+
issues.push({ message: gap });
|
|
1072
|
+
for (const drift of item.reviewedDuringDrift ?? []) {
|
|
1073
|
+
issues.push({ message: `the workspace changed while this verdict was being formed (${drift}), so it does not bind a stable revision` });
|
|
1074
|
+
}
|
|
1075
|
+
const recorded = item.reviewedArtifacts;
|
|
1076
|
+
// A file that appeared under a bound directory scope after the verdict was
|
|
1077
|
+
// never reviewed: a broad scope must not quietly absorb new work.
|
|
1078
|
+
const recordedPaths = new Set((recorded ?? []).map((artifact) => artifact.path));
|
|
1079
|
+
for (const pattern of item.reviewedScope ?? []) {
|
|
1080
|
+
if (!isDirectoryPattern(pattern))
|
|
1081
|
+
continue;
|
|
1082
|
+
for (const entry of audit.expandedScope ?? []) {
|
|
1083
|
+
if (entry.present !== true || recordedPaths.has(entry.path))
|
|
1084
|
+
continue;
|
|
1085
|
+
if (!pathMatchesScope(entry.path, pattern))
|
|
1086
|
+
continue;
|
|
1087
|
+
issues.push({ path: entry.path, message: `${entry.path} appeared under the reviewed scope ${pattern} after the verdict and was never reviewed` });
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
if (recorded !== undefined && recorded.length > 0) {
|
|
1091
|
+
issues.push(...artifactDriftDetails(recorded, audit.artifacts));
|
|
1092
|
+
return issues;
|
|
1093
|
+
}
|
|
1094
|
+
// A record written before content-bound verdicts existed carries no digests,
|
|
1095
|
+
// but the revision it judged is still identifiable: the bytes the target
|
|
1096
|
+
// claimed to change cannot have been written after the verdict closed.
|
|
1097
|
+
for (const changed of target?.changedPaths ?? []) {
|
|
1098
|
+
const normalized = normalizeWorkspacePath(changed);
|
|
1099
|
+
if (normalized === undefined)
|
|
1100
|
+
continue;
|
|
1101
|
+
const current = audit.artifacts?.find((artifact) => artifact.path === normalized);
|
|
1102
|
+
if (current === undefined)
|
|
1103
|
+
continue;
|
|
1104
|
+
if (current.unreadable === true) {
|
|
1105
|
+
issues.push({ path: normalized, message: `${normalized} cannot be read now (${current.reason ?? 'unknown error'}), so the verdict cannot be confirmed` });
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
if (current.present === true && current.mtimeMs !== undefined && current.mtimeMs > item.updatedAt + MTIME_TOLERANCE_MS) {
|
|
1109
|
+
issues.push({ path: normalized, message: `${normalized} was modified after this verdict closed (file mtime is later than the verdict)` });
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
if (current.present !== true) {
|
|
1113
|
+
// No digest was recorded, so the plugin cannot date the disappearance:
|
|
1114
|
+
// the file the implementation changed is gone and the verdict cannot be
|
|
1115
|
+
// confirmed against it. A later sound verdict supersedes this one.
|
|
1116
|
+
issues.push({ path: normalized, message: `${normalized} is recorded as changed by ${target?.id ?? 'the judged task'} but no longer exists, and no digest was recorded to confirm the verdict` });
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return issues;
|
|
1120
|
+
}
|
|
1121
|
+
/** A verdict whose evidence is absent altogether cannot be audited at all. */
|
|
1122
|
+
function unboundVerdict(item) {
|
|
1123
|
+
return (item.reviewedArtifacts?.length ?? 0) === 0
|
|
1124
|
+
&& (item.reviewedGaps?.length ?? 0) === 0
|
|
1125
|
+
&& item.reviewedAttempt === undefined;
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Audit every completed verdict against the current revision (issue #161).
|
|
1129
|
+
*
|
|
1130
|
+
* A drifted review/requirements pass is the delivery evidence itself, so it
|
|
1131
|
+
* blocks — unless a later sound verdict covers the same lineage, which is the
|
|
1132
|
+
* normal shape of `pass → repair → new pass` and must converge instead of
|
|
1133
|
+
* blocking forever behind an old snapshot. A drifted `verification` or a
|
|
1134
|
+
* superseded pass is reported as a warning: a later repair legitimately
|
|
1135
|
+
* supersedes it, but never silently. A verdict that predates content binding
|
|
1136
|
+
* is reported as an unauditable gap rather than being read as a pass over the
|
|
1137
|
+
* current bytes.
|
|
1138
|
+
*/
|
|
1139
|
+
export function verdictAuditNotes(team, audit = {}) {
|
|
1140
|
+
const notes = [];
|
|
1141
|
+
const verdicts = team.tasks.filter((item) => {
|
|
1142
|
+
const kind = taskKindOf(item);
|
|
1143
|
+
if (!VERDICT_KINDS.includes(kind))
|
|
1144
|
+
return false;
|
|
1145
|
+
if (item.status !== 'completed')
|
|
1146
|
+
return false;
|
|
1147
|
+
return !((kind === 'review' || kind === 'requirements') && item.verdict !== 'pass');
|
|
1148
|
+
});
|
|
1149
|
+
const issues = new Map();
|
|
1150
|
+
for (const item of verdicts) {
|
|
1151
|
+
const found = verdictDrift(team, item, audit);
|
|
1152
|
+
if (found.length > 0)
|
|
1153
|
+
issues.set(item.id, found);
|
|
1154
|
+
}
|
|
1155
|
+
// Only a verdict that actually recorded the bytes it judged, over a complete
|
|
1156
|
+
// scope, and that still matches the workspace may supersede an older one. An
|
|
1157
|
+
// unbound (pre-fix) pass records no identity at all: it cannot "bind the
|
|
1158
|
+
// current bytes" and must never clear another verdict's drift.
|
|
1159
|
+
const sound = verdicts.filter((item) => !issues.has(item.id) && bindsRecordedRevision(team, item, audit));
|
|
1160
|
+
for (const item of verdicts) {
|
|
1161
|
+
const kind = taskKindOf(item);
|
|
1162
|
+
const found = issues.get(item.id);
|
|
1163
|
+
if (found === undefined) {
|
|
1164
|
+
if (unboundVerdict(item) && audit.artifacts !== undefined) {
|
|
1165
|
+
notes.push({
|
|
1166
|
+
taskId: item.id,
|
|
1167
|
+
level: 'warning',
|
|
1168
|
+
message: `${item.id} (${kind}) predates content-bound verdicts and recorded no artifact identity; delivery cannot confirm the current bytes are the revision it judged — re-review to bind them`,
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
const detail = `${item.id} (${kind}) verdict covers a revision that no longer exists: ${found.map((entry) => entry.message).join('; ')}`;
|
|
1174
|
+
// Superseding needs the same lineage AND coverage of every drifted path or
|
|
1175
|
+
// gap: a narrower new review over path A must not clear an unreviewed
|
|
1176
|
+
// change to path B.
|
|
1177
|
+
const covering = sound.find((candidate) => (supersedes(team, candidate, item) && coversDrift(team, candidate, item, found)));
|
|
1178
|
+
if (covering !== undefined) {
|
|
1179
|
+
notes.push({ taskId: item.id, level: 'warning', message: `${detail} — superseded by ${covering.id}, which binds the current bytes` });
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
// A drifted gate without a sound covering verdict leaves the current bytes
|
|
1183
|
+
// with no valid evidence, whether it is a review or a verification: the
|
|
1184
|
+
// only difference is what the captain has to do next.
|
|
1185
|
+
if (kind === 'verification') {
|
|
1186
|
+
notes.push({ taskId: item.id, level: 'blocker', message: `${detail} — re-verify the current bytes; no later sound verdict bound them` });
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
notes.push({ taskId: item.id, level: 'blocker', message: `${detail} — re-review the current bytes; do not report this revision as verified` });
|
|
1190
|
+
}
|
|
1191
|
+
return notes;
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Does this verdict hold a real, complete, still-matching record of the bytes
|
|
1195
|
+
* it judged? Attempt drift, an incomplete scope, or a workspace that moved
|
|
1196
|
+
* while the gate was open all disqualify it.
|
|
1197
|
+
*/
|
|
1198
|
+
function bindsRecordedRevision(team, item, audit) {
|
|
1199
|
+
if ((item.reviewedArtifacts?.length ?? 0) === 0)
|
|
1200
|
+
return false;
|
|
1201
|
+
if ((item.reviewedGaps?.length ?? 0) > 0)
|
|
1202
|
+
return false;
|
|
1203
|
+
if ((item.reviewedDuringDrift?.length ?? 0) > 0)
|
|
1204
|
+
return false;
|
|
1205
|
+
const target = reviewedTargetOf(team, item);
|
|
1206
|
+
if (target !== undefined && item.reviewedAttempt !== undefined && (target.attempt ?? 0) !== item.reviewedAttempt)
|
|
1207
|
+
return false;
|
|
1208
|
+
if (audit.artifacts === undefined)
|
|
1209
|
+
return false;
|
|
1210
|
+
return artifactDriftDetails(item.reviewedArtifacts, audit.artifacts).length === 0;
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* A later verdict clears an older one only when it covers what actually
|
|
1214
|
+
* drifted: every implicated path must be among the bytes it bound. Reasons
|
|
1215
|
+
* that name no path (an incomplete scope, a re-attempted target, a workspace
|
|
1216
|
+
* that moved mid-review) are cleared only by a sound verdict about the same
|
|
1217
|
+
* target's current attempt.
|
|
1218
|
+
*/
|
|
1219
|
+
function coversDrift(team, candidate, prior, drifted) {
|
|
1220
|
+
if (drifted.length === 0)
|
|
1221
|
+
return false;
|
|
1222
|
+
const bound = new Set((candidate.reviewedArtifacts ?? []).map((artifact) => artifact.path));
|
|
1223
|
+
for (const issue of drifted) {
|
|
1224
|
+
if (issue.path !== undefined) {
|
|
1225
|
+
if (!bound.has(issue.path))
|
|
1226
|
+
return false;
|
|
1227
|
+
continue;
|
|
1228
|
+
}
|
|
1229
|
+
const candidateTarget = reviewedTargetOf(team, candidate);
|
|
1230
|
+
const priorTarget = reviewedTargetOf(team, prior);
|
|
1231
|
+
if (candidateTarget === undefined || priorTarget === undefined || candidateTarget.id !== priorTarget.id)
|
|
1232
|
+
return false;
|
|
1233
|
+
if (candidate.reviewedAttempt !== (candidateTarget.attempt ?? 0))
|
|
1234
|
+
return false;
|
|
1235
|
+
if (lineageRoot(team, candidate) === undefined || lineageRoot(team, candidate) !== lineageRoot(team, prior))
|
|
1236
|
+
return false;
|
|
1237
|
+
}
|
|
1238
|
+
return true;
|
|
1239
|
+
}
|
|
1240
|
+
function verdictBindingIssues(team, audit) {
|
|
666
1241
|
const blockers = [];
|
|
1242
|
+
const warnings = [];
|
|
1243
|
+
for (const note of verdictAuditNotes(team, audit)) {
|
|
1244
|
+
if (note.level === 'blocker')
|
|
1245
|
+
blockers.push(note.message);
|
|
1246
|
+
else
|
|
1247
|
+
warnings.push(note.message);
|
|
1248
|
+
}
|
|
1249
|
+
return { blockers, warnings };
|
|
1250
|
+
}
|
|
1251
|
+
/** Staleness of one auto-generated work order against the current workspace. */
|
|
1252
|
+
export function contractStaleness(task, current) {
|
|
1253
|
+
const snapshot = task.contractSnapshot;
|
|
1254
|
+
if (snapshot === undefined)
|
|
1255
|
+
return [];
|
|
1256
|
+
const stale = [];
|
|
1257
|
+
for (const gap of gapDrift(snapshot.gaps)) {
|
|
1258
|
+
stale.push(`${task.id} work order recorded only partial coverage (${gap}), so a change outside it would not be visible`);
|
|
1259
|
+
}
|
|
1260
|
+
if (snapshot.artifacts.length === 0 || current === undefined)
|
|
1261
|
+
return stale;
|
|
1262
|
+
stale.push(...artifactDrift(snapshot.artifacts, current).map((item) => `${task.id} work order was generated before: ${item}`));
|
|
1263
|
+
return stale;
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Open auto-generated work orders whose cited bytes moved on (issue #174).
|
|
1267
|
+
* A repair round's acceptance/inScope is a snapshot of the workspace at
|
|
1268
|
+
* finding-generation time; every later round invalidates it, and nothing used
|
|
1269
|
+
* to say so — a member following it literally edits the wrong line or redoes
|
|
1270
|
+
* work that is already done.
|
|
1271
|
+
*/
|
|
1272
|
+
export function workOrderWarnings(team, current) {
|
|
1273
|
+
if (current === undefined)
|
|
1274
|
+
return [];
|
|
1275
|
+
const warnings = [];
|
|
1276
|
+
for (const task of team.tasks) {
|
|
1277
|
+
if (task.contractSnapshot === undefined)
|
|
1278
|
+
continue;
|
|
1279
|
+
if (!OPEN_FOLLOW_UP_STATUSES.includes(task.status))
|
|
1280
|
+
continue;
|
|
1281
|
+
const stale = contractStaleness(task, current);
|
|
1282
|
+
if (stale.length === 0)
|
|
1283
|
+
continue;
|
|
1284
|
+
warnings.push({
|
|
1285
|
+
taskId: task.id,
|
|
1286
|
+
message: `${stale.join('; ')}. Its auto-generated acceptance/inScope describe the workspace as it was then: locate the cited code by content anchor, and amend the contract if the target is gone.`,
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
return warnings;
|
|
1290
|
+
}
|
|
1291
|
+
/** Statuses in which a repair still counts as the follow-up of a failed gate. */
|
|
1292
|
+
const FOLLOW_UP_VISIBLE_STATUSES = ['pending', 'claimed', 'in_progress', 'completed'];
|
|
1293
|
+
/**
|
|
1294
|
+
* Does a repair exist that addresses this failed gate's findings? The causal
|
|
1295
|
+
* link can be recorded three ways, and all of them count: the repair's
|
|
1296
|
+
* declared source, an explicit dependency edge on the failed gate, or the
|
|
1297
|
+
* finding ids the repair was opened for (issue #175A — the transcript edge
|
|
1298
|
+
* used to be invisible to the graph, so delivery reported
|
|
1299
|
+
* `failed without a follow-up repair` for loops that were already closed).
|
|
1300
|
+
*/
|
|
1301
|
+
function hasFollowUpRepair(team, item) {
|
|
1302
|
+
const kind = taskKindOf(item);
|
|
1303
|
+
if (kind === 'requirements') {
|
|
1304
|
+
return team.tasks.some((candidate) => (taskKindOf(candidate) === 'requirements' && (candidate.round ?? 1) > (item.round ?? 1)));
|
|
1305
|
+
}
|
|
1306
|
+
const findingIds = new Set((item.findings ?? []).map((finding) => finding.id));
|
|
1307
|
+
const upstreams = new Set([item.id, item.reviewedTaskId ?? item.sourceTaskId].filter((id) => id !== undefined));
|
|
1308
|
+
return team.tasks.some((candidate) => {
|
|
1309
|
+
if (taskKindOf(candidate) !== 'repair' || !FOLLOW_UP_VISIBLE_STATUSES.includes(candidate.status))
|
|
1310
|
+
return false;
|
|
1311
|
+
if (candidate.sourceTaskId !== undefined && upstreams.has(candidate.sourceTaskId))
|
|
1312
|
+
return true;
|
|
1313
|
+
if (candidate.dependencies.includes(item.id))
|
|
1314
|
+
return true;
|
|
1315
|
+
if (candidate.findingSourceTaskIds?.includes(item.id) === true)
|
|
1316
|
+
return true;
|
|
1317
|
+
return candidate.sourceFindingIds !== undefined
|
|
1318
|
+
&& candidate.sourceFindingIds.some((findingId) => findingIds.has(findingId));
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
/** Failed gates with no follow-up at all: work that genuinely remains. */
|
|
1322
|
+
function unresolvedGates(team) {
|
|
1323
|
+
return team.tasks.filter((item) => isQualityKind(taskKindOf(item))
|
|
1324
|
+
&& item.status === 'failed'
|
|
1325
|
+
&& !hasFollowUpRepair(team, item));
|
|
1326
|
+
}
|
|
1327
|
+
export function canDeclareDelivery(team, audit = {}) {
|
|
1328
|
+
const blockers = [];
|
|
1329
|
+
const warnings = [];
|
|
667
1330
|
if (team.phase === 'staged')
|
|
668
1331
|
blockers.push('team plan is awaiting approval');
|
|
669
1332
|
if (team.halted === true)
|
|
@@ -690,15 +1353,7 @@ export function canDeclareDelivery(team) {
|
|
|
690
1353
|
continue;
|
|
691
1354
|
}
|
|
692
1355
|
if (item.status === 'failed') {
|
|
693
|
-
|
|
694
|
-
? quality.some((candidate) => (taskKindOf(candidate) === 'repair'
|
|
695
|
-
&& candidate.sourceTaskId === (item.reviewedTaskId ?? item.sourceTaskId)
|
|
696
|
-
&& (candidate.status === 'pending' || candidate.status === 'claimed' || candidate.status === 'in_progress' || candidate.status === 'completed')))
|
|
697
|
-
: kind === 'requirements'
|
|
698
|
-
? quality.some((candidate) => (taskKindOf(candidate) === 'requirements'
|
|
699
|
-
&& (candidate.round ?? 1) > (item.round ?? 1)))
|
|
700
|
-
: quality.some((candidate) => (taskKindOf(candidate) === 'repair' && candidate.sourceTaskId === item.id));
|
|
701
|
-
if (!repaired)
|
|
1356
|
+
if (!hasFollowUpRepair(team, item))
|
|
702
1357
|
blockers.push(`${item.id} failed without a follow-up repair`);
|
|
703
1358
|
continue;
|
|
704
1359
|
}
|
|
@@ -718,7 +1373,21 @@ export function canDeclareDelivery(team) {
|
|
|
718
1373
|
}
|
|
719
1374
|
}
|
|
720
1375
|
}
|
|
721
|
-
|
|
1376
|
+
const binding = verdictBindingIssues(team, audit);
|
|
1377
|
+
blockers.push(...binding.blockers);
|
|
1378
|
+
warnings.push(...binding.warnings);
|
|
1379
|
+
if (audit.artifacts !== undefined) {
|
|
1380
|
+
warnings.push(...workOrderWarnings(team, audit.artifacts).map((warning) => warning.message));
|
|
1381
|
+
}
|
|
1382
|
+
// An incomplete audit is not evidence: a scope that outgrew its cap, or a
|
|
1383
|
+
// path that cannot be read, must block instead of passing as a clean read.
|
|
1384
|
+
const auditGaps = audit.gaps ?? [];
|
|
1385
|
+
if (auditGaps.length > 0 && (quality.length > 0 || blockers.length > 0 || audit.artifacts !== undefined)) {
|
|
1386
|
+
const shown = auditGaps.slice(0, 3).map((gap) => `${gap.pattern}: ${gap.reason}`);
|
|
1387
|
+
const more = auditGaps.length > shown.length ? ` (+${auditGaps.length - shown.length} more)` : '';
|
|
1388
|
+
blockers.push(`the current workspace audit is incomplete (${shown.join('; ')}${more}); no recorded verdict can be confirmed over partially enumerated or unreadable artifacts`);
|
|
1389
|
+
}
|
|
1390
|
+
return { ok: blockers.length === 0, blockers, warnings };
|
|
722
1391
|
}
|
|
723
1392
|
export function resumeTeamState(team, reason) {
|
|
724
1393
|
if (!nonemptyString(reason)) {
|
|
@@ -737,6 +1406,14 @@ export function resumeTeamState(team, reason) {
|
|
|
737
1406
|
},
|
|
738
1407
|
};
|
|
739
1408
|
}
|
|
1409
|
+
/**
|
|
1410
|
+
* The durable `findings[].line` contract, shared by the write-side parser
|
|
1411
|
+
* (`parseFindings`) and the durable validator (`isReviewFinding`) so the two
|
|
1412
|
+
* cannot drift apart again (issue #188, #105 class).
|
|
1413
|
+
*/
|
|
1414
|
+
export function isValidFindingLine(value) {
|
|
1415
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
1416
|
+
}
|
|
740
1417
|
export function isReviewFinding(value) {
|
|
741
1418
|
if (!isRecord(value))
|
|
742
1419
|
return false;
|
|
@@ -745,7 +1422,7 @@ export function isReviewFinding(value) {
|
|
|
745
1422
|
&& nonemptyString(value['problem'])
|
|
746
1423
|
&& nonemptyString(value['requiredFix'])
|
|
747
1424
|
&& (value['file'] === undefined || nonemptyString(value['file']))
|
|
748
|
-
&& (value['line'] === undefined || (
|
|
1425
|
+
&& (value['line'] === undefined || isValidFindingLine(value['line']))
|
|
749
1426
|
&& (value['resolved'] === undefined || typeof value['resolved'] === 'boolean');
|
|
750
1427
|
}
|
|
751
1428
|
export function isAcceptanceResult(value) {
|
|
@@ -772,7 +1449,40 @@ export function isTaskRevision(value) {
|
|
|
772
1449
|
&& nonemptyStringList(value['fields'])
|
|
773
1450
|
&& isRecord(value['previous']);
|
|
774
1451
|
}
|
|
775
|
-
|
|
1452
|
+
export function isArtifactDigest(value) {
|
|
1453
|
+
if (!isRecord(value))
|
|
1454
|
+
return false;
|
|
1455
|
+
if (!nonemptyString(value['path']) || normalizeWorkspacePath(value['path']) === undefined)
|
|
1456
|
+
return false;
|
|
1457
|
+
if (typeof value['present'] !== 'boolean')
|
|
1458
|
+
return false;
|
|
1459
|
+
if (value['sha256'] !== undefined && !(typeof value['sha256'] === 'string' && /^[0-9a-f]{64}$/u.test(value['sha256'])))
|
|
1460
|
+
return false;
|
|
1461
|
+
if (value['size'] !== undefined && !(Number.isSafeInteger(value['size']) && value['size'] >= 0))
|
|
1462
|
+
return false;
|
|
1463
|
+
if (value['mtimeMs'] !== undefined && !(typeof value['mtimeMs'] === 'number' && Number.isFinite(value['mtimeMs'])))
|
|
1464
|
+
return false;
|
|
1465
|
+
if (value['unreadable'] !== undefined && typeof value['unreadable'] !== 'boolean')
|
|
1466
|
+
return false;
|
|
1467
|
+
if (value['reason'] !== undefined && !nonemptyString(value['reason']))
|
|
1468
|
+
return false;
|
|
1469
|
+
if (value['present'] === true && value['sha256'] === undefined)
|
|
1470
|
+
return false;
|
|
1471
|
+
return true;
|
|
1472
|
+
}
|
|
1473
|
+
export function isArtifactScopeGap(value) {
|
|
1474
|
+
return isRecord(value) && nonemptyString(value['pattern']) && nonemptyString(value['reason']);
|
|
1475
|
+
}
|
|
1476
|
+
export function isArtifactSnapshot(value) {
|
|
1477
|
+
if (!isRecord(value))
|
|
1478
|
+
return false;
|
|
1479
|
+
if (!Number.isSafeInteger(value['at']) || value['at'] < 0)
|
|
1480
|
+
return false;
|
|
1481
|
+
if (!Array.isArray(value['artifacts']) || !value['artifacts'].every(isArtifactDigest))
|
|
1482
|
+
return false;
|
|
1483
|
+
return value['gaps'] === undefined
|
|
1484
|
+
|| (Array.isArray(value['gaps']) && value['gaps'].length > 0 && value['gaps'].every(isArtifactScopeGap));
|
|
1485
|
+
} // Optional fields whose persisted values must be non-empty when present
|
|
776
1486
|
// (mirrors the checks in hasValidQualityTaskFields). Some models materialize
|
|
777
1487
|
// optional tool parameters as "" instead of omitting them (e.g. GPT-5.6
|
|
778
1488
|
// sending reviewedTaskId:"" or profile:""), which would otherwise be written
|
|
@@ -815,7 +1525,62 @@ export function normalizeBlankOptionalTaskFields(task) {
|
|
|
815
1525
|
else
|
|
816
1526
|
next[key] = kept;
|
|
817
1527
|
}
|
|
818
|
-
return next;
|
|
1528
|
+
return normalizeSentinelNumericTaskFields(next);
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* Numeric task fields whose durable contract is a bounded range, paired with
|
|
1532
|
+
* their minimum (`Number.isSafeInteger` plus the lower bound). Some models
|
|
1533
|
+
* materialize "no value" as a numeric sentinel (`line: -1`, `round: 0`,
|
|
1534
|
+
* `reviewedAttempt: -1`) that the write side used to persist verbatim and the
|
|
1535
|
+
* durable validator then rejected — bricking the whole team on the next read
|
|
1536
|
+
* (issue #188, #105 class).
|
|
1537
|
+
*/
|
|
1538
|
+
const SENTINEL_NUMERIC_TASK_FIELDS = [
|
|
1539
|
+
['round', 1],
|
|
1540
|
+
['reviewedAttempt', 0],
|
|
1541
|
+
['attempt', 0],
|
|
1542
|
+
];
|
|
1543
|
+
/**
|
|
1544
|
+
* Treat an out-of-contract **numeric** sentinel as absent, including
|
|
1545
|
+
* `findings[].line` (contract: `isValidFindingLine`).
|
|
1546
|
+
*
|
|
1547
|
+
* Only numbers are healed. A value of any other type (`round: "oops"`,
|
|
1548
|
+
* `attempt: {}`, `line: "12"`) is left exactly as written and stays a hard,
|
|
1549
|
+
* named validation failure, so genuine corruption is never silently swallowed
|
|
1550
|
+
* while `-1`-style sentinels — the #188 brick — self-heal. Field order, unknown
|
|
1551
|
+
* fields and every in-range value are preserved.
|
|
1552
|
+
*/
|
|
1553
|
+
export function normalizeSentinelNumericTaskFields(task) {
|
|
1554
|
+
const next = { ...task };
|
|
1555
|
+
let changed = false;
|
|
1556
|
+
for (const [key, minimum] of SENTINEL_NUMERIC_TASK_FIELDS) {
|
|
1557
|
+
const value = next[key];
|
|
1558
|
+
if (typeof value !== 'number')
|
|
1559
|
+
continue;
|
|
1560
|
+
if (Number.isSafeInteger(value) && value >= minimum)
|
|
1561
|
+
continue;
|
|
1562
|
+
delete next[key];
|
|
1563
|
+
changed = true;
|
|
1564
|
+
}
|
|
1565
|
+
if (Array.isArray(next['findings'])) {
|
|
1566
|
+
let findingsChanged = false;
|
|
1567
|
+
const findings = next['findings'].map((finding) => {
|
|
1568
|
+
if (!isRecord(finding))
|
|
1569
|
+
return finding;
|
|
1570
|
+
const line = finding['line'];
|
|
1571
|
+
if (typeof line !== 'number' || isValidFindingLine(line))
|
|
1572
|
+
return finding;
|
|
1573
|
+
const cleaned = { ...finding };
|
|
1574
|
+
delete cleaned['line'];
|
|
1575
|
+
findingsChanged = true;
|
|
1576
|
+
return cleaned;
|
|
1577
|
+
});
|
|
1578
|
+
if (findingsChanged) {
|
|
1579
|
+
next['findings'] = findings;
|
|
1580
|
+
changed = true;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
return (changed ? next : task);
|
|
819
1584
|
}
|
|
820
1585
|
export function hasValidQualityTaskFields(value) {
|
|
821
1586
|
if (value['kind'] !== undefined && !TASK_KINDS.includes(value['kind']))
|
|
@@ -833,7 +1598,7 @@ export function hasValidQualityTaskFields(value) {
|
|
|
833
1598
|
if (value['reviewedAttempt'] !== undefined && !(Number.isSafeInteger(value['reviewedAttempt']) && value['reviewedAttempt'] >= 0)) {
|
|
834
1599
|
return false;
|
|
835
1600
|
}
|
|
836
|
-
const stringLists = ['inScope', 'outOfScope', 'acceptance', 'verify', 'deliverables', 'nonGoals', 'changedPaths', 'sourceFindingIds', 'coverageOf'];
|
|
1601
|
+
const stringLists = ['inScope', 'outOfScope', 'acceptance', 'verify', 'deliverables', 'nonGoals', 'changedPaths', 'sourceFindingIds', 'findingSourceTaskIds', 'coverageOf'];
|
|
837
1602
|
for (const key of stringLists) {
|
|
838
1603
|
if (value[key] === undefined)
|
|
839
1604
|
continue;
|
|
@@ -861,6 +1626,20 @@ export function hasValidQualityTaskFields(value) {
|
|
|
861
1626
|
if (!Array.isArray(value['revisions']) || !value['revisions'].every(isTaskRevision))
|
|
862
1627
|
return false;
|
|
863
1628
|
}
|
|
1629
|
+
if (value['reviewedArtifacts'] !== undefined
|
|
1630
|
+
&& (!Array.isArray(value['reviewedArtifacts']) || !value['reviewedArtifacts'].every(isArtifactDigest)))
|
|
1631
|
+
return false;
|
|
1632
|
+
if (value['reviewedGaps'] !== undefined
|
|
1633
|
+
&& (!Array.isArray(value['reviewedGaps']) || value['reviewedGaps'].length === 0 || !value['reviewedGaps'].every(isArtifactScopeGap)))
|
|
1634
|
+
return false;
|
|
1635
|
+
if (value['reviewedScope'] !== undefined && !nonemptyStringList(value['reviewedScope']))
|
|
1636
|
+
return false;
|
|
1637
|
+
if (value['reviewedDuringDrift'] !== undefined && !nonemptyStringList(value['reviewedDuringDrift']))
|
|
1638
|
+
return false;
|
|
1639
|
+
if (value['contractSnapshot'] !== undefined && !isArtifactSnapshot(value['contractSnapshot']))
|
|
1640
|
+
return false;
|
|
1641
|
+
if (value['verdictStartSnapshot'] !== undefined && !isArtifactSnapshot(value['verdictStartSnapshot']))
|
|
1642
|
+
return false;
|
|
864
1643
|
return true;
|
|
865
1644
|
}
|
|
866
1645
|
function isRecord(value) {
|
|
@@ -957,8 +1736,9 @@ export function qualityPlanningPrompt() {
|
|
|
957
1736
|
'halted means the human stopped the team; call agent_teams_resume before creating more work. escalated means the automatic review loop hit its ceiling; that is not halt.',
|
|
958
1737
|
].join(' ');
|
|
959
1738
|
}
|
|
960
|
-
export function describeQualityLoop(team) {
|
|
961
|
-
const delivery = canDeclareDelivery(team);
|
|
1739
|
+
export function describeQualityLoop(team, audit = {}) {
|
|
1740
|
+
const delivery = canDeclareDelivery(team, audit);
|
|
1741
|
+
const warnings = [...delivery.warnings];
|
|
962
1742
|
if (team.halted === true) {
|
|
963
1743
|
return {
|
|
964
1744
|
state: 'halted',
|
|
@@ -966,6 +1746,7 @@ export function describeQualityLoop(team) {
|
|
|
966
1746
|
escalated: team.escalated === true,
|
|
967
1747
|
deliverable: false,
|
|
968
1748
|
summary: 'Team is halted. Call agent_teams_resume with a reason before creating more work.',
|
|
1749
|
+
warnings,
|
|
969
1750
|
};
|
|
970
1751
|
}
|
|
971
1752
|
if (delivery.ok) {
|
|
@@ -974,16 +1755,19 @@ export function describeQualityLoop(team) {
|
|
|
974
1755
|
halted: false,
|
|
975
1756
|
escalated: team.escalated === true,
|
|
976
1757
|
deliverable: true,
|
|
977
|
-
summary:
|
|
1758
|
+
summary: `All required work and quality gates passed. The captain may report delivery.${warnings.length === 0 ? '' : ` Warnings: ${warnings.join('; ')}`}`,
|
|
1759
|
+
warnings,
|
|
978
1760
|
};
|
|
979
1761
|
}
|
|
980
1762
|
if (team.escalated === true) {
|
|
1763
|
+
const unresolved = unresolvedGates(team);
|
|
981
1764
|
return {
|
|
982
1765
|
state: 'escalated',
|
|
983
1766
|
halted: false,
|
|
984
1767
|
escalated: true,
|
|
985
1768
|
deliverable: false,
|
|
986
|
-
summary:
|
|
1769
|
+
summary: `Automatic review/repair loop hit its ceiling. The team is still running; do not treat this as halt. Escalate to the user instead of inventing another needs_revision cycle.${unresolved.length === 0 ? '' : ` Genuinely unresolved gates: ${unresolved.map((item) => `${item.id} (${taskKindOf(item)})`).join(', ')}; any failed gate not listed here already has a follow-up repair.`}`,
|
|
1770
|
+
warnings,
|
|
987
1771
|
};
|
|
988
1772
|
}
|
|
989
1773
|
const open = team.tasks.some((item) => OPEN_STATUSES.includes(item.status));
|
|
@@ -994,7 +1778,8 @@ export function describeQualityLoop(team) {
|
|
|
994
1778
|
deliverable: false,
|
|
995
1779
|
summary: open
|
|
996
1780
|
? 'Work remains on the shared task list; wait for the scheduler or complete owned tasks.'
|
|
997
|
-
: `Delivery is blocked: ${delivery.blockers.join('; ') || 'unresolved quality gates'}
|
|
1781
|
+
: `Delivery is blocked: ${delivery.blockers.join('; ') || 'unresolved quality gates'}.${warnings.length === 0 ? '' : ` Warnings: ${warnings.join('; ')}`}`,
|
|
1782
|
+
warnings,
|
|
998
1783
|
};
|
|
999
1784
|
}
|
|
1000
1785
|
export { QUALITY_KINDS, WRITE_KINDS };
|