@deftai/directive-core 0.69.0 → 0.70.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 (38) hide show
  1. package/dist/deposit/contain.d.ts +41 -0
  2. package/dist/deposit/contain.js +113 -0
  3. package/dist/deposit/copy-tree.js +8 -2
  4. package/dist/doctor/checks.d.ts +11 -0
  5. package/dist/doctor/checks.js +75 -2
  6. package/dist/doctor/main.js +54 -1
  7. package/dist/doctor/manifest.d.ts +20 -0
  8. package/dist/doctor/manifest.js +22 -0
  9. package/dist/doctor/types.d.ts +8 -0
  10. package/dist/init-deposit/init-deposit.js +6 -0
  11. package/dist/init-deposit/refresh.js +5 -0
  12. package/dist/intake/issue-ingest.d.ts +11 -0
  13. package/dist/intake/issue-ingest.js +86 -11
  14. package/dist/policy/plan-extensions.d.ts +31 -0
  15. package/dist/policy/plan-extensions.js +45 -0
  16. package/dist/policy/wip.d.ts +2 -2
  17. package/dist/policy/wip.js +2 -2
  18. package/dist/swarm/routing.js +10 -3
  19. package/dist/triage/bulk/index.d.ts +11 -2
  20. package/dist/triage/bulk/index.js +41 -4
  21. package/dist/triage/help/registry-data.d.ts +5 -5
  22. package/dist/triage/help/registry-data.js +18 -5
  23. package/dist/triage/scope/cli.d.ts +2 -1
  24. package/dist/triage/scope/cli.js +30 -6
  25. package/dist/triage/scope/mutations.d.ts +10 -0
  26. package/dist/triage/scope/mutations.js +22 -0
  27. package/dist/triage/welcome/constants.d.ts +1 -2
  28. package/dist/triage/welcome/constants.js +1 -2
  29. package/dist/triage/welcome/index.d.ts +1 -0
  30. package/dist/triage/welcome/index.js +1 -0
  31. package/dist/triage/welcome/onboard.d.ts +35 -0
  32. package/dist/triage/welcome/onboard.js +94 -0
  33. package/dist/umbrella-current-shape/index.d.ts +25 -1
  34. package/dist/umbrella-current-shape/index.js +84 -4
  35. package/dist/vbrief-build/project-definition-io.d.ts +6 -0
  36. package/dist/vbrief-build/project-definition-io.js +7 -2
  37. package/dist/vbrief-validate/precutover.js +7 -3
  38. package/package.json +3 -3
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { basename, join, resolve } from "node:path";
3
3
  import { cacheGet } from "../cache/operations.js";
4
+ import { scan } from "../cache/scanner.js";
4
5
  import { hasArtifactSuffix, resolveLifecycleRoot } from "../layout/resolve.js";
5
6
  import { call } from "../scm/call.js";
6
7
  import { resolveProjectRoot } from "../scope/project-context.js";
