@deftai/directive-core 0.90.0 → 0.91.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 (62) hide show
  1. package/dist/doctor/openclaw-skills.d.ts +42 -8
  2. package/dist/doctor/openclaw-skills.js +281 -38
  3. package/dist/eval/health.d.ts +10 -1
  4. package/dist/eval/health.js +16 -16
  5. package/dist/init-deposit/hygiene.js +3 -0
  6. package/dist/init-deposit/init-deposit.d.ts +5 -0
  7. package/dist/init-deposit/init-deposit.js +37 -2
  8. package/dist/intake/github-auth-modes.d.ts +1 -1
  9. package/dist/intake/github-auth-modes.js +25 -3
  10. package/dist/intake/issue-ingest-cli.js +13 -0
  11. package/dist/intake/reconcile-issues-cli.js +13 -0
  12. package/dist/policy/plan-extensions.d.ts +10 -0
  13. package/dist/policy/plan-extensions.js +16 -0
  14. package/dist/pr-closing-keywords/detect.d.ts +13 -0
  15. package/dist/pr-closing-keywords/detect.js +73 -8
  16. package/dist/pr-closing-keywords/index.d.ts +2 -2
  17. package/dist/pr-closing-keywords/index.js +1 -1
  18. package/dist/pr-closing-keywords/main.js +119 -90
  19. package/dist/pr-closing-keywords/types.d.ts +10 -0
  20. package/dist/render/constants.d.ts +16 -0
  21. package/dist/render/constants.js +16 -1
  22. package/dist/render/export-spec.d.ts +7 -1
  23. package/dist/render/export-spec.js +51 -5
  24. package/dist/render/index.d.ts +3 -2
  25. package/dist/render/index.js +2 -2
  26. package/dist/render/roadmap-render.js +164 -60
  27. package/dist/render/scope-outlook.d.ts +5 -0
  28. package/dist/render/scope-outlook.js +3 -0
  29. package/dist/render/spec-render.d.ts +28 -2
  30. package/dist/render/spec-render.js +111 -14
  31. package/dist/render/text-utils.d.ts +6 -0
  32. package/dist/render/text-utils.js +23 -0
  33. package/dist/scm/binary.d.ts +3 -0
  34. package/dist/scm/binary.js +10 -2
  35. package/dist/scm/call.d.ts +5 -0
  36. package/dist/scm/call.js +5 -0
  37. package/dist/scm/index.d.ts +1 -0
  38. package/dist/scm/index.js +1 -0
  39. package/dist/scm/main.d.ts +5 -0
  40. package/dist/scm/main.js +27 -0
  41. package/dist/scm/readiness-cli.d.ts +24 -0
  42. package/dist/scm/readiness-cli.js +85 -0
  43. package/dist/scm/readiness.d.ts +104 -0
  44. package/dist/scm/readiness.js +421 -0
  45. package/dist/scope/batch-promote.d.ts +26 -0
  46. package/dist/scope/batch-promote.js +167 -0
  47. package/dist/scope/index.d.ts +1 -0
  48. package/dist/scope/index.js +1 -0
  49. package/dist/scope/main.d.ts +2 -0
  50. package/dist/scope/main.js +58 -7
  51. package/dist/scope/wip-cap-check.d.ts +6 -0
  52. package/dist/scope/wip-cap-check.js +14 -2
  53. package/dist/session/session-start.d.ts +7 -0
  54. package/dist/session/session-start.js +54 -0
  55. package/dist/triage/welcome/index.d.ts +1 -1
  56. package/dist/triage/welcome/index.js +1 -1
  57. package/dist/triage/welcome/onboard.js +7 -1
  58. package/dist/triage/welcome/prior-state.d.ts +7 -0
  59. package/dist/triage/welcome/prior-state.js +16 -2
  60. package/dist/triage/welcome/writers.d.ts +10 -0
  61. package/dist/triage/welcome/writers.js +72 -2
  62. package/package.json +3 -3
@@ -3,6 +3,7 @@ import { dirname, join } 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";
6
+ import { batchPromote } from "./batch-promote.js";
6
7
  import { TRANSITIONS } from "./constants.js";
7
8
  import { batchDemote, DEFAULT_OLDER_THAN_DAYS, demoteOne, resolveDemoteFilePath, resolveFilePath, resolveProjectRootStrict, } from "./demote.js";
