@deftai/directive-core 0.99.0 → 0.100.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 (102) hide show
  1. package/dist/authz/actions.d.ts +6 -0
  2. package/dist/authz/actions.js +3 -0
  3. package/dist/authz/classify.js +157 -23
  4. package/dist/authz/decompose-apply.d.ts +67 -0
  5. package/dist/authz/decompose-apply.js +302 -0
  6. package/dist/authz/index.d.ts +3 -1
  7. package/dist/authz/index.js +3 -1
  8. package/dist/authz/store.d.ts +53 -0
  9. package/dist/authz/store.js +219 -1
  10. package/dist/authz/types.d.ts +19 -2
  11. package/dist/authz/types.js +5 -1
  12. package/dist/doctor/checks.d.ts +44 -0
  13. package/dist/doctor/checks.js +428 -2
  14. package/dist/doctor/main.d.ts +6 -7
  15. package/dist/doctor/main.js +72 -79
  16. package/dist/doctor/which.d.ts +20 -1
  17. package/dist/doctor/which.js +67 -1
  18. package/dist/finish-loop/pr-finish-loop.d.ts +13 -2
  19. package/dist/finish-loop/pr-finish-loop.js +116 -5
  20. package/dist/finish-loop/types.d.ts +1 -1
  21. package/dist/hooks/dispatcher.d.ts +9 -5
  22. package/dist/hooks/dispatcher.js +23 -15
  23. package/dist/hooks/readonly.d.ts +6 -3
  24. package/dist/hooks/readonly.js +44 -25
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +1 -0
  27. package/dist/init-deposit/agent-hooks.d.ts +20 -0
  28. package/dist/init-deposit/agent-hooks.js +51 -31
  29. package/dist/intake/issue-ingest.js +29 -0
  30. package/dist/lifecycle/completed-consistency.d.ts +60 -0
  31. package/dist/lifecycle/completed-consistency.js +443 -0
  32. package/dist/lifecycle/completed-tracked-on-delivery.d.ts +47 -0
  33. package/dist/lifecycle/completed-tracked-on-delivery.js +415 -0
  34. package/dist/lifecycle/index.d.ts +2 -0
  35. package/dist/lifecycle/index.js +2 -0
  36. package/dist/literal-acceptance/capture.d.ts +52 -0
  37. package/dist/literal-acceptance/capture.js +658 -0
  38. package/dist/literal-acceptance/evaluate.d.ts +47 -0
  39. package/dist/literal-acceptance/evaluate.js +193 -0
  40. package/dist/literal-acceptance/index.d.ts +12 -0
  41. package/dist/literal-acceptance/index.js +12 -0
  42. package/dist/literal-acceptance/run.d.ts +38 -0
  43. package/dist/literal-acceptance/run.js +216 -0
  44. package/dist/literal-acceptance/safety.d.ts +24 -0
  45. package/dist/literal-acceptance/safety.js +251 -0
  46. package/dist/literal-acceptance/types.d.ts +82 -0
  47. package/dist/literal-acceptance/types.js +26 -0
  48. package/dist/policy/ceremony-dial.d.ts +67 -11
  49. package/dist/policy/ceremony-dial.js +174 -23
  50. package/dist/policy/index.d.ts +1 -0
  51. package/dist/policy/index.js +1 -0
  52. package/dist/policy/merge-approval-head.d.ts +129 -0
  53. package/dist/policy/merge-approval-head.js +461 -0
  54. package/dist/policy/require-human-merge.d.ts +5 -0
  55. package/dist/policy/require-human-merge.js +5 -0
  56. package/dist/pr-merge-readiness/ci-gate.d.ts +20 -3
  57. package/dist/pr-merge-readiness/ci-gate.js +65 -5
  58. package/dist/pr-merge-readiness/compute.d.ts +13 -0
  59. package/dist/pr-merge-readiness/compute.js +99 -14
  60. package/dist/pr-merge-readiness/gh.d.ts +49 -0
  61. package/dist/pr-merge-readiness/gh.js +268 -0
  62. package/dist/pr-merge-readiness/index.d.ts +2 -2
  63. package/dist/pr-merge-readiness/index.js +2 -2
  64. package/dist/pr-merge-readiness/test-gh-fixtures.helpers.js +4 -0
  65. package/dist/pr-wait-mergeable/cascade.d.ts +11 -0
  66. package/dist/pr-wait-mergeable/cascade.js +115 -1
  67. package/dist/pr-wait-mergeable/main.d.ts +4 -0
  68. package/dist/pr-wait-mergeable/main.js +4 -0
  69. package/dist/pr-wait-mergeable/types.d.ts +3 -1
  70. package/dist/pr-wait-mergeable/wrappers.d.ts +6 -1
  71. package/dist/pr-wait-mergeable/wrappers.js +7 -1
  72. package/dist/preflight/evaluate.d.ts +15 -1
  73. package/dist/preflight/evaluate.js +33 -2
  74. package/dist/render/framework-commands.js +4 -0
  75. package/dist/scope/acceptance-evidence.d.ts +76 -0
  76. package/dist/scope/acceptance-evidence.js +348 -0
  77. package/dist/scope/coverage-map.d.ts +98 -0
  78. package/dist/scope/coverage-map.js +558 -0
  79. package/dist/scope/decompose.js +125 -11
  80. package/dist/scope/index.d.ts +3 -0
  81. package/dist/scope/index.js +3 -0
  82. package/dist/scope/parent-lineage.d.ts +125 -0
  83. package/dist/scope/parent-lineage.js +626 -0
  84. package/dist/scope/transition.d.ts +8 -0
  85. package/dist/scope/transition.js +61 -2
  86. package/dist/session/active-cli.d.ts +79 -0
  87. package/dist/session/active-cli.js +382 -0
  88. package/dist/session/effort-budget.d.ts +130 -0
  89. package/dist/session/effort-budget.js +334 -0
  90. package/dist/session/index.d.ts +2 -0
  91. package/dist/session/index.js +2 -0
  92. package/dist/session/session-start.d.ts +6 -0
  93. package/dist/session/session-start.js +53 -2
  94. package/dist/session/verify-session-ritual.d.ts +14 -0
  95. package/dist/session/verify-session-ritual.js +33 -0
  96. package/dist/story-ready/evaluate.d.ts +11 -0
  97. package/dist/story-ready/evaluate.js +41 -3
  98. package/dist/triage/actions/candidates-log.d.ts +1 -2
  99. package/dist/triage/actions/candidates-log.js +37 -6
  100. package/dist/xbrief-migrate/migrate-project.d.ts +4 -0
  101. package/dist/xbrief-migrate/migrate-project.js +92 -3
  102. package/package.json +7 -3