@@ -11,6 +12,27 @@ import { LEGACY_ARTIFACT_SUFFIX, LEGACY_INFO_ROOT_KEY, LEGACY_VBRIEF_VERSION, MI
11
12
  import { findAcHeading, parseCheckboxItems, parseListItems, sliceAcSection, stripCodeBlocks, stripFencedCodeBlocks, } from "./markdown-scanners.js";
12
13
  import { detectRepo, extractReferencesFromVbrief, fetchOpenIssues, GITHUB_ISSUE_REF_TYPES, LIFECYCLE_FOLDERS, parseIssueNumber, } from "./reconcile-issues.js";
13
14
  export const INGEST_STATUSES = ["proposed", "pending", "active"];
15
+ /**
16
+ * Thrown when the quarantine scanner hard-fails (credential-shaped content) on
17
+ * an ingested issue body/comment thread (#2306). Ingest MUST fail closed: emit
18
+ * nothing and propagate a non-zero exit rather than persisting the xBRIEF.
19
+ */
20
+ export class ScannerHardFailError extends Error {
21
+ issueNumber;
22
+ flags;
23
+ constructor(issueNumber, flags) {
24
+ const details = flags
25
+ .filter((f) => f.severity === "hard-fail")
26
+ .map((f) => f.detail)
27
+ .join("; ");
28
+ super(`issue:ingest refused #${issueNumber}: quarantine scanner hard-fail` +
29
+ (details.length > 0 ? ` (${details})` : "") +
30
+ " -- nothing written.");
31
+ this.name = "ScannerHardFailError";
32
+ this.issueNumber = issueNumber;
33
+ this.flags = flags;
34
+ }
35
+ }
14
36
  /** Enriched on issues after `fetchIssue` when the comment thread is non-empty (#2143). */
15
37
  export const ISSUE_COMMENT_THREAD_KEY = "issueCommentThread";
16
38
  const STATUS_MAP = {
@@ -311,7 +333,14 @@ export function buildIssueVbrief(issue, status, repoUrl, options = {}) {
311
333
  };
312
334
  if (overviewSource.length > 0) {
313
335
  warnBodyControlCharacters(number, overviewSource);
314
- narratives.Overview = overviewSource;
336
+ // #2306: quarantine-scan untrusted body + comment-thread content before it
337
+ // is persisted as agent-facing scope authority. Fail closed on a credential
338
+ // hard-fail; otherwise persist the fenced/quarantined transform.
339
+ const scanResult = scan(overviewSource);
340
+ if (!scanResult.passed) {
341
+ throw new ScannerHardFailError(number, scanResult.flags);
342
+ }
343
+ narratives.Overview = scanResult.transformed_content;
315
344
  }
316
345
  if (labelNames.length > 0) {
317
346
  narratives.Labels = labelNames.join(", ");
@@ -369,6 +398,19 @@ export function fetchFromCache(repo, number, options = {}) {
369
398
  if (typeof issue.html_url === "string" && issue.html_url.length > 0) {
370
399
  issue.url = issue.html_url;
371
400
  }
401
+ // #2306: consume the cache entry's SCANNED content.md (fenced/quarantined at
402
+ // cache-put) rather than the raw body, so the cache read path cannot bypass
403
+ // the quarantine transform. When content.md is absent (e.g. a credential
404
+ // hard-fail deleted it), the raw body falls through and is re-scanned in
405
+ // buildIssueVbrief.
406
+ if (result.contentPath !== null) {
407
+ try {
408
+ issue.body = readFileSync(result.contentPath, "utf8");
409
+ }
410
+ catch {
411
+ // fall back to the raw body (re-scanned downstream)
412
+ }
413
+ }
372
414
  return issue;
373
415
  }
374
416
  catch {
@@ -514,9 +556,25 @@ export function ingestBulk(issues, options) {
514
556
  created: [],
515
557
  duplicate: [],
516
558
  dryrun: [],
559
+ failed: [],
517
560
  };
518
561
  for (const issue of filtered) {
519
- const [result, path, _msg] = ingestOne(issue, { ...options, existingRefs: refs });
562
+ let ingested;
563
+ try {
564
+ ingested = ingestOne(issue, { ...options, existingRefs: refs });
565
+ }
566
+ catch (exc) {
567
+ // #2306: a per-issue quarantine hard-fail must not sink the whole batch;
568
+ // record it, emit nothing for that issue, and surface a non-zero exit
569
+ // upstream via the `failed` bucket.
570
+ if (exc instanceof ScannerHardFailError) {
571
+ summary.failed.push(`#${exc.issueNumber}`);
572
+ process.stderr.write(`${exc.message}\n`);
573
+ continue;
574
+ }
575
+ throw exc;
576
+ }
577
+ const [result, path, _msg] = ingested;
520
578
  const rel = path !== null ? path.replace(`${options.vbriefDir}/`, "").replace(/\\/g, "/") : "";
521
579
  summary[result].push(rel);
522
580
  if (result === "created" && path !== null) {
@@ -581,7 +639,8 @@ export function issueIngestMain(args) {
581
639
  const created = summary.created;
582
640
  const duplicate = summary.duplicate;
583
641
  const dryrun = summary.dryrun;
584
- process.stdout.write(`issue:ingest bulk summary: ${created.length} created, ${duplicate.length} duplicate, ${dryrun.length} dry-run (total considered: ${summary.total})\n`);
642
+ const failed = summary.failed ?? [];
643
+ process.stdout.write(`issue:ingest bulk summary: ${created.length} created, ${duplicate.length} duplicate, ${dryrun.length} dry-run, ${failed.length} refused (total considered: ${summary.total})\n`);
585
644
  for (const entry of created) {
586
645
  process.stdout.write(` CREATED ${entry}\n`);
587
646
  }
@@ -591,19 +650,35 @@ export function issueIngestMain(args) {
591
650
  for (const entry of duplicate) {
592
651
  process.stdout.write(` SKIP ${entry} (already has scope vBRIEF)\n`);
593
652
  }
594
- return 0;
653
+ for (const entry of failed) {
654
+ process.stdout.write(` REFUSED ${entry} (quarantine scanner hard-fail; nothing written)\n`);
655
+ }
656
+ // #2306: fail closed on any quarantine hard-fail in the batch.
657
+ return failed.length > 0 ? 2 : 0;
595
658
  }
596
659
  const issue = fetchIssue(repo, args.number, { cwd: projectRoot });
597
660
  if (issue === null) {
598
661
  return 2;
599
662
  }
600
- const [result, _path, msg] = ingestOne(issue, {
601
- vbriefDir,
602
- status,
603
- repoUrl,
604
- dryRun: args.dryRun,
605
- cwd: projectRoot,
606
- });
663
+ let result;
664
+ let msg;
665
+ try {
666
+ [result, , msg] = ingestOne(issue, {
667
+ vbriefDir,
668
+ status,
669
+ repoUrl,
670
+ dryRun: args.dryRun,
671
+ cwd: projectRoot,
672
+ });
673
+ }
674
+ catch (exc) {
675
+ // #2306: fail closed -- emit nothing, non-zero exit on a quarantine hard-fail.
676
+ if (exc instanceof ScannerHardFailError) {
677
+ process.stderr.write(`${exc.message}\n`);
678
+ return 2;
679
+ }
680
+ throw exc;
681
+ }
607
682
  process.stdout.write(`${msg}\n`);
608
683
  return result === "duplicate" ? 1 : 0;
609
684
  }
@@ -32,4 +32,35 @@ export declare function readPlanCompletedNote(plan: unknown): unknown;
32
32
  * bare block alongside the namespaced one.
33
33
  */
34
34
  export declare function migrateLegacyPolicyKey(plan: Record<string, unknown>): void;
35
+ /** The namespaced/legacy key pairs a bare block can silently shadow (#2301). */
36
+ export declare const SHADOWABLE_PLAN_EXTENSIONS: ReadonlyArray<{
37
+ readonly namespacedKey: string;
38
+ readonly legacyKey: string;
39
+ }>;
40
+ /** A plan-extension key whose bare form is silently shadowed by the namespaced form. */
41
+ export interface ShadowedPlanExtension {
42
+ /** The namespaced key that wins the read (e.g. `x-directive/policy`). */
43
+ readonly namespacedKey: string;
44
+ /** The bare legacy key that is silently ignored (e.g. `policy`). */
45
+ readonly legacyKey: string;
46
+ /**
47
+ * Best-effort list of sub-keys present in the shadowed bare object (e.g.
48
+ * `["triageScope", "wipCap"]`). Empty when the bare value is not an object.
49
+ */
50
+ readonly shadowedSubKeys: readonly string[];
51
+ }
52
+ /**
53
+ * Detect every plan-extension key where a bare (legacy) block coexists with the
54
+ * namespaced form (#2301). Because `readPlanExtension` is namespace-first, the
55
+ * bare block is never read once the namespaced key exists -- edits to it take no
56
+ * effect. Detecting the coexistence lets callers emit a loud diagnostic instead
57
+ * of the silent no-op that the #2295 onboarding trap exhibited.
58
+ */
59
+ export declare function detectShadowedPlanExtensions(plan: unknown): ShadowedPlanExtension[];
60
+ /**
61
+ * Render a human-readable, loud diagnostic for a single shadowed plan-extension
62
+ * key. The message is surface-agnostic (no leading tag) so each caller can
63
+ * prefix it (`[policy:show]`, a doctor finding, ...).
64
+ */
65
+ export declare function describeShadowedPlanExtension(shadow: ShadowedPlanExtension): string;
35
66
  //# sourceMappingURL=plan-extensions.d.ts.map
@@ -54,4 +54,49 @@ export function migrateLegacyPolicyKey(plan) {
54
54
  delete plan[LEGACY_PLAN_POLICY_KEY];
55
55
  }
56
56
  }
57
+ /** The namespaced/legacy key pairs a bare block can silently shadow (#2301). */
58
+ export const SHADOWABLE_PLAN_EXTENSIONS = [
59
+ { namespacedKey: PLAN_POLICY_KEY, legacyKey: LEGACY_PLAN_POLICY_KEY },
60
+ { namespacedKey: PLAN_COMPLETED_NOTE_KEY, legacyKey: LEGACY_PLAN_COMPLETED_NOTE_KEY },
61
+ ];
62
+ /**
63
+ * Detect every plan-extension key where a bare (legacy) block coexists with the
64
+ * namespaced form (#2301). Because `readPlanExtension` is namespace-first, the
65
+ * bare block is never read once the namespaced key exists -- edits to it take no
66
+ * effect. Detecting the coexistence lets callers emit a loud diagnostic instead
67
+ * of the silent no-op that the #2295 onboarding trap exhibited.
68
+ */
69
+ export function detectShadowedPlanExtensions(plan) {
70
+ const planObj = asPlanObject(plan);
71
+ if (planObj === null) {
72
+ return [];
73
+ }
74
+ const shadows = [];
75
+ for (const { namespacedKey, legacyKey } of SHADOWABLE_PLAN_EXTENSIONS) {
76
+ if (planObj[namespacedKey] === undefined || planObj[legacyKey] === undefined) {
77
+ continue;
78
+ }
79
+ const legacyValue = planObj[legacyKey];
80
+ const shadowedSubKeys = typeof legacyValue === "object" && legacyValue !== null && !Array.isArray(legacyValue)
81
+ ? Object.keys(legacyValue)
82
+ : [];
83
+ shadows.push({ namespacedKey, legacyKey, shadowedSubKeys });
84
+ }
85
+ return shadows;
86
+ }
87
+ /**
88
+ * Render a human-readable, loud diagnostic for a single shadowed plan-extension
89
+ * key. The message is surface-agnostic (no leading tag) so each caller can
90
+ * prefix it (`[policy:show]`, a doctor finding, ...).
91
+ */
92
+ export function describeShadowedPlanExtension(shadow) {
93
+ const subKeys = shadow.shadowedSubKeys.length > 0
94
+ ? ` Shadowed field(s): ${shadow.shadowedSubKeys
95
+ .map((k) => `plan.${shadow.legacyKey}.${k}`)
96
+ .join(", ")}.`
97
+ : "";
98
+ return (`bare \`plan.${shadow.legacyKey}\` coexists with namespaced \`plan.${shadow.namespacedKey}\`; ` +
99
+ `the bare block is IGNORED (namespaced-first read) so edits to it silently take no effect.${subKeys} ` +
100
+ `Fold its values into \`plan.${shadow.namespacedKey}\` and delete \`plan.${shadow.legacyKey}\`.`);
101
+ }
57
102
  //# sourceMappingURL=plan-extensions.js.map
@@ -1,5 +1,5 @@
1
- /** Framework default WIP cap (#1124 / umbrella #1119). */
2
- export declare const DEFAULT_WIP_CAP = 10;
1
+ /** Framework default WIP cap (#2319; raised from 10 per #1124 / umbrella #1119). */
2
+ export declare const DEFAULT_WIP_CAP = 20;
3
3
  /** vBRIEF lifecycle folders that count toward the WIP set. */
4
4
  export declare const WIP_LIFECYCLE_DIRS: readonly ["pending", "active"];
5
5
  export type WipCapSource = "typed" | "default" | "default-on-error";
@@ -3,8 +3,8 @@ import { join } from "node:path";
3
3
  import { hasArtifactSuffix, resolveLifecycleRoot } from "../layout/resolve.js";
4
4
  import { readPlanPolicy } from "./plan-extensions.js";
5
5
  import { loadProjectDefinition } from "./resolve.js";
6
- /** Framework default WIP cap (#1124 / umbrella #1119). */
7
- export const DEFAULT_WIP_CAP = 10;
6
+ /** Framework default WIP cap (#2319; raised from 10 per #1124 / umbrella #1119). */
7
+ export const DEFAULT_WIP_CAP = 20;
8
8
  /** vBRIEF lifecycle folders that count toward the WIP set. */
9
9
  export const WIP_LIFECYCLE_DIRS = ["pending", "active"];
10
10
  function pythonTypeName(value) {
@@ -186,7 +186,9 @@ export function resolveDispatchProvider(environ = process.env) {
186
186
  * Keys that would mutate the prototype chain rather than set an own property
187
187
  * if used as a computed object key. Rejected for provider/role names so a
188
188
  * malicious routing input cannot pollute `Object.prototype` (CodeQL
189
- * js/prototype-polluting-assignment).
189
+ * js/prototype-polluting-assignment). This validation guard produces the
190
+ * caller-facing error; the null-prototype write targets in
191
+ * `writeModelDecision` are the structural backstop CodeQL recognizes.
190
192
  */
191
193
  const FORBIDDEN_ROUTING_KEYS = new Set(["__proto__", "constructor", "prototype"]);
192
194
  function assertSafeRoutingKey(kind, key) {
@@ -203,9 +205,14 @@ export function writeModelDecision(path, provider, role, decision) {
203
205
  assertSafeRoutingKey("provider", provider);
204
206
  assertSafeRoutingKey("role", role);
205
207
  const { data } = loadRoutingFile(path);
206
- const file = data ?? {};
208
+ // Null-prototype write targets so a computed provider/role key can only ever
209
+ // set an own property and can never reach `Object.prototype`, even if the
210
+ // validation guard above regresses. CodeQL's js/prototype-polluting-assignment
211
+ // barrier does not track the interprocedural `assertSafeRoutingKey` guard, so
212
+ // this structural sink is what closes alert #52.
213
+ const file = Object.assign(Object.create(null), data ?? {});
207
214
  const existing = providerBlockOf(file, provider);
208
- const block = existing ?? {};
215
+ const block = Object.assign(Object.create(null), existing ?? {});
209
216
  block[role] = {
210
217
  model: decision.model,
211
218
  mode: decision.mode ??
@@ -88,12 +88,21 @@ export declare function excludeLogged(candidates: Iterable<IssuePayload>, option
88
88
  export declare function bulkAction(actionKey: string, repo: string, options?: BulkActionOptions): number;
89
89
  export declare function createFilesystemCacheModule(): CacheModule;
90
90
  export declare function createFilesystemCandidatesLogModule(logPath?: string): CandidatesLogModule;
91
+ /**
92
+ * Native TypeScript bulk actions module (#2279).
93
+ *
94
+ * The bulk path defaults to this factory instead of {@link createPythonActionsModule}
95
+ * so `triage:bulk-*` never spawns a project-local `scripts/triage_actions.py`
96
+ * (an arbitrary-code-execution trust boundary a malicious checkout could plant).
97
+ * Each verb delegates to the already-native per-issue triage actions
98
+ * (`@deftai/directive-core/triage/actions`, #1725) bound to `createDefaultDeps(projectRoot)`.
99
+ */
100
+ export declare function createNativeActionsModule(projectRoot: string): TriageActionsModule;
91
101
  export declare function createPythonActionsModule(scriptsDir: string): TriageActionsModule;
92
102
  export interface DefaultBulkDepsOptions {
93
103
  readonly cacheRoot?: string;
94
104
  readonly candidatesLogPath?: string;
95
- readonly scriptsDir?: string;
96
- readonly deftRoot?: string;
105
+ readonly projectRoot?: string;
97
106
  }
98
107
  export declare function bulkActionWithDefaults(actionKey: string, repo: string, options?: BulkActionOptions & DefaultBulkDepsOptions): number;
99
108
  //# sourceMappingURL=index.d.ts.map
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import { existsSync, readdirSync, readFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { resolveEvalPath } from "../../layout/resolve.js";
5
+ import { createDefaultDeps, accept as nativeAccept, deferAction as nativeDefer, needsAc as nativeNeedsAc, reject as nativeReject, } from "../actions/index.js";
5
6
  export const ACTION_FN_NAMES = {
6
7
  accept: "accept",
7
8
  reject: "reject",
@@ -402,6 +403,40 @@ export function createFilesystemCandidatesLogModule(logPath = resolveEvalPath(pr
402
403
  },
403
404
  };
404
405
  }
406
+ /**
407
+ * Native TypeScript bulk actions module (#2279).
408
+ *
409
+ * The bulk path defaults to this factory instead of {@link createPythonActionsModule}
410
+ * so `triage:bulk-*` never spawns a project-local `scripts/triage_actions.py`
411
+ * (an arbitrary-code-execution trust boundary a malicious checkout could plant).
412
+ * Each verb delegates to the already-native per-issue triage actions
413
+ * (`@deftai/directive-core/triage/actions`, #1725) bound to `createDefaultDeps(projectRoot)`.
414
+ */
415
+ export function createNativeActionsModule(projectRoot) {
416
+ const deps = createDefaultDeps(projectRoot);
417
+ return {
418
+ accept(n, repo) {
419
+ nativeAccept(n, repo, deps, { projectRoot });
420
+ },
421
+ reject(n, repo, ...args) {
422
+ let reason = "";
423
+ const first = args[0];
424
+ if (typeof first === "object" && first !== null && "reason" in first) {
425
+ reason = String(first.reason);
426
+ }
427
+ else if (typeof first === "string") {
428
+ reason = first;
429
+ }
430
+ nativeReject(n, repo, reason, deps, { projectRoot });
431
+ },
432
+ defer(n, repo) {
433
+ nativeDefer(n, repo, "bulk defer", deps, { projectRoot });
434
+ },
435
+ needs_ac(n, repo) {
436
+ nativeNeedsAc(n, repo, deps, { projectRoot });
437
+ },
438
+ };
439
+ }
405
440
  export function createPythonActionsModule(scriptsDir) {
406
441
  const runAction = (cmd, issueNumber, repo, extra = []) => {
407
442
  const result = spawnSync("uv", [
@@ -442,14 +477,16 @@ export function createPythonActionsModule(scriptsDir) {
442
477
  };
443
478
  }
444
479
  export function bulkActionWithDefaults(actionKey, repo, options = {}) {
445
- const deftRoot = options.deftRoot ?? process.cwd();
446
- const scriptsDir = options.scriptsDir ?? join(deftRoot, "scripts");
480
+ // #2279: the bulk path no longer resolves a `scripts/` dir from DEFT_ROOT /
481
+ // the project cwd. It defaults to the native TypeScript actions module so a
482
+ // malicious project-local `scripts/triage_actions.py` can never be executed.
483
+ const projectRoot = options.projectRoot ?? process.cwd();
447
484
  return bulkAction(actionKey, repo, {
448
485
  ...options,
449
- cacheRoot: options.cacheRoot ?? join(deftRoot, ".deft-cache"),
486
+ cacheRoot: options.cacheRoot ?? join(projectRoot, ".deft-cache"),
450
487
  cacheModule: options.cacheModule ?? createFilesystemCacheModule(),
451
488
  candidatesLogModule: options.candidatesLogModule ?? createFilesystemCandidatesLogModule(options.candidatesLogPath),
452
- actionsModule: options.actionsModule ?? createPythonActionsModule(scriptsDir),
489
+ actionsModule: options.actionsModule ?? createNativeActionsModule(projectRoot),
453
490
  });
454
491
  }
455
492
  //# sourceMappingURL=index.js.map
@@ -191,11 +191,11 @@ export declare const registryData: {
191
191
  readonly "task triage:welcome": {
192
192
  readonly name: "task triage:welcome";
193
193
  readonly summary: "Single-entry-point upgrade ritual";
194
- readonly refs: "(N3 / #1143)";
195
- readonly description: "6-phase onboarding ritual: detect prior state, prompt for subscription scope, run triage:bootstrap, prompt for wipCap, offer WIP relief, print triage:summary. Idempotent on re-run; safe entrypoint for fresh consumers.";
196
- readonly usage: "task triage:welcome [-- --no-subprocess]";
197
- readonly flags: readonly [readonly ["--no-subprocess", "(off)", "Dry-mode: don't shell out to sibling tasks."]];
198
- readonly examples: readonly ["task triage:welcome"];
194
+ readonly refs: "(N3 / #1143, #2295)";
195
+ readonly description: "6-phase onboarding ritual: detect prior state, prompt for subscription scope, run triage:bootstrap, prompt for wipCap, offer WIP relief, print triage:summary. Idempotent on re-run; safe entrypoint for fresh consumers. Pass --onboard for the non-interactive path that applies a triage-scope preset (and optional --wip-cap) without prompting -- the form agents/CI use.";
196
+ readonly usage: "task triage:welcome [-- --onboard [--preset small|mid|mega] [--wip-cap N]] [--no-subprocess]";
197
+ readonly flags: readonly [readonly ["--onboard", "(off)", "Non-interactive onboarding: apply a triage-scope preset (and optional WIP cap) without prompting."], readonly ["--preset small|mid|mega", "small", "Subscription-scope preset applied by --onboard."], readonly ["--wip-cap N", "(policy default)", "Persist an explicit in-flight scope cap during --onboard (positive integer)."], readonly ["--no-subprocess", "(off)", "Dry-mode: don't shell out to sibling tasks."]];
198
+ readonly examples: readonly ["task triage:welcome", "task triage:welcome -- --onboard --preset small"];
199
199
  readonly see_also: readonly ["task triage:bootstrap", "task triage:summary", "#1119 / N3"];
200
200
  readonly placeholder: false;
201
201
  };
@@ -296,11 +296,24 @@ export const registryData = {
296
296
  "task triage:welcome": {
297
297
  name: "task triage:welcome",
298
298
  summary: "Single-entry-point upgrade ritual",
299
- refs: "(N3 / #1143)",
300
- description: "6-phase onboarding ritual: detect prior state, prompt for subscription scope, run triage:bootstrap, prompt for wipCap, offer WIP relief, print triage:summary. Idempotent on re-run; safe entrypoint for fresh consumers.",
301
- usage: "task triage:welcome [-- --no-subprocess]",
302
- flags: [["--no-subprocess", "(off)", "Dry-mode: don't shell out to sibling tasks."]],
303
- examples: ["task triage:welcome"],
299
+ refs: "(N3 / #1143, #2295)",
300
+ description: "6-phase onboarding ritual: detect prior state, prompt for subscription scope, run triage:bootstrap, prompt for wipCap, offer WIP relief, print triage:summary. Idempotent on re-run; safe entrypoint for fresh consumers. Pass --onboard for the non-interactive path that applies a triage-scope preset (and optional --wip-cap) without prompting -- the form agents/CI use.",
301
+ usage: "task triage:welcome [-- --onboard [--preset small|mid|mega] [--wip-cap N]] [--no-subprocess]",
302
+ flags: [
303
+ [
304
+ "--onboard",
305
+ "(off)",
306
+ "Non-interactive onboarding: apply a triage-scope preset (and optional WIP cap) without prompting.",
307
+ ],
308
+ ["--preset small|mid|mega", "small", "Subscription-scope preset applied by --onboard."],
309
+ [
310
+ "--wip-cap N",
311
+ "(policy default)",
312
+ "Persist an explicit in-flight scope cap during --onboard (positive integer).",
313
+ ],
314
+ ["--no-subprocess", "(off)", "Dry-mode: don't shell out to sibling tasks."],
315
+ ],
316
+ examples: ["task triage:welcome", "task triage:welcome -- --onboard --preset small"],
304
317
  see_also: ["task triage:bootstrap", "task triage:summary", "#1119 / N3"],
305
318
  placeholder: false,
306
319
  },
@@ -6,13 +6,14 @@ export interface ParsedCliArgs {
6
6
  addLabel: string | undefined;
7
7
  addMilestone: string | undefined;
8
8
  ignoreLabel: string | undefined;
9
+ setPreset: string | undefined;
9
10
  diffFromUpstream: boolean;
10
11
  source: string;
11
12
  cacheRoot: string | undefined;
12
13
  count: number | undefined;
13
14
  }
14
15
  export declare function parseCliArgs(argv: string[]): ParsedCliArgs;
15
- export declare const CLI_HELP = "usage: triage_scope.py [-h] [--project-root PROJECT_ROOT] [--list]\n [--refresh-denominator] [--repo REPO]\n [--add-label ADD_LABEL] [--add-milestone ADD_MILESTONE]\n [--ignore-label IGNORE_LABEL] [--diff-from-upstream]\n [--source SOURCE] [--cache-root CACHE_ROOT]\n [--count COUNT]\n\nInspect, mutate, and diff the typed plan.policy.triageScope[] subscription +\nplan.policy.triageScopeIgnores[] (#1131 / D12, #1133 / D14, #1182 / D14c).\nRead paths never trigger a recompute; use --refresh-denominator to update the\ncoverage cache. Mutation flags --add-label / --add-milestone / --ignore-label\nare idempotent and atomic; every mutation appends a subscription-change audit\nentry to vbrief/.eval/subscription-history.jsonl.\n";
16
+ export declare const CLI_HELP = "usage: triage_scope.py [-h] [--project-root PROJECT_ROOT] [--list]\n [--refresh-denominator] [--repo REPO]\n [--add-label ADD_LABEL] [--add-milestone ADD_MILESTONE]\n [--ignore-label IGNORE_LABEL] [--set-preset PRESET]\n [--diff-from-upstream]\n [--source SOURCE] [--cache-root CACHE_ROOT]\n [--count COUNT]\n\nInspect, mutate, and diff the typed plan.policy.triageScope[] subscription +\nplan.policy.triageScopeIgnores[] (#1131 / D12, #1133 / D14, #1182 / D14c).\nRead paths never trigger a recompute; use --refresh-denominator to update the\ncoverage cache. Mutation flags --add-label / --add-milestone / --ignore-label /\n--set-preset are idempotent and atomic; every mutation appends a\nsubscription-change audit entry to vbrief/.eval/subscription-history.jsonl.\n--set-preset small|mid|mega overwrites plan.policy.triageScope[] with a named\nsubscription preset via the same writer that backs triage:welcome --onboard (#2301).\n";
16
17
  /** Run triage:scope CLI; returns exit code and captured output for parity. */
17
18
  export declare function runCliCapture(argv: string[]): {
18
19
  code: number;
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { coveragePath, writeCoverageDenominator } from "./coverage.js";
5
- import { addLabelToIgnores, addLabelToScope, addMilestoneToScope, computeDiffFromUpstream, fetchUpstreamLabelsAndMilestones, renderDiffReport, } from "./mutations.js";
5
+ import { addLabelToIgnores, addLabelToScope, addMilestoneToScope, computeDiffFromUpstream, fetchUpstreamLabelsAndMilestones, renderDiffReport, setScopePreset, } from "./mutations.js";
6
6
  import { subscriptionHash } from "./normalize.js";
7
7
  import { pyListRepr } from "./python-repr.js";
8
8
  import { renderIgnores, renderList } from "./renderers.js";
@@ -17,6 +17,7 @@ export function parseCliArgs(argv) {
17
17
  addLabel: undefined,
18
18
  addMilestone: undefined,
19
19
  ignoreLabel: undefined,
20
+ setPreset: undefined,
20
21
  diffFromUpstream: false,
21
22
  source: "github-issue",
22
23
  cacheRoot: undefined,
@@ -60,6 +61,12 @@ export function parseCliArgs(argv) {
60
61
  }
61
62
  else if (arg?.startsWith("--ignore-label="))
62
63
  parsed.ignoreLabel = arg.slice("--ignore-label=".length);
64
+ else if (arg === "--set-preset") {
65
+ parsed.setPreset = argv[i + 1];
66
+ i += 1;
67
+ }
68
+ else if (arg?.startsWith("--set-preset="))
69
+ parsed.setPreset = arg.slice("--set-preset=".length);
63
70
  else if (arg === "--source") {
64
71
  parsed.source = argv[i + 1] ?? "github-issue";
65
72
  i += 1;
@@ -86,16 +93,19 @@ export function parseCliArgs(argv) {
86
93
  export const CLI_HELP = `usage: triage_scope.py [-h] [--project-root PROJECT_ROOT] [--list]
87
94
  [--refresh-denominator] [--repo REPO]
88
95
  [--add-label ADD_LABEL] [--add-milestone ADD_MILESTONE]
89
- [--ignore-label IGNORE_LABEL] [--diff-from-upstream]
96
+ [--ignore-label IGNORE_LABEL] [--set-preset PRESET]
97
+ [--diff-from-upstream]
90
98
  [--source SOURCE] [--cache-root CACHE_ROOT]
91
99
  [--count COUNT]
92
100
 
93
101
  Inspect, mutate, and diff the typed plan.policy.triageScope[] subscription +
94
102
  plan.policy.triageScopeIgnores[] (#1131 / D12, #1133 / D14, #1182 / D14c).
95
103
  Read paths never trigger a recompute; use --refresh-denominator to update the
96
- coverage cache. Mutation flags --add-label / --add-milestone / --ignore-label
97
- are idempotent and atomic; every mutation appends a subscription-change audit
98
- entry to vbrief/.eval/subscription-history.jsonl.
104
+ coverage cache. Mutation flags --add-label / --add-milestone / --ignore-label /
105
+ --set-preset are idempotent and atomic; every mutation appends a
106
+ subscription-change audit entry to vbrief/.eval/subscription-history.jsonl.
107
+ --set-preset small|mid|mega overwrites plan.policy.triageScope[] with a named
108
+ subscription preset via the same writer that backs triage:welcome --onboard (#2301).
99
109
  `;
100
110
  function handleMutation(projectRoot, args) {
101
111
  const out = { stdout: [], stderr: [] };
@@ -115,6 +125,10 @@ function handleMutation(projectRoot, args) {
115
125
  [changed, message] = addLabelToIgnores(projectRoot, args.ignoreLabel);
116
126
  verb = "ignore-label";
117
127
  }
128
+ else if (args.setPreset !== undefined) {
129
+ [changed, message] = setScopePreset(projectRoot, args.setPreset);
130
+ verb = "set-preset";
131
+ }
118
132
  else {
119
133
  throw new Error("internal: mutation flag set but no handler matched");
120
134
  }
@@ -148,12 +162,13 @@ export function runCliCapture(argv) {
148
162
  args.addLabel !== undefined ? "--add-label" : null,
149
163
  args.addMilestone !== undefined ? "--add-milestone" : null,
150
164
  args.ignoreLabel !== undefined ? "--ignore-label" : null,
165
+ args.setPreset !== undefined ? "--set-preset" : null,
151
166
  ].filter((f) => f !== null);
152
167
  if (mutationFlags.length > 1) {
153
168
  return {
154
169
  code: 2,
155
170
  stdout: "",
156
- stderr: "triage:scope: --add-label / --add-milestone / --ignore-label " +
171
+ stderr: "triage:scope: --add-label / --add-milestone / --ignore-label / --set-preset " +
157
172
  `are mutually exclusive (got ${pyListRepr(mutationFlags)}).\n`,
158
173
  };
159
174
  }
@@ -171,6 +186,15 @@ export function runCliCapture(argv) {
171
186
  if (mutation.code !== 0) {
172
187
  return { code: mutation.code, stdout: stdout.join(""), stderr: stderr.join("") };
173
188
  }
189
+ // A preset write comes from the trusted SUBSCRIPTION_PRESETS constant (the
190
+ // same source the onboard path writes). The `mega` preset intentionally
191
+ // ships an `explicit-watch` scaffold with an empty `issues` list for the
192
+ // operator to fill in, which the strict hand-edit validator below flags.
193
+ // Bypass that re-validation for a preset write so `--set-preset` reports the
194
+ // successful, persisted mutation instead of a spurious exit 1 (#2301).
195
+ if (args.setPreset !== undefined) {
196
+ return { code: 0, stdout: stdout.join(""), stderr: stderr.join("") };
197
+ }
174
198
  }
175
199
  const data = loadProjectDefinition(projectRoot);
176
200
  const rules = resolveScopeRules(projectRoot, data);
@@ -18,4 +18,14 @@ export declare function fetchUpstreamLabelsAndMilestones(repo: string, binary?:
18
18
  export declare function addLabelToScope(projectRoot: string, label: string, actor?: string | null): [boolean, string];
19
19
  export declare function addMilestoneToScope(projectRoot: string, milestone: string, actor?: string | null): [boolean, string];
20
20
  export declare function addLabelToIgnores(projectRoot: string, label: string): [boolean, string];
21
+ /** Valid subscription preset keys for `triage:scope --set-preset` (#2301). */
22
+ export declare function scopePresetKeys(): string[];
23
+ /**
24
+ * Overwrite plan.policy.triageScope with a named subscription preset (#2301).
25
+ * Reuses the shared `subscriptionPreset` + `writeTriageScope` helper that backs
26
+ * `triage:welcome --onboard`, so the write path (namespaced key, legacy-key
27
+ * migration, audit trail, mutation lock) stays identical across surfaces rather
28
+ * than being duplicated here.
29
+ */
30
+ export declare function setScopePreset(projectRoot: string, presetKey: string): [boolean, string];
21
31
  //# sourceMappingURL=mutations.d.ts.map
@@ -1,5 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { SUBPROCESS_MAX_BUFFER } from "../../subprocess/max-buffer.js";
3
+ import { SUBSCRIPTION_PRESETS } from "../welcome/constants.js";
4
+ import { subscriptionPreset, writeTriageScope } from "../welcome/writers.js";
3
5
  import { collectMilestoneSubscribedNames, rulesRequestIsOpen } from "./milestone.js";
4
6
  import { addIgnore, subscribe } from "./mutations-core.js";
5
7
  import { resolveScopeIgnores, resolveScopeRules } from "./resolve.js";
@@ -200,4 +202,24 @@ export function addLabelToIgnores(projectRoot, label) {
200
202
  throw new Error(`label must be a non-empty string; got ${JSON.stringify(label)}`);
201
203
  return addIgnore(projectRoot, label);
202
204
  }
205
+ /** Valid subscription preset keys for `triage:scope --set-preset` (#2301). */
206
+ export function scopePresetKeys() {
207
+ return Object.keys(SUBSCRIPTION_PRESETS);
208
+ }
209
+ /**
210
+ * Overwrite plan.policy.triageScope with a named subscription preset (#2301).
211
+ * Reuses the shared `subscriptionPreset` + `writeTriageScope` helper that backs
212
+ * `triage:welcome --onboard`, so the write path (namespaced key, legacy-key
213
+ * migration, audit trail, mutation lock) stays identical across surfaces rather
214
+ * than being duplicated here.
215
+ */
216
+ export function setScopePreset(projectRoot, presetKey) {
217
+ const key = presetKey.trim();
218
+ const validKeys = scopePresetKeys();
219
+ if (!validKeys.includes(key)) {
220
+ throw new Error(`unknown preset ${JSON.stringify(presetKey)}; valid presets: ${validKeys.join(", ")}`);
221
+ }
222
+ const rules = subscriptionPreset(key);
223
+ return writeTriageScope(projectRoot, rules, { presetLabel: key });
224
+ }
203
225
  //# sourceMappingURL=mutations.js.map
@@ -1,10 +1,9 @@
1
- export declare const PROJECT_DEFINITION_REL_PATH = "vbrief/PROJECT-DEFINITION.vbrief.json";
2
1
  export declare const CACHE_DIR_NAME = ".deft-cache";
3
2
  export declare const CACHE_SOURCE = "github-issue";
4
3
  export declare const CANDIDATES_RELPATH: readonly ["vbrief", ".eval", "candidates.jsonl"];
5
4
  export declare const WIP_LIFECYCLE_DIRS: readonly ["pending", "active"];
6
5
  export declare const AUDIT_LOG_REL_PATH = "meta/policy-changes.log";
7
- export declare const DEFAULT_WIP_CAP = 10;
6
+ export declare const DEFAULT_WIP_CAP = 20;
8
7
  export declare const DEFAULT_RELIEF_AGE_DAYS = 30;
9
8
  export declare const TRIAGE_SKILL_PATH = "skills/deft-directive-triage/SKILL.md";
10
9
  export declare const WELCOME_AUDIT_TAG = "triage-welcome";
@@ -1,10 +1,9 @@
1
- export const PROJECT_DEFINITION_REL_PATH = "vbrief/PROJECT-DEFINITION.vbrief.json";
2
1
  export const CACHE_DIR_NAME = ".deft-cache";
3
2
  export const CACHE_SOURCE = "github-issue";
4
3
  export const CANDIDATES_RELPATH = ["vbrief", ".eval", "candidates.jsonl"];
5
4
  export const WIP_LIFECYCLE_DIRS = ["pending", "active"];
6
5
  export const AUDIT_LOG_REL_PATH = "meta/policy-changes.log";
7
- export const DEFAULT_WIP_CAP = 10;
6
+ export const DEFAULT_WIP_CAP = 20;
8
7
  export const DEFAULT_RELIEF_AGE_DAYS = 30;
9
8
  export const TRIAGE_SKILL_PATH = "skills/deft-directive-triage/SKILL.md";
10
9
  export const WELCOME_AUDIT_TAG = "triage-welcome";
@@ -1,5 +1,6 @@
1
1
  export * from "./constants.js";
2
2
  export { type DefaultModeOptions, formatWelcomeCommand, normalizeTaskPrefix, runDefaultMode, type WelcomeOutcome, } from "./default-mode.js";
3
+ export { DEFAULT_ONBOARD_PRESET, type OnboardOptions, type OnboardOutcome, runOnboardMode, } from "./onboard.js";
3
4
  export { candidatesLogPath, classifyOnboarding, detectPriorState, type PriorState, pendingDecisionsNudgeLine, } from "./prior-state.js";
4
5
  export { computeSummary, emitOneliner, formatOneLiner, formatSummary, type SummaryResult, } from "./summary.js";
5
6
  export { appendAuditEntry, previewWipRelief, type ReliefPreview, subscriptionPreset, writeTriageScope, writeWipCap, } from "./writers.js";
@@ -1,5 +1,6 @@
1
1
  export * from "./constants.js";
2
2
  export { formatWelcomeCommand, normalizeTaskPrefix, runDefaultMode, } from "./default-mode.js";
3
+ export { DEFAULT_ONBOARD_PRESET, runOnboardMode, } from "./onboard.js";
3
4
  export { candidatesLogPath, classifyOnboarding, detectPriorState, pendingDecisionsNudgeLine, } from "./prior-state.js";
4
5
  export { computeSummary, emitOneliner, formatOneLiner, formatSummary, } from "./summary.js";
5
6
  export { appendAuditEntry, previewWipRelief, subscriptionPreset, writeTriageScope, writeWipCap, } from "./writers.js";