8
9
  import { completedPathForScopeMove, findOpenUmbrellaReferences, renderOpenUmbrellaWarning, } from "./open-umbrella-warning.js";
@@ -10,12 +11,13 @@ import { resolveProjectRoot } from "./project-context.js";
10
11
  import { recordWipCapOverride, runTransition } from "./transition.js";
11
12
  import { findByDecisionId, isAlreadyUndone, REVERSIBLE_ACTIONS, undoBatch, undoOne, } from "./undo.js";
12
13
  import { checkWipCap, formatWipCapRefusal } from "./wip-cap-check.js";
13
- const LIFECYCLE_USAGE_STDERR = "usage: scope_lifecycle.py [-h] [--project-root PROJECT_ROOT] [--force]\n" +
14
+ const LIFECYCLE_USAGE_STDERR = "usage: scope_lifecycle.py [-h] [--project-root PROJECT_ROOT] [--force] [--batch]\n" +
14
15
  " {activate,block,cancel,complete,fail,promote,restore,unblock}\n" +
15
- " file\n" +
16
- "scope_lifecycle.py: error: the following arguments are required: action, file\n";
16
+ " [file ...]\n" +
17
+ "scope_lifecycle.py: error: the following arguments are required: action, file\n" +
18
+ "(promote --batch may omit file and promotes all proposed/ scopes; #3011)\n";
17
19
  function parseLifecycleArgv(argv) {
18
- if (argv.length < 2) {
20
+ if (argv.length < 1) {
19
21
  return { args: null, error: "usage" };
20
22
  }
21
23
  const action = argv[0] ?? "";
@@ -25,6 +27,8 @@ function parseLifecycleArgv(argv) {
25
27
  let file = "";
26
28
  let projectRoot;
27
29
  let force = false;
30
+ let batch = false;
31
+ const batchFiles = [];
28
32
  for (let i = 1; i < argv.length; i += 1) {
29
33
  const arg = argv[i];
30
34
  if (arg === undefined) {
@@ -33,6 +37,9 @@ function parseLifecycleArgv(argv) {
33
37
  if (arg === "--force") {
34
38
  force = true;
35
39
  }
40
+ else if (arg === "--batch") {
41
+ batch = true;
42
+ }
36
43
  else if (arg === "--project-root") {
37
44
  projectRoot = argv[i + 1];
38
45
  i += 1;
@@ -40,13 +47,36 @@ function parseLifecycleArgv(argv) {
40
47
  else if (arg?.startsWith("--project-root=")) {
41
48
  projectRoot = arg.slice("--project-root=".length);
42
49
  }
43
- else if (!arg.startsWith("-") && file.length === 0) {
44
- file = arg;
50
+ else if (!arg.startsWith("-")) {
51
+ if (batch) {
52
+ batchFiles.push(arg);
53
+ }
54
+ else if (file.length === 0) {
55
+ file = arg;
56
+ }
57
+ else {
58
+ return { args: null, error: "usage" };
59
+ }
45
60
  }
46
61
  else {
47
62
  return { args: null, error: "usage" };
48
63
  }
49
64
  }
65
+ if (batch) {
66
+ if (action !== "promote") {
67
+ return { args: null, error: "usage" };
68
+ }
69
+ return {
70
+ args: {
71
+ action,
72
+ file: file.length > 0 ? file : "",
73
+ projectRoot,
74
+ force,
75
+ batch: true,
76
+ batchFiles: batchFiles.length > 0 ? batchFiles : file.length > 0 ? [file] : [],
77
+ },
78
+ };
79
+ }
50
80
  if (file.length === 0) {
51
81
  return { args: null, error: "usage" };
52
82
  }
@@ -61,7 +91,28 @@ export function lifecycleMain(argv) {
61
91
  }
62
92
  return 2;
63
93
  }
64
- const { action, file, projectRoot, force } = parsed.args;
94
+ const { action, file, projectRoot, force, batch, batchFiles } = parsed.args;
95
+ if (batch === true && action === "promote") {
96
+ const result = batchPromote({
97
+ files: batchFiles && batchFiles.length > 0 ? batchFiles : undefined,
98
+ force: force === true,
99
+ projectRoot,
100
+ });
101
+ for (const line of result.messages) {
102
+ process.stdout.write(`${line}\n`);
103
+ }
104
+ for (const line of result.skipped) {
105
+ process.stdout.write(` skipped: ${line}\n`);
106
+ }
107
+ if (!result.ok && result.exitCode !== 0) {
108
+ for (const line of result.messages) {
109
+ if (line.startsWith("ERROR:") || line.includes("WIP cap")) {
110
+ process.stderr.write(`${line}\n`);
111
+ }
112
+ }
113
+ }
114
+ return result.exitCode;
115
+ }
65
116
  const [filePath, error] = resolveFilePath(file, projectRoot);
66
117
  if (error !== null || filePath === null) {
67
118
  process.stderr.write(`Error: ${error}\n`);
@@ -7,5 +7,11 @@ export interface WipCapCheck {
7
7
  }
8
8
  /** Resolve WIP cap and decide if promotion is allowed (#1124). */
9
9
  export declare function checkWipCap(projectRoot: string, force?: boolean): WipCapCheck;
10
+ /**
11
+ * WIP check for promoting `additional` scopes in one batch (#3011).
12
+ * Single promote is `additional = 1` (same as historic count >= cap refuse).
13
+ * Batch of N refuses when `count + N > cap` unless `--force`.
14
+ */
15
+ export declare function checkWipCapForAdditional(projectRoot: string, additional: number, force?: boolean): WipCapCheck;
10
16
  export declare function formatWipCapRefusal(check: WipCapCheck): string;
11
17
  //# sourceMappingURL=wip-cap-check.d.ts.map
@@ -1,10 +1,21 @@
1
1
  import { countVbriefWip, resolveWipCap } from "../policy/wip.js";
2
2
  /** Resolve WIP cap and decide if promotion is allowed (#1124). */
3
3
  export function checkWipCap(projectRoot, force = false) {
4
+ return checkWipCapForAdditional(projectRoot, 1, force);
5
+ }
6
+ /**
7
+ * WIP check for promoting `additional` scopes in one batch (#3011).
8
+ * Single promote is `additional = 1` (same as historic count >= cap refuse).
9
+ * Batch of N refuses when `count + N > cap` unless `--force`.
10
+ */
11
+ export function checkWipCapForAdditional(projectRoot, additional, force = false) {
4
12
  const capResult = resolveWipCap(projectRoot);
5
13
  const cap = capResult.cap;
6
14
  const count = countVbriefWip(projectRoot);
7
- const overCap = count >= cap;
15
+ const add = Math.max(0, Math.floor(additional));
16
+ // Single-promote parity: refuse when already at/over cap before adding one.
17
+ // Batch: refuse when current + additional would exceed the cap.
18
+ const overCap = add <= 1 ? count >= cap : count + add > cap;
8
19
  if (!overCap) {
9
20
  return { allowed: true, cap, count, source: capResult.source, forceOverride: false };
10
21
  }
@@ -18,6 +29,7 @@ export function formatWipCapRefusal(check) {
18
29
  "Either:\n" +
19
30
  " task scope:demote <existing> # return one to proposed/\n" +
20
31
  " task scope:demote --batch --older-than-days 30 # bulk relief (D9 folded into D1)\n" +
21
- " task scope:promote <file> --force # override (logged)");
32
+ " task scope:promote <file> --force # override (logged)\n" +
33
+ " task scope:promote --batch --force # batch override (#3011)");
22
34
  }
23
35
  //# sourceMappingURL=wip-cap-check.js.map
@@ -1,4 +1,5 @@
1
1
  import { type EnvironmentContext } from "../platform/shell-context.js";
2
+ import { type ProbeScmReadinessOptions, type ScmReadinessReport } from "../scm/readiness.js";
2
3
  import { type ResolveUserMdResult } from "../user-config/resolve-user-md.js";
3
4
  import type { GitRunner } from "./git.js";
4
5
  import { type ReleaseAvailabilityProbeOptions } from "./release-availability.js";
@@ -86,6 +87,12 @@ export interface SessionStartOptions {
86
87
  readonly allowOptionalNetwork?: boolean;
87
88
  /** Process env for network opt-in resolution (tests inject). */
88
89
  readonly env?: NodeJS.ProcessEnv;
90
+ /**
91
+ * #2275: SCM tooling + auth readiness probe for mismatched/headless envs.
92
+ * Defaults to probeScmReadiness; shallow on hot path, deep when optional
93
+ * network is enabled. Inject in tests.
94
+ */
95
+ readonly probeScm?: (options: ProbeScmReadinessOptions) => ScmReadinessReport;
89
96
  }
90
97
  /** Format preferred `session:start` recovery command for cold vs re-arm (#2992). */
91
98
  export declare function formatSessionStartRecoveryCommand(tier?: SessionCeremonyTier): string;
@@ -9,6 +9,7 @@ import { humanMergeDisclosureLine, resolveHumanMergePolicy, } from "../policy/re
9
9
  import { resolvePolicy } from "../policy/resolve.js";
10
10
  import { maybeFormatProductSignalConsentPrompt } from "../product-signal/consent-prompt.js";
11
11
  import { formatFrameworkCommand } from "../render/framework-commands.js";
12
+ import { formatScmReadinessLines, probeScmReadiness, scmReadinessToDict, } from "../scm/readiness.js";
12
13
  import { maybeRunStalenessTickler } from "../staleness-tickler/run.js";
13
14
  import { runDefaultMode } from "../triage/welcome/default-mode.js";
14
15
  import { resolveUserMdPath } from "../user-config/resolve-user-md.js";
@@ -292,6 +293,44 @@ export function defaultBranchSync(projectRoot, runGit = defaultGitRunner) {
292
293
  }
293
294
  return { branch, upstream, ahead, behind, warning };
294
295
  }
296
+ /**
297
+ * Resolve SCM readiness for session orientation (#2275).
298
+ * Shallow by default (PATH + auth status); deep when optional network is on.
299
+ * Never throws — session:start must not hard-block on SCM absence.
300
+ */
301
+ function resolveSessionScmReadiness(options, allowOptionalNetwork) {
302
+ const probe = options.probeScm ?? probeScmReadiness;
303
+ try {
304
+ return probe({
305
+ depth: allowOptionalNetwork ? "deep" : "shallow",
306
+ env: options.env,
307
+ });
308
+ }
309
+ catch {
310
+ return {
311
+ ready: false,
312
+ binary: null,
313
+ binaryPath: null,
314
+ authState: "unknown",
315
+ githubAuthMode: "host-gh",
316
+ runtimeMode: "local-unsandboxed",
317
+ injectedTokenPresent: false,
318
+ depth: allowOptionalNetwork ? "deep" : "shallow",
319
+ detail: "SCM readiness probe failed unexpectedly; treating SCM-dependent gates as skipped",
320
+ remediation: null,
321
+ skippedGates: [
322
+ "triage:queue",
323
+ "issue:ingest",
324
+ "pr:*",
325
+ "reconcile:issues",
326
+ "cache:fetch-all",
327
+ "scm:*",
328
+ ],
329
+ login: null,
330
+ failureKind: "probe_error",
331
+ };
332
+ }
333
+ }
295
334
  function runReadOnlySessionStart(projectRoot, options, instant, environment) {
296
335
  const lines = [];
297
336
  const resolveUserMd = options.resolveUserMd ?? ((root) => resolveUserMdPath({ projectRoot: root }));
@@ -301,9 +340,12 @@ function runReadOnlySessionStart(projectRoot, options, instant, environment) {
301
340
  const userMdLine = userMd.found
302
341
  ? `USER.md resolved (${userMd.rung}): ${safePath}`
303
342
  : safeDiagnostic;
343
+ // #2275: report SCM availability even on read-only alignment (shallow; no network).
344
+ const scm = resolveSessionScmReadiness(options, false);
304
345
  lines.push(READ_ONLY_ALIGNMENT_MESSAGE);
305
346
  lines.push(userMdLine);
306
347
  lines.push(formatEnvironmentContext(environment));
348
+ lines.push(...formatScmReadinessLines(scm));
307
349
  const resultPayload = {
308
350
  ready: true,
309
351
  exit_code: 0,
@@ -324,6 +366,7 @@ function runReadOnlySessionStart(projectRoot, options, instant, environment) {
324
366
  diagnostic: userMd.diagnostic,
325
367
  },
326
368
  environment: environmentContextToDict(environment),
369
+ scm: scmReadinessToDict(scm),
327
370
  message: READ_ONLY_RESULT_MESSAGE,
328
371
  };
329
372
  return { code: 0, payload: resultPayload, lines };
@@ -364,10 +407,13 @@ function runSessionRearm(projectRoot, options, instant, environment) {
364
407
  ? `USER.md resolved (${userMd.rung}): ${safePath}`
365
408
  : safeDiagnostic;
366
409
  const alignmentMessage = `${READ_ONLY_ALIGNMENT_MESSAGE} ${userMdLine}`;
410
+ // #2275: re-arm still reports SCM state (shallow; no network).
411
+ const scm = resolveSessionScmReadiness(options, false);
367
412
  const lines = [
368
413
  READ_ONLY_ALIGNMENT_MESSAGE,
369
414
  userMdLine,
370
415
  formatEnvironmentContext(environment),
416
+ ...formatScmReadinessLines(scm),
371
417
  REARM_SKIPPED_FAT_PATH_MESSAGE,
372
418
  ];
373
419
  // Light branch-policy disclosure (local only) so re-arm still surfaces policy state.
@@ -484,6 +530,7 @@ function runSessionRearm(projectRoot, options, instant, environment) {
484
530
  diagnostic: userMd.diagnostic,
485
531
  },
486
532
  environment: environmentContextToDict(environment),
533
+ scm: scmReadinessToDict(scm),
487
534
  message: code === 0 ? "session ritual re-armed" : "session ritual re-arm failed",
488
535
  },
489
536
  lines,
@@ -594,6 +641,12 @@ export function runSessionStart(projectRoot, options = {}) {
594
641
  stepTimings.push({ name: "alignment", duration_ms: 0, skipped: true });
595
642
  }
596
643
  lines.push(formatEnvironmentContext(environment));
644
+ // #2275: SCM tooling + auth readiness — shallow on hot path, deep with --with-network.
645
+ // Never fails session:start; reports which SCM-dependent gates are skipped.
646
+ const scmStepStarted = performance.now();
647
+ const scm = resolveSessionScmReadiness(options, allowOptionalNetwork);
648
+ lines.push(...formatScmReadinessLines(scm));
649
+ stepTimings.push({ name: "scm_readiness", duration_ms: elapsedMs(scmStepStarted) });
597
650
  if (!quickSteps.branch_policy) {
598
651
  const stepStarted = performance.now();
599
652
  const result = resolvePolicy(projectRoot);
@@ -793,6 +846,7 @@ export function runSessionStart(projectRoot, options = {}) {
793
846
  diagnostic: userMd.diagnostic,
794
847
  },
795
848
  environment: environmentContextToDict(environment),
849
+ scm: scmReadinessToDict(scm),
796
850
  message: code === 0 ? "session ritual recorded" : "session ritual failed",
797
851
  };
798
852
  // #2994: local process-cost event (best-effort; never blocks ceremony).
@@ -3,5 +3,5 @@ export { type DefaultModeOptions, formatWelcomeCommand, normalizeTaskPrefix, run
3
3
  export { DEFAULT_ONBOARD_PRESET, type OnboardOptions, type OnboardOutcome, runOnboardMode, } from "./onboard.js";
4
4
  export { candidatesLogPath, classifyOnboarding, detectPriorState, type PriorState, pendingDecisionsNudgeLine, } from "./prior-state.js";
5
5
  export { computeSummary, emitOneliner, formatOneLiner, formatSummary, type SummaryResult, } from "./summary.js";
6
- export { appendAuditEntry, previewWipRelief, type ReliefPreview, subscriptionPreset, writeTriageScope, writeWipCap, } from "./writers.js";
6
+ export { appendAuditEntry, previewWipRelief, type ReliefPreview, subscriptionPreset, writeTriageScope, writeWipCap, writeWipCapDecision, } from "./writers.js";
7
7
  //# sourceMappingURL=index.d.ts.map
@@ -3,5 +3,5 @@ export { formatWelcomeCommand, normalizeTaskPrefix, runDefaultMode, } from "./de
3
3
  export { DEFAULT_ONBOARD_PRESET, runOnboardMode, } from "./onboard.js";
4
4
  export { candidatesLogPath, classifyOnboarding, detectPriorState, pendingDecisionsNudgeLine, } from "./prior-state.js";
5
5
  export { computeSummary, emitOneliner, formatOneLiner, formatSummary, } from "./summary.js";
6
- export { appendAuditEntry, previewWipRelief, subscriptionPreset, writeTriageScope, writeWipCap, } from "./writers.js";
6
+ export { appendAuditEntry, previewWipRelief, subscriptionPreset, writeTriageScope, writeWipCap, writeWipCapDecision, } from "./writers.js";
7
7
  //# sourceMappingURL=index.js.map
@@ -7,7 +7,7 @@ import { DEFAULT_WIP_CAP, SUBSCRIPTION_PRESETS, TRIAGE_SKILL_PATH } from "./cons
7
7
  import { formatWelcomeCommand } from "./default-mode.js";
8
8
  import { detectPriorState } from "./prior-state.js";
9
9
  import { emitOneliner } from "./summary.js";
10
- import { previewWipRelief, subscriptionPreset, writeTriageScope, writeWipCap } from "./writers.js";
10
+ import { previewWipRelief, subscriptionPreset, writeTriageScope, writeWipCap, writeWipCapDecision, } from "./writers.js";
11
11
  /** Default triage-scope preset applied when `--onboard` is run without `--preset`. */
12
12
  export const DEFAULT_ONBOARD_PRESET = "small";
13
13
  function failure(exitCode) {
@@ -66,8 +66,14 @@ export function runOnboardMode(projectRoot, options = {}) {
66
66
  const [triageScopeChanged] = writeTriageScope(projectRoot, rules, { presetLabel: preset });
67
67
  let wipCapChanged = false;
68
68
  if (wipCap !== null) {
69
+ // writeWipCap also records out-of-band decision-provenance (#1694).
69
70
  [wipCapChanged] = writeWipCap(projectRoot, wipCap);
70
71
  }
72
+ else {
73
+ // Accepting the framework default is a real onboarding decision: record it
74
+ // without materializing plan.policy.wipCap (#1694 / #1186 D1 / #1250).
75
+ writeWipCapDecision(projectRoot, { acceptedDefault: true });
76
+ }
71
77
  emitOneliner(projectRoot, {
72
78
  writeHistory: options.writeHistory !== false,
73
79
  now: options.now,
@@ -3,7 +3,14 @@ export interface PriorState {
3
3
  readonly triageScopeSummary: string;
4
4
  readonly cacheEmpty: boolean;
5
5
  readonly cacheEntryCount: number;
6
+ /** True when plan.policy.wipCap is materialized (deliberate non-default override). */
6
7
  readonly wipCapSet: boolean;
8
+ /**
9
+ * True when an out-of-band decision-provenance marker records that the
10
+ * operator considered WIP cap (#1694). Orthogonal to {@link wipCapSet}:
11
+ * accepting the framework default sets this without materializing the field.
12
+ */
13
+ readonly wipCapDecided: boolean;
7
14
  readonly wipCap: number;
8
15
  readonly wipCount: number;
9
16
  readonly auditLogPresent: boolean;
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { hasArtifactSuffix, resolveAuditPath, resolveLifecycleRoot, resolveProjectDefinitionPath, } from "../../layout/resolve.js";
4
- import { readPlanPolicy } from "../../policy/plan-extensions.js";
4
+ import { readPlanOnboarding, readPlanPolicy } from "../../policy/plan-extensions.js";
5
5
  import { resolveCandidatesLogPath } from "../cache-path.js";
6
6
  import { CACHE_DIR_NAME, CACHE_SOURCE, DEFAULT_WIP_CAP, SUBSCRIPTION_PRESETS, WIP_LIFECYCLE_DIRS, } from "./constants.js";
7
7
  function loadProjectDefinition(projectRoot) {
@@ -152,6 +152,17 @@ export function detectPriorState(projectRoot) {
152
152
  wipCapSet = true;
153
153
  }
154
154
  }
155
+ // Decision-provenance is out-of-band from the value field (#1694 / #1695).
156
+ // Accepting the framework default records wipCapDecided without materializing
157
+ // plan.policy.wipCap. A materialized override also counts as a decision.
158
+ const onboarding = readPlanOnboarding(plan);
159
+ let wipCapDecided = false;
160
+ if (typeof onboarding === "object" && onboarding !== null && !Array.isArray(onboarding)) {
161
+ wipCapDecided = onboarding.wipCapDecided === true;
162
+ }
163
+ if (wipCapSet) {
164
+ wipCapDecided = true;
165
+ }
155
166
  const [scopeSet, scopeLabel] = summarizeScope(scopeRules);
156
167
  const cacheCount = countCacheEntries(projectRoot);
157
168
  return {
@@ -160,6 +171,7 @@ export function detectPriorState(projectRoot) {
160
171
  cacheEmpty: cacheCount === 0,
161
172
  cacheEntryCount: cacheCount,
162
173
  wipCapSet,
174
+ wipCapDecided,
163
175
  wipCap,
164
176
  wipCount: countWip(projectRoot),
165
177
  auditLogPresent: existsSync(candidatesLogPath(projectRoot)),
@@ -167,10 +179,12 @@ export function detectPriorState(projectRoot) {
167
179
  };
168
180
  }
169
181
  export function classifyOnboarding(state) {
182
+ // wipCap onboarding completeness is decision-provenance, not field presence
183
+ // (#1694). Omission of plan.policy.wipCap with wipCapDecided=true is fully set up.
170
184
  const signals = {
171
185
  "candidates.jsonl": state.auditLogPresent,
172
186
  triageScope: state.triageScopeSet,
173
- wipCap: state.wipCapSet,
187
+ wipCap: state.wipCapDecided,
174
188
  };
175
189
  const present = Object.keys(signals).filter((k) => signals[k]);
176
190
  const missing = Object.keys(signals).filter((k) => !signals[k]);
@@ -3,6 +3,16 @@ export declare function writeTriageScope(projectRoot: string, rules: Array<Recor
3
3
  presetLabel: string;
4
4
  actor?: string;
5
5
  }): [boolean, string];
6
+ /**
7
+ * Record out-of-band WIP-cap decision provenance without materializing
8
+ * `plan.policy.wipCap` (#1694). Accepting the framework default is a real
9
+ * onboarding decision and MUST be representable without writing the value field.
10
+ */
11
+ export declare function writeWipCapDecision(projectRoot: string, options?: {
12
+ actor?: string;
13
+ acceptedDefault?: boolean;
14
+ value?: number | null;
15
+ }): [boolean, string];
6
16
  export declare function writeWipCap(projectRoot: string, wipCap: number, options?: {
7
17
  actor?: string;
8
18
  }): [boolean, string];
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, s
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { containedWrite } from "../../fs/contained-write.js";
4
4
  import { hasArtifactSuffix, resolveLifecycleFolder, resolveProjectDefinitionPath, } from "../../layout/resolve.js";
5
- import { migrateLegacyPolicyKey, PLAN_POLICY_KEY } from "../../policy/plan-extensions.js";
5
+ import { migrateLegacyPolicyKey, PLAN_ONBOARDING_KEY, PLAN_POLICY_KEY, } from "../../policy/plan-extensions.js";
6
6
  import { projectDefinitionMutationLock } from "../../vbrief-build/project-definition-io.js";
7
7
  import { AUDIT_LOG_REL_PATH, DEFAULT_RELIEF_AGE_DAYS, DEFAULT_WIP_CAP, SUBSCRIPTION_PRESETS, WELCOME_AUDIT_TAG, } from "./constants.js";
8
8
  function projectDefinitionPath(projectRoot) {
@@ -97,6 +97,57 @@ export function writeTriageScope(projectRoot, rules, options = { presetLabel: "c
97
97
  return [changed, auditEntry];
98
98
  });
99
99
  }
100
+ /**
101
+ * Record out-of-band WIP-cap decision provenance without materializing
102
+ * `plan.policy.wipCap` (#1694). Accepting the framework default is a real
103
+ * onboarding decision and MUST be representable without writing the value field.
104
+ */
105
+ export function writeWipCapDecision(projectRoot, options = {}) {
106
+ return projectDefinitionMutationLock(projectRoot, () => {
107
+ const path = projectDefinitionPath(projectRoot);
108
+ if (!existsSync(path))
109
+ throw new Error(`PROJECT-DEFINITION not found at ${path}`);
110
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
111
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
112
+ throw new Error(`PROJECT-DEFINITION at ${path} top-level value is not a JSON object`);
113
+ }
114
+ const data = parsed;
115
+ const plan = data.plan;
116
+ if (typeof plan !== "object" || plan === null || Array.isArray(plan)) {
117
+ throw new Error("PROJECT-DEFINITION 'plan' is not an object");
118
+ }
119
+ const planRec = plan;
120
+ const previous = planRec[PLAN_ONBOARDING_KEY];
121
+ const previousRec = typeof previous === "object" && previous !== null && !Array.isArray(previous)
122
+ ? previous
123
+ : {};
124
+ const alreadyDecided = previousRec.wipCapDecided === true;
125
+ const acceptedDefault = options.acceptedDefault ?? true;
126
+ const actor = options.actor ?? WELCOME_AUDIT_TAG;
127
+ // Rebuild cleanly so acceptedDefault=true never retains a prior custom value.
128
+ const next = {
129
+ wipCapDecided: true,
130
+ acceptedDefault,
131
+ decidedAt: utcIso(),
132
+ actor,
133
+ };
134
+ if (!acceptedDefault && options.value !== undefined && options.value !== null) {
135
+ next.value = options.value;
136
+ }
137
+ planRec[PLAN_ONBOARDING_KEY] = next;
138
+ atomicWrite(projectRoot, path, data);
139
+ const changed = !alreadyDecided || previousRec.acceptedDefault !== acceptedDefault;
140
+ const auditEntry = [
141
+ `actor=${actor}`,
142
+ `field=${PLAN_ONBOARDING_KEY}.wipCapDecided`,
143
+ "value=true",
144
+ `acceptedDefault=${acceptedDefault ? "true" : "false"}`,
145
+ `changed=${changed ? "true" : "false"}`,
146
+ ].join(" ");
147
+ appendAuditEntry(projectRoot, auditEntry);
148
+ return [changed, auditEntry];
149
+ });
150
+ }
100
151
  export function writeWipCap(projectRoot, wipCap, options = {}) {
101
152
  if (!Number.isInteger(wipCap) || wipCap < 1) {
102
153
  throw new Error(`wipCap must be a positive int, got ${JSON.stringify(wipCap)}`);
@@ -125,8 +176,27 @@ export function writeWipCap(projectRoot, wipCap, options = {}) {
125
176
  const policyRec = policy;
126
177
  const previous = policyRec.wipCap;
127
178
  const actor = options.actor ?? WELCOME_AUDIT_TAG;
179
+ // Always record decision-provenance out-of-band (#1694). Accepting the
180
+ // default omits the value field but still marks the decision complete.
181
+ // Rebuild the onboarding record (do not spread a prior custom `value` into
182
+ // an acceptedDefault=true payload -- that is contradictory provenance).
183
+ const acceptedDefault = wipCap === DEFAULT_WIP_CAP;
184
+ const onboardingRecord = {
185
+ wipCapDecided: true,
186
+ acceptedDefault,
187
+ decidedAt: utcIso(),
188
+ actor,
189
+ };
190
+ if (!acceptedDefault) {
191
+ onboardingRecord.value = wipCap;
192
+ }
193
+ planRec[PLAN_ONBOARDING_KEY] = onboardingRecord;
128
194
  if (previous === undefined && wipCap === DEFAULT_WIP_CAP) {
129
- return [false, ""];
195
+ atomicWrite(projectRoot, path, data);
196
+ const auditEntry = `actor=${actor} field=${PLAN_ONBOARDING_KEY}.wipCapDecided value=true ` +
197
+ `acceptedDefault=true changed=true note=default-no-materialize`;
198
+ appendAuditEntry(projectRoot, auditEntry);
199
+ return [true, auditEntry];
130
200
  }
131
201
  if (previous !== undefined && wipCap === DEFAULT_WIP_CAP) {
132
202
  delete policyRec.wipCap;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deftai/directive-core",
3
- "version": "0.90.0",
3
+ "version": "0.91.0",
4
4
  "description": "TypeScript engine core for the Directive framework.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -334,8 +334,8 @@
334
334
  "provenance": true
335
335
  },
336
336
  "dependencies": {
337
- "@deftai/directive-content": "^0.90.0",
338
- "@deftai/directive-types": "^0.90.0",
337
+ "@deftai/directive-content": "^0.91.0",
338
+ "@deftai/directive-types": "^0.91.0",
339
339
  "archiver": "^8.0.0"
340
340
  },
341
341
  "scripts": {