@deftai/directive-core 0.95.0 → 0.96.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 (47) hide show
  1. package/dist/cache/archive.d.ts +134 -0
  2. package/dist/cache/archive.js +630 -0
  3. package/dist/cache/index.d.ts +1 -0
  4. package/dist/cache/index.js +1 -0
  5. package/dist/cache/main.js +298 -1
  6. package/dist/content-contracts/skills/helpers.d.ts +1 -1
  7. package/dist/content-contracts/skills/helpers.js +1 -0
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.js +1 -0
  10. package/dist/init-deposit/hygiene.d.ts +1 -1
  11. package/dist/init-deposit/hygiene.js +16 -4
  12. package/dist/init-deposit/scaffold.js +6 -5
  13. package/dist/parent-turn-shape/evaluate.d.ts +84 -0
  14. package/dist/parent-turn-shape/evaluate.js +353 -0
  15. package/dist/parent-turn-shape/index.d.ts +8 -0
  16. package/dist/parent-turn-shape/index.js +8 -0
  17. package/dist/review-monitor/constants.js +3 -2
  18. package/dist/review-monitor/tier-detection.d.ts +7 -3
  19. package/dist/review-monitor/tier-detection.js +18 -1
  20. package/dist/review-monitor/verify.js +18 -0
  21. package/dist/scope/index.d.ts +2 -0
  22. package/dist/scope/index.js +2 -0
  23. package/dist/scope/main.d.ts +10 -0
  24. package/dist/scope/main.js +109 -24
  25. package/dist/scope/promote-from-issue.d.ts +49 -0
  26. package/dist/scope/promote-from-issue.js +367 -0
  27. package/dist/scope/promote-path.d.ts +39 -0
  28. package/dist/scope/promote-path.js +105 -0
  29. package/dist/swarm/routing.d.ts +4 -2
  30. package/dist/swarm/routing.js +26 -4
  31. package/dist/triage/actions/index.js +62 -2
  32. package/dist/triage/actions/types.d.ts +8 -1
  33. package/dist/triage/author-filter.d.ts +51 -0
  34. package/dist/triage/author-filter.js +152 -0
  35. package/dist/triage/classify/index.d.ts +2 -2
  36. package/dist/triage/classify/index.js +2 -2
  37. package/dist/triage/classify/label-mirror.d.ts +68 -5
  38. package/dist/triage/classify/label-mirror.js +261 -31
  39. package/dist/triage/help/registry-data.d.ts +49 -38
  40. package/dist/triage/help/registry-data.js +115 -40
  41. package/dist/triage/index.d.ts +1 -0
  42. package/dist/triage/index.js +1 -0
  43. package/dist/triage/queue/index.d.ts +1 -0
  44. package/dist/triage/queue/index.js +1 -0
  45. package/dist/triage/queue/render.d.ts +2 -0
  46. package/dist/triage/queue/render.js +6 -0
  47. package/package.json +7 -3
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
- import { dirname, join } from "node:path";
2
+ import { dirname } from "node:path";
3
3
  import { maybeRunStalenessTickler } from "../staleness-tickler/run.js";
4
4
  import { reconcileUmbrellas, renderUmbrellasReport } from "../vbrief-reconcile/umbrellas.js";
5
5
  import { canonicalLogPath, readAll } from "./audit-log.js";
@@ -9,14 +9,18 @@ import { isNonDeliveryDisposition, } from "./delivery-evidence.js";
9
9
  import { batchDemote, DEFAULT_OLDER_THAN_DAYS, demoteOne, resolveDemoteFilePath, resolveFilePath, resolveProjectRootStrict, } from "./demote.js";
10
10
  import { completedPathForScopeMove, findOpenUmbrellaReferences, renderOpenUmbrellaWarning, } from "./open-umbrella-warning.js";
11
11
  import { resolveProjectRoot } from "./project-context.js";
12
- import { recordWipCapOverride, runTransition } from "./transition.js";
12
+ import { promoteFromIssue } from "./promote-from-issue.js";
13
+ import { promotePath } from "./promote-path.js";
14
+ import { runTransition } from "./transition.js";
13
15
  import { findByDecisionId, isAlreadyUndone, REVERSIBLE_ACTIONS, undoBatch, undoOne, } from "./undo.js";
