@deftai/directive-core 0.81.0 → 0.83.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 (75) hide show
  1. package/dist/content-contracts/skills/helpers.d.ts +6 -0
  2. package/dist/content-contracts/skills/helpers.js +47 -3
  3. package/dist/content-contracts/skills/skill-external-fetch-gate.d.ts +18 -0
  4. package/dist/content-contracts/skills/skill-external-fetch-gate.js +81 -0
  5. package/dist/eval/crud-telemetry.d.ts +2 -0
  6. package/dist/eval/crud-telemetry.js +30 -0
  7. package/dist/hooks/cursor-hooks.d.ts +23 -0
  8. package/dist/hooks/cursor-hooks.js +95 -0
  9. package/dist/hooks/dispatcher.d.ts +8 -1
  10. package/dist/hooks/dispatcher.js +23 -5
  11. package/dist/hooks/index.d.ts +1 -0
  12. package/dist/hooks/index.js +1 -0
  13. package/dist/init-deposit/agent-hooks.d.ts +4 -2
  14. package/dist/init-deposit/agent-hooks.js +137 -14
  15. package/dist/init-deposit/hygiene.js +1 -0
  16. package/dist/intake/github-body-cli.js +2 -0
  17. package/dist/intake/github-body.d.ts +11 -0
  18. package/dist/intake/github-body.js +95 -24
  19. package/dist/integration-e2e/helpers.js +1 -1
  20. package/dist/issue-sync/sync-from-xbrief.js +3 -0
  21. package/dist/lifecycle/events.d.ts +1 -0
  22. package/dist/lifecycle/events.js +59 -9
  23. package/dist/orphan-active/evaluate.d.ts +25 -0
  24. package/dist/orphan-active/evaluate.js +275 -0
  25. package/dist/orphan-active/index.d.ts +3 -0
  26. package/dist/orphan-active/index.js +3 -0
  27. package/dist/orphan-active/refs.d.ts +16 -0
  28. package/dist/orphan-active/refs.js +105 -0
  29. package/dist/policy/host-hooks.d.ts +21 -0
  30. package/dist/policy/host-hooks.js +96 -0
  31. package/dist/policy/index.d.ts +1 -0
  32. package/dist/policy/index.js +15 -1
  33. package/dist/pr-wait-mergeable/cascade.d.ts +8 -0
  34. package/dist/pr-wait-mergeable/cascade.js +18 -0
  35. package/dist/pr-wait-mergeable/main.d.ts +4 -0
  36. package/dist/pr-wait-mergeable/main.js +44 -73
  37. package/dist/pr-wait-mergeable/result.d.ts +2 -1
  38. package/dist/pr-wait-mergeable/result.js +4 -0
  39. package/dist/pr-wait-mergeable/semantic-green.d.ts +27 -0
  40. package/dist/pr-wait-mergeable/semantic-green.js +202 -0
  41. package/dist/pr-wait-mergeable/types.d.ts +1 -0
  42. package/dist/pr-watch/constants.d.ts +4 -4
  43. package/dist/pr-watch/constants.js +4 -4
  44. package/dist/pr-watch/watch.js +5 -3
  45. package/dist/product-signal/consent.d.ts +20 -3
  46. package/dist/product-signal/consent.js +63 -6
  47. package/dist/product-signal/gates.d.ts +1 -1
  48. package/dist/product-signal/submit.d.ts +5 -1
  49. package/dist/product-signal/submit.js +42 -18
  50. package/dist/release/native-steps.js +11 -4
  51. package/dist/render/framework-commands.js +6 -0
  52. package/dist/scope/brief-io.d.ts +26 -0
  53. package/dist/scope/brief-io.js +77 -0
  54. package/dist/scope/decompose.js +2 -2
  55. package/dist/scope/decomposed-refs.js +3 -3
  56. package/dist/scope/demote.js +2 -2
  57. package/dist/scope/project-definition-sync.d.ts +2 -2
  58. package/dist/scope/project-definition-sync.js +15 -3
  59. package/dist/scope/registry-artifact-sync.d.ts +6 -1
  60. package/dist/scope/registry-artifact-sync.js +31 -13
  61. package/dist/scope/transition.js +23 -15
  62. package/dist/scope/undo.js +2 -2
  63. package/dist/scope/vbrief-json.d.ts +1 -2
  64. package/dist/scope/vbrief-json.js +1 -4
  65. package/dist/staleness-tickler/state.js +19 -2
  66. package/dist/triage/queue/cache.js +5 -0
  67. package/dist/vbrief-validate/plan-hooks.d.ts +2 -0
  68. package/dist/vbrief-validate/plan-hooks.js +25 -0
  69. package/dist/verify-env/agent-hooks.d.ts +2 -1
  70. package/dist/verify-env/agent-hooks.js +3 -2
  71. package/dist/verify-source/index.d.ts +1 -0
  72. package/dist/verify-source/index.js +1 -0
  73. package/dist/verify-source/skill-external-fetch-gate.d.ts +21 -0
  74. package/dist/verify-source/skill-external-fetch-gate.js +71 -0
  75. package/package.json +7 -3
