@deftai/directive-core 0.99.0 → 0.101.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 (166) 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 +241 -30
  4. package/dist/authz/decompose-apply.d.ts +81 -0
  5. package/dist/authz/decompose-apply.js +377 -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/check/cached-orchestrator.d.ts +26 -5
  13. package/dist/check/cached-orchestrator.js +228 -6
  14. package/dist/check/gate-lists.d.ts +10 -4
  15. package/dist/check/gate-lists.js +19 -5
  16. package/dist/check/index.d.ts +3 -2
  17. package/dist/check/index.js +2 -1
  18. package/dist/check/named-cause.d.ts +45 -0
  19. package/dist/check/named-cause.js +121 -0
  20. package/dist/check/orchestrator.js +6 -1
  21. package/dist/doctor/checks.d.ts +44 -0
  22. package/dist/doctor/checks.js +428 -2
  23. package/dist/doctor/help.d.ts +5 -0
  24. package/dist/doctor/help.js +31 -0
  25. package/dist/doctor/index.d.ts +2 -0
  26. package/dist/doctor/index.js +2 -0
  27. package/dist/doctor/main.d.ts +6 -7
  28. package/dist/doctor/main.js +130 -79
  29. package/dist/doctor/session-coda.d.ts +82 -0
  30. package/dist/doctor/session-coda.js +151 -0
  31. package/dist/doctor/types.d.ts +16 -0
  32. package/dist/doctor/which.d.ts +20 -1
  33. package/dist/doctor/which.js +67 -1
  34. package/dist/finish-loop/pr-finish-loop.d.ts +13 -2
  35. package/dist/finish-loop/pr-finish-loop.js +116 -5
  36. package/dist/finish-loop/types.d.ts +1 -1
  37. package/dist/hooks/dispatcher.d.ts +9 -5
  38. package/dist/hooks/dispatcher.js +23 -15
  39. package/dist/hooks/readonly.d.ts +6 -3
  40. package/dist/hooks/readonly.js +44 -25
  41. package/dist/index.d.ts +3 -0
  42. package/dist/index.js +3 -0
  43. package/dist/init-deposit/agent-hooks.d.ts +20 -0
  44. package/dist/init-deposit/agent-hooks.js +51 -31
  45. package/dist/init-deposit/gitignore.js +2 -0
  46. package/dist/intake/issue-ingest.js +36 -0
  47. package/dist/lifecycle/completed-consistency.d.ts +60 -0
  48. package/dist/lifecycle/completed-consistency.js +443 -0
  49. package/dist/lifecycle/completed-tracked-on-delivery.d.ts +47 -0
  50. package/dist/lifecycle/completed-tracked-on-delivery.js +415 -0
  51. package/dist/lifecycle/index.d.ts +2 -0
  52. package/dist/lifecycle/index.js +2 -0
  53. package/dist/literal-acceptance/capture.d.ts +52 -0
  54. package/dist/literal-acceptance/capture.js +658 -0
  55. package/dist/literal-acceptance/evaluate.d.ts +47 -0
  56. package/dist/literal-acceptance/evaluate.js +193 -0
  57. package/dist/literal-acceptance/index.d.ts +12 -0
  58. package/dist/literal-acceptance/index.js +12 -0
  59. package/dist/literal-acceptance/run.d.ts +38 -0
  60. package/dist/literal-acceptance/run.js +216 -0
  61. package/dist/literal-acceptance/safety.d.ts +24 -0
  62. package/dist/literal-acceptance/safety.js +251 -0
  63. package/dist/literal-acceptance/types.d.ts +82 -0
  64. package/dist/literal-acceptance/types.js +26 -0
  65. package/dist/policy/ac-pass-banking.d.ts +58 -0
  66. package/dist/policy/ac-pass-banking.js +150 -0
  67. package/dist/policy/ceremony-dial.d.ts +96 -11
  68. package/dist/policy/ceremony-dial.js +250 -23
  69. package/dist/policy/index.d.ts +2 -0
  70. package/dist/policy/index.js +16 -1
  71. package/dist/policy/merge-approval-head.d.ts +129 -0
  72. package/dist/policy/merge-approval-head.js +461 -0
  73. package/dist/policy/require-human-merge.d.ts +5 -0
  74. package/dist/policy/require-human-merge.js +5 -0
  75. package/dist/pr-merge-readiness/ci-gate.d.ts +20 -3
  76. package/dist/pr-merge-readiness/ci-gate.js +65 -5
  77. package/dist/pr-merge-readiness/compute.d.ts +13 -0
  78. package/dist/pr-merge-readiness/compute.js +99 -14
  79. package/dist/pr-merge-readiness/gh.d.ts +49 -0
  80. package/dist/pr-merge-readiness/gh.js +268 -0
  81. package/dist/pr-merge-readiness/index.d.ts +2 -2
  82. package/dist/pr-merge-readiness/index.js +2 -2
  83. package/dist/pr-merge-readiness/test-gh-fixtures.helpers.js +4 -0
  84. package/dist/pr-wait-mergeable/cascade.d.ts +11 -0
  85. package/dist/pr-wait-mergeable/cascade.js +115 -1
  86. package/dist/pr-wait-mergeable/main.d.ts +4 -0
  87. package/dist/pr-wait-mergeable/main.js +4 -0
  88. package/dist/pr-wait-mergeable/types.d.ts +3 -1
  89. package/dist/pr-wait-mergeable/wrappers.d.ts +6 -1
  90. package/dist/pr-wait-mergeable/wrappers.js +7 -1
  91. package/dist/preflight/evaluate.d.ts +15 -1
  92. package/dist/preflight/evaluate.js +33 -2
  93. package/dist/preflight-cache/evaluate.d.ts +3 -0
  94. package/dist/preflight-cache/evaluate.js +3 -0
  95. package/dist/product-first-done-gate/acceptance.d.ts +25 -0
  96. package/dist/product-first-done-gate/acceptance.js +247 -0
  97. package/dist/product-first-done-gate/check-mode.d.ts +45 -0
  98. package/dist/product-first-done-gate/check-mode.js +148 -0
  99. package/dist/product-first-done-gate/evaluate.d.ts +50 -0
  100. package/dist/product-first-done-gate/evaluate.js +255 -0
  101. package/dist/product-first-done-gate/index.d.ts +11 -0
  102. package/dist/product-first-done-gate/index.js +11 -0
  103. package/dist/product-first-done-gate/types.d.ts +64 -0
  104. package/dist/product-first-done-gate/types.js +61 -0
  105. package/dist/render/framework-commands.js +4 -0
  106. package/dist/run-summary/emit.d.ts +56 -0
  107. package/dist/run-summary/emit.js +171 -0
  108. package/dist/run-summary/index.d.ts +4 -0
  109. package/dist/run-summary/index.js +4 -0
  110. package/dist/run-summary/path.d.ts +25 -0
  111. package/dist/run-summary/path.js +78 -0
  112. package/dist/run-summary/types.d.ts +76 -0
  113. package/dist/run-summary/types.js +21 -0
  114. package/dist/scope/acceptance-evidence.d.ts +110 -0
  115. package/dist/scope/acceptance-evidence.js +422 -0
  116. package/dist/scope/coverage-map.d.ts +98 -0
  117. package/dist/scope/coverage-map.js +558 -0
  118. package/dist/scope/decompose.d.ts +5 -0
  119. package/dist/scope/decompose.js +134 -11
  120. package/dist/scope/index.d.ts +3 -0
  121. package/dist/scope/index.js +3 -0
  122. package/dist/scope/parent-lineage.d.ts +125 -0
  123. package/dist/scope/parent-lineage.js +626 -0
  124. package/dist/scope/transition.d.ts +8 -0
  125. package/dist/scope/transition.js +61 -2
  126. package/dist/session/ac-pass-banking.d.ts +221 -0
  127. package/dist/session/ac-pass-banking.js +761 -0
  128. package/dist/session/active-cli.d.ts +79 -0
  129. package/dist/session/active-cli.js +382 -0
  130. package/dist/session/deposit-sha.d.ts +49 -0
  131. package/dist/session/deposit-sha.js +121 -0
  132. package/dist/session/effort-budget.d.ts +136 -0
  133. package/dist/session/effort-budget.js +352 -0
  134. package/dist/session/index.d.ts +7 -0
  135. package/dist/session/index.js +7 -0
  136. package/dist/session/orientation-compression.d.ts +127 -0
  137. package/dist/session/orientation-compression.js +425 -0
  138. package/dist/session/orientation-state.d.ts +29 -0
  139. package/dist/session/orientation-state.js +85 -0
  140. package/dist/session/session-start.d.ts +31 -0
  141. package/dist/session/session-start.js +228 -2
  142. package/dist/session/toolchain-preflight.d.ts +64 -0
  143. package/dist/session/toolchain-preflight.js +151 -0
  144. package/dist/session/verify-session-ritual.d.ts +14 -0
  145. package/dist/session/verify-session-ritual.js +33 -0
  146. package/dist/story-ready/evaluate.d.ts +11 -0
  147. package/dist/story-ready/evaluate.js +41 -3
  148. package/dist/triage/actions/candidates-log.d.ts +1 -2
  149. package/dist/triage/actions/candidates-log.js +37 -6
  150. package/dist/triage/bootstrap/gitignore.d.ts +1 -1
  151. package/dist/triage/bootstrap/gitignore.js +8 -2
  152. package/dist/triage/bootstrap/index.js +15 -3
  153. package/dist/triage/classify/index.d.ts +1 -0
  154. package/dist/triage/classify/index.js +2 -0
  155. package/dist/triage/classify/label-mirror.js +16 -0
  156. package/dist/triage/classify/mirror-discovery-tip.d.ts +95 -0
  157. package/dist/triage/classify/mirror-discovery-tip.js +234 -0
  158. package/dist/triage/reconcile/reconcile.js +28 -6
  159. package/dist/triage/summary/index.js +12 -4
  160. package/dist/triage/welcome/default-mode.js +16 -0
  161. package/dist/ts-check-lane/run-lane.js +21 -4
  162. package/dist/vbrief-validate/conformance.js +7 -0
  163. package/dist/vitest-runner/coverage-debt-teardown.js +16 -4
  164. package/dist/xbrief-migrate/migrate-project.d.ts +4 -0
  165. package/dist/xbrief-migrate/migrate-project.js +92 -3
  166. package/package.json +15 -3
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
+ import { evaluateParentLineage, formatParentLineageLine, } from "../scope/parent-lineage.js";
3
4
  import { parseAllocationSection, SOLO_KIND, SWARM_COHORT_KIND, VALID_DISPATCH_KINDS, } from "./allocation.js";