@@ -1,22 +1,18 @@
1
1
  import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
- import { VBRIEF_VERSION } from "@deftai/directive-types";
4
3
  import { evaluate as evaluateAgentsMdAdvisory } from "../agents-md-advisory/evaluate.js";
5
4
  import { evaluateConsumerGateIntegrity, formatConsumerGateIntegrityFailure, } from "../check/consumer-gate-integrity.js";
6
5
  import { contentRoot } from "../content-root.js";
7
- import { resolveProjectDefinitionPath } from "../layout/resolve.js";
8
6
  import { DEFT_DIRECTIVE_DISABLE_FLAG_NAME, DEFT_DIRECTIVE_DISABLE_STATUS, DEFT_DIRECTIVE_DISABLE_TRACKED_WARNING, detectDeftDirectiveDisable, formatDeftDirectiveDisableMessage, isDeftDirectiveDisableActive, } from "../policy/deft-directive-disable.js";
9
7
  import { detectNoDeftDirective, NO_DEFT_DIRECTIVE_DISABLED_MESSAGE, NO_DEFT_DIRECTIVE_FLAG_NAME, NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE, NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY, } from "../policy/no-deft-directive.js";
10
8
  import { describeShadowedPlanExtension, detectShadowedPlanExtensions, } from "../policy/plan-extensions.js";
11
9
  import { loadProjectDefinition } from "../policy/resolve.js";
12
10
  import { checkLocalEngineIntegrity, classify, detectPackageManager, evaluateSkew, reconcileVersions, plan as resolvePlan, } from "../resolution/index.js";
13
- import { classifyXbriefSchemaDistance } from "../staleness-tickler/probe-xbrief.js";
14
11
  import { resolveUserMdPath } from "../user-config/resolve-user-md.js";
15
12
  import { evaluateAgentHooks } from "../verify-env/agent-hooks.js";
16
13
  import { probeAgentHooksLive } from "../verify-env/agent-hooks-live-probe.js";
17
- import { readDeclaredArtifactVersion } from "../xbrief-migrate/transforms.js";
18
14
  import { agentsRefreshPlan, hasV3ManagedMarker } from "./agents-md.js";
19
- import { runChecks } from "./checks.js";
15
+ import { checkXbriefEnvelopeMajorVersion, runChecks, XBRIEF_ENVELOPE_MAJOR_CHECK, XBRIEF_ENVELOPE_MIGRATE_COMMAND, } from "./checks.js";
20
16
  import { CONSUMER_FRAMEWORK_DIRS, EXPECTED_CONTENT_DIRS, EXPECTED_FRAMEWORK_DIRS, NETWORK_DISCLOSURE_LINE, PAYLOAD_STALENESS_OFFLINE_SKIP_MESSAGE, TASKFILE_INCLUDE_SNIPPET, UV_INSTALL_URL, } from "./constants.js";
21
17
  import { decideThrottle, formatIsoZ, readState, renderDoctorStatusLine, writeState, } from "./doctor-state.js";
22
18
  import { formatAllowedFlagsHint, formatUnknownFlagsError, parseDoctorFlags } from "./flags.js";
@@ -33,8 +29,6 @@ import { runLocalSignpostChecks } from "./signpost-checks.js";
33
29
  import { classifyTaskfileInclude, formatGatesSurfaceDualRemediation, formatMissingIncludeSnippet, GATES_SURFACE_DEFT_REMEDIATION, includesBlockHasDeftTaskfile, resolveConsumerTaskfile, } from "./taskfile.js";
34
30
  import { defaultWhich } from "./which.js";
35
31
  const DEFAULT_RESOLUTION_PLATFORMS = ["linux", "darwin", "win32"];
