@nanmicoder/dsh-agent-teams 0.1.22 → 0.1.24-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +35 -13
  2. package/README_ZH.md +35 -13
  3. package/compatibility.json +6 -2
  4. package/lib/artifacts.js +342 -0
  5. package/lib/capabilities.js +6 -4
  6. package/lib/client/ActivityPanel.js +5 -2
  7. package/lib/client/agent-teams-card-definition.js +100 -6
  8. package/lib/client.js +90 -7
  9. package/lib/client.js.map +1 -1
  10. package/lib/command.js +3 -2
  11. package/lib/git-artifact-stamp.json +2 -2
  12. package/lib/harness-compat.js +139 -24
  13. package/lib/index.js +26 -3
  14. package/lib/lineage.js +198 -0
  15. package/lib/mailbox.js +59 -34
  16. package/lib/members.js +355 -82
  17. package/lib/profiles.js +3 -2
  18. package/lib/quality-gates.js +850 -65
  19. package/lib/scheduler.js +22 -3
  20. package/lib/snapshot.js +22 -1
  21. package/lib/state.js +282 -13
  22. package/lib/tool-policies.js +187 -0
  23. package/lib/tools.js +382 -109
  24. package/lib/types/artifacts.d.ts +66 -0
  25. package/lib/types/client/activity-monitor.d.ts +6 -0
  26. package/lib/types/client/agent-teams-card-definition.d.ts +56 -0
  27. package/lib/types/harness-compat.d.ts +78 -15
  28. package/lib/types/index.d.ts +26 -1
  29. package/lib/types/lineage.d.ts +92 -0
  30. package/lib/types/members.d.ts +113 -2
  31. package/lib/types/profiles.d.ts +6 -0
  32. package/lib/types/quality-gates.d.ts +216 -10
  33. package/lib/types/scheduler.d.ts +2 -1
  34. package/lib/types/snapshot.d.ts +20 -0
  35. package/lib/types/state.d.ts +22 -5
  36. package/lib/types/tool-policies.d.ts +119 -0
  37. package/lib/types/tools.d.ts +12 -1
  38. package/lib/types/types.d.ts +100 -0
  39. package/lib/types/workspace-audit.d.ts +27 -0
  40. package/lib/workspace-audit.js +52 -0
  41. package/package.json +61 -54
  42. package/pnpm-workspace.yaml +304 -273
  43. package/release-notes/v0.1.23.md +34 -0
  44. package/release-notes/v0.1.24-rc.1.md +13 -0
  45. package/scripts/legacy-session-source-repair.mjs +344 -0
@@ -111,13 +111,19 @@ export function pathMatchesScope(path, pattern) {
111
111
  }
112
112
  return normalizedPath === normalizedPattern;
113
113
  }
114
- function isDefaultExcluded(path) {
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); when nothing legal remains,
434
- * the source task's own inScope is kept as the fallback. Over-inclusion is
435
- * accepted: inScope is an audit upper bound, and the requiredFix text still
436
- * tells the implementer what to touch.
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
- * Keep all derived paths until the generator resolves inherited exclusions;
439
- * filtering first would silently discard a required fix target.
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 || derived.includes(normalized))
477
+ if (normalized === undefined || paths.includes(normalized))
446
478
  return;
447
- derived.push(normalized);
479
+ paths.push(normalized);
448
480
  };
449
- for (const finding of findings) {
450
- if (nonemptyString(finding.file))
451
- push(finding.file);
452
- for (const match of finding.requiredFix.matchAll(REPAIR_SCOPE_PATH_PATTERN))
453
- push(match[0]);
454
- }
455
- return derived.length > 0 ? derived : fallback === undefined ? undefined : [...new Set(fallback)];
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
- export function planQualityFollowUp(team, closed) {
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 ((kind !== 'review' && kind !== 'requirements') || closed.status !== 'failed')
688
+ if (closed.status !== 'failed')
583
689
  return empty;
584
- if (closed.verdict === 'reject')
585
- return { ...empty, escalated: true, status: 'escalated' };
586
- if (closed.verdict !== 'needs_revision')
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 { ...empty, escalated: true, status: 'escalated' };
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 sourceId = closed.reviewedTaskId ?? closed.sourceTaskId;
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 findingIds = findings.map((finding) => finding.id);
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 { ...empty, escalated: true, status: 'escalated' };
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 plus any
618
- // workspace-relative paths referenced by the requiredFix instructions.
619
- const repairScope = repairScopeFromFindings(findings, source?.inScope);
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: [sourceId],
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: source?.verify,
632
- acceptance: findings.map((finding) => finding.requiredFix),
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 reviewer = schedulableAssignee(closed.assignee !== implementer ? closed.assignee : undefined, team, implementer);
637
- const review = {
638
- id: `review-round-${nextRound}`,
639
- kind: 'review',
640
- subject: `review-round-${nextRound}`,
641
- assignee: reviewer,
642
- dependencies: [repair.id ?? `repair-round-${nextRound}`],
643
- round: nextRound,
644
- objective: sanitizeReviewObjective(closed.objective, DEFAULT_REVIEW_OBJECTIVE),
645
- acceptance: sanitizeReviewAcceptance(closed.acceptance),
646
- reviewedTaskId: repair.id,
647
- };
648
- return { created: [repair, review], tasks: [repair, review] };
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
- export function canDeclareDelivery(team) {
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
- const repaired = kind === 'review'
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
- return { ok: blockers.length === 0, blockers };
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 || (Number.isSafeInteger(value['line']) && value['line'] >= 0))
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
- // Optional fields whose persisted values must be non-empty when present
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: 'All required work and quality gates passed. The captain may report delivery.',
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: '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.',
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 };