mandrel 2.20.0 → 2.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/agents/story-worker.md +15 -0
  3. package/.agents/instructions.md +14 -17
  4. package/.agents/rules/git-conventions.md +1 -1
  5. package/.agents/rules/known-tooling-behavior.md +114 -0
  6. package/.agents/scripts/check-context-budget.js +134 -2
  7. package/.agents/scripts/deliver-light.js +72 -8
  8. package/.agents/scripts/lib/audit-suite/selector.js +275 -162
  9. package/.agents/scripts/lib/config/temp-paths.js +113 -7
  10. package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
  11. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
  12. package/.agents/scripts/lib/label-constants.js +12 -1
  13. package/.agents/scripts/lib/observability/runtime-friction.js +13 -1
  14. package/.agents/scripts/lib/observability/signals-writer.js +133 -14
  15. package/.agents/scripts/lib/observability/source-classifier.js +131 -1
  16. package/.agents/scripts/lib/orchestration/code-review.js +12 -0
  17. package/.agents/scripts/lib/orchestration/complexity-gate.js +119 -52
  18. package/.agents/scripts/lib/orchestration/deliver-recover.js +253 -6
  19. package/.agents/scripts/lib/orchestration/light-suitability.js +194 -11
  20. package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
  21. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  22. package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
  23. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
  24. package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
  25. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
  26. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
  27. package/.agents/scripts/lib/orchestration/run-epilogue.js +51 -1
  28. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +11 -1
  29. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
  30. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
  31. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +1 -1
  32. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
  33. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +117 -4
  34. package/.agents/scripts/lib/orchestration/story-follow-ups.js +305 -10
  35. package/.agents/scripts/lib/story-body/story-body.js +248 -174
  36. package/.agents/scripts/lib/temp-retention.js +23 -8
  37. package/.agents/scripts/resolve-stories.js +52 -33
  38. package/.agents/scripts/single-story-confirm-merge.js +6 -8
  39. package/.agents/workflows/helpers/deliver-digest.md +8 -6
  40. package/.agents/workflows/helpers/deliver-light.md +45 -5
  41. package/.agents/workflows/helpers/deliver-reference.md +15 -12
  42. package/.agents/workflows/helpers/deliver-story-reference.md +56 -21
  43. package/.agents/workflows/helpers/deliver-story.md +8 -5
  44. package/.agents/workflows/helpers/plan-reference.md +5 -4
  45. package/docs/CHANGELOG.md +28 -0
  46. package/package.json +1 -1
@@ -324,16 +324,20 @@ export function _resetWebSurfaceCache() {
324
324
  }
325
325
 
326
326
  /**
327
- * True when the root `package.json` declares a web-framework dependency.
327
+ * True when the root `package.json` declares a dependency whose name (or a
328
+ * scope/name segment of it) appears in `packageList`. The shared probe behind
329
+ * {@link declaresWebFramework} and {@link declaresOrmDependency}.
330
+ *
328
331
  * Returns `null` — *indeterminate* — when the manifest exists but cannot be
329
- * read or parsed; the caller fails open on that (see {@link hasWebSurface}).
330
- * A genuinely absent manifest (`ENOENT`) is determinate: there is no
331
- * declaration, so there is no signal, and the file scan gets its turn.
332
+ * read or parsed; callers fail open on that. A genuinely absent manifest
333
+ * (`ENOENT`) is determinate: there is no declaration, so there is no signal,
334
+ * and the file scan gets its turn.
332
335
  *
333
336
  * @param {string} root
337
+ * @param {readonly string[]} packageList
334
338
  * @returns {boolean|null}
335
339
  */