36
- /** Next-action command for project-envelope behind-major (#2971). */
37
- const XBRIEF_ENVELOPE_MIGRATE_COMMAND = "deft migrate:xbrief";
38
32
  /**
39
33
  * Read the `packageManager` field (Corepack) from a project package.json, or
40
34
  * null when absent/unreadable. Lets the doctor detect a pnpm project that has
@@ -653,6 +647,11 @@ function runInstallIntegrityChecks(projectRoot, sink, addFinding, seams) {
653
647
  const name = String(entry.name ?? "install-integrity");
654
648
  const status = String(entry.status ?? "");
655
649
  const detail = String(entry.detail ?? "");
650
+ // Authoritative envelope-major emission is runXbriefEnvelopeVersionCheck
651
+ // (runs for framework repo + consumers). Skip install-integrity duplicate.
652
+ if (name === XBRIEF_ENVELOPE_MAJOR_CHECK) {
653
+ continue;
654
+ }
656
655
  if (status === "pass") {
657
656
  sink.success(`${name}: pass`);
658
657
  continue;
@@ -954,105 +953,99 @@ function runTaskfileIncludeCheck(projectRoot, fixMode, jsonMode, sink, addFindin
954
953
  });
955
954
  }
956
955
  /**
957
- * Fail closed when PROJECT-DEFINITION under an xbrief/ layout still declares
958
- * envelope 0.6 while the framework schema is 0.8 (#2971). Greenfield (no
959
- * project definition yet) and current 0.8 envelopes pass. Unreadable paths
960
- * (test seams / permission) skip rather than false-positive fail. Behind-major
961
- * records `deft migrate:xbrief` as the next action — layout rename alone is not
962
- * enough. Distinct from deposited-schema (`stale-xbrief-schema-deposit`) which
963
- * must NOT route to migrate:xbrief when only framework schema files are stale.
956
+ * Fail closed when any scanned project xbrief lifecycle `*.xbrief.json` envelope
957
+ * declares a schema major behind the framework target (#2971 / #3243). Greenfield
958
+ * (no envelopes) skips. Behind-major records `deft migrate:xbrief` as the next
959
+ * action (hybrid in-place rewrite on already-xbrief trees #3236). Distinct from
960
+ * deposited-schema (`stale-xbrief-schema-deposit`) which must NOT route to
961
+ * migrate:xbrief when only framework schema files are stale.
964
962
  */