4
5
  export const ACTIVE_FOLDER = "active";
5
6
  export const ELIGIBLE_STATUS = "running";
@@ -66,7 +67,7 @@ function checkVbrief(vbriefPath) {
66
67
  "is eligible for a story start",
67
68
  };
68
69
  }
69
- return { ok: true };
70
+ return { ok: true, path, payload: payload };
70
71
  }
71
72
  function readyMessage(treeNote, suffix) {
72
73
  return `OK: ready to start -- ${treeNote}, vBRIEF active+running, ${suffix}`;
@@ -116,6 +117,9 @@ function classifyAllocation(fields, treeNote) {
116
117
  /**
117
118
  * Pure evaluator — returns exit code + human message. Faithful to
118
119
  * `scripts/preflight_story_start.evaluate`.
120
+ *
121
+ * #3241: after structural active+running checks, re-check parent requirement
122
+ * lineage (coverage map + behavioral deltas) when the parent authors IDs.
119
123
  */
120
124
  export function evaluate(vbriefPath, options = {}) {
121
125
  const gitStatus = options.gitStatus ?? null;
@@ -147,15 +151,49 @@ export function evaluate(vbriefPath, options = {}) {
147
151
  message: `not ready: ${vbriefCheck.reason}.`,
148
152
  };
149
153
  }
154
+ // #3241 parent lineage (fail closed when parent authors IDs and child lacks coverage).
155
+ const lineage = evaluateParentLineage({
156
+ child: vbriefCheck.payload,
157
+ childPath: vbriefCheck.path,
158
+ projectRoot: options.projectRoot,
159
+ skip: options.skipParentLineage === true,
160
+ });
161
+ if (!lineage.ok) {
162
+ return {
163
+ exitCode: 1,
164
+ dispatchKind: null,
165
+ parentLineage: lineage,
166
+ message: `not ready: ${lineage.message}` +
167
+ (lineage.defect_class !== null ? ` [defect_class=${lineage.defect_class}]` : "") +
168
+ `\n${formatParentLineageLine(lineage)}`,
169
+ };
170
+ }
171
+ const lineageNote = lineage.applicable
172
+ ? `parent lineage OK (${lineage.parent_requirement_ids.length} req IDs` +
173
+ (lineage.negative_invariant_ids.length > 0
174
+ ? `, ${lineage.negative_invariant_ids.length} negative invariants`
175
+ : "") +
176
+ ")"
177
+ : null;
150
178
  const [found, fields] = options.parsed ?? parseAllocationSection(options.allocationContext ?? null);
151
179
  if (!found) {
180
+ const base = readyMessage(treeNote, "no `## Allocation context` section (solo path, #1371 carve-out).");
152
181
  return {
153
182
  exitCode: 0,
154
183
  dispatchKind: null,
155
- message: readyMessage(treeNote, "no `## Allocation context` section (solo path, #1371 carve-out)."),
184
+ parentLineage: lineage,
185
+ message: lineageNote !== null ? `${base.replace(/\.$/, "")}; ${lineageNote}.` : base,
156
186
  };
157
187
  }
158
- return classifyAllocation(fields, treeNote);
188
+ const classified = classifyAllocation(fields, treeNote);
189
+ return {
190
+ ...classified,
191
+ parentLineage: lineage,
192
+ message: classified.exitCode === 0 && lineageNote !== null
193
+ ? `${classified.message.replace(/\.$/, "")}; ${lineageNote}.`
194
+ : classified.message,
195
+ };
159
196
  }
197
+ export { evaluateParentLineage, formatParentLineageLine, } from "../scope/parent-lineage.js";
160
198
  export { parseAllocationSection, SOLO_KIND, SWARM_COHORT_KIND };
161
199
  //# sourceMappingURL=evaluate.js.map
@@ -1,6 +1,5 @@
1
1
  import { resolveCandidatesLogPath } from "../cache-path.js";
2
- import type { AuditEntry, CandidatesLog } from "./types.js";
3
- /** Display/back-compat constant; resolution flows through resolveTriageCachePath (#1703). */
2
+ import type { AuditEntry, CandidatesLog } from "./types.js"; /** Display/back-compat constant; resolution flows through resolveTriageCachePath (#1703). */
4
3
  export declare const AUDIT_LOG_REL_PATH = "xbrief/.triage-cache/candidates.jsonl";
5
4
  export { resolveCandidatesLogPath };
6
5
  /** Read every well-formed audit-log row in insertion order (#1698 shared reader). */
@@ -1,9 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
2
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
4
+ import { containedWrite } from "../../fs/contained-write.js";
4
5
  import { resolveCandidatesLogPath, TRIAGE_CANDIDATES_LOG_REL_PATH } from "../cache-path.js";
5
6
  import { CandidatesLogError } from "./errors.js";
6
- /** Display/back-compat constant; resolution flows through resolveTriageCachePath (#1703). */
7
7
  export const AUDIT_LOG_REL_PATH = TRIAGE_CANDIDATES_LOG_REL_PATH;
8
8
  export { resolveCandidatesLogPath };
9
9
  const VALID_DECISIONS = new Set([
@@ -108,6 +108,32 @@ function resolveLogPath(projectRoot, override) {
108
108
  }
109
109
  return resolveCandidatesLogPath(projectRoot);
110
110
  }
111
+ /**
112
+ * Containment root for candidates-log product writes (#3245).
113
+ * Prefer project root when the log is nested under it; otherwise parent dir
114
+ * (matches intake candidates-log parity for override paths outside the tree).
115
+ */
116
+ function containmentRootForLog(projectRoot, logPath) {
117
+ const rootAbs = resolve(projectRoot);
118
+ const logAbs = resolve(logPath);
119
+ const parent = dirname(logAbs);
120
+ mkdirSync(parent, { recursive: true });
121
+ const rel = relative(rootAbs, logAbs);
122
+ if (rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel)) {
123
+ return rootAbs;
124
+ }
125
+ return resolve(parent);
126
+ }
127
+ /** #3245 / #2980: product append refuses leaf symlink follow (in-tree or escape). */
128
+ function appendAuditLine(projectRoot, logPath, line) {
129
+ const root = containmentRootForLog(projectRoot, logPath);
130
+ containedWrite({
131
+ root,
132
+ target: logPath,
133
+ data: line.endsWith("\n") ? line : `${line}\n`,
134
+ mode: "append",
135
+ });
136
+ }
111
137
  function stableStringify(entry) {
112
138
  const sortedKeys = Object.keys(entry).sort();
113
139
  const sorted = {};
@@ -186,8 +212,7 @@ export function createCandidatesLog(defaultProjectRoot) {
186
212
  append(entry, options) {
187
213
  validateEntry(entry);
188
214
  const logPath = resolveLogPath(defaultProjectRoot, options?.path);
189
- mkdirSync(join(logPath, ".."), { recursive: true });
190
- appendFileSync(logPath, `${stableStringify(entry)}\n`, { encoding: "utf8" });
215
+ appendAuditLine(defaultProjectRoot, logPath, `${stableStringify(entry)}\n`);
191
216
  return entry.decision_id;
192
217
  },
193
218
  latestDecision(issueNumber, repo, options) {
@@ -225,7 +250,13 @@ export function rollbackAuditEntry(decisionId, projectRoot, logPathOverride) {
225
250
  }
226
251
  }
227
252
  if (removed) {
228
- writeFileSync(path, kept.join(""), { encoding: "utf8" });
253
+ // #3245: replace rewrite also refuses leaf symlink follow.
254
+ containedWrite({
255
+ root: containmentRootForLog(projectRoot, path),
256
+ target: path,
257
+ data: kept.join(""),
258
+ mode: "replace",
259
+ });
229
260
  }
230
261
  return removed;
231
262
  }
@@ -8,7 +8,7 @@ export declare function gitignoreTriageCacheEntries(projectRoot: string): readon
8
8
  export declare function gitattributesTriageCacheGlob(projectRoot: string): string;
9
9
  export declare const GITATTRIBUTES_EVAL_RULE = "vbrief/.triage-cache/*.jsonl merge=union";
10
10
  export declare const FORBIDDEN_BLANKET_EVAL_LINES: readonly string[];
11
- export declare const EVAL_README_BODY = "# `vbrief/.triage-cache/` \u2014 triage working-set files\n\nThis directory holds JSON-lines logs and scratch files that Deft triage and\nslicing workflows emit. Deft configures your repo's `.gitignore` and\n`.gitattributes` so some files stay local while team-shared records can be\ncommitted.\n\n## What lives here\n\n| File | Committed? | Notes |\n| --- | --- | --- |\n| `slices.jsonl` | Yes | Team-shared cohort records from slicing skills. New teammates use prior cohort outputs to spot orphans and avoid re-slicing the same scope. |\n| `candidates.jsonl` | No | Your local triage accept / defer / reject stream. Re-create on a fresh clone with `deft triage:bootstrap`. |\n| `summary-history.jsonl` | No | Local history of `deft triage:summary` output; not required for day-to-day work. |\n| `scope-lifecycle.jsonl` | No | Local audit trail for scope demotions (`deft scope:demote`). Each operator's stream stays on their machine. |\n| `decompositions/` | No | Draft story-decomposition scratch. Produced child story xBRIEFs live in lifecycle folders via `deft scope:decompose`. |\n| `doctor-state.json` | No | Per-clone throttle state for `deft doctor` re-probe timing. |\n| `staleness-tickler-state.json` | No | Per-clone upgrade-tickler throttle state. |\n| `release-availability-state.json` | No | Per-clone release-availability probe throttle state. |\n\nPaths listed as \"No\" above are added to `.gitignore` during bootstrap; anything\nnot listed remains committable by default. The selective ignore entries live in\nthe repo-root `.gitignore` (`vbrief/.triage-cache/candidates.jsonl`,\n`vbrief/.triage-cache/summary-history.jsonl`, `vbrief/.triage-cache/scope-lifecycle.jsonl`,\n`vbrief/.triage-cache/decompositions/`, `vbrief/.triage-cache/doctor-state.json`,\n`vbrief/.triage-cache/staleness-tickler-state.json`, and\n`vbrief/.triage-cache/release-availability-state.json`).\n\n## Fresh clone\n\nIf `candidates.jsonl` is missing, run:\n\n```\ndeft triage:bootstrap\n```\n\nBootstrap rebuilds the local candidates log without altering committed\n`slices.jsonl`.\n\n## Merge behavior for `*.jsonl`\n\nThe repo-root `.gitattributes` may declare:\n\n```\nvbrief/.triage-cache/*.jsonl merge=union\n```\n\nThe `union` merge driver concatenates both sides' appended lines on auto-merge,\nso parallel append-only edits to the same JSON-lines file rebase without manual\nconflict surgery. It does not dedupe semantically similar records \u2014 downstream\nreaders should tolerate duplicate-looking entries.\n\n## See also\n\n- `.gitignore` \u2014 selective ignore rules for operator-private files\n- `.gitattributes` \u2014 merge driver for committed JSON-lines logs\n";
11
+ export declare const EVAL_README_BODY = "# `vbrief/.triage-cache/` \u2014 triage working-set files\n\nThis directory holds JSON-lines logs and scratch files that Deft triage and\nslicing workflows emit. Deft configures your repo's `.gitignore` and\n`.gitattributes` so some files stay local while team-shared records can be\ncommitted.\n\n## What lives here\n\n| File | Committed? | Notes |\n| --- | --- | --- |\n| `slices.jsonl` | Yes | Team-shared cohort records from slicing skills. New teammates use prior cohort outputs to spot orphans and avoid re-slicing the same scope. |\n| `candidates.jsonl` | No | Your local triage accept / defer / reject stream. Re-create on a fresh clone with `deft triage:bootstrap`. |\n| `summary-history.jsonl` | No | Local history of `deft triage:summary` output; not required for day-to-day work. |\n| `scope-lifecycle.jsonl` | No | Local audit trail for scope demotions (`deft scope:demote`). Each operator's stream stays on their machine. |\n| `decompositions/` | No | Draft story-decomposition scratch. Produced child story xBRIEFs live in lifecycle folders via `deft scope:decompose`. |\n| `doctor-state.json` | No | Per-clone throttle state for `deft doctor` re-probe timing. |\n| `staleness-tickler-state.json` | No | Per-clone upgrade-tickler throttle state. |\n| `release-availability-state.json` | No | Per-clone release-availability probe throttle state. |\n| `scm-label-mirror-discovery-state.json` | No | Per-clone SCM label-mirror discovery tip throttle (#3124). |\n\nPaths listed as \"No\" above are added to `.gitignore` during bootstrap; anything\nnot listed remains committable by default. The selective ignore entries live in\nthe repo-root `.gitignore` (`vbrief/.triage-cache/candidates.jsonl`,\n`vbrief/.triage-cache/summary-history.jsonl`, `vbrief/.triage-cache/scope-lifecycle.jsonl`,\n`vbrief/.triage-cache/decompositions/`, `vbrief/.triage-cache/doctor-state.json`,\n`vbrief/.triage-cache/staleness-tickler-state.json`,\n`vbrief/.triage-cache/release-availability-state.json`, and\n`vbrief/.triage-cache/scm-label-mirror-discovery-state.json`).\n\n## Fresh clone\n\nIf `candidates.jsonl` is missing, run:\n\n```\ndeft triage:bootstrap\n```\n\nBootstrap rebuilds the local candidates log without altering committed\n`slices.jsonl`.\n\n## Merge behavior for `*.jsonl`\n\nThe repo-root `.gitattributes` may declare:\n\n```\nvbrief/.triage-cache/*.jsonl merge=union\n```\n\nThe `union` merge driver concatenates both sides' appended lines on auto-merge,\nso parallel append-only edits to the same JSON-lines file rebase without manual\nconflict surgery. It does not dedupe semantically similar records \u2014 downstream\nreaders should tolerate duplicate-looking entries.\n\n## See also\n\n- `.gitignore` \u2014 selective ignore rules for operator-private files\n- `.gitattributes` \u2014 merge driver for committed JSON-lines logs\n";
12
12
  /** Layout-aware triage-cache README body for the active lifecycle tree (#2344 / #2349). */
13
13
  export declare function generateTriageCacheReadmeBody(projectRoot: string): string;
14
14
  /** Strip an inline `# ...` comment from a gitignore line. */
@@ -23,6 +23,8 @@ export const GITIGNORE_EVAL_ENTRIES = [
23
23
  // Per-clone session state (#3146); selective only — hybrid policy preserved.
24
24
  "xbrief/.triage-cache/staleness-tickler-state.json",
25
25
  "xbrief/.triage-cache/release-availability-state.json",
26
+ // SCM label-mirror discovery tip throttle (#3124).
27
+ "xbrief/.triage-cache/scm-label-mirror-discovery-state.json",
26
28
  ];
27
29
  /** Layout-aware gitignore lines for triage working-set files (#1703). */
28
30
  export function gitignoreTriageCacheEntries(projectRoot) {
@@ -36,6 +38,8 @@ export function gitignoreTriageCacheEntries(projectRoot) {
36
38
  // Per-clone session state (#3146); selective only — hybrid policy preserved.
37
39
  triageCacheRelPath(projectRoot, "staleness-tickler-state.json"),
38
40
  triageCacheRelPath(projectRoot, "release-availability-state.json"),
41
+ // SCM label-mirror discovery tip throttle (#3124).
42
+ triageCacheRelPath(projectRoot, "scm-label-mirror-discovery-state.json"),
39
43
  ];
40
44
  }
41
45
  export function gitattributesTriageCacheGlob(projectRoot) {
@@ -116,14 +120,16 @@ committed.
116
120
  | \`doctor-state.json\` | No | Per-clone throttle state for \`deft doctor\` re-probe timing. |
117
121
  | \`staleness-tickler-state.json\` | No | Per-clone upgrade-tickler throttle state. |
118
122
  | \`release-availability-state.json\` | No | Per-clone release-availability probe throttle state. |
123
+ | \`scm-label-mirror-discovery-state.json\` | No | Per-clone SCM label-mirror discovery tip throttle (#3124). |
119
124
 
120
125
  Paths listed as "No" above are added to \`.gitignore\` during bootstrap; anything
121
126
  not listed remains committable by default. The selective ignore entries live in
122
127
  the repo-root \`.gitignore\` (\`vbrief/.triage-cache/candidates.jsonl\`,
123
128
  \`vbrief/.triage-cache/summary-history.jsonl\`, \`vbrief/.triage-cache/scope-lifecycle.jsonl\`,
124
129
  \`vbrief/.triage-cache/decompositions/\`, \`vbrief/.triage-cache/doctor-state.json\`,
125
- \`vbrief/.triage-cache/staleness-tickler-state.json\`, and
126
- \`vbrief/.triage-cache/release-availability-state.json\`).
130
+ \`vbrief/.triage-cache/staleness-tickler-state.json\`,
131
+ \`vbrief/.triage-cache/release-availability-state.json\`, and
132
+ \`vbrief/.triage-cache/scm-label-mirror-discovery-state.json\`).
127
133
 
128
134
  ## Fresh clone
129
135
 
@@ -1,7 +1,8 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
4
4
  import { dirname, join, resolve } from "node:path";
5
+ import { containedWrite } from "../../fs/contained-write.js";
5
6
  import { hasArtifactSuffix, resolveLifecycleRoot } from "../../layout/resolve.js";
6
7
  import { resolveCandidatesLogPath } from "../cache-path.js";
7
8
  import { loadDefaultCacheModule } from "./cache-module.js";
@@ -289,10 +290,21 @@ function buildAuditEntry(repo, issueNumber, sourceFolder, nowIso) {
289
290
  reason: `bootstrap backfill: vBRIEF already in vbrief/${sourceFolder}/ at opt-in time`,
290
291
  };
291
292
  }
293
+ /**
294
+ * Default audit append for bootstrap backfill (#3288 / #3245 / #2980).
295
+ * Containment root is the audit parent (project root may be unavailable on inject path);
296
+ * leaf symlink follow is refused either way.
297
+ */
292
298
  function appendAuditEntryDefault(auditPath, entry) {
293
- mkdirSync(dirname(auditPath), { recursive: true });
299
+ const parent = dirname(auditPath);
300
+ mkdirSync(parent, { recursive: true });
294
301
  const sorted = Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b)));
295
- writeFileSync(auditPath, `${JSON.stringify(sorted)}\n`, { encoding: "utf8", flag: "a" });
302
+ containedWrite({
303
+ root: resolve(parent),
304
+ target: resolve(auditPath),
305
+ data: `${JSON.stringify(sorted)}\n`,
306
+ mode: "append",
307
+ });
296
308
  }
297
309
  /** Backfill `accept` audit entries for items already in lifecycle folders. */
298
310
  export function stepBackfillAuditLog(projectRoot, repo, options = {}) {
@@ -86,6 +86,7 @@ export declare function validateProject(projectRoot: string): {
86
86
  /** Render --list output for a project root. */
87
87
  export declare function listProject(projectRoot: string): string;
88
88
  import { buildLabelMirrorDigest, type ClassifyAction, DEFAULT_APPLY_BATCH_SIZE, DEFAULT_APPLY_DELAY_MS, DEFAULT_DIGEST_SAMPLE_LIMIT, DEFAULT_IDEMPOTENCY_LABEL, defaultLabelMirrorPolicy, desiredLabelsForClassification, type LabelMirrorDigest, type LabelMirrorEngine, type LabelMirrorFilters, type LabelMirrorItem, type LabelMirrorOptions as LabelMirrorOptionsCore, type LabelMirrorOutcome, type LabelMirrorPolicy, type LabelMirrorSleepFn, type LabelMirrorStatus, labelMirrorOutcomeToJson, type ResolvedLabelMirrorPolicy, renderLabelMirrorReport, resolveLabelMirrorPolicy, validateLabelMirrorPolicy, validateTriageLabelMirrorOnPlan as validateTriageLabelMirrorOnPlanFromModule } from "./label-mirror.js";
89
+ export { formatMirrorDiscoveryDigestCues, formatMirrorDiscoveryTip, formatMirrorDiscoveryTipBody, isMirrorDiscoveryTipDue, MIRROR_DISCOVERY_ACK_COMMAND, MIRROR_DISCOVERY_ANTI_SWALLOW_RULE, MIRROR_DISCOVERY_DRY_RUN_COMMAND, MIRROR_DISCOVERY_EMPTY_ACTION_LABELS_HINT, MIRROR_DISCOVERY_NO_MATCH_DOMINATION_HINT, MIRROR_DISCOVERY_POLICY_SHOW_COMMAND, MIRROR_DISCOVERY_RECOMMENDED_ACTION_LABELS, MIRROR_DISCOVERY_STATE_FILE, type MirrorDiscoveryState, type MirrorDiscoveryTipOptions, maybeFormatMirrorDiscoveryTip, mirrorDiscoveryStateExists, parseMirrorDiscoveryState, readMirrorDiscoveryState, recordMirrorDiscoveryAcked, recordMirrorDiscoverySuccessfulDryRun, resolveMirrorDiscoveryStatePath, } from "./mirror-discovery-tip.js";
89
90
  export { buildLabelMirrorDigest, type ClassifyAction, DEFAULT_APPLY_BATCH_SIZE, DEFAULT_APPLY_DELAY_MS, DEFAULT_DIGEST_SAMPLE_LIMIT, DEFAULT_IDEMPOTENCY_LABEL, defaultLabelMirrorPolicy, desiredLabelsForClassification, type LabelMirrorDigest, type LabelMirrorEngine, type LabelMirrorFilters, type LabelMirrorItem, type LabelMirrorOutcome, type LabelMirrorPolicy, type LabelMirrorSleepFn, type LabelMirrorStatus, labelMirrorOutcomeToJson, type ResolvedLabelMirrorPolicy, renderLabelMirrorReport, resolveLabelMirrorPolicy, validateLabelMirrorPolicy, validateTriageLabelMirrorOnPlanFromModule as validateTriageLabelMirrorOnPlan, };
90
91
  /** Public options: engine is optional (defaults to this module's classify API). */
91
92
  export type LabelMirrorOptions = Omit<LabelMirrorOptionsCore, "engine"> & {
@@ -752,6 +752,8 @@ export function listProject(projectRoot) {
752
752
  // Public mirrorLabels injects the classify engine into the pure implementation.
753
753
  // ---------------------------------------------------------------------------
754
754
  import { buildLabelMirrorDigest, DEFAULT_APPLY_BATCH_SIZE, DEFAULT_APPLY_DELAY_MS, DEFAULT_DIGEST_SAMPLE_LIMIT, DEFAULT_IDEMPOTENCY_LABEL, defaultLabelMirrorPolicy, desiredLabelsForClassification, labelMirrorOutcomeToJson, mirrorLabels as mirrorLabelsCore, renderLabelMirrorReport, resolveLabelMirrorPolicy, validateLabelMirrorPolicy, validateTriageLabelMirrorOnPlan as validateTriageLabelMirrorOnPlanFromModule, } from "./label-mirror.js";
755
+ // Operator discovery tip (#3124) — re-export for CLI + welcome.
756
+ export { formatMirrorDiscoveryDigestCues, formatMirrorDiscoveryTip, formatMirrorDiscoveryTipBody, isMirrorDiscoveryTipDue, MIRROR_DISCOVERY_ACK_COMMAND, MIRROR_DISCOVERY_ANTI_SWALLOW_RULE, MIRROR_DISCOVERY_DRY_RUN_COMMAND, MIRROR_DISCOVERY_EMPTY_ACTION_LABELS_HINT, MIRROR_DISCOVERY_NO_MATCH_DOMINATION_HINT, MIRROR_DISCOVERY_POLICY_SHOW_COMMAND, MIRROR_DISCOVERY_RECOMMENDED_ACTION_LABELS, MIRROR_DISCOVERY_STATE_FILE, maybeFormatMirrorDiscoveryTip, mirrorDiscoveryStateExists, parseMirrorDiscoveryState, readMirrorDiscoveryState, recordMirrorDiscoveryAcked, recordMirrorDiscoverySuccessfulDryRun, resolveMirrorDiscoveryStatePath, } from "./mirror-discovery-tip.js";
755
757
  export { buildLabelMirrorDigest, DEFAULT_APPLY_BATCH_SIZE, DEFAULT_APPLY_DELAY_MS, DEFAULT_DIGEST_SAMPLE_LIMIT, DEFAULT_IDEMPOTENCY_LABEL, defaultLabelMirrorPolicy, desiredLabelsForClassification, labelMirrorOutcomeToJson, renderLabelMirrorReport, resolveLabelMirrorPolicy, validateLabelMirrorPolicy, validateTriageLabelMirrorOnPlanFromModule as validateTriageLabelMirrorOnPlan, };
756
758
  function defaultLabelMirrorEngine() {
757
759
  return {
@@ -27,6 +27,7 @@ import { latestDecisions, readAuditLog } from "../actions/candidates-log.js";
27
27
  import { authorLoginFromRawIssue, matchesAuthorFilter, } from "../author-filter.js";
28
28
  import { resolveCandidatesLogPath } from "../cache-path.js";
29
29
  import { iterCachedIssues } from "../summary/index.js";
30
+ import { formatMirrorDiscoveryDigestCues } from "./mirror-discovery-tip.js";
30
31
  export const DEFAULT_IDEMPOTENCY_LABEL = "triaged";
31
32
  export const CACHE_DIR_NAME = ".deft-cache";
32
33
  export const CACHE_SOURCE = "github-issue";
@@ -903,6 +904,21 @@ export function renderLabelMirrorReport(outcome) {
903
904
  ? "Dry-run — re-run with --mirror --re-enrich --apply to write additive labels via SCM (batched; never triage:accept; never removals)."
904
905
  : "Dry-run — re-run with --mirror --apply to write these labels via SCM (batched; never triage:accept).");
905
906
  }
907
+ // #3124 SHOULD: digest upgrade cues (empty actionLabels / open no_match domination).
908
+ const actionLabelsEmpty = Object.keys(outcome.policy.actionLabels).length === 0;
909
+ const digestCues = formatMirrorDiscoveryDigestCues({
910
+ planned: outcome.planned,
911
+ applied: outcome.applied,
912
+ skipped_no_match: outcome.skipped_no_match,
913
+ skipped_already_triaged: outcome.skipped_already_triaged,
914
+ skipped_closed: outcome.skipped_closed,
915
+ actionLabelsEmpty,
916
+ dry_run: outcome.dry_run,
917
+ });
918
+ if (digestCues.length > 0) {
919
+ lines.push("");
920
+ lines.push(...digestCues);
921
+ }
906
922
  return `${lines.join("\n")}\n`;
907
923
  }
908
924
  /** JSON-serializable outcome including Wave 2 digest aggregates. */
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Operator discovery tip for SCM label mirror (#3124).
3
+ *
4
+ * Throttled existence + get-the-most-out-of-it disclosure for cold session /
5
+ * triage welcome / doctor. Hides after operator ack or first successful dry-run.
6
+ * Does not fire on session re-arm (welcome skipped).
7
+ *
8
+ * Never auto-apply; never auto-accept into proposed/.
9
+ */
10
+ /** State file basename under xbrief/.triage-cache/. */
11
+ export declare const MIRROR_DISCOVERY_STATE_FILE = "scm-label-mirror-discovery-state.json";
12
+ /** Display/back-compat relative path. */
13
+ export declare const MIRROR_DISCOVERY_STATE_RELATIVE_PATH: string;
14
+ export interface MirrorDiscoveryState {
15
+ /** ISO timestamp when the tip was last emitted. */
16
+ readonly shownAt?: string;
17
+ /** ISO timestamp when the operator acked (tip stays hidden). */
18
+ readonly ackedAt?: string;
19
+ /** ISO timestamp of first successful --mirror dry-run (tip stays hidden). */
20
+ readonly successfulDryRunAt?: string;
21
+ }
22
+ export interface MirrorDiscoveryTipOptions {
23
+ readonly now?: Date;
24
+ readonly readState?: (path: string) => string | null;
25
+ readonly writeState?: (path: string, content: string) => void;
26
+ /** When false, evaluate eligibility without persisting shownAt. Default true. */
27
+ readonly recordShown?: boolean;
28
+ }
29
+ export declare function parseMirrorDiscoveryState(text: string | null): MirrorDiscoveryState;
30
+ export declare function readMirrorDiscoveryState(projectRoot: string, options?: Pick<MirrorDiscoveryTipOptions, "readState">): MirrorDiscoveryState;
31
+ /** True when tip should still surface (not acked and no successful dry-run yet). */
32
+ export declare function isMirrorDiscoveryTipDue(projectRoot: string, options?: Pick<MirrorDiscoveryTipOptions, "readState">): boolean;
33
+ /**
34
+ * Recommended full five-chip machine map for consumers (#3124 / #2611).
35
+ * Names only — do not invent vocabulary; labels must exist on GitHub.
36
+ */
37
+ export declare const MIRROR_DISCOVERY_RECOMMENDED_ACTION_LABELS: {
38
+ readonly defer: readonly ["triage:deferred"];
39
+ readonly archive: readonly ["triage:archived"];
40
+ readonly accept: readonly ["triage:lifecycle-linked"];
41
+ readonly escalate: readonly ["triage:needs-human"];
42
+ };
43
+ /** Dry-run command shown in the tip (open-only default). */
44
+ export declare const MIRROR_DISCOVERY_DRY_RUN_COMMAND = "deft triage:classify -- --mirror";
45
+ /** Operator ack command that dismisses the tip without a dry-run (#3124 Greptile). */
46
+ export declare const MIRROR_DISCOVERY_ACK_COMMAND = "deft triage:classify -- --ack-discovery";
47
+ /** Policy inspect command. */
48
+ export declare const MIRROR_DISCOVERY_POLICY_SHOW_COMMAND = "deft policy:show --field=plan.policy.triageLabelMirror";
49
+ /**
50
+ * Agent-facing anti-swallow rule embedded in tip output (#3124).
51
+ * When this tip fires, agents MUST restate existence + get-the-most in the
52
+ * user-visible message (not only absorb ceremony output).
53
+ */
54
+ export declare const MIRROR_DISCOVERY_ANTI_SWALLOW_RULE: string;
55
+ /**
56
+ * Full operator-facing tip body (existence + get-the-most).
57
+ * Kept short enough for ceremony but complete enough for PROJECT-DEFINITION edits.
58
+ */
59
+ export declare function formatMirrorDiscoveryTipBody(): string;
60
+ export declare function formatMirrorDiscoveryTip(): string;
61
+ /**
62
+ * Emit tip when due; record shownAt. Returns empty string when throttled.
63
+ * Fail-open on write errors (ceremony must not hard-block).
64
+ */
65
+ export declare function maybeFormatMirrorDiscoveryTip(projectRoot: string, options?: MirrorDiscoveryTipOptions): string;
66
+ /** Persist operator ack so the tip no longer surfaces. */
67
+ export declare function recordMirrorDiscoveryAcked(projectRoot: string, options?: MirrorDiscoveryTipOptions): void;
68
+ /**
69
+ * Persist first successful --mirror dry-run so the tip no longer surfaces.
70
+ * Idempotent: keeps the earliest successfulDryRunAt.
71
+ */
72
+ export declare function recordMirrorDiscoverySuccessfulDryRun(projectRoot: string, options?: MirrorDiscoveryTipOptions): void;
73
+ /** State path helper for tests / docs. */
74
+ export declare function resolveMirrorDiscoveryStatePath(projectRoot: string): string;
75
+ /** Whether a state file currently exists (tests / diagnostics). */
76
+ export declare function mirrorDiscoveryStateExists(projectRoot: string): boolean;
77
+ export interface MirrorDiscoveryDigestCueInput {
78
+ readonly planned: number;
79
+ readonly applied: number;
80
+ readonly skipped_no_match: number;
81
+ readonly skipped_already_triaged: number;
82
+ readonly skipped_closed: number;
83
+ readonly actionLabelsEmpty: boolean;
84
+ readonly dry_run: boolean;
85
+ }
86
+ /** Footer when planned rows exist but actionLabels is empty. */
87
+ export declare const MIRROR_DISCOVERY_EMPTY_ACTION_LABELS_HINT: string;
88
+ /** Footer when open no_match dominates under universal rules. */
89
+ export declare const MIRROR_DISCOVERY_NO_MATCH_DOMINATION_HINT: string;
90
+ /**
91
+ * Build optional digest footer lines for consumer upgrade cues.
92
+ * Empty when neither condition holds.
93
+ */
94
+ export declare function formatMirrorDiscoveryDigestCues(input: MirrorDiscoveryDigestCueInput): readonly string[];
95
+ //# sourceMappingURL=mirror-discovery-tip.d.ts.map
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Operator discovery tip for SCM label mirror (#3124).
3
+ *
4
+ * Throttled existence + get-the-most-out-of-it disclosure for cold session /
5
+ * triage welcome / doctor. Hides after operator ack or first successful dry-run.
6
+ * Does not fire on session re-arm (welcome skipped).
7
+ *
8
+ * Never auto-apply; never auto-accept into proposed/.
9
+ */
10
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
11
+ import { basename, dirname, join, resolve } from "node:path";
12
+ import { containedWrite } from "../../fs/contained-write.js";
13
+ import { resolveTriageCachePath } from "../cache-path.js";
14
+ /** State file basename under xbrief/.triage-cache/. */
15
+ export const MIRROR_DISCOVERY_STATE_FILE = "scm-label-mirror-discovery-state.json";
16
+ /** Display/back-compat relative path. */
17
+ export const MIRROR_DISCOVERY_STATE_RELATIVE_PATH = join("xbrief", ".triage-cache", MIRROR_DISCOVERY_STATE_FILE);
18
+ function resolveStatePath(projectRoot) {
19
+ return resolveTriageCachePath(projectRoot, MIRROR_DISCOVERY_STATE_FILE);
20
+ }
21
+ function defaultReadState(path) {
22
+ try {
23
+ return readFileSync(path, "utf8");
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ function defaultWriteState(path, content) {
30
+ const dir = dirname(path);
31
+ mkdirSync(dir, { recursive: true });
32
+ // Direct contained write (no tmp/rename) — tip state is advisory and small.
33
+ const base = basename(path);
34
+ containedWrite({
35
+ root: resolve(dir),
36
+ target: base,
37
+ data: content,
38
+ mode: "replace",
39
+ });
40
+ }
41
+ export function parseMirrorDiscoveryState(text) {
42
+ if (text === null)
43
+ return {};
44
+ try {
45
+ const parsed = JSON.parse(text);
46
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
47
+ const obj = parsed;
48
+ return {
49
+ ...(typeof obj.shownAt === "string" ? { shownAt: obj.shownAt } : {}),
50
+ ...(typeof obj.ackedAt === "string" ? { ackedAt: obj.ackedAt } : {}),
51
+ ...(typeof obj.successfulDryRunAt === "string"
52
+ ? { successfulDryRunAt: obj.successfulDryRunAt }
53
+ : {}),
54
+ };
55
+ }
56
+ }
57
+ catch {
58
+ // Corrupt advisory state never blocks ceremony.
59
+ }
60
+ return {};
61
+ }
62
+ export function readMirrorDiscoveryState(projectRoot, options = {}) {
63
+ const path = resolveStatePath(projectRoot);
64
+ const read = options.readState ?? defaultReadState;
65
+ return parseMirrorDiscoveryState(read(path));
66
+ }
67
+ function writeMirrorDiscoveryState(projectRoot, state, options = {}) {
68
+ const path = resolveStatePath(projectRoot);
69
+ const write = options.writeState ?? defaultWriteState;
70
+ write(path, `${JSON.stringify(state, null, 2)}\n`);
71
+ }
72
+ /** True when tip should still surface (not acked and no successful dry-run yet). */
73
+ export function isMirrorDiscoveryTipDue(projectRoot, options = {}) {
74
+ const state = readMirrorDiscoveryState(projectRoot, options);
75
+ if (state.ackedAt !== undefined && state.ackedAt.length > 0)
76
+ return false;
77
+ if (state.successfulDryRunAt !== undefined && state.successfulDryRunAt.length > 0) {
78
+ return false;
79
+ }
80
+ return true;
81
+ }
82
+ /**
83
+ * Recommended full five-chip machine map for consumers (#3124 / #2611).
84
+ * Names only — do not invent vocabulary; labels must exist on GitHub.
85
+ */
86
+ export const MIRROR_DISCOVERY_RECOMMENDED_ACTION_LABELS = {
87
+ defer: ["triage:deferred"],
88
+ archive: ["triage:archived"],
89
+ accept: ["triage:lifecycle-linked"],
90
+ escalate: ["triage:needs-human"],
91
+ };
92
+ /** Dry-run command shown in the tip (open-only default). */
93
+ export const MIRROR_DISCOVERY_DRY_RUN_COMMAND = "deft triage:classify -- --mirror";
94
+ /** Operator ack command that dismisses the tip without a dry-run (#3124 Greptile). */
95
+ export const MIRROR_DISCOVERY_ACK_COMMAND = "deft triage:classify -- --ack-discovery";
96
+ /** Policy inspect command. */
97
+ export const MIRROR_DISCOVERY_POLICY_SHOW_COMMAND = "deft policy:show --field=plan.policy.triageLabelMirror";
98
+ /**
99
+ * Agent-facing anti-swallow rule embedded in tip output (#3124).
100
+ * When this tip fires, agents MUST restate existence + get-the-most in the
101
+ * user-visible message (not only absorb ceremony output).
102
+ */
103
+ export const MIRROR_DISCOVERY_ANTI_SWALLOW_RULE = "! Agent anti-swallow (#3124): when this tip fires, restate existence + get-the-most " +
104
+ "in the **user-visible** message (not ceremony dump alone): feature is default-on; " +
105
+ "dry-run first; defaults only stamp **triaged** on matches (control stamp, not disposition); " +
106
+ "board usability is greatly decreased without **actionLabels** (full five-chip map); " +
107
+ "more matches need **triageAutoClassify**; never auto-accept into proposed/; " +
108
+ "where policy lives + policy:show; labels must exist on GitHub; " +
109
+ "applying triaged before actionLabels skips re-enrichment on re-run; " +
110
+ "point at consumer kit #2611 — do not invent label vocabulary.";
111
+ /**
112
+ * Full operator-facing tip body (existence + get-the-most).
113
+ * Kept short enough for ceremony but complete enough for PROJECT-DEFINITION edits.
114
+ */
115
+ export function formatMirrorDiscoveryTipBody() {
116
+ const mapJson = JSON.stringify({
117
+ enabled: true,
118
+ idempotencyLabel: "triaged",
119
+ alwaysLabels: ["triaged"],
120
+ actionLabels: MIRROR_DISCOVERY_RECOMMENDED_ACTION_LABELS,
121
+ }, null, 2);
122
+ return [
123
+ "[deft triage] SCM label mirror discovery (#3124 / #1423):",
124
+ " Feature exists and is **default-on** (plan.policy.triageLabelMirror resolves even when absent).",
125
+ ` Dry-run first (open-only default; --include-closed opt-in): \`${MIRROR_DISCOVERY_DRY_RUN_COMMAND}\``,
126
+ " `--apply` writes labels in batches; **never** auto-accepts into proposed/ (triage:accept is separate).",
127
+ " Defaults only stamp **`triaged`** on **matches** — a machine control stamp (idempotency), not a disposition board.",
128
+ " **Board usability is greatly decreased without `actionLabels`** — every match looks like a single triaged chip.",
129
+ " Recommend full five-chip map (create these labels on GitHub first, or apply fails closed):",
130
+ " defer→triage:deferred, archive→triage:archived, accept→triage:lifecycle-linked,",
131
+ " escalate→triage:needs-human (+ always triaged).",
132
+ " Chip setup (all five) is separate from rule aggressiveness — start rules minimal; escalate carefully.",
133
+ " More matches → configure `plan.policy.triageAutoClassify` in PROJECT-DEFINITION",
134
+ ' (xbrief/PROJECT-DEFINITION.xbrief.json → plan["x-directive/policy"] / docs: plan.policy.*).',
135
+ ` Inspect: \`${MIRROR_DISCOVERY_POLICY_SHOW_COMMAND}\` (and triageAutoClassify).`,
136
+ " Labels must **exist on GitHub** (policy JSON alone is insufficient).",
137
+ " Warn: applying triaged **before** actionLabels stamps issues that re-run then skips",
138
+ " (use --mirror --re-enrich later for additive chips; #3197).",
139
+ " Recommended consumer labels + PD sketch: consumer kit **#2611**",
140
+ " (content/docs/consumer-issue-label-kit.md) — do not invent new triage:* vocabulary.",
141
+ " Depth: commands.md § triage:classify --mirror; tip hides after first successful dry-run",
142
+ ` or operator ack: \`${MIRROR_DISCOVERY_ACK_COMMAND}\` (not every session re-arm).`,
143
+ " Recommended PD sketch:",
144
+ ...mapJson.split("\n").map((line) => ` ${line}`),
145
+ MIRROR_DISCOVERY_ANTI_SWALLOW_RULE,
146
+ ].join("\n");
147
+ }
148
+ export function formatMirrorDiscoveryTip() {
149
+ return `${formatMirrorDiscoveryTipBody()}\n`;
150
+ }
151
+ /**
152
+ * Emit tip when due; record shownAt. Returns empty string when throttled.
153
+ * Fail-open on write errors (ceremony must not hard-block).
154
+ */
155
+ export function maybeFormatMirrorDiscoveryTip(projectRoot, options = {}) {
156
+ if (!isMirrorDiscoveryTipDue(projectRoot, options)) {
157
+ return "";
158
+ }
159
+ if (options.recordShown !== false) {
160
+ try {
161
+ const now = options.now ?? new Date();
162
+ const prior = readMirrorDiscoveryState(projectRoot, options);
163
+ writeMirrorDiscoveryState(projectRoot, {
164
+ ...prior,
165
+ shownAt: now.toISOString(),
166
+ }, options);
167
+ }
168
+ catch {
169
+ // Advisory tip state must never block welcome / session start.
170
+ }
171
+ }
172
+ return formatMirrorDiscoveryTip();
173
+ }
174
+ /** Persist operator ack so the tip no longer surfaces. */
175
+ export function recordMirrorDiscoveryAcked(projectRoot, options = {}) {
176
+ const now = options.now ?? new Date();
177
+ const prior = readMirrorDiscoveryState(projectRoot, options);
178
+ writeMirrorDiscoveryState(projectRoot, {
179
+ ...prior,
180
+ ackedAt: now.toISOString(),
181
+ }, options);
182
+ }
183
+ /**
184
+ * Persist first successful --mirror dry-run so the tip no longer surfaces.
185
+ * Idempotent: keeps the earliest successfulDryRunAt.
186
+ */
187
+ export function recordMirrorDiscoverySuccessfulDryRun(projectRoot, options = {}) {
188
+ const prior = readMirrorDiscoveryState(projectRoot, options);
189
+ if (prior.successfulDryRunAt !== undefined && prior.successfulDryRunAt.length > 0) {
190
+ return;
191
+ }
192
+ const now = options.now ?? new Date();
193
+ writeMirrorDiscoveryState(projectRoot, {
194
+ ...prior,
195
+ successfulDryRunAt: now.toISOString(),
196
+ }, options);
197
+ }
198
+ /** State path helper for tests / docs. */
199
+ export function resolveMirrorDiscoveryStatePath(projectRoot) {
200
+ return resolveStatePath(projectRoot);
201
+ }
202
+ /** Whether a state file currently exists (tests / diagnostics). */
203
+ export function mirrorDiscoveryStateExists(projectRoot) {
204
+ return existsSync(resolveStatePath(projectRoot));
205
+ }
206
+ /** Footer when planned rows exist but actionLabels is empty. */
207
+ export const MIRROR_DISCOVERY_EMPTY_ACTION_LABELS_HINT = "Hint (#3124): planned matches will only gain alwaysLabels (triaged) — " +
208
+ "set plan.policy.triageLabelMirror.actionLabels to map defer/archive/accept/escalate " +
209
+ "to extra SCM chips (full five recommended; see consumer kit #2611). " +
210
+ "Board usability is greatly decreased without actionLabels.";
211
+ /** Footer when open no_match dominates under universal rules. */
212
+ export const MIRROR_DISCOVERY_NO_MATCH_DOMINATION_HINT = "Hint (#3124): few open matches under current rules (no_match dominates) — " +
213
+ "add plan.policy.triageAutoClassify in PROJECT-DEFINITION to classify more of the backlog.";
214
+ /**
215
+ * Build optional digest footer lines for consumer upgrade cues.
216
+ * Empty when neither condition holds.
217
+ */
218
+ export function formatMirrorDiscoveryDigestCues(input) {
219
+ const lines = [];
220
+ const writeTotal = input.planned + input.applied;
221
+ if (writeTotal > 0 && input.actionLabelsEmpty) {
222
+ lines.push(MIRROR_DISCOVERY_EMPTY_ACTION_LABELS_HINT);
223
+ }
224
+ // Open-ish population: exclude closed_skipped from domination base.
225
+ const openish = writeTotal + input.skipped_no_match + Math.max(0, input.skipped_already_triaged);
226
+ if (input.skipped_no_match > 0 &&
227
+ openish > 0 &&
228
+ input.skipped_no_match >= writeTotal &&
229
+ input.skipped_no_match * 2 >= openish) {
230
+ lines.push(MIRROR_DISCOVERY_NO_MATCH_DOMINATION_HINT);
231
+ }
232
+ return lines;
233
+ }
234
+ //# sourceMappingURL=mirror-discovery-tip.js.map