14
- import { checkWipCap, formatWipCapRefusal } from "./wip-cap-check.js";
15
16
  const LIFECYCLE_USAGE_STDERR = "usage: scope_lifecycle.py [-h] [--project-root PROJECT_ROOT] [--force] [--batch]\n" +
17
+ " [--from-issue N] [--repo OWNER/NAME] [--strict] [--force-no-cache]\n" +
18
+ " [--path PATH]\n" +
16
19
  " {activate,block,cancel,complete,fail,promote,restore,unblock}\n" +
17
20
  " [file ...]\n" +
18
21
  "scope_lifecycle.py: error: the following arguments are required: action, file\n" +
19
- "(promote --batch may omit file and promotes all proposed/ scopes; #3011)\n";
22
+ "(promote --batch may omit file and promotes all proposed/ scopes; #3011)\n" +
23
+ "(promote --from-issue=N may omit file; #1136)\n";
20
24
  function parseLifecycleArgv(argv) {
21
25
  if (argv.length < 1) {
22
26
  return { args: null, error: "usage" };
@@ -30,6 +34,10 @@ function parseLifecycleArgv(argv) {
30
34
  let force = false;
31
35
  let batch = false;
32
36
  const batchFiles = [];
37
+ let fromIssue;
38
+ let strict = false;
39
+ let forceNoCache = false;
40
+ let pathFlag;
33
41
  let nonDeliveryDisposition;
34
42
  let prNumber;
35
43
  let mergeCommit;
@@ -48,6 +56,37 @@ function parseLifecycleArgv(argv) {
48
56
  else if (arg === "--batch") {
49
57
  batch = true;
50
58
  }
59
+ else if (arg === "--strict") {
60
+ strict = true;
61
+ }
62
+ else if (arg === "--force-no-cache") {
63
+ forceNoCache = true;
64
+ }
65
+ else if (arg === "--from-issue") {
66
+ const raw = argv[i + 1];
67
+ i += 1;
68
+ if (raw === undefined || !/^[1-9]\d*$/.test(raw)) {
69
+ return { args: null, error: "usage" };
70
+ }
71
+ fromIssue = Number.parseInt(raw, 10);
72
+ }
73
+ else if (arg?.startsWith("--from-issue=")) {
74
+ const raw = arg.slice("--from-issue=".length);
75
+ if (!/^[1-9]\d*$/.test(raw)) {
76
+ return { args: null, error: "usage" };
77
+ }
78
+ fromIssue = Number.parseInt(raw, 10);
79
+ }
80
+ else if (arg === "--path") {
81
+ pathFlag = argv[i + 1];
82
+ i += 1;
83
+ if (pathFlag === undefined) {
84
+ return { args: null, error: "usage" };
85
+ }
86
+ }
87
+ else if (arg?.startsWith("--path=")) {
88
+ pathFlag = arg.slice("--path=".length);
89
+ }
51
90
  else if (arg === "--project-root") {
52
91
  projectRoot = argv[i + 1];
53
92
  i += 1;
@@ -127,18 +166,20 @@ function parseLifecycleArgv(argv) {
127
166
  return { args: null, error: "usage" };
128
167
  }
129
168
  }
169
+ // Delivery evidence uses --repo as repository; --from-issue also uses --repo as triage slug.
170
+ // Prefer treating --repo as triage slug when --from-issue is set (#1136).
130
171
  const deliveryEvidence = prNumber !== undefined ||
131
172
  mergeCommit !== undefined ||
132
173
  prBase !== undefined ||
133
174
  deliveryBranch !== undefined ||
134
- repo !== undefined ||
175
+ (repo !== undefined && fromIssue === undefined) ||
135
176
  mergedAt !== undefined
136
177
  ? {
137
178
  prNumber: prNumber !== undefined && Number.isFinite(prNumber) ? prNumber : null,
138
179
  mergeCommit: mergeCommit ?? null,
139
180
  prBase: prBase ?? null,
140
181
  deliveryBranch: deliveryBranch ?? null,
141
- repository: repo ?? null,
182
+ repository: fromIssue === undefined ? (repo ?? null) : null,
142
183
  mergedAt: mergedAt ?? (mergeCommit !== undefined ? "supplied" : null),
143
184
  verifier: "scope:complete",
144
185
  }
@@ -158,6 +199,27 @@ function parseLifecycleArgv(argv) {
158
199
  },
159
200
  };
160
201
  }
202
+ if (fromIssue !== undefined) {
203
+ if (action !== "promote") {
204
+ return { args: null, error: "usage" };
205
+ }
206
+ if (Number.isNaN(fromIssue) || fromIssue < 1) {
207
+ return { args: null, error: "usage" };
208
+ }
209
+ return {
210
+ args: {
211
+ action,
212
+ file: file.length > 0 ? file : (pathFlag ?? ""),
213
+ projectRoot,
214
+ force,
215
+ fromIssue,
216
+ repo,
217
+ strict,
218
+ forceNoCache,
219
+ pathFlag: pathFlag ?? (file.length > 0 ? file : undefined),
220
+ },
221
+ };
222
+ }
161
223
  if (file.length === 0) {
162
224
  return { args: null, error: "usage" };
163
225
  }
@@ -181,7 +243,7 @@ export function lifecycleMain(argv) {
181
243
  }
182
244
  return 2;
183
245
  }
184
- const { action, file, projectRoot, force, batch, batchFiles, nonDeliveryDisposition, deliveryEvidence, } = parsed.args;
246
+ const { action, file, projectRoot, force, batch, batchFiles, fromIssue, repo, strict, forceNoCache, pathFlag, nonDeliveryDisposition, deliveryEvidence, } = parsed.args;
185
247
  if (batch === true && action === "promote") {
186
248
  const result = batchPromote({
187
249
  files: batchFiles && batchFiles.length > 0 ? batchFiles : undefined,
@@ -203,21 +265,54 @@ export function lifecycleMain(argv) {
203
265
  }
204
266
  return result.exitCode;
205
267
  }
268
+ if (fromIssue !== undefined && action === "promote") {
269
+ const result = promoteFromIssue({
270
+ issueNumber: fromIssue,
271
+ repo: repo ?? null,
272
+ projectRoot,
273
+ force: force === true,
274
+ forceNoCache: forceNoCache === true,
275
+ strict: strict === true,
276
+ explicitPath: pathFlag,
277
+ });
278
+ for (const line of result.warnings) {
279
+ process.stderr.write(`${line}\n`);
280
+ }
281
+ if (result.ok) {
282
+ process.stdout.write(`${result.message}\n`);
283
+ if (result.wipCapOverride) {
284
+ process.stderr.write("\u26a0 WIP cap exceeded; promote allowed via --force. " +
285
+ "audit: scope-lifecycle.jsonl entry tagged wip_cap_override (#1124).\n");
286
+ }
287
+ return 0;
288
+ }
289
+ process.stderr.write(`Error: ${result.message}\n`);
290
+ return result.exitCode;
291
+ }
206
292
  const [filePath, error] = resolveFilePath(file, projectRoot);
207
293
  if (error !== null || filePath === null) {
208
294
  process.stderr.write(`Error: ${error}\n`);
209
295
  return 2;
210
296
  }
211
- let capCheck = null;
297
+ // Path-based promote uses shared promotePath for WIP + optional audit consistency (#1136).
212
298
  if (action === "promote") {
213
- const rootForCap = resolveProjectRoot(projectRoot);
214
- if (rootForCap !== null) {
215
- capCheck = checkWipCap(rootForCap, force === true);
216
- if (!capCheck.allowed) {
217
- process.stderr.write(`${formatWipCapRefusal(capCheck)}\n`);
218
- return 1;
299
+ const promoteResult = promotePath(filePath, {
300
+ projectRoot,
301
+ force: force === true,
302
+ });
303
+ if (promoteResult.ok) {
304
+ process.stdout.write(`${promoteResult.message}\n`);
305
+ if (promoteResult.wipCapOverride) {
306
+ process.stderr.write("\u26a0 WIP cap exceeded " +
307
+ `(promote allowed via --force). ` +
308
+ "audit: scope-lifecycle.jsonl entry tagged wip_cap_override (#1124).\n");
219
309
  }
310
+ return 0;
220
311
  }
312
+ process.stderr.write(promoteResult.exitCode === 1 && promoteResult.message.includes("WIP")
313
+ ? `${promoteResult.message}\n`
314
+ : `Error: ${promoteResult.message}\n`);
315
+ return promoteResult.exitCode;
221
316
  }
222
317
  const transitionOptions = {
223
318
  nonDeliveryDisposition,
@@ -226,16 +321,6 @@ export function lifecycleMain(argv) {
226
321
  };
227
322
  const result = runTransition(action, filePath, new Date(), transitionOptions);
228
323
  if (result.ok) {
229
- if (action === "promote" && capCheck !== null && capCheck.forceOverride) {
230
- const rootForAudit = resolveProjectRoot(projectRoot);
231
- if (rootForAudit !== null) {
232
- const newPath = join(rootForAudit, "vbrief", "pending", filePath.split(/[/\\]/).pop() ?? "");
233
- recordWipCapOverride(newPath, rootForAudit, capCheck);
234
- }
235
- process.stderr.write("\u26a0 WIP cap exceeded " +
236
- `(count=${capCheck.count}, cap=${capCheck.cap}); promote allowed via --force. ` +
237
- "audit: vbrief/.eval/scope-lifecycle.jsonl entry tagged wip_cap_override (#1124).\n");
238
- }
239
324
  process.stdout.write(`${result.message}\n`);
240
325
  if (action === "complete") {
241
326
  try {
@@ -0,0 +1,49 @@
1
+ /**
2
+ * scope:promote --from-issue reciprocity gate (#1136 / D18).
3
+ *
4
+ * Gates promote of a proposed scope on the latest triage-cache decision for
5
+ * the issue, locates the proposed artifact via provenance scan, and runs the
6
+ * shared promotePath path (WIP rules unchanged).
7
+ */
8
+ import type { AuditEntry } from "../triage/actions/types.js";
9
+ import { type PromotePathResult } from "./promote-path.js";
10
+ export interface PromoteFromIssueOptions {
11
+ readonly issueNumber: number;
12
+ readonly repo?: string | null;
13
+ readonly projectRoot?: string;
14
+ /** WIP-cap override. */
15
+ readonly force?: boolean;
16
+ /** Skip reciprocity gate; still audit that force was used. */
17
+ readonly forceNoCache?: boolean;
18
+ /** Missing decision becomes hard fail (default: soft warn + proceed). */
19
+ readonly strict?: boolean;
20
+ readonly actor?: string;
21
+ /** Explicit path when multiple proposed artifacts match the issue. */
22
+ readonly explicitPath?: string;
23
+ readonly now?: Date;
24
+ /** Optional candidates-log path override (tests). */
25
+ readonly candidatesLogPath?: string;
26
+ /** Inject latest decision (tests). */
27
+ readonly latestDecision?: AuditEntry | null;
28
+ }
29
+ export interface PromoteFromIssueResult extends PromotePathResult {
30
+ readonly warnings: string[];
31
+ readonly repo: string | null;
32
+ readonly matchedPaths: string[];
33
+ readonly cacheStateAtPromote: string | null;
34
+ readonly cacheDecisionId: string | null;
35
+ }
36
+ /**
37
+ * Locate lifecycle artifacts whose provenance owns ``issueNumber`` (optionally repo-scoped).
38
+ */
39
+ export declare function findLifecycleArtifactsForIssue(projectRoot: string, issueNumber: number, options?: {
40
+ folder?: "proposed" | "pending";
41
+ repo?: string | null;
42
+ }): string[];
43
+ /** Locate proposed/ artifacts for issue N (repo-scoped when provided). */
44
+ export declare function findProposedArtifactsForIssue(projectRoot: string, issueNumber: number, repo?: string | null): string[];
45
+ /**
46
+ * Promote the proposed scope for an issue, gated on triage-cache latestDecision.
47
+ */
48
+ export declare function promoteFromIssue(options: PromoteFromIssueOptions): PromoteFromIssueResult;
49
+ //# sourceMappingURL=promote-from-issue.d.ts.map
@@ -0,0 +1,367 @@
1
+ /**
2
+ * scope:promote --from-issue reciprocity gate (#1136 / D18).
3
+ *
4
+ * Gates promote of a proposed scope on the latest triage-cache decision for
5
+ * the issue, locates the proposed artifact via provenance scan, and runs the
6
+ * shared promotePath path (WIP rules unchanged).
7
+ */
8
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
9
+ import { isAbsolute, join, resolve } from "node:path";
10
+ import { provenanceIssueNumber, scanProvenanceRefs } from "../intake/issue-ingest.js";
11
+ import { hasArtifactSuffix, resolveLifecycleRoot } from "../layout/resolve.js";
12
+ import { createCandidatesLog, resolveAuditLogPath } from "../triage/actions/candidates-log.js";
13
+ import { resolveRepo } from "../triage/queue/repo.js";
14
+ import { resolveProjectRoot } from "./project-context.js";
15
+ import { promotePath } from "./promote-path.js";
16
+ /** Collapse CR/LF in operator-facing messages (SLizard CWE-116). */
17
+ function sanitizeMsg(value) {
18
+ return value.replace(/\r?\n/g, " ");
19
+ }
20
+ /** True when brief references or Origin point at owner/name issue N. */
21
+ function briefMatchesIssueAndRepo(data, issueNumber, repo) {
22
+ if (provenanceIssueNumber(data) !== issueNumber) {
23
+ // Also accept plan.references github-issue URIs without Origin text.
24
+ const plan = data.plan;
25
+ if (typeof plan !== "object" || plan === null || Array.isArray(plan)) {
26
+ return false;
27
+ }
28
+ const refs = plan.references;
29
+ if (!Array.isArray(refs)) {
30
+ return false;
31
+ }
32
+ let hit = false;
33
+ for (const ref of refs) {
34
+ if (typeof ref !== "object" || ref === null || Array.isArray(ref))
35
+ continue;
36
+ const uri = String(ref.uri ?? "");
37
+ if (uri.includes(`/issues/${issueNumber}`)) {
38
+ hit = true;
39
+ if (repo === null ||
40
+ uri.includes(`github.com/${repo}/`) ||
41
+ uri.includes(`${repo}/issues/`)) {
42
+ return true;
43
+ }
44
+ }
45
+ }
46
+ return hit && repo === null;
47
+ }
48
+ if (repo === null) {
49
+ return true;
50
+ }
51
+ const plan = data.plan;
52
+ if (typeof plan === "object" && plan !== null && !Array.isArray(plan)) {
53
+ const refs = plan.references;
54
+ if (Array.isArray(refs)) {
55
+ for (const ref of refs) {
56
+ if (typeof ref !== "object" || ref === null || Array.isArray(ref))
57
+ continue;
58
+ const uri = String(ref.uri ?? "");
59
+ if (uri.includes(`/issues/${issueNumber}`) &&
60
+ (uri.includes(`github.com/${repo}/`) || uri.includes(`${repo}/issues/`))) {
61
+ return true;
62
+ }
63
+ }
64
+ }
65
+ const narratives = plan.narratives;
66
+ if (typeof narratives === "object" && narratives !== null && !Array.isArray(narratives)) {
67
+ const origin = String(narratives.Origin ?? "");
68
+ if (origin.includes(`github.com/${repo}/issues/${issueNumber}`)) {
69
+ return true;
70
+ }
71
+ }
72
+ }
73
+ // Provenance matched issue number but no repo-scoped URI — refuse when repo known.
74
+ return false;
75
+ }
76
+ /**
77
+ * Locate lifecycle artifacts whose provenance owns ``issueNumber`` (optionally repo-scoped).
78
+ */
79
+ export function findLifecycleArtifactsForIssue(projectRoot, issueNumber, options = {}) {
80
+ const folder = options.folder ?? "proposed";
81
+ const repo = options.repo ?? null;
82
+ const root = resolve(projectRoot);
83
+ let lifecycleRoot;
84
+ try {
85
+ lifecycleRoot = resolveLifecycleRoot(root);
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ const byIssue = scanProvenanceRefs(lifecycleRoot);
91
+ const rels = (byIssue.get(issueNumber) ?? []).filter((rel) => {
92
+ const f = rel.split(/[/\\]/)[0];
93
+ return f === folder;
94
+ });
95
+ const candidates = rels.map((rel) => join(lifecycleRoot, rel));
96
+ // Always also scan the folder for Origin-only scaffolds.
97
+ const folderDir = join(lifecycleRoot, folder);
98
+ if (existsSync(folderDir)) {
99
+ for (const name of readdirSync(folderDir).filter((f) => hasArtifactSuffix(f))) {
100
+ candidates.push(join(folderDir, name));
101
+ }
102
+ }
103
+ const out = [];
104
+ for (const abs of uniqueExisting(candidates)) {
105
+ try {
106
+ const data = JSON.parse(readFileSync(abs, "utf8"));
107
+ if (briefMatchesIssueAndRepo(data, issueNumber, repo)) {
108
+ out.push(abs);
109
+ }
110
+ }
111
+ catch {
112
+ /* skip */
113
+ }
114
+ }
115
+ return uniqueExisting(out);
116
+ }
117
+ /** Locate proposed/ artifacts for issue N (repo-scoped when provided). */
118
+ export function findProposedArtifactsForIssue(projectRoot, issueNumber, repo = null) {
119
+ return findLifecycleArtifactsForIssue(projectRoot, issueNumber, { folder: "proposed", repo });
120
+ }
121
+ function uniqueExisting(paths) {
122
+ const seen = new Set();
123
+ const out = [];
124
+ for (const p of paths) {
125
+ const key = resolve(p);
126
+ if (seen.has(key))
127
+ continue;
128
+ seen.add(key);
129
+ if (existsSync(key)) {
130
+ out.push(key);
131
+ }
132
+ }
133
+ return out.sort();
134
+ }
135
+ function resolveLatestDecision(options, projectRoot, repo) {
136
+ if (options.latestDecision !== undefined) {
137
+ return options.latestDecision;
138
+ }
139
+ const log = createCandidatesLog(projectRoot);
140
+ const path = options.candidatesLogPath ?? resolveAuditLogPath(projectRoot);
141
+ return log.latestDecision(options.issueNumber, repo, { path });
142
+ }
143
+ /**
144
+ * Promote the proposed scope for an issue, gated on triage-cache latestDecision.
145
+ */
146
+ export function promoteFromIssue(options) {
147
+ const warnings = [];
148
+ const projectRoot = resolveProjectRoot(options.projectRoot);
149
+ if (projectRoot === null) {
150
+ return {
151
+ ok: false,
152
+ message: "Cannot determine project root. Pass --project-root PATH, set $DEFT_PROJECT_ROOT, or run from inside a directory tree that contains vbrief/ or .git/ (#535).",
153
+ exitCode: 2,
154
+ warnings,
155
+ repo: null,
156
+ matchedPaths: [],
157
+ cacheStateAtPromote: null,
158
+ cacheDecisionId: null,
159
+ };
160
+ }
161
+ const repo = resolveRepo(options.repo, projectRoot);
162
+ if (repo === null) {
163
+ return {
164
+ ok: false,
165
+ message: "scope:promote --from-issue requires --repo OWNER/NAME (or $DEFT_TRIAGE_REPO / git remote origin).",
166
+ exitCode: 2,
167
+ warnings,
168
+ repo: null,
169
+ matchedPaths: [],
170
+ cacheStateAtPromote: null,
171
+ cacheDecisionId: null,
172
+ };
173
+ }
174
+ const repoSafe = sanitizeMsg(repo);
175
+ const n = options.issueNumber;
176
+ if (!Number.isInteger(n) || n < 1) {
177
+ return {
178
+ ok: false,
179
+ message: `Invalid --from-issue value: ${String(options.issueNumber)} (expected positive integer).`,
180
+ exitCode: 2,
181
+ warnings,
182
+ repo,
183
+ matchedPaths: [],
184
+ cacheStateAtPromote: null,
185
+ cacheDecisionId: null,
186
+ };
187
+ }
188
+ const latest = resolveLatestDecision(options, projectRoot, repo);
189
+ const cacheState = latest?.decision ?? null;
190
+ const cacheDecisionId = latest?.decision_id ?? null;
191
+ const forceNoCache = options.forceNoCache === true;
192
+ if (!forceNoCache) {
193
+ if (latest === null) {
194
+ if (options.strict === true) {
195
+ return {
196
+ ok: false,
197
+ message: `No triage-cache decision for #${n} (${repoSafe}). ` +
198
+ `Accept first: task triage:accept -- --issue ${n} --repo ${repoSafe} ` +
199
+ `(or omit --strict to soft-warn and proceed; --force-no-cache skips the gate).`,
200
+ exitCode: 1,
201
+ warnings,
202
+ repo,
203
+ matchedPaths: [],
204
+ cacheStateAtPromote: null,
205
+ cacheDecisionId: null,
206
+ };
207
+ }
208
+ warnings.push(`[scope:promote --from-issue] no triage-cache decision for #${n} (${repoSafe}); proceeding (soft warn). Use --strict to fail.`);
209
+ }
210
+ else if (latest.decision !== "accept") {
211
+ return {
212
+ ok: false,
213
+ message: `Refusing promote for #${n} (${repoSafe}): latest triage decision is '${sanitizeMsg(latest.decision)}' ` +
214
+ `(decision_id=${latest.decision_id}). ` +
215
+ `Accept first: task triage:accept -- --issue ${n} --repo ${repoSafe} ` +
216
+ `or override with --force-no-cache.`,
217
+ exitCode: 1,
218
+ warnings,
219
+ repo,
220
+ matchedPaths: [],
221
+ cacheStateAtPromote: cacheState,
222
+ cacheDecisionId,
223
+ };
224
+ }
225
+ }
226
+ else {
227
+ warnings.push(`[scope:promote --from-issue] --force-no-cache: skipped reciprocity gate for #${n} ` +
228
+ `(cache decision was ${cacheState === null ? "absent" : `'${sanitizeMsg(cacheState)}'`}).`);
229
+ }
230
+ // Already in pending/ for this issue → idempotent success (auto-promote re-entry).
231
+ const alreadyPending = findLifecycleArtifactsForIssue(projectRoot, n, {
232
+ folder: "pending",
233
+ repo,
234
+ });
235
+ if (alreadyPending.length === 1 && options.explicitPath === undefined) {
236
+ warnings.push(`[scope:promote --from-issue] #${n} already pending (${sanitizeMsg(alreadyPending[0] ?? "")}); no-op.`);
237
+ return {
238
+ ok: true,
239
+ message: `No-op: issue #${n} already has pending scope ${alreadyPending[0]}`,
240
+ exitCode: 0,
241
+ warnings,
242
+ repo,
243
+ matchedPaths: alreadyPending,
244
+ cacheStateAtPromote: cacheState,
245
+ cacheDecisionId: forceNoCache && cacheState !== "accept" ? null : cacheDecisionId,
246
+ destPath: alreadyPending[0],
247
+ };
248
+ }
249
+ let matched = findProposedArtifactsForIssue(projectRoot, n, repo);
250
+ if (options.explicitPath !== undefined && options.explicitPath.trim().length > 0) {
251
+ const raw = options.explicitPath.trim();
252
+ const explicit = isAbsolute(raw) ? resolve(raw) : resolve(projectRoot, raw);
253
+ if (!existsSync(explicit)) {
254
+ return {
255
+ ok: false,
256
+ message: `Explicit path not found: ${sanitizeMsg(explicit)}`,
257
+ exitCode: 2,
258
+ warnings,
259
+ repo,
260
+ matchedPaths: matched,
261
+ cacheStateAtPromote: cacheState,
262
+ cacheDecisionId,
263
+ };
264
+ }
265
+ // Never accept an unrelated path — must match issue provenance (and repo).
266
+ try {
267
+ const data = JSON.parse(readFileSync(explicit, "utf8"));
268
+ if (!briefMatchesIssueAndRepo(data, n, repo)) {
269
+ return {
270
+ ok: false,
271
+ message: `Explicit path is not a provenance match for #${n} (${repoSafe}): ${sanitizeMsg(explicit)}.`,
272
+ exitCode: 2,
273
+ warnings,
274
+ repo,
275
+ matchedPaths: matched,
276
+ cacheStateAtPromote: cacheState,
277
+ cacheDecisionId,
278
+ };
279
+ }
280
+ }
281
+ catch (err) {
282
+ return {
283
+ ok: false,
284
+ message: `Explicit path is not readable JSON: ${sanitizeMsg(explicit)} (${String(err)})`,
285
+ exitCode: 2,
286
+ warnings,
287
+ repo,
288
+ matchedPaths: matched,
289
+ cacheStateAtPromote: cacheState,
290
+ cacheDecisionId,
291
+ };
292
+ }
293
+ if (matched.length > 0) {
294
+ const hit = matched.find((p) => resolve(p) === explicit);
295
+ if (hit === undefined) {
296
+ return {
297
+ ok: false,
298
+ message: `Explicit path is not among proposed matches for #${n}: ${sanitizeMsg(explicit)}. ` +
299
+ `Matched: ${matched.map(sanitizeMsg).join(", ")}`,
300
+ exitCode: 2,
301
+ warnings,
302
+ repo,
303
+ matchedPaths: matched,
304
+ cacheStateAtPromote: cacheState,
305
+ cacheDecisionId,
306
+ };
307
+ }
308
+ matched = [hit];
309
+ }
310
+ else {
311
+ matched = [explicit];
312
+ }
313
+ }
314
+ if (matched.length === 0) {
315
+ return {
316
+ ok: false,
317
+ message: `No proposed/ scope artifact found for issue #${n} (${repoSafe}). ` +
318
+ `Ingest first (task triage:accept -- --issue ${n} --repo ${repoSafe}) ` +
319
+ `or pass an explicit path: task scope:promote -- <path>.`,
320
+ exitCode: 1,
321
+ warnings,
322
+ repo,
323
+ matchedPaths: [],
324
+ cacheStateAtPromote: cacheState,
325
+ cacheDecisionId,
326
+ };
327
+ }
328
+ if (matched.length > 1) {
329
+ const list = matched.map((p) => ` - ${sanitizeMsg(p)}`).join("\n");
330
+ return {
331
+ ok: false,
332
+ message: `Multiple proposed/ artifacts match issue #${n}; refuse to guess.\n${list}\n` +
333
+ `Promote one path explicitly: task scope:promote -- <path> ` +
334
+ `or re-run with --path <one-of-the-above>.`,
335
+ exitCode: 1,
336
+ warnings,
337
+ repo,
338
+ matchedPaths: matched,
339
+ cacheStateAtPromote: cacheState,
340
+ cacheDecisionId,
341
+ };
342
+ }
343
+ const filePath = matched[0];
344
+ const promoteResult = promotePath(filePath, {
345
+ projectRoot,
346
+ force: options.force,
347
+ actor: options.actor,
348
+ now: options.now,
349
+ fromIssue: n,
350
+ cacheDecisionId: forceNoCache && cacheState !== "accept" ? null : cacheDecisionId,
351
+ cacheStateAtPromote: cacheState,
352
+ forceNoCache,
353
+ requireAudit: true,
354
+ });
355
+ warnings.push(`[scope:promote --from-issue] cache decision for #${n} was ` +
356
+ `${cacheState === null ? "absent" : `'${sanitizeMsg(cacheState)}'`}` +
357
+ (promoteResult.ok ? " (proceeded)" : " (promote failed)"));
358
+ return {
359
+ ...promoteResult,
360
+ warnings,
361
+ repo,
362
+ matchedPaths: matched,
363
+ cacheStateAtPromote: cacheState,
364
+ cacheDecisionId: forceNoCache && cacheState !== "accept" ? null : cacheDecisionId,
365
+ };
366
+ }
367
+ //# sourceMappingURL=promote-from-issue.js.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Shared single-path promote (proposed/ → pending/) with optional triage audit linkage (#1136).
3
+ */
4
+ export interface PromotePathOptions {
5
+ readonly projectRoot?: string;
6
+ /** WIP-cap override (--force). */
7
+ readonly force?: boolean;
8
+ readonly actor?: string;
9
+ readonly now?: Date;
10
+ /** Issue number that triggered this promote (from-issue / auto-promote). */
11
+ readonly fromIssue?: number;
12
+ /** Accept (or other) decision_id from candidates.jsonl, when known. */
13
+ readonly cacheDecisionId?: string | null;
14
+ /** Latest cache decision string at promote time (accept / defer / null). */
15
+ readonly cacheStateAtPromote?: string | null;
16
+ /** True when reciprocity gate was skipped via --force-no-cache. */
17
+ readonly forceNoCache?: boolean;
18
+ /** Write promote audit entry even without from-issue linkage (default: only when linkage present). */
19
+ readonly alwaysAudit?: boolean;
20
+ /**
21
+ * When true, audit append failure is a hard error (from-issue / auto-promote
22
+ * require from_issue + cache fields). Default false for plain path promote.
23
+ */
24
+ readonly requireAudit?: boolean;
25
+ }
26
+ export interface PromotePathResult {
27
+ readonly ok: boolean;
28
+ readonly message: string;
29
+ readonly exitCode: number;
30
+ readonly destPath?: string;
31
+ readonly auditEntry?: Record<string, unknown> | null;
32
+ readonly wipCapOverride?: boolean;
33
+ }
34
+ /**
35
+ * Promote a single proposed-scope path to pending/, enforcing WIP and
36
+ * optionally recording from_issue / cache_decision_id on the scope audit log.
37
+ */
38
+ export declare function promotePath(filePath: string, options?: PromotePathOptions): PromotePathResult;
39
+ //# sourceMappingURL=promote-path.d.ts.map