@@ -16,22 +16,45 @@ export function parseWaitMergeableArgs(argv) {
16
16
  let capMinutes = 60;
17
17
  const protectedValues = [];
18
18
  let emitJson = false;
19
+ let cascadeMode = false;
20
+ let requireMasterCiGreen = false;
21
+ let baseBranch = null;
22
+ const baseReturn = () => ({
23
+ prNumber,
24
+ repo,
25
+ capMinutes,
26
+ protectedValues,
27
+ emitJson,
28
+ cascadeMode,
29
+ requireMasterCiGreen,
30
+ baseBranch,
31
+ });
19
32
  for (let i = 0; i < argv.length; i += 1) {
20
33
  const arg = argv[i];
21
34
  if (arg === "--json") {
22
35
  emitJson = true;
23
36
  }
37
+ else if (arg === "--cascade") {
38
+ cascadeMode = true;
39
+ }
40
+ else if (arg === "--require-master-ci-green") {
41
+ requireMasterCiGreen = true;
42
+ }
43
+ else if (arg === "--base-branch") {
44
+ const value = argv[i + 1];
45
+ if (value === undefined) {
46
+ return { ...baseReturn(), error: "argument --base-branch: expected one argument" };
47
+ }
48
+ baseBranch = value;
49
+ i += 1;
50
+ }
51
+ else if (arg?.startsWith("--base-branch=")) {
52
+ baseBranch = arg.slice("--base-branch=".length);
53
+ }
24
54
  else if (arg === "--repo") {
25
55
  const value = argv[i + 1];
26
56
  if (value === undefined) {
27
- return {
28
- prNumber,
29
- repo,
30
- capMinutes,
31
- protectedValues,
32
- emitJson,
33
- error: "argument --repo: expected one argument",
34
- };
57
+ return { ...baseReturn(), error: "argument --repo: expected one argument" };
35
58
  }
36
59
  repo = value;
37
60
  i += 1;
@@ -42,25 +65,11 @@ export function parseWaitMergeableArgs(argv) {
42
65
  else if (arg === "--cap-minutes") {
43
66
  const value = argv[i + 1];
44
67
  if (value === undefined) {
45
- return {
46
- prNumber,
47
- repo,
48
- capMinutes,
49
- protectedValues,
50
- emitJson,
51
- error: "argument --cap-minutes: expected one argument",
52
- };
68
+ return { ...baseReturn(), error: "argument --cap-minutes: expected one argument" };
53
69
  }
54
70
  const parsed = Number(value);
55
71
  if (!Number.isFinite(parsed)) {
56
- return {
57
- prNumber,
58
- repo,
59
- capMinutes,
60
- protectedValues,
61
- emitJson,
62
- error: `invalid --cap-minutes value: ${value}`,
63
- };
72
+ return { ...baseReturn(), error: `invalid --cap-minutes value: ${value}` };
64
73
  }
65
74
  capMinutes = parsed;
66
75
  i += 1;
@@ -69,28 +78,14 @@ export function parseWaitMergeableArgs(argv) {
69
78
  const value = arg.slice("--cap-minutes=".length);
70
79
  const parsed = Number(value);
71
80
  if (!Number.isFinite(parsed)) {
72
- return {
73
- prNumber,
74
- repo,
75
- capMinutes,
76
- protectedValues,
77
- emitJson,
78
- error: `invalid --cap-minutes value: ${value}`,
79
- };
81
+ return { ...baseReturn(), error: `invalid --cap-minutes value: ${value}` };
80
82
  }
81
83
  capMinutes = parsed;
82
84
  }
83
85
  else if (arg === "--protected") {
84
86
  const value = argv[i + 1];
85
87
  if (value === undefined) {
86
- return {
87
- prNumber,
88
- repo,
89
- capMinutes,
90
- protectedValues,
91
- emitJson,
92
- error: "argument --protected: expected one argument",
93
- };
88
+ return { ...baseReturn(), error: "argument --protected: expected one argument" };
94
89
  }
95
90
  protectedValues.push(value);
96
91
  i += 1;
@@ -99,51 +94,23 @@ export function parseWaitMergeableArgs(argv) {
99
94
  protectedValues.push(arg.slice("--protected=".length));
100
95
  }
101
96
  else if (arg?.startsWith("-")) {
102
- return {
103
- prNumber,
104
- repo,
105
- capMinutes,
106
- protectedValues,
107
- emitJson,
108
- error: `unrecognized arguments: ${arg}`,
109
- };
97
+ return { ...baseReturn(), error: `unrecognized arguments: ${arg}` };
110
98
  }
111
99
  else if (prNumber === null) {
112
100
  const n = Number(arg);
113
101
  if (!Number.isInteger(n)) {
114
- return {
115
- prNumber,
116
- repo,
117
- capMinutes,
118
- protectedValues,
119
- emitJson,
120
- error: `invalid PR number: ${arg}`,
121
- };
102
+ return { ...baseReturn(), error: `invalid PR number: ${arg}` };
122
103
  }
123
104
  prNumber = n;
124
105
  }
125
106
  else {
126
- return {
127
- prNumber,
128
- repo,
129
- capMinutes,
130
- protectedValues,
131
- emitJson,
132
- error: `unrecognized arguments: ${arg}`,
133
- };
107
+ return { ...baseReturn(), error: `unrecognized arguments: ${arg}` };
134
108
  }
135
109
  }
136
110
  if (prNumber === null) {
137
- return {
138
- prNumber,
139
- repo,
140
- capMinutes,
141
- protectedValues,
142
- emitJson,
143
- error: "the following arguments are required: pr_number",
144
- };
111
+ return { ...baseReturn(), error: "the following arguments are required: pr_number" };
145
112
  }
146
- return { prNumber, repo, capMinutes, protectedValues, emitJson };
113
+ return baseReturn();
147
114
  }
148
115
  function summaryLabelForExit(exitCode) {
149
116
  switch (exitCode) {
@@ -183,6 +150,10 @@ export function runWaitMergeable(argv, options = {}) {
183
150
  protectedFn: options.protectedFn,
184
151
  monitorFn: options.monitorFn,
185
152
  mergeFn: options.mergeFn,
153
+ semanticGreenFn: options.semanticGreenFn,
154
+ cascadeMode: args.cascadeMode,
155
+ requireMasterCiGreen: args.requireMasterCiGreen,
156
+ baseBranch: args.baseBranch,
186
157
  });
187
158
  const summaryLabel = summaryLabelForExit(result.exitCode);
188
159
  process.stderr.write(`[pr_wait_mergeable] PR #${result.prNumber} repo=${result.repo} ` +
@@ -1,8 +1,9 @@
1
1
  import type { WaitMergeableResult } from "./types.js";
2
2
  export declare function toResultDict(result: WaitMergeableResult): Record<string, unknown>;
3
- export declare function makeResult(fields: Omit<WaitMergeableResult, "monitorResult" | "protectedCheck" | "mergeStdout" | "mergeStderr"> & {
3
+ export declare function makeResult(fields: Omit<WaitMergeableResult, "monitorResult" | "protectedCheck" | "semanticGreen" | "mergeStdout" | "mergeStderr"> & {
4
4
  readonly monitorResult?: Record<string, unknown>;
5
5
  readonly protectedCheck?: Record<string, unknown>;
6
+ readonly semanticGreen?: Record<string, unknown>;
6
7
  readonly mergeStdout?: string;
7
8
  readonly mergeStderr?: string;
8
9
  }): WaitMergeableResult;
@@ -11,6 +11,9 @@ export function toResultDict(result) {
11
11
  if (Object.keys(result.protectedCheck).length > 0) {
12
12
  payload.protected_check = result.protectedCheck;
13
13
  }
14
+ if (Object.keys(result.semanticGreen).length > 0) {
15
+ payload.semantic_green = result.semanticGreen;
16
+ }
14
17
  if (result.mergeStdout.length > 0) {
15
18
  payload.merge_stdout = result.mergeStdout;
16
19
  }
@@ -26,6 +29,7 @@ export function makeResult(fields) {
26
29
  return {
27
30
  monitorResult: fields.monitorResult ?? {},
28
31
  protectedCheck: fields.protectedCheck ?? {},
32
+ semanticGreen: fields.semanticGreen ?? {},
29
33
  mergeStdout: fields.mergeStdout ?? "",
30
34
  mergeStderr: fields.mergeStderr ?? "",
31
35
  prNumber: fields.prNumber,
@@ -0,0 +1,27 @@
1
+ import type { RunGhFn } from "../pr-merge-readiness/types.js";
2
+ export interface SemanticGreenOptions {
3
+ /** Enable merge-cascade semantic-green checks (#2385). */
4
+ readonly cascadeMode?: boolean;
5
+ /** After a prior cascade merge, require target-branch CI green at HEAD before the next merge. */
6
+ readonly requireMasterCiGreen?: boolean;
7
+ /** Target branch for stale-base comparison (defaults to PR base.ref). */
8
+ readonly baseBranch?: string | null;
9
+ readonly runGh?: RunGhFn;
10
+ }
11
+ export interface SemanticGreenPayload {
12
+ readonly pr_base_sha: string | null;
13
+ readonly target_branch: string | null;
14
+ readonly target_head_sha: string | null;
15
+ readonly master_ci: Record<string, unknown>;
16
+ }
17
+ export interface SemanticGreenResult {
18
+ readonly ok: boolean;
19
+ readonly outcome: string | null;
20
+ readonly exitCode: number | null;
21
+ readonly error: string | null;
22
+ readonly payload: SemanticGreenPayload;
23
+ }
24
+ /** Pre-merge semantic-green gate for merge cascades (#2385). */
25
+ export declare function evaluateSemanticGreen(prNumber: number, repo: string, options?: SemanticGreenOptions): SemanticGreenResult;
26
+ export type SemanticGreenFn = typeof evaluateSemanticGreen;
27
+ //# sourceMappingURL=semantic-green.d.ts.map
@@ -0,0 +1,202 @@
1
+ import { buildCiSummaryLine, evaluateCiGate } from "../pr-merge-readiness/ci-gate.js";
2
+ import { defaultRunGh, fetchCheckRunsRest } from "../pr-merge-readiness/gh.js";
3
+ import { EXIT_CONFIG_ERROR, EXIT_TIMEOUT_OR_ESCALATION } from "./constants.js";
4
+ function emptyPayload() {
5
+ return {
6
+ pr_base_sha: null,
7
+ target_branch: null,
8
+ target_head_sha: null,
9
+ master_ci: {},
10
+ };
11
+ }
12
+ function fetchPrBase(prNumber, repo, runGh) {
13
+ const rc = runGh(["gh", "api", `repos/${repo}/pulls/${prNumber}`]);
14
+ if (rc.returncode !== 0) {
15
+ return {
16
+ baseRef: null,
17
+ baseSha: null,
18
+ error: `gh api /pulls/${prNumber} failed: ${rc.stderr.trim()}`,
19
+ };
20
+ }
21
+ if (!rc.stdout.trim()) {
22
+ return { baseRef: null, baseSha: null, error: "empty body from gh api /pulls/<N>" };
23
+ }
24
+ let payload;
25
+ try {
26
+ payload = JSON.parse(rc.stdout);
27
+ }
28
+ catch (exc) {
29
+ const message = exc instanceof Error ? exc.message : String(exc);
30
+ return { baseRef: null, baseSha: null, error: `could not parse PR JSON: ${message}` };
31
+ }
32
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
33
+ return { baseRef: null, baseSha: null, error: "unexpected PR JSON shape (not a dict)" };
34
+ }
35
+ const base = payload.base;
36
+ if (base === null || typeof base !== "object" || Array.isArray(base)) {
37
+ return { baseRef: null, baseSha: null, error: "PR JSON missing base object" };
38
+ }
39
+ const baseRecord = base;
40
+ const baseRef = typeof baseRecord.ref === "string" && baseRecord.ref.length > 0 ? baseRecord.ref : null;
41
+ const baseSha = typeof baseRecord.sha === "string" && baseRecord.sha.length > 0 ? baseRecord.sha : null;
42
+ if (baseRef === null || baseSha === null) {
43
+ return { baseRef, baseSha, error: "PR JSON missing base.ref or base.sha" };
44
+ }
45
+ return { baseRef, baseSha, error: null };
46
+ }
47
+ function fetchBranchHeadSha(repo, branch, runGh) {
48
+ const encoded = branch.split("/").map(encodeURIComponent).join("/");
49
+ const rc = runGh(["gh", "api", `repos/${repo}/git/ref/heads/${encoded}`]);
50
+ if (rc.returncode !== 0) {
51
+ return {
52
+ sha: null,
53
+ error: `gh api /git/ref/heads/${branch} failed: ${rc.stderr.trim()}`,
54
+ };
55
+ }
56
+ if (!rc.stdout.trim()) {
57
+ return { sha: null, error: `empty body from gh api /git/ref/heads/${branch}` };
58
+ }
59
+ let payload;
60
+ try {
61
+ payload = JSON.parse(rc.stdout);
62
+ }
63
+ catch (exc) {
64
+ const message = exc instanceof Error ? exc.message : String(exc);
65
+ return { sha: null, error: `could not parse branch ref JSON: ${message}` };
66
+ }
67
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
68
+ return { sha: null, error: "unexpected branch ref JSON shape (not a dict)" };
69
+ }
70
+ const object = payload.object;
71
+ if (object === null || typeof object !== "object" || Array.isArray(object)) {
72
+ return { sha: null, error: "branch ref JSON missing object.sha" };
73
+ }
74
+ const sha = object.sha;
75
+ if (typeof sha !== "string" || sha.length === 0) {
76
+ return { sha: null, error: "branch ref JSON missing object.sha" };
77
+ }
78
+ return { sha, error: null };
79
+ }
80
+ function evaluateTargetBranchCi(repo, headSha, runGh) {
81
+ const check = fetchCheckRunsRest(headSha, repo, runGh);
82
+ if (check.error.length > 0) {
83
+ return {
84
+ ok: false,
85
+ error: "Required CI check-runs on target branch HEAD could not be fetched; " +
86
+ `fail closed (#2385). ${check.error}`,
87
+ masterCi: { ready_state: "blocked", error: check.error },
88
+ };
89
+ }
90
+ const ciResult = evaluateCiGate(check.checkRuns);
91
+ const masterCi = {
92
+ ...ciResult.summary,
93
+ summary_line: buildCiSummaryLine(ciResult.summary),
94
+ };
95
+ if (ciResult.summary.ready_state !== "ready") {
96
+ const detail = ciResult.failures.length > 0
97
+ ? ciResult.failures.join(" ")
98
+ : `target branch CI ready_state=${ciResult.summary.ready_state}`;
99
+ return {
100
+ ok: false,
101
+ error: "Target branch CI is not green at current HEAD; block cascade merge until master is green (#2385). " +
102
+ detail,
103
+ masterCi,
104
+ };
105
+ }
106
+ return { ok: true, error: null, masterCi };
107
+ }
108
+ /** Pre-merge semantic-green gate for merge cascades (#2385). */
109
+ export function evaluateSemanticGreen(prNumber, repo, options = {}) {
110
+ if (options.cascadeMode !== true) {
111
+ return { ok: true, outcome: null, exitCode: null, error: null, payload: emptyPayload() };
112
+ }
113
+ const runGh = options.runGh ?? defaultRunGh;
114
+ const prBase = fetchPrBase(prNumber, repo, runGh);
115
+ if (prBase.error !== null) {
116
+ return {
117
+ ok: false,
118
+ outcome: "config-error",
119
+ exitCode: EXIT_CONFIG_ERROR,
120
+ error: prBase.error,
121
+ payload: {
122
+ ...emptyPayload(),
123
+ pr_base_sha: prBase.baseSha,
124
+ target_branch: prBase.baseRef,
125
+ },
126
+ };
127
+ }
128
+ const targetBranch = options.baseBranch ?? prBase.baseRef;
129
+ if (targetBranch === null || targetBranch.length === 0) {
130
+ return {
131
+ ok: false,
132
+ outcome: "config-error",
133
+ exitCode: EXIT_CONFIG_ERROR,
134
+ error: "Could not resolve target branch for semantic-green gate (--base-branch or PR base.ref).",
135
+ payload: {
136
+ pr_base_sha: prBase.baseSha,
137
+ target_branch: null,
138
+ target_head_sha: null,
139
+ master_ci: {},
140
+ },
141
+ };
142
+ }
143
+ const branchHead = fetchBranchHeadSha(repo, targetBranch, runGh);
144
+ if (branchHead.error !== null) {
145
+ return {
146
+ ok: false,
147
+ outcome: "config-error",
148
+ exitCode: EXIT_CONFIG_ERROR,
149
+ error: branchHead.error,
150
+ payload: {
151
+ pr_base_sha: prBase.baseSha,
152
+ target_branch: targetBranch,
153
+ target_head_sha: null,
154
+ master_ci: {},
155
+ },
156
+ };
157
+ }
158
+ const payload = {
159
+ pr_base_sha: prBase.baseSha,
160
+ target_branch: targetBranch,
161
+ target_head_sha: branchHead.sha,
162
+ master_ci: {},
163
+ };
164
+ if (prBase.baseSha !== branchHead.sha) {
165
+ return {
166
+ ok: false,
167
+ outcome: "semantic-stale-base",
168
+ exitCode: EXIT_TIMEOUT_OR_ESCALATION,
169
+ error: "PR base SHA is behind the current target branch HEAD (merge-tree-clean but semantically stale). " +
170
+ `Rebase/update-branch onto ${targetBranch} at ${branchHead.sha} and wait for fresh green CI before cascade merge (#2385). ` +
171
+ `pr_base=${prBase.baseSha} target_head=${branchHead.sha}`,
172
+ payload,
173
+ };
174
+ }
175
+ if (options.requireMasterCiGreen === true) {
176
+ const ci = evaluateTargetBranchCi(repo, branchHead.sha, runGh);
177
+ if (!ci.ok) {
178
+ return {
179
+ ok: false,
180
+ outcome: "master-ci-not-green",
181
+ exitCode: EXIT_TIMEOUT_OR_ESCALATION,
182
+ error: ci.error,
183
+ payload: {
184
+ ...payload,
185
+ master_ci: ci.masterCi,
186
+ },
187
+ };
188
+ }
189
+ return {
190
+ ok: true,
191
+ outcome: null,
192
+ exitCode: null,
193
+ error: null,
194
+ payload: {
195
+ ...payload,
196
+ master_ci: ci.masterCi,
197
+ },
198
+ };
199
+ }
200
+ return { ok: true, outcome: null, exitCode: null, error: null, payload };
201
+ }
202
+ //# sourceMappingURL=semantic-green.js.map
@@ -5,6 +5,7 @@ export interface WaitMergeableResult {
5
5
  readonly exitCode: number;
6
6
  readonly monitorResult: Record<string, unknown>;
7
7
  readonly protectedCheck: Record<string, unknown>;
8
+ readonly semanticGreen: Record<string, unknown>;
8
9
  readonly mergeStdout: string;
9
10
  readonly mergeStderr: string;
10
11
  readonly error: string | null;
@@ -39,10 +39,10 @@ export declare const DEFAULT_POLL_SECONDS = 90;
39
39
  */
40
40
  export declare const WATCH_HELP: string;
41
41
  /**
42
- * Consecutive polls with a review PRESENT but stuck on a stale (non-HEAD)
43
- * commit before the loop surfaces STALL instead of waiting the full cap. Keeps
44
- * a Greptile review that never re-reviews the new HEAD from silently eating the
45
- * whole 30-minute budget (#1039 STALL terminal).
42
+ * Consecutive polls where the CLEAN gate is wedged on HEAD (!has_blocking &&
43
+ * !is_clean with a holdout other than sha_match) before STALL (#1039). Stale-SHA
44
+ * reads (sha_match holdout) do NOT advance this counter re-review in flight
45
+ * waits until max-wait cap (#2313 / #1259 INCOMPLETE_BUT_RATED).
46
46
  */
47
47
  export declare const DEFAULT_STALL_THRESHOLD = 3;
48
48
  /**
@@ -61,10 +61,10 @@ export const WATCH_HELP = "usage: task pr:watch -- <pr_number> [options]\n" +
61
61
  " 1 NEW_P0_P1 Blocking findings on the current (SHA-matched) review\n" +
62
62
  " 2 ERRORED | STALL | TIMEOUT | CI_BLOCKED | RUNNER_CAPACITY_STALL | config / usage error\n";
63
63
  /**
64
- * Consecutive polls with a review PRESENT but stuck on a stale (non-HEAD)
65
- * commit before the loop surfaces STALL instead of waiting the full cap. Keeps
66
- * a Greptile review that never re-reviews the new HEAD from silently eating the
67
- * whole 30-minute budget (#1039 STALL terminal).
64
+ * Consecutive polls where the CLEAN gate is wedged on HEAD (!has_blocking &&
65
+ * !is_clean with a holdout other than sha_match) before STALL (#1039). Stale-SHA
66
+ * reads (sha_match holdout) do NOT advance this counter re-review in flight
67
+ * waits until max-wait cap (#2313 / #1259 INCOMPLETE_BUT_RATED).
68
68
  */
69
69
  export const DEFAULT_STALL_THRESHOLD = 3;
70
70
  /**
@@ -99,9 +99,11 @@ export function watch(prNumber, repo, options = {}) {
99
99
  if (!oneShot && ciBlockedStreak >= DEFAULT_CI_BLOCKED_THRESHOLD) {
100
100
  return build(VERDICT_CI_BLOCKED, EXIT_TERMINAL_ERROR, probe, poll);
101
101
  }
102
- // Stall = a review IS present but stuck on a non-HEAD commit; surface it
103
- // rather than burning the whole cap waiting for a re-review that isn't coming.
104
- if (probe.found && !probe.shaMatch) {
102
+ // STALL (#1039): wedged CLEAN-gate on HEAD !has_blocking && !is_clean for N
103
+ // consecutive polls with a holdout OTHER than sha_match. Stale-SHA reads
104
+ // (clean_gate_holdout=sha_match) are INCOMPLETE for HEAD per #1259 / #2313 —
105
+ // keep polling until cap, not early STALL while re-review is in flight.
106
+ if (!probe.hasBlocking && !probe.isClean && probe.cleanGateHoldout !== "sha_match") {
105
107
  stallStreak += 1;
106
108
  }
107
109
  else {
@@ -1,12 +1,14 @@
1
1
  export declare const PRODUCT_SIGNAL_CONSENT_FILENAME = "product-signal-consent.json";
2
- /** Consent record schema version (#2693 D2). */
3
- export declare const PRODUCT_SIGNAL_CONSENT_VERSION = 1;
2
+ /** Consent record schema version (#2693 D2, #2767 v2 sink binding). */
3
+ export declare const PRODUCT_SIGNAL_CONSENT_VERSION = 2;
4
4
  /** Phase-1 consent tier permitting qualitative outbound (#2693 D2). */
5
5
  export declare const PRODUCT_SIGNAL_CONSENT_TIER = "product-signal";
6
6
  export interface ProductSignalConsentRecord {
7
7
  readonly consentVersion: number;
8
8
  readonly grantedAt: string;
9
9
  readonly tier: string;
10
+ /** Normalized owner/repo sink authorized by v2 consent (#2767). */
11
+ readonly sinkRepo?: string;
10
12
  readonly revokedAt?: string;
11
13
  }
12
14
  export interface ResolveConsentPathOptions {
@@ -14,16 +16,31 @@ export interface ResolveConsentPathOptions {
14
16
  readonly env?: NodeJS.ProcessEnv;
15
17
  readonly homeDir?: string;
16
18
  }
19
+ export interface SinkAuthorizationResult {
20
+ readonly authorized: boolean;
21
+ readonly configuredSink: string;
22
+ readonly consentedSink: string | null;
23
+ readonly sinksMatch: boolean;
24
+ readonly message: string;
25
+ }
17
26
  /** Platform-config consent path adjacent to USER.md (#2693 D2). */
18
27
  export declare function resolveProductSignalConsentPath(options?: ResolveConsentPathOptions): string;
28
+ /** Normalize sinkRepo to lowercase owner/repo (#2767). */
29
+ export declare function normalizeProductSignalSinkRepo(raw: string): string;
30
+ /** Resolve the sink authorized by a consent record (#2767). */
31
+ export declare function resolveConsentedProductSignalSink(consent: ProductSignalConsentRecord | null): string | null;
32
+ /** Authorize configured sink against install consent (#2767). */
33
+ export declare function authorizeProductSignalSink(configuredSink: string, consent: ProductSignalConsentRecord | null): SinkAuthorizationResult;
19
34
  /** Read consent file; returns null when absent, invalid, or revoked. */
20
35
  export declare function readProductSignalConsent(options?: ResolveConsentPathOptions): ProductSignalConsentRecord | null;
21
36
  /** True when a non-revoked consent grant exists. */
22
37
  export declare function isProductSignalConsented(options?: ResolveConsentPathOptions): boolean;
23
38
  export interface WriteConsentOptions extends ResolveConsentPathOptions {
24
39
  readonly now?: Date;
40
+ /** Normalized sink to bind into v2 consent (#2767). Defaults to baked-in sink. */
41
+ readonly sinkRepo?: string;
25
42
  }
26
- /** Write a fresh consent grant (#2693 D17 yes path). */
43
+ /** Write a fresh consent grant (#2693 D17 yes path, #2767 v2 sink binding). */
27
44
  export declare function grantProductSignalConsent(options?: WriteConsentOptions): ProductSignalConsentRecord;
28
45
  /** Revoke consent by setting revokedAt (#2693 D2). */
29
46
  export declare function revokeProductSignalConsent(options?: WriteConsentOptions): boolean;
@@ -1,10 +1,13 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
+ import { DEFAULT_PRODUCT_SIGNAL_SINK_REPO } from "../policy/product-signal.js";
4
5
  import { platformUserConfigDir } from "../user-config/resolve-user-md.js";
5
6
  export const PRODUCT_SIGNAL_CONSENT_FILENAME = "product-signal-consent.json";
6
- /** Consent record schema version (#2693 D2). */
7
- export const PRODUCT_SIGNAL_CONSENT_VERSION = 1;
7
+ /** Consent record schema version (#2693 D2, #2767 v2 sink binding). */
8
+ export const PRODUCT_SIGNAL_CONSENT_VERSION = 2;
9
+ /** Legacy consent schema — authorizes default sink only (#2767). */
10
+ const PRODUCT_SIGNAL_CONSENT_VERSION_V1 = 1;
8
11
  /** Phase-1 consent tier permitting qualitative outbound (#2693 D2). */
9
12
  export const PRODUCT_SIGNAL_CONSENT_TIER = "product-signal";
10
13
  function resolveHomeDirForConsent(options) {
@@ -32,25 +35,76 @@ export function resolveProductSignalConsentPath(options = {}) {
32
35
  const homeDir = resolveHomeDirForConsent(options);
33
36
  return join(platformUserConfigDir(platform, env, homeDir), PRODUCT_SIGNAL_CONSENT_FILENAME);
34
37
  }
38
+ /** Normalize sinkRepo to lowercase owner/repo (#2767). */
39
+ export function normalizeProductSignalSinkRepo(raw) {
40
+ const sink = raw.trim().replace(/^https?:\/\/github\.com\//i, "");
41
+ return sink.replace(/\/+$/, "").toLowerCase();
42
+ }
35
43
  function parseConsentRecord(raw) {
36
44
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
37
45
  return null;
38
46
  }
39
47
  const rec = raw;
40
- if (typeof rec.consentVersion !== "number" || typeof rec.grantedAt !== "string") {
48
+ if (typeof rec.consentVersion !== "number" ||
49
+ typeof rec.grantedAt !== "string" ||
50
+ typeof rec.tier !== "string") {
41
51
  return null;
42
52
  }
43
- if (typeof rec.tier !== "string") {
53
+ const revokedAt = typeof rec.revokedAt === "string" ? rec.revokedAt : undefined;
54
+ let sinkRepo;
55
+ if (rec.consentVersion >= PRODUCT_SIGNAL_CONSENT_VERSION) {
56
+ if (typeof rec.sinkRepo !== "string" || rec.sinkRepo.trim().length === 0) {
57
+ return null;
58
+ }
59
+ sinkRepo = normalizeProductSignalSinkRepo(rec.sinkRepo);
60
+ if (sinkRepo.length === 0) {
61
+ return null;
62
+ }
63
+ }
64
+ else if (rec.consentVersion !== PRODUCT_SIGNAL_CONSENT_VERSION_V1) {
44
65
  return null;
45
66
  }
46
- const revokedAt = typeof rec.revokedAt === "string" ? rec.revokedAt : undefined;
47
67
  return {
48
68
  consentVersion: rec.consentVersion,
49
69
  grantedAt: rec.grantedAt,
50
70
  tier: rec.tier,
71
+ sinkRepo,
51
72
  revokedAt,
52
73
  };
53
74
  }
75
+ /** Resolve the sink authorized by a consent record (#2767). */
76
+ export function resolveConsentedProductSignalSink(consent) {
77
+ if (consent === null) {
78
+ return null;
79
+ }
80
+ if (consent.consentVersion >= PRODUCT_SIGNAL_CONSENT_VERSION) {
81
+ return consent.sinkRepo ?? null;
82
+ }
83
+ if (consent.consentVersion === PRODUCT_SIGNAL_CONSENT_VERSION_V1) {
84
+ return normalizeProductSignalSinkRepo(DEFAULT_PRODUCT_SIGNAL_SINK_REPO);
85
+ }
86
+ return null;
87
+ }
88
+ /** Authorize configured sink against install consent (#2767). */
89
+ export function authorizeProductSignalSink(configuredSink, consent) {
90
+ const configured = normalizeProductSignalSinkRepo(configuredSink);
91
+ const consented = resolveConsentedProductSignalSink(consent);
92
+ const sinksMatch = consented !== null && configured === consented;
93
+ let message = "sink authorized";
94
+ if (!sinksMatch) {
95
+ message =
96
+ consented === null
97
+ ? "product-signal requires consent (`task product-signal:consent -- --grant`)."
98
+ : `product-signal skipped (sink-unconsented): configured sink=${configured} does not match consented sink=${consented}. Re-run \`task product-signal:consent -- --grant\` after confirming the destination.`;
99
+ }
100
+ return {
101
+ authorized: sinksMatch,
102
+ configuredSink: configured,
103
+ consentedSink: consented,
104
+ sinksMatch,
105
+ message,
106
+ };
107
+ }
54
108
  /** Read consent file; returns null when absent, invalid, or revoked. */
55
109
  export function readProductSignalConsent(options = {}) {
56
110
  const path = resolveProductSignalConsentPath(options);
@@ -76,13 +130,16 @@ export function readProductSignalConsent(options = {}) {
76
130
  export function isProductSignalConsented(options = {}) {
77
131
  return readProductSignalConsent(options) !== null;
78
132
  }
79
- /** Write a fresh consent grant (#2693 D17 yes path). */
133
+ /** Write a fresh consent grant (#2693 D17 yes path, #2767 v2 sink binding). */
80
134
  export function grantProductSignalConsent(options = {}) {
81
135
  const now = options.now ?? new Date();
136
+ const normalizedSink = normalizeProductSignalSinkRepo((options.sinkRepo ?? DEFAULT_PRODUCT_SIGNAL_SINK_REPO).trim());
137
+ const sinkRepo = normalizedSink || normalizeProductSignalSinkRepo(DEFAULT_PRODUCT_SIGNAL_SINK_REPO);
82
138
  const record = {
83
139
  consentVersion: PRODUCT_SIGNAL_CONSENT_VERSION,
84
140
  grantedAt: now.toISOString().replace(/\.\d{3}Z$/, "Z"),
85
141
  tier: PRODUCT_SIGNAL_CONSENT_TIER,
142
+ sinkRepo,
86
143
  };
87
144
  const path = resolveProductSignalConsentPath(options);
88
145
  mkdirSync(dirname(path), { recursive: true });
@@ -1,4 +1,4 @@
1
- export type ProductSignalOutcome = "submitted" | "dry-run" | "disabled" | "no-consent" | "no-network" | "non-interactive" | "sink-unreachable" | "sink-unauthorized" | "validation" | "error-config";
1
+ export type ProductSignalOutcome = "submitted" | "dry-run" | "disabled" | "no-consent" | "no-network" | "non-interactive" | "sink-unconsented" | "sink-unreachable" | "sink-unauthorized" | "validation" | "error-config";
2
2
  export interface GateEvaluation {
3
3
  readonly allowed: boolean;
4
4
  readonly outcome: ProductSignalOutcome;