336
- function declaresWebFramework(root) {
340
+ function declaresDependencyIn(root, packageList) {
337
341
  let raw;
338
342
  try {
339
343
  raw = readFileSync(path.join(root, 'package.json'), 'utf8');
@@ -355,10 +359,22 @@ function declaresWebFramework(root) {
355
359
  name
356
360
  .replace(/^@/, '')
357
361
  .split('/')
358
- .some((segment) => WEB_FRAMEWORK_PACKAGES.includes(segment)),
362
+ .some((segment) => packageList.includes(segment)),
359
363
  );
360
364
  }
361
365
 
366
+ /**
367
+ * True when the root `package.json` declares a web-framework dependency.
368
+ * Tri-state contract per {@link declaresDependencyIn}; the fail-open caller
369
+ * is {@link hasWebSurface}.
370
+ *
371
+ * @param {string} root
372
+ * @returns {boolean|null}
373
+ */
374
+ function declaresWebFramework(root) {
375
+ return declaresDependencyIn(root, WEB_FRAMEWORK_PACKAGES);
376
+ }
377
+
362
378
  /**
363
379
  * True when a bounded scan of the source tree finds a web asset file
364
380
  * (`.html` / `.css` / `.jsx` / `.tsx`) outside the skipped directories.
@@ -514,38 +530,14 @@ export function _resetPersistenceLayerCache() {
514
530
 
515
531
  /**
516
532
  * True when the root `package.json` declares an ORM / query-builder dependency.
517
- * Returns `null` *indeterminate* when the manifest exists but cannot be
518
- * read or parsed (the caller fails open on that). A genuinely absent manifest
519
- * (`ENOENT`) is determinate: there is no declaration, so the file scan gets its
520
- * turn. Mirrors {@link declaresWebFramework}.
533
+ * Tri-state contract per {@link declaresDependencyIn}; the fail-open caller
534
+ * is {@link hasPersistenceLayer}.
521
535
  *
522
536
  * @param {string} root
523
537
  * @returns {boolean|null}
524
538
  */
525
539
  function declaresOrmDependency(root) {
526
- let raw;
527
- try {
528
- raw = readFileSync(path.join(root, 'package.json'), 'utf8');
529
- } catch (err) {
530
- if (err?.code === 'ENOENT') return false;
531
- return null;
532
- }
533
- let pkg;
534
- try {
535
- pkg = JSON.parse(raw);
536
- } catch {
537
- return null;
538
- }
539
- const names = [
540
- ...Object.keys(pkg?.dependencies ?? {}),
541
- ...Object.keys(pkg?.devDependencies ?? {}),
542
- ];
543
- return names.some((name) =>
544
- name
545
- .replace(/^@/, '')
546
- .split('/')
547
- .some((segment) => ORM_PACKAGES.includes(segment)),
548
- );
540
+ return declaresDependencyIn(root, ORM_PACKAGES);
549
541
  }
550
542
 
551
543
  /**
@@ -802,24 +794,10 @@ export async function selectAudits({
802
794
  }) {
803
795
  const config = resolveConfig();
804
796
  const timeoutMs = gitTimeoutMsOverride ?? DEFAULT_GIT_TIMEOUT_MS;
805
-
806
- const rulesPath = path.join(
807
- PROJECT_ROOT,
808
- getPaths(config).schemasRoot,
809
- 'audit-rules.json',
810
- );
811
- let rulesData;
812
- try {
813
- rulesData = JSON.parse(await fs.readFile(rulesPath, 'utf8'));
814
- } catch (err) {
815
- throw new Error(
816
- `Failed to read audit-rules from ${rulesPath}: ${err.message}`,
817
- );
818
- }
797
+ const rulesData = await loadAuditRules(config);
819
798
 
820
799
  const ticket = await provider.getTicket(ticketId);
821
- const contentToSearch =
822
- `${ticket.title || ''} ${ticket.body || ''}`.toLowerCase();
800
+ const contentToSearch = ticketSearchText(ticket);
823
801
 
824
802
  const runGit = injectedGitSpawn ?? (async (...args) => gitSpawn(...args));
825
803
 
@@ -831,102 +809,279 @@ export async function selectAudits({
831
809
  // empty by construction.
832
810
  const hasInjectedChangedFiles = Array.isArray(injectedChangedFiles);
833
811
 
834
- // Resolve `headRef` to a commit before diffing. A non-default `headRef`
835
- // (Epic-mode callers pass `refs/heads/epic/<id>`) that the repo can't
836
- // resolve means the requested Epic's branch is not present in this
837
- // checkout — diffing `baseBranch...HEAD` would silently report a
838
- // *different* Epic's change set (Story #3362). Surface that as an explicit
839
- // degraded signal instead of leaking the wrong scope. `HEAD` is always
840
- // resolvable in a valid repo, so the default-path callers skip the probe
841
- // cost on the common case.
842
812
  if (!hasInjectedChangedFiles && headRef !== 'HEAD') {
843
- let resolved;
844
- try {
845
- resolved = await withTimeout(
846
- runGit(process.cwd(), 'rev-parse', '--verify', '--quiet', headRef),
847
- timeoutMs,
848
- { label: 'select-audits rev-parse headRef' },
849
- );
850
- } catch (err) {
851
- if (err?.code === 'ETIMEDOUT') {
852
- return softFailOrThrow(
853
- 'GIT_DIFF_TIMEOUT',
854
- `select-audits: git rev-parse ${headRef} timed out after ${timeoutMs} ms`,
855
- gateModeOpts,
856
- );
857
- }
858
- throw err;
859
- }
860
- if (resolved?.status !== 0 || !resolved.stdout.trim()) {
813
+ const degraded = await resolveHeadRefOrDegrade({
814
+ runGit,
815
+ headRef,
816
+ timeoutMs,
817
+ gateModeOpts,
818
+ });
819
+ if (degraded) return degraded;
820
+ }
821
+
822
+ let changedFiles;
823
+ if (hasInjectedChangedFiles) {
824
+ changedFiles = injectedChangedFiles
825
+ .map((f) => String(f).trim())
826
+ .filter(Boolean);
827
+ } else {
828
+ const acquired = await acquireChangedFilesFromDiff({
829
+ runGit,
830
+ baseBranch,
831
+ headRef,
832
+ timeoutMs,
833
+ gateModeOpts,
834
+ });
835
+ if (acquired.degraded) return acquired.degraded;
836
+ changedFiles = acquired.files;
837
+ }
838
+
839
+ const selectedAudits = matchAuditRules({
840
+ audits: rulesData.audits ?? {},
841
+ gate,
842
+ contentToSearch,
843
+ changedFiles,
844
+ projectSupportsTarget: makeTargetApplicabilityProbe({
845
+ config,
846
+ hasWebSurfaceFn,
847
+ hasPersistenceLayerFn,
848
+ }),
849
+ });
850
+
851
+ return {
852
+ selectedAudits,
853
+ ticketId,
854
+ gate,
855
+ context: {
856
+ // Full file list, exposed so Epic-mode callers (e.g. epic-audit) can
857
+ // pass it through as the {{changedFiles}} substitution value. Existing
858
+ // callers that read only `changedFilesCount` remain unaffected.
859
+ changedFiles,
860
+ changedFilesCount: changedFiles.length,
861
+ // The ref the change set was actually diffed against. Epic-mode callers
862
+ // assert this matches the requested Epic branch (Story #3362) so a
863
+ // mis-pinned diff never reaches the audit-lens selector silently. `null`
864
+ // on the injected path: no ref was diffed, and naming one would invite
865
+ // exactly that assertion to pass against a diff nobody took.
866
+ resolvedRef: hasInjectedChangedFiles ? null : headRef,
867
+ ticketTitle: ticket.title,
868
+ },
869
+ };
870
+ }
871
+
872
+ /**
873
+ * Read and parse `audit-rules.json` from the configured schemas root.
874
+ *
875
+ * @param {object} config Resolved `.agentrc.json` wrapper.
876
+ * @returns {Promise<object>}
877
+ */
878
+ async function loadAuditRules(config) {
879
+ const rulesPath = path.join(
880
+ PROJECT_ROOT,
881
+ getPaths(config).schemasRoot,
882
+ 'audit-rules.json',
883
+ );
884
+ try {
885
+ return JSON.parse(await fs.readFile(rulesPath, 'utf8'));
886
+ } catch (err) {
887
+ throw new Error(
888
+ `Failed to read audit-rules from ${rulesPath}: ${err.message}`,
889
+ );
890
+ }
891
+ }
892
+
893
+ /** Lower-cased ticket text the keyword triggers match against. */
894
+ function ticketSearchText(ticket) {
895
+ return `${ticket.title || ''} ${ticket.body || ''}`.toLowerCase();
896
+ }
897
+
898
+ /**
899
+ * Resolve a non-default `headRef` to a commit before diffing. A ref the repo
900
+ * cannot resolve means the requested branch is not present in this checkout —
901
+ * diffing `baseBranch...HEAD` would silently report a *different* change set
902
+ * (Story #3362) — so that surfaces as an explicit degraded signal instead of
903
+ * leaking the wrong scope. Returns `null` when the ref resolves; otherwise
904
+ * the degraded envelope from `softFailOrThrow` (which throws in gate-mode).
905
+ * A non-timeout spawn failure propagates.
906
+ *
907
+ * @param {{ runGit: Function, headRef: string, timeoutMs: number, gateModeOpts?: object }} params
908
+ * @returns {Promise<object|null>}
909
+ */
910
+ async function resolveHeadRefOrDegrade({
911
+ runGit,
912
+ headRef,
913
+ timeoutMs,
914
+ gateModeOpts,
915
+ }) {
916
+ let resolved;
917
+ try {
918
+ resolved = await withTimeout(
919
+ runGit(process.cwd(), 'rev-parse', '--verify', '--quiet', headRef),
920
+ timeoutMs,
921
+ { label: 'select-audits rev-parse headRef' },
922
+ );
923
+ } catch (err) {
924
+ if (err?.code === 'ETIMEDOUT') {
861
925
  return softFailOrThrow(
862
- 'HEAD_REF_UNRESOLVED',
863
- `select-audits: requested ref '${headRef}' could not be resolved in this checkout; refusing to diff against a phantom change set`,
926
+ 'GIT_DIFF_TIMEOUT',
927
+ `select-audits: git rev-parse ${headRef} timed out after ${timeoutMs} ms`,
864
928
  gateModeOpts,
865
929
  );
866
930
  }
931
+ throw err;
867
932
  }
933
+ if (resolved?.status !== 0 || !resolved.stdout.trim()) {
934
+ return softFailOrThrow(
935
+ 'HEAD_REF_UNRESOLVED',
936
+ `select-audits: requested ref '${headRef}' could not be resolved in this checkout; refusing to diff against a phantom change set`,
937
+ gateModeOpts,
938
+ );
939
+ }
940
+ return null;
941
+ }
868
942
 
869
- let changedFiles = hasInjectedChangedFiles
870
- ? injectedChangedFiles.map((f) => String(f).trim()).filter(Boolean)
871
- : [];
943
+ /**
944
+ * Acquire the change set from `git diff --name-only base...head`. Returns
945
+ * `{ files }` on success — a diff that exits non-zero yields an empty list,
946
+ * preserving the pre-extraction flow — or `{ degraded }` with the soft-fail
947
+ * envelope on timeout. A non-timeout spawn failure propagates.
948
+ *
949
+ * @param {{ runGit: Function, baseBranch: string, headRef: string, timeoutMs: number, gateModeOpts?: object }} params
950
+ * @returns {Promise<{ files?: string[], degraded?: object }>}
951
+ */
952
+ async function acquireChangedFilesFromDiff({
953
+ runGit,
954
+ baseBranch,
955
+ headRef,
956
+ timeoutMs,
957
+ gateModeOpts,
958
+ }) {
959
+ let diff;
872
960
  try {
873
- const diff = hasInjectedChangedFiles
874
- ? null
875
- : await withTimeout(
876
- runGit(
877
- process.cwd(),
878
- 'diff',
879
- '--name-only',
880
- `${baseBranch}...${headRef}`,
881
- ),
882
- timeoutMs,
883
- { label: 'select-audits git diff' },
884
- );
885
- if (diff?.status === 0) {
886
- changedFiles = diff.stdout
887
- .split('\n')
888
- .map((f) => f.trim())
889
- .filter(Boolean);
890
- }
961
+ diff = await withTimeout(
962
+ runGit(
963
+ process.cwd(),
964
+ 'diff',
965
+ '--name-only',
966
+ `${baseBranch}...${headRef}`,
967
+ ),
968
+ timeoutMs,
969
+ { label: 'select-audits git diff' },
970
+ );
891
971
  } catch (err) {
892
972
  if (err?.code === 'ETIMEDOUT') {
893
973
  // Soft-fail contract (Tech Spec #819): in default mode, return a
894
974
  // degraded envelope so the caller sees the explicit signal instead of
895
975
  // silently falling through to keyword-only matching. In gate-mode,
896
976
  // hard-fail closed.
897
- return softFailOrThrow(
898
- 'GIT_DIFF_TIMEOUT',
899
- `select-audits: git diff against ${baseBranch} timed out after ${timeoutMs} ms`,
900
- gateModeOpts,
901
- );
977
+ return {
978
+ degraded: softFailOrThrow(
979
+ 'GIT_DIFF_TIMEOUT',
980
+ `select-audits: git diff against ${baseBranch} timed out after ${timeoutMs} ms`,
981
+ gateModeOpts,
982
+ ),
983
+ };
902
984
  }
903
985
  throw err;
904
986
  }
987
+ if (diff?.status !== 0) return { files: [] };
988
+ return {
989
+ files: diff.stdout
990
+ .split('\n')
991
+ .map((f) => f.trim())
992
+ .filter(Boolean),
993
+ };
994
+ }
905
995
 
906
- const selectedAudits = [];
996
+ /**
997
+ * Whole-word keyword match over the ticket's search text: a bare substring
998
+ * test selects lenses on accidental fragments ("ui" inside "requires",
999
+ * "auth" inside "author" — #4579).
1000
+ *
1001
+ * @param {string[]} keywords
1002
+ * @param {string} content Lower-cased ticket text.
1003
+ * @returns {boolean}
1004
+ */
1005
+ function matchesAnyKeyword(keywords, content) {
1006
+ return keywords.some((kw) => {
1007
+ const escaped = kw.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1008
+ return new RegExp(`\\b${escaped}\\b`).test(content);
1009
+ });
1010
+ }
907
1011
 
908
- // Applicability probes, resolved at most once per target per call, and only
909
- // if a lens declaring that target actually clears its gate — a Node-only
910
- // project must not pay a filesystem scan on a roster with no `web` lens in
911
- // it, nor a DB-less project pay one for `data-model`.
912
- const targetProbes = {
1012
+ /**
1013
+ * Build the once-per-call target-applicability probe. Probes resolve at most
1014
+ * once per target, and only if a lens declaring that target actually clears
1015
+ * its gate — a Node-only project must not pay a filesystem scan on a roster
1016
+ * with no `web` lens in it, nor a DB-less project pay one for `data-model`.
1017
+ *
1018
+ * @param {{ config: object, hasWebSurfaceFn: Function, hasPersistenceLayerFn: Function }} params
1019
+ * @returns {(target: string) => boolean}
1020
+ */
1021
+ function makeTargetApplicabilityProbe({
1022
+ config,
1023
+ hasWebSurfaceFn,
1024
+ hasPersistenceLayerFn,
1025
+ }) {
1026
+ const probes = {
913
1027
  web: hasWebSurfaceFn,
914
1028
  'data-model': hasPersistenceLayerFn,
915
1029
  };
916
- const targetApplicabilityMemo = new Map();
917
- const projectSupportsTarget = (target) => {
918
- if (!targetApplicabilityMemo.has(target)) {
919
- const probe = targetProbes[target];
920
- targetApplicabilityMemo.set(target, probe ? probe({ config }) : true);
1030
+ const memo = new Map();
1031
+ return (target) => {
1032
+ if (!memo.has(target)) {
1033
+ const probe = probes[target];
1034
+ memo.set(target, probe ? probe({ config }) : true);
921
1035
  }
922
- return targetApplicabilityMemo.get(target);
1036
+ return memo.get(target);
923
1037
  };
1038
+ }
1039
+
1040
+ /**
1041
+ * True when any of a lens's content triggers fires against the change set:
1042
+ * whole-word keywords, file-pattern globs, or the coverage-gap
1043
+ * `sourceWithoutSiblingTest` predicate (#4628).
1044
+ *
1045
+ * @param {object} triggers The lens's `triggers` block.
1046
+ * @param {string} contentToSearch Lower-cased ticket text.
1047
+ * @param {string[]} changedFiles
1048
+ * @returns {boolean}
1049
+ */
1050
+ function lensTriggerFires(triggers, contentToSearch, changedFiles) {
1051
+ return (
1052
+ matchesAnyKeyword(triggers.keywords || [], contentToSearch) ||
1053
+ matchesAnyFilePattern(triggers.filePatterns || [], changedFiles) ||
1054
+ (triggers.sourceWithoutSiblingTest === true &&
1055
+ changeSetLacksSiblingTest(changedFiles))
1056
+ );
1057
+ }
924
1058
 
925
- for (const [auditName, ruleOpts] of Object.entries(rulesData.audits || {})) {
1059
+ /**
1060
+ * Run the audit-rules matching loop over one gate's roster: a lens is
1061
+ * selected when its gate matches, its `target` applicability gate passes,
1062
+ * and any of its keyword / file-pattern / sibling-test triggers fires.
1063
+ *
1064
+ * @param {{
1065
+ * audits: Record<string, object>,
1066
+ * gate: string,
1067
+ * contentToSearch: string,
1068
+ * changedFiles: string[],
1069
+ * projectSupportsTarget: (target: string) => boolean,
1070
+ * }} params
1071
+ * @returns {string[]} Selected lens identifiers, in manifest order.
1072
+ */
1073
+ function matchAuditRules({
1074
+ audits,
1075
+ gate,
1076
+ contentToSearch,
1077
+ changedFiles,
1078
+ projectSupportsTarget,
1079
+ }) {
1080
+ const selectedAudits = [];
1081
+ for (const [auditName, ruleOpts] of Object.entries(audits)) {
926
1082
  const triggers = ruleOpts.triggers || {};
927
1083
 
928
- const gateMatch = triggers.gates?.includes(gate);
929
- if (!gateMatch) continue;
1084
+ if (!triggers.gates?.includes(gate)) continue;
930
1085
 
931
1086
  // Target-applicability gate (#4579, #4633). A lens declaring a `target`
932
1087
  // has nothing to read on a project lacking that surface, yet still
@@ -939,51 +1094,9 @@ export async function selectAudits({
939
1094
  const { target } = ruleOpts;
940
1095
  if (target && target !== 'any' && !projectSupportsTarget(target)) continue;
941
1096
 
942
- const keywords = triggers.keywords || [];
943
- let keywordMatch = false;
944
- for (const kw of keywords) {
945
- // Whole-word match: a bare substring test selects lenses on accidental
946
- // fragments ("ui" inside "requires", "auth" inside "author" — #4579).
947
- const escaped = kw.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
948
- if (new RegExp(`\\b${escaped}\\b`).test(contentToSearch)) {
949
- keywordMatch = true;
950
- break;
951
- }
952
- }
953
-
954
- const fileMatch = matchesAnyFilePattern(
955
- triggers.filePatterns || [],
956
- changedFiles,
957
- );
958
-
959
- // Coverage-gap routing (#4628): a lens declaring `sourceWithoutSiblingTest`
960
- // fires when the change set touches source lacking a sibling test.
961
- const siblingMatch =
962
- triggers.sourceWithoutSiblingTest === true &&
963
- changeSetLacksSiblingTest(changedFiles);
964
-
965
- if (keywordMatch || fileMatch || siblingMatch) {
1097
+ if (lensTriggerFires(triggers, contentToSearch, changedFiles)) {
966
1098
  selectedAudits.push(auditName);
967
1099
  }
968
1100
  }
969
-
970
- return {
971
- selectedAudits,
972
- ticketId,
973
- gate,
974
- context: {
975
- // Full file list, exposed so Epic-mode callers (e.g. epic-audit) can
976
- // pass it through as the {{changedFiles}} substitution value. Existing
977
- // callers that read only `changedFilesCount` remain unaffected.
978
- changedFiles,
979
- changedFilesCount: changedFiles.length,
980
- // The ref the change set was actually diffed against. Epic-mode callers
981
- // assert this matches the requested Epic branch (Story #3362) so a
982
- // mis-pinned diff never reaches the audit-lens selector silently. `null`
983
- // on the injected path: no ref was diffed, and naming one would invite
984
- // exactly that assertion to pass against a diff nobody took.
985
- resolvedRef: hasInjectedChangedFiles ? null : headRef,
986
- ticketTitle: ticket.title,
987
- },
988
- };
1101
+ return selectedAudits;
989
1102
  }
@@ -185,12 +185,22 @@ export function _clearTestContextScratchCache() {
185
185
  * process was started with the `--test` flag (a direct `node --test <file>`
186
186
  * runner process, or in-process isolation modes).
187
187
  *
188
- * @param {NodeJS.ProcessEnv} env
189
- * @param {string[]} execArgv
188
+ * Exported since Story #4837: the feedback loop's issue-filing path guards
189
+ * live GitHub writes on this same signal, and a second hand-rolled copy of
190
+ * the detection is exactly how the two would drift apart.
191
+ *
192
+ * @param {NodeJS.ProcessEnv} [env=process.env]
193
+ * @param {string[]} [execArgv=process.execArgv]
190
194
  * @returns {boolean}
191
195
  */
192
- function inNodeTestContext(env, execArgv) {
193
- return Boolean(env?.NODE_TEST_CONTEXT) || execArgv.includes('--test');
196
+ export function inNodeTestContext(
197
+ env = process.env,
198
+ execArgv = process.execArgv,
199
+ ) {
200
+ return (
201
+ Boolean(env?.NODE_TEST_CONTEXT) ||
202
+ (Array.isArray(execArgv) && execArgv.includes('--test'))
203
+ );
194
204
  }
195
205
 
196
206
  /**
@@ -312,6 +322,68 @@ export function orchestrationLogDir(config) {
312
322
  return path.join(anchorTempRoot(tempRootFrom(config)), ORCHESTRATION_DIRNAME);
313
323
  }
314
324
 
325
+ /**
326
+ * Basename of one Story's close gate log (Story #4816 lifted it here from
327
+ * `single-story-close/gate-log.js`).
328
+ *
329
+ * The writer that appends this file and the reader that uses its **freshness**
330
+ * to tell a live close from a dead one (`deliver-recover.js`) sit in different
331
+ * subtrees, and the reader importing the writer is the wrong edge to draw for
332
+ * a filename. Both take it from the module that already owns every other
333
+ * tempRoot path instead.
334
+ *
335
+ * `null` is the sink's no-Story sentinel and keeps its `unknown` spelling.
336
+ *
337
+ * @param {number|null} sid
338
+ * @returns {string}
339
+ */
340
+ function closeGateLogName(sid) {
341
+ return `close-gates-${sid ?? 'unknown'}.log`;
342
+ }
343
+
344
+ /**
345
+ * `<tempRoot>/orchestration/close-gates-<sid>.log`.
346
+ *
347
+ * @param {number|null} sid
348
+ * @param {object} [config]
349
+ * @returns {string}
350
+ */
351
+ export function closeGateLogPath(sid, config) {
352
+ return path.join(orchestrationLogDir(config), closeGateLogName(sid));
353
+ }
354
+
355
+ /**
356
+ * Basename of the persisted terminal envelope for one Story (Story #4816).
357
+ *
358
+ * @param {number} sid
359
+ * @returns {string}
360
+ */
361
+ function storyTerminalEnvelopeName(sid) {
362
+ return `story-deliver-terminal-${storyId(sid)}.json`;
363
+ }
364
+
365
+ /**
366
+ * `<tempRoot>/orchestration/story-deliver-terminal-<sid>.json` — the on-disk
367
+ * copy of the one terminal envelope a Story's close-and-land emits (Story
368
+ * #4816).
369
+ *
370
+ * Deliberately a sibling of the gate log rather than a per-Story temp dir
371
+ * entry: the envelope is a run artifact of the same close that writes
372
+ * `close-gates-<sid>.log`, and `deliver-recover.js` reads the pair together to
373
+ * tell a finished close from a live one. Sharing `orchestrationLogDir` also
374
+ * means it inherits main-checkout anchoring for free — the close runs inside
375
+ * `.worktrees/story-<sid>/` while the `/deliver` host reads from the main
376
+ * checkout, and an un-anchored path would put the envelope somewhere the
377
+ * router never looks.
378
+ *
379
+ * @param {number} sid
380
+ * @param {object} [config]
381
+ * @returns {string}
382
+ */
383
+ export function storyTerminalEnvelopePath(sid, config) {
384
+ return path.join(orchestrationLogDir(config), storyTerminalEnvelopeName(sid));
385
+ }
386
+
315
387
  const runId = (id) => {
316
388
  if (!Number.isInteger(id) || id <= 0) {
317
389
  throw new Error(`[temp-paths] runId must be a positive integer; got ${id}`);
@@ -395,11 +467,19 @@ export function storyTempDir(eid, sid, config) {
395
467
  const checkedEid = storyEpicId(eid);
396
468
  const parent =
397
469
  checkedEid === null
398
- ? path.join(anchorTempRoot(tempRootFrom(config)), 'standalone')
470
+ ? path.join(anchorTempRoot(tempRootFrom(config)), STANDALONE_DIRNAME)
399
471
  : runTempDir(checkedEid, config);
400
- return path.join(parent, 'stories', `story-${storyId(sid)}`);
472
+ return path.join(parent, STORIES_DIRNAME, `story-${storyId(sid)}`);
401
473
  }
402
474
 
475
+ /**
476
+ * Basename of a per-Story signal stream. Exported so the reader that
477
+ * *discovers* streams by walking the temp tree (`signals-writer.js`'s
478
+ * cross-Story gather, Story #4824) names the same file the writer does,
479
+ * instead of re-spelling the literal in a second module.
480
+ */
481
+ export const SIGNALS_BASENAME = 'signals.ndjson';
482
+
403
483
  /**
404
484
  * `temp/run-<eid>/stories/story-<sid>/signals.ndjson` — append-only
405
485
  * signal stream consumed by the analyzer (Epic #1030 AC1).
@@ -410,7 +490,33 @@ export function storyTempDir(eid, sid, config) {
410
490
  * @returns {string}
411
491
  */
412
492
  export function signalsFile(eid, sid, config) {
413
- return path.join(storyTempDir(eid, sid, config), 'signals.ndjson');
493
+ return path.join(storyTempDir(eid, sid, config), SIGNALS_BASENAME);
494
+ }
495
+
496
+ /**
497
+ * Directory segment holding the standalone-Story subtree —
498
+ * `<tempRoot>/standalone/stories/story-<sid>/`. Named here (rather than
499
+ * inlined in `storyTempDir`) so the cross-Story stream discovery in
500
+ * `signals-writer.js` can recognise it without re-spelling the literal.
501
+ */
502
+ export const STANDALONE_DIRNAME = 'standalone';
503
+
504
+ /**
505
+ * Directory segment holding the per-Story subtree under either an Epic run
506
+ * dir or the standalone dir (Story #2940's separator).
507
+ */
508
+ export const STORIES_DIRNAME = 'stories';
509
+
510
+ /**
511
+ * `<tempRoot>` itself, resolved and main-checkout-anchored. The discovery
512
+ * walk needs the root the named helpers are built from; every other consumer
513
+ * should keep using a named helper.
514
+ *
515
+ * @param {object} [config]
516
+ * @returns {string}
517
+ */
518
+ export function resolvedTempRoot(config) {
519
+ return anchorTempRoot(tempRootFrom(config));
414
520
  }
415
521
 
416
522
  /**