965
963
  export function runXbriefEnvelopeVersionCheck(projectRoot, sink, addFinding, seams) {
966
- const checkName = "xbrief-envelope-version";
967
- const isFile = seams.isFile ?? ((p) => existsSync(p));
968
- const readText = seams.readText ?? readTextSafe;
969
- const targetVersion = VBRIEF_VERSION;
964
+ const checkName = XBRIEF_ENVELOPE_MAJOR_CHECK;
970
965
  if (seams.probeXbriefEnvelope) {
971
966
  const probe = seams.probeXbriefEnvelope(projectRoot);
972
967
  emitXbriefEnvelopeFinding(checkName, probe, sink, addFinding);
973
968
  return;
974
969
  }
975
- let definitionPath;
976
- try {
977
- definitionPath = resolveProjectDefinitionPath(projectRoot);
978
- }
979
- catch {
980
- // Pure vbrief/-only trees are already covered by layout migrate signposts.
981
- const skipMessage = `${checkName}: skip -- legacy-only layout (use layout migrate first)`;
982
- sink.info(skipMessage);
983
- addFinding({
984
- severity: "skip",
985
- message: skipMessage,
986
- check: checkName,
987
- status: "skip",
988
- reason: "legacy-only-layout",
989
- });
990
- return;
991
- }
992
- if (!isFile(definitionPath)) {
993
- const skipMessage = `${checkName}: skip -- no PROJECT-DEFINITION yet (greenfield)`;
994
- sink.info(skipMessage);
995
- addFinding({
996
- severity: "skip",
997
- message: skipMessage,
998
- check: checkName,
999
- status: "skip",
1000
- reason: "no-project-definition",
1001
- });
1002
- return;
1003
- }
1004
- const text = readText(definitionPath);
1005
- if (text === null) {
1006
- // Unreadable path (permissions or injectable seams) is not proof of 0.6.
1007
- const skipMessage = `${checkName}: skip -- PROJECT-DEFINITION unreadable`;
970
+ const result = checkXbriefEnvelopeMajorVersion(projectRoot, {
971
+ ...(seams.readText ? { readText: seams.readText } : {}),
972
+ ...(seams.isFile ? { isFile: seams.isFile } : {}),
973
+ ...(seams.isDir ? { isDir: seams.isDir } : {}),
974
+ });
975
+ const data = (result.data ?? {});
976
+ const targetVersion = typeof data.target_version === "string"
977
+ ? data.target_version
978
+ : String(data.target_version ?? "");
979
+ const declaredFromData = data.declared_versions;
980
+ const declaredVersion = Array.isArray(declaredFromData)
981
+ ? (declaredFromData.find((v) => typeof v === "string") ?? null)
982
+ : null;
983
+ if (result.status === "skip") {
984
+ const skipMessage = `${checkName}: skip -- ${result.detail}`;
1008
985
  sink.info(skipMessage);
1009
986
  addFinding({
1010
987
  severity: "skip",
1011
988
  message: skipMessage,
1012
989
  check: checkName,
1013
990
  status: "skip",
1014
- reason: "unreadable",
991
+ reason: typeof data.reason === "string" ? data.reason : "skip",
992
+ target_version: targetVersion || undefined,
1015
993
  });
1016
994
  return;
1017
995
  }
1018
- let declaredVersion = null;
1019
- try {
1020
- const parsed = JSON.parse(text);
1021
- if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
1022
- declaredVersion = readDeclaredArtifactVersion(parsed);
1023
- }
1024
- else {
1025
- const skipMessage = `${checkName}: skip -- PROJECT-DEFINITION is not a JSON object`;
1026
- sink.info(skipMessage);
996
+ if (result.status === "pass") {
997
+ const status = typeof data.status === "string" ? data.status : "current";
998
+ if (status === "behind-minor") {
999
+ // Major-only check (#3243) does not fail closed, but surface an advisory
1000
+ // when the shared scan observed behind-minor distance.
1001
+ const warnMessage = `${checkName}: behind-minor -- ${result.detail}. Prefer \`${XBRIEF_ENVELOPE_MIGRATE_COMMAND}\` when convenient.`;
1002
+ sink.warn(warnMessage);
1027
1003
  addFinding({
1028
- severity: "skip",
1029
- message: skipMessage,
1004
+ severity: "warning",
1005
+ message: warnMessage,
1030
1006
  check: checkName,
1031
- status: "skip",
1032
- reason: "invalid-json-shape",
1007
+ status: "behind-minor",
1008
+ suggestion: XBRIEF_ENVELOPE_MIGRATE_COMMAND,
1009
+ target_version: targetVersion || undefined,
1010
+ next_command: XBRIEF_ENVELOPE_MIGRATE_COMMAND,
1033
1011
  });
1034
1012
  return;
1035
1013
  }
1036
- }
1037
- catch {
1038
- const skipMessage = `${checkName}: skip -- PROJECT-DEFINITION JSON parse failed`;
1039
- sink.info(skipMessage);
1014
+ const okMessage = `${checkName}: ${result.detail}`;
1015
+ sink.success(okMessage);
1040
1016
  addFinding({
1041
1017
  severity: "skip",
1042
- message: skipMessage,
1018
+ message: okMessage,
1043
1019
  check: checkName,
1044
- status: "skip",
1045
- reason: "parse-error",
1020
+ status: "current",
1021
+ target_version: targetVersion || undefined,
1046
1022
  });
1047
1023
  return;
1048
1024
  }
1049
- const distance = classifyXbriefSchemaDistance(declaredVersion, targetVersion);
1050
- emitXbriefEnvelopeFinding(checkName, {
1051
- declaredVersion,
1052
- targetVersion,
1053
- distance,
1054
- stale: distance !== "current",
1055
- }, sink, addFinding);
1025
+ // fail / error → behind-major fail closed (migratable 0.6 or non-migratable)
1026
+ const status = typeof data.status === "string" ? data.status : "behind-major";
1027
+ const nextCommand = typeof data.next_command === "string"
1028
+ ? data.next_command
1029
+ : data.next_command === null
1030
+ ? null
1031
+ : XBRIEF_ENVELOPE_MIGRATE_COMMAND;
1032
+ const suggestion = typeof data.suggestion === "string"
1033
+ ? data.suggestion
1034
+ : (nextCommand ?? `set xBRIEFInfo.version to framework target`);
1035
+ const message = `${checkName}: ${result.detail}`;
1036
+ sink.error(message);
1037
+ addFinding({
1038
+ severity: "error",
1039
+ message,
1040
+ check: checkName,
1041
+ status,
1042
+ suggestion,
1043
+ declared_version: declaredVersion,
1044
+ target_version: targetVersion || undefined,
1045
+ next_command: nextCommand,
1046
+ sample_paths: data.sample_paths,
1047
+ behind_major_count: data.behind_major_count,
1048
+ });
1056
1049
  }
1057
1050
  function emitXbriefEnvelopeFinding(checkName, probe, sink, addFinding) {
1058
1051
  if (probe.distance === "current") {
@@ -1,3 +1,22 @@
1
- /** Default PATH lookup mirroring Python `shutil.which`. */
1
+ export interface WhichAllOptions {
2
+ readonly env?: NodeJS.ProcessEnv;
3
+ readonly platform?: NodeJS.Platform;
4
+ readonly exists?: (path: string) => boolean;
5
+ /** Optional executable predicate (defaults to file + X_OK on posix). */
6
+ readonly isExecutable?: (path: string) => boolean;
7
+ }
8
+ /**
9
+ * Enumerate every PATH match for `name` (PATH order, first = highest precedence).
10
+ * Pure PATH scan — no shell locator — so tests and win32/posix stay hermetic and
11
+ * gated ritual never executes a PATH-substituted `which`/`where` (#3233).
12
+ * Only files (posix: executable bit) are accepted — not bare directories.
13
+ */
14
+ export declare function whichAllFromPath(name: string, options?: WhichAllOptions): string[];
15
+ /**
16
+ * All PATH matches for `name` without shelling out (#3233 security).
17
+ * Alias of {@link whichAllFromPath} — never runs bare `which` / `where`.
18
+ */
19
+ export declare function defaultWhichAll(name: string, options?: WhichAllOptions): string[];
20
+ /** Default PATH lookup mirroring Python `shutil.which` (first match only). */
2
21
  export declare function defaultWhich(name: string): string | null;
3
22
  //# sourceMappingURL=which.d.ts.map
@@ -1,5 +1,71 @@
1
1
  import { execFileSync } from "node:child_process";
2
- /** Default PATH lookup mirroring Python `shutil.which`. */
2
+ import { accessSync, constants, existsSync, statSync } from "node:fs";
3
+ import { posix, win32 } from "node:path";
4
+ function defaultIsExecutable(path, platform) {
5
+ try {
6
+ const st = statSync(path);
7
+ // Directories named `deft` must not shadow later executables (#3233 Greptile).
8
+ if (st.isDirectory())
9
+ return false;
10
+ if (!st.isFile() && !st.isSymbolicLink())
11
+ return false;
12
+ if (platform === "win32") {
13
+ return true;
14
+ }
15
+ accessSync(path, constants.X_OK);
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ /**
23
+ * Enumerate every PATH match for `name` (PATH order, first = highest precedence).
24
+ * Pure PATH scan — no shell locator — so tests and win32/posix stay hermetic and
25
+ * gated ritual never executes a PATH-substituted `which`/`where` (#3233).
26
+ * Only files (posix: executable bit) are accepted — not bare directories.
27
+ */
28
+ export function whichAllFromPath(name, options = {}) {
29
+ const env = options.env ?? process.env;
30
+ const platform = options.platform ?? process.platform;
31
+ const exists = options.exists ?? existsSync;
32
+ const isExecutable = options.isExecutable ?? ((p) => defaultIsExecutable(p, platform));
33
+ const pathValue = env.PATH ?? env.Path ?? "";
34
+ if (pathValue === "") {
35
+ return [];
36
+ }
37
+ const isWindows = platform === "win32";
38
+ const exts = isWindows ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";") : [""];
39
+ const sep = isWindows ? ";" : ":";
40
+ const joinPath = isWindows ? win32.join : posix.join;
41
+ const seen = new Set();
42
+ const out = [];
43
+ for (const dir of pathValue.split(sep)) {
44
+ if (dir === "")
45
+ continue;
46
+ for (const ext of exts) {
47
+ const candidate = joinPath(dir, `${name}${ext}`);
48
+ const key = isWindows ? candidate.toLowerCase() : candidate;
49
+ if (seen.has(key))
50
+ continue;
51
+ if (exists(candidate) && isExecutable(candidate)) {
52
+ seen.add(key);
53
+ out.push(candidate);
54
+ // One match per PATH directory (first PATHEXT hit wins on win32).
55
+ break;
56
+ }
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+ /**
62
+ * All PATH matches for `name` without shelling out (#3233 security).
63
+ * Alias of {@link whichAllFromPath} — never runs bare `which` / `where`.
64
+ */
65
+ export function defaultWhichAll(name, options = {}) {
66
+ return whichAllFromPath(name, options);
67
+ }
68
+ /** Default PATH lookup mirroring Python `shutil.which` (first match only). */
3
69
  export function defaultWhich(name) {
4
70
  const locator = process.platform === "win32" ? "where" : "which";
5
71
  try {
@@ -7,6 +7,7 @@
7
7
  * - CLEAN + bot merge allowed + --merge → optional wait-mergeable-and-merge
8
8
  * - Missing grants → fail closed BLOCKED
9
9
  */
10
+ import { type EnforceMergeApprovalHeadInput, type MergeApprovalHeadResult } from "../policy/merge-approval-head.js";
10
11
  import { evaluateAgentMerge } from "../policy/require-human-merge.js";
11
12
  import type { WatchOptions, WatchResult } from "../pr-watch/types.js";
12
13
  import { type PrFinishLoopResult } from "./types.js";
@@ -20,13 +21,23 @@ export interface PrFinishLoopOptions {
20
21
  /** Attempt merge when CLEAN and policy allows (default false). */
21
22
  readonly merge?: boolean;
22
23
  readonly skipGrantGate?: boolean;
24
+ /** Skip #3235 head-bound plan:approved gate (tests). */
25
+ readonly skipMergeApprovalHeadGate?: boolean;
23
26
  readonly env?: Readonly<Record<string, string | undefined>>;
24
27
  readonly now?: Date;
25
28
  /** Inject watch for tests. */
26
29
  readonly watchFn?: (prNumber: number, repo: string | null, options?: WatchOptions) => WatchResult;
27
- /** Inject merge cascade for tests; returns exit code. */
28
- readonly mergeFn?: (prNumber: number, repo: string | null) => number;
30
+ /**
31
+ * Inject merge cascade for tests; returns exit code.
32
+ * Optional `matchHeadCommit` pins the merge to the gated head (#3235).
33
+ */
34
+ readonly mergeFn?: (prNumber: number, repo: string | null, options?: {
35
+ readonly matchHeadCommit?: string | null;
36
+ }) => number;
29
37
  readonly agentMergeFn?: typeof evaluateAgentMerge;
38
+ readonly mergeApprovalHeadFn?: (input: EnforceMergeApprovalHeadInput) => MergeApprovalHeadResult;
39
+ /** Inject live HEAD fetch (tests); defaults to REST pulls head.sha. */
40
+ readonly fetchPrHeadShaFn?: (prNumber: number, repo: string | null) => string | null;
30
41
  readonly writeProgress?: boolean;
31
42
  readonly iteration?: number;
32
43
  }
@@ -7,6 +7,7 @@
7
7
  * - CLEAN + bot merge allowed + --merge → optional wait-mergeable-and-merge
8
8
  * - Missing grants → fail closed BLOCKED
9
9
  */
10
+ import { disablePullRequestAutoMerge, enforceMergeApprovalHead, fetchPrHeadShaRest, } from "../policy/merge-approval-head.js";
10
11
  import { evaluateAgentMerge } from "../policy/require-human-merge.js";
11
12
  import { EXIT_CLEAN, EXIT_NEW_P0_P1, EXIT_TERMINAL_ERROR, VERDICT_CLEAN, VERDICT_NEW_P0_P1, } from "../pr-watch/constants.js";
12
13
  import { watch } from "../pr-watch/watch.js";
@@ -70,7 +71,9 @@ export function runPrFinishLoop(options) {
70
71
  }
71
72
  // --- pr:watch ---
72
73
  const watchFn = options.watchFn ?? watch;
73
- const repo = options.repo ?? process.env.GH_REPO ?? null;
74
+ // Prefer explicit repo; GH_REPO next. Repo is required for head-bound approval
75
+ // scoping when plan:approved records exist (#3235 cross-repo collision).
76
+ const repo = options.repo ?? process.env.GH_REPO ?? process.env.GITHUB_REPOSITORY ?? null;
74
77
  let watchResult;
75
78
  try {
76
79
  watchResult = watchFn(prNumber, repo, {
@@ -129,6 +132,61 @@ export function runPrFinishLoop(options) {
129
132
  grantId: null,
130
133
  };
131
134
  }
135
+ // #3235: head-bound plan:approved vs LIVE PR HEAD (before merge path).
136
+ // Always re-fetch HEAD. Never fall back to the watch snapshot: a failed live
137
+ // read + snapshot A would retain auto-merge while GitHub head is B.
138
+ if (options.skipMergeApprovalHeadGate !== true) {
139
+ const fetchHead = options.fetchPrHeadShaFn ?? fetchPrHeadShaRest;
140
+ const liveHead = fetchHead(prNumber, repo);
141
+ if (liveHead === null || liveHead.trim() === "") {
142
+ disablePullRequestAutoMerge(prNumber, repo);
143
+ const message = `pr:finish-loop ACTION_REQUIRED on PR #${prNumber}: cannot read live HEAD ` +
144
+ "after CLEAN; auto-merge disabled (fail closed, #3235).";
145
+ log("merge", "stale-merge-approval", message);
146
+ return {
147
+ exitCode: EXIT_ACTION_REQUIRED,
148
+ haltReason: "stale-merge-approval",
149
+ message,
150
+ prNumber,
151
+ watchVerdict: VERDICT_CLEAN,
152
+ mergeAttempted: false,
153
+ mergeSkippedReason: "stale-merge-approval",
154
+ grantId: null,
155
+ };
156
+ }
157
+ const headGateFn = options.mergeApprovalHeadFn ?? enforceMergeApprovalHead;
158
+ const headGate = headGateFn({
159
+ prNumber,
160
+ repo,
161
+ projectRoot,
162
+ currentHeadSha: liveHead,
163
+ disableAutoMergeOnDeny: true,
164
+ });
165
+ if (!headGate.allowed) {
166
+ const message = [
167
+ `pr:finish-loop ACTION_REQUIRED on PR #${prNumber}: stale or unbound merge approval.`,
168
+ headGate.message,
169
+ headGate.recovery ?? "",
170
+ ]
171
+ .filter((line) => line.length > 0)
172
+ .join("\n");
173
+ log("merge", "stale-merge-approval", message, {
174
+ approved_head_sha: headGate.approved_head_sha,
175
+ current_head_sha: headGate.current_head_sha,
176
+ auto_merge_disabled: headGate.auto_merge_disabled,
177
+ });
178
+ return {
179
+ exitCode: EXIT_ACTION_REQUIRED,
180
+ haltReason: "stale-merge-approval",
181
+ message,
182
+ prNumber,
183
+ watchVerdict: VERDICT_CLEAN,
184
+ mergeAttempted: false,
185
+ mergeSkippedReason: "stale-merge-approval",
186
+ grantId: null,
187
+ };
188
+ }
189
+ }
132
190
  // CLEAN path
133
191
  if (options.merge !== true) {
134
192
  const message = `pr:finish-loop CLEAN on PR #${prNumber}. Merge not requested ` +
@@ -165,12 +223,62 @@ export function runPrFinishLoop(options) {
165
223
  grantId: null,
166
224
  };
167
225
  }
168
- // Optional merge when policy allows
226
+ // Optional merge when policy allows — re-fetch live HEAD (not watch snapshot)
227
+ // and pin matchHeadCommit (#3235 TOCTOU).
169
228
  if (options.mergeFn !== undefined) {
170
- const rc = options.mergeFn(prNumber, repo);
229
+ const fetchHead = options.fetchPrHeadShaFn ?? fetchPrHeadShaRest;
230
+ const liveHead = fetchHead(prNumber, repo);
231
+ if (options.skipMergeApprovalHeadGate !== true) {
232
+ if (liveHead === null || liveHead.trim() === "") {
233
+ disablePullRequestAutoMerge(prNumber, repo);
234
+ const message = `pr:finish-loop ACTION_REQUIRED on PR #${prNumber}: cannot read live HEAD ` +
235
+ "before merge; auto-merge disabled (fail closed, #3235).";
236
+ log("merge", "stale-merge-approval", message);
237
+ return {
238
+ exitCode: EXIT_ACTION_REQUIRED,
239
+ haltReason: "stale-merge-approval",
240
+ message,
241
+ prNumber,
242
+ watchVerdict: VERDICT_CLEAN,
243
+ mergeAttempted: false,
244
+ mergeSkippedReason: "stale-merge-approval",
245
+ grantId: null,
246
+ };
247
+ }
248
+ const headGateFn = options.mergeApprovalHeadFn ?? enforceMergeApprovalHead;
249
+ const recheck = headGateFn({
250
+ prNumber,
251
+ repo,
252
+ projectRoot,
253
+ currentHeadSha: liveHead,
254
+ disableAutoMergeOnDeny: true,
255
+ });
256
+ if (!recheck.allowed) {
257
+ const message = [
258
+ `pr:finish-loop ACTION_REQUIRED on PR #${prNumber}: stale merge approval at merge time.`,
259
+ recheck.message,
260
+ recheck.recovery ?? "",
261
+ ]
262
+ .filter((line) => line.length > 0)
263
+ .join("\n");
264
+ log("merge", "stale-merge-approval", message);
265
+ return {
266
+ exitCode: EXIT_ACTION_REQUIRED,
267
+ haltReason: "stale-merge-approval",
268
+ message,
269
+ prNumber,
270
+ watchVerdict: VERDICT_CLEAN,
271
+ mergeAttempted: false,
272
+ mergeSkippedReason: "stale-merge-approval",
273
+ grantId: null,
274
+ };
275
+ }
276
+ }
277
+ const pinnedHead = liveHead;
278
+ const rc = options.mergeFn(prNumber, repo, { matchHeadCommit: pinnedHead });
171
279
  if (rc === 0) {
172
280
  const message = `pr:finish-loop MERGED PR #${prNumber}`;
173
- log("merge", "merged", message);
281
+ log("merge", "merged", message, { matchHeadCommit: pinnedHead });
174
282
  return {
175
283
  exitCode: EXIT_OK,
176
284
  haltReason: "merged",
@@ -182,7 +290,10 @@ export function runPrFinishLoop(options) {
182
290
  grantId: null,
183
291
  };
184
292
  }
185
- const message = `pr:finish-loop merge attempt failed exit=${rc} for PR #${prNumber}`;
293
+ // Pin mismatch / merge fail: revoke auto-merge so unapproved head cannot land.
294
+ disablePullRequestAutoMerge(prNumber, repo);
295
+ const message = `pr:finish-loop merge attempt failed exit=${rc} for PR #${prNumber}; ` +
296
+ "disabled auto-merge after pinned-merge failure (#3235).";
186
297
  log("merge", "error", message);
187
298
  return {
188
299
  exitCode: EXIT_BLOCKED,
@@ -8,7 +8,7 @@ export declare const EXIT_OK = 0;
8
8
  export declare const EXIT_ACTION_REQUIRED = 1;
9
9
  /** BLOCKED (grant/gate), ERRORED, TIMEOUT, config. */
10
10
  export declare const EXIT_BLOCKED = 2;
11
- export type FinishLoopHaltReason = "empty-queue" | "grant-missing" | "grant-expired" | "grant-deny" | "gate-deny" | "max-iterations" | "require-human-merge" | "address-findings" | "agent-implement" | "clean" | "merged" | "error";
11
+ export type FinishLoopHaltReason = "empty-queue" | "grant-missing" | "grant-expired" | "grant-deny" | "gate-deny" | "max-iterations" | "require-human-merge" | "stale-merge-approval" | "address-findings" | "agent-implement" | "clean" | "merged" | "error";
12
12
  export type FinishLoopPhase = "gate" | "queue-scan" | "implement" | "pr-open" | "pr-watch" | "address" | "merge" | "halt";
13
13
  export interface FinishLoopProgressLine {
14
14
  readonly schemaVersion: 1;
@@ -142,11 +142,15 @@ export declare function decideHook(input: HookDispatchInput, seams?: HookPolicyS
142
142
  /**
143
143
  * Render host-facing hook output.
144
144
  *
145
- * Cursor deposits use `failClosed: true`. Cursor treats empty/null stdout as a
146
- * hook failure and blocks the tool — so Cursor allows must emit explicit
147
- * `{"permission":"allow"}`. Other hosts keep empty allow so the host permission
148
- * flow is unchanged except session.start / session.compact soft re-bind
149
- * injection (#3171), which surfaces checklist text without a write tool.
145
+ * Cursor deposits use `failClosed: true` with a tool.before timeout above the
146
+ * gated-ritual / live agent-hook readiness budget
147
+ * (`CURSOR_TOOL_BEFORE_TIMEOUT_SECONDS` in init-deposit/agent-hooks; #3246).
148
+ * Cursor treats empty/null stdout (or a host timeout kill) as a hook failure
149
+ * and blocks the tool so Cursor allows must emit explicit
150
+ * `{"permission":"allow"}` within the deposit timeout. Other hosts keep empty
151
+ * allow so the host permission flow is unchanged — except session.start /
152
+ * session.compact soft re-bind injection (#3171), which surfaces checklist
153
+ * text without a write tool.
150
154
  *
151
155
  * Cursor stdout always includes `code` (stable machine-readable decision code)
152
156
  * so agents can distinguish policy denials from host-integration failures
@@ -184,10 +184,10 @@ export function isAllowlistedAssistScratchPath(projectRoot, targetPath) {
184
184
  export function isAssistScratchWrite(projectRoot, targetPath, payload, environ = process.env) {
185
185
  if (!isAllowlistedAssistScratchPath(projectRoot, targetPath))
186
186
  return false;
187
- // Structural classification only — compose with #3080 ephemeral markers.
187
+ // Structural classification only — compose with #3080 / #3259 ephemeral markers.
188
188
  if (isAssistPosture(payload, environ))
189
189
  return true;
190
- if (isEphemeralSpawn(payload))
190
+ if (isEphemeralSpawn(payload, environ))
191
191
  return true;
192
192
  return false;
193
193
  }
@@ -656,14 +656,18 @@ function inspectMutationGates(input, toolName, seams, options) {
656
656
  if (!outsideRoot || isSpawnTool(toolName)) {
657
657
  let proposedPathHint;
658
658
  if (isSpawnTool(toolName)) {
659
- // Multi-path recovery for implement-class spawns (#3080 AC4).
659
+ // Multi-path recovery for implement-class spawns (#3080 AC4 / #3259 honesty).
660
+ // Structural markers only — free-text prompt brackets are not sufficient.
660
661
  proposedPathHint =
661
662
  " Recovery: (1) Product implementation — run `deft scope:activate -- <path>` " +
662
663
  "for the approved xBRIEF, then re-run the pre-start_agent gate stack. " +
663
- "(2) Read-only research — spawn with `subagent_type`/`worker_role` explore. " +
664
- "(3) Ephemeral docs/analysisspawn with `worker_role: ephemeral` " +
665
- "(aliases: docs, assist; see commands.md), or continue in the parent without " +
666
- "a lifecycle story. Do not invent a fake scope only to satisfy this gate.";
664
+ "(2) Read-only research — spawn with structural `subagent_type`/`worker_role` explore. " +
665
+ "(3) Ephemeral docs/local-devset structural tool fields " +
666
+ "`worker_role`/`subagent_type` ∈ {ephemeral, docs, assist} (hosts that support them), " +
667
+ "or set session assist (`DEFT_SESSION_POSTURE=assist` or `DEFT_HOOK_ASSIST=1`), " +
668
+ "or run local-dev Shell (`docker compose` / `pnpm dev`) in the parent without a " +
669
+ "lifecycle story. Free-text markers such as `[worker_role: ephemeral]` in the " +
670
+ "prompt are NOT sufficient. Do not invent a fake scope only to satisfy this gate.";
667
671
  }
668
672
  else if (options.proposedLifecycleExempt &&
669
673
  relTarget !== null &&
@@ -895,9 +899,9 @@ export function decideHook(input, seams = {}) {
895
899
  scopePath: null,
896
900
  };
897
901
  }
898
- // Ephemeral/docs/assist: write-capable non-lifecycle spawn; no active xBRIEF (#3080).
899
- // Does not authorize push/merge/deploy — those remain on shell/MCP matchers.
900
- if (isEphemeralSpawn(input.payload)) {
902
+ // Ephemeral/docs/assist (+ session assist env #3259): non-lifecycle spawn;
903
+ // no active xBRIEF. Does not authorize push/merge/deploy — shell/MCP matchers.
904
+ if (isEphemeralSpawn(input.payload, environ)) {
901
905
  return {
902
906
  verdict: "allow",
903
907
  code: "spawn-ephemeral-ready",
@@ -951,11 +955,15 @@ function softAgentsRebindWireText(decision) {
951
955
  /**
952
956
  * Render host-facing hook output.
953
957
  *
954
- * Cursor deposits use `failClosed: true`. Cursor treats empty/null stdout as a
955
- * hook failure and blocks the tool — so Cursor allows must emit explicit
956
- * `{"permission":"allow"}`. Other hosts keep empty allow so the host permission
957
- * flow is unchanged except session.start / session.compact soft re-bind
958
- * injection (#3171), which surfaces checklist text without a write tool.
958
+ * Cursor deposits use `failClosed: true` with a tool.before timeout above the
959
+ * gated-ritual / live agent-hook readiness budget
960
+ * (`CURSOR_TOOL_BEFORE_TIMEOUT_SECONDS` in init-deposit/agent-hooks; #3246).
961
+ * Cursor treats empty/null stdout (or a host timeout kill) as a hook failure
962
+ * and blocks the tool so Cursor allows must emit explicit
963
+ * `{"permission":"allow"}` within the deposit timeout. Other hosts keep empty
964
+ * allow so the host permission flow is unchanged — except session.start /
965
+ * session.compact soft re-bind injection (#3171), which surfaces checklist
966
+ * text without a write tool.
959
967
  *
960
968
  * Cursor stdout always includes `code` (stable machine-readable decision code)
961
969
  * so agents can distinguish policy denials from host-integration failures
@@ -13,9 +13,12 @@ export declare function isExploreSpawn(payload: unknown): boolean;
13
13
  */
14
14
  export declare function isAssistPosture(payload: unknown, environ?: NodeJS.ProcessEnv): boolean;
15
15
  /**
16
- * Ephemeral / assist / docs spawns skip active-xBRIEF implementation gates (#3080).
16
+ * Ephemeral / assist / docs spawns skip active-xBRIEF implementation gates (#3080 / #3259).
17
17
  * True only with an explicit allowlisted marker. Absent marker → false (fail closed).
18
- * When an ephemeral marker conflicts with implement envelope signals, implement wins.
18
+ * Markers: structural `worker_role`/`subagent_type` {ephemeral,docs,assist}, OR
19
+ * session assist env (`DEFT_SESSION_POSTURE` assist-set / `DEFT_HOOK_ASSIST=1`) for spawn.
20
+ * Free-text prompt strings are never classified. When a marker conflicts with implement
21
+ * envelope signals, implement wins.
19
22
  */
20
- export declare function isEphemeralSpawn(payload: unknown): boolean;
23
+ export declare function isEphemeralSpawn(payload: unknown, environ?: NodeJS.ProcessEnv): boolean;
21
24
  //# sourceMappingURL=readonly.d.ts.map