@deftai/directive-core 0.90.0 → 0.92.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 (101) hide show
  1. package/dist/cache/fetch.js +3 -2
  2. package/dist/cache/io.d.ts +13 -2
  3. package/dist/cache/io.js +36 -7
  4. package/dist/cache/operations.js +5 -3
  5. package/dist/doctor/main.js +37 -0
  6. package/dist/doctor/openclaw-skills.d.ts +42 -8
  7. package/dist/doctor/openclaw-skills.js +281 -38
  8. package/dist/eval/health.d.ts +10 -1
  9. package/dist/eval/health.js +16 -16
  10. package/dist/hooks/dispatcher.d.ts +6 -1
  11. package/dist/hooks/dispatcher.js +30 -0
  12. package/dist/init-deposit/gitignore.js +3 -0
  13. package/dist/init-deposit/hygiene.d.ts +43 -1
  14. package/dist/init-deposit/hygiene.js +112 -7
  15. package/dist/init-deposit/init-deposit.d.ts +5 -0
  16. package/dist/init-deposit/init-deposit.js +37 -2
  17. package/dist/init-deposit/scaffold.js +3 -1
  18. package/dist/intake/github-auth-modes.d.ts +1 -1
  19. package/dist/intake/github-auth-modes.js +25 -3
  20. package/dist/intake/issue-ingest-cli.js +13 -0
  21. package/dist/intake/reconcile-issues-cli.js +13 -0
  22. package/dist/orchestration/probe-session.d.ts +5 -1
  23. package/dist/orchestration/probe-session.js +24 -13
  24. package/dist/platform/agents-md.js +1 -1
  25. package/dist/policy/deft-directive-disable.d.ts +86 -0
  26. package/dist/policy/deft-directive-disable.js +167 -0
  27. package/dist/policy/delivery-branch.d.ts +33 -0
  28. package/dist/policy/delivery-branch.js +124 -0
  29. package/dist/policy/index.d.ts +2 -0
  30. package/dist/policy/index.js +17 -1
  31. package/dist/policy/plan-extensions.d.ts +10 -0
  32. package/dist/policy/plan-extensions.js +16 -0
  33. package/dist/pr-closing-keywords/detect.d.ts +13 -0
  34. package/dist/pr-closing-keywords/detect.js +73 -8
  35. package/dist/pr-closing-keywords/index.d.ts +2 -2
  36. package/dist/pr-closing-keywords/index.js +1 -1
  37. package/dist/pr-closing-keywords/main.js +119 -90
  38. package/dist/pr-closing-keywords/types.d.ts +10 -0
  39. package/dist/render/constants.d.ts +16 -0
  40. package/dist/render/constants.js +16 -1
  41. package/dist/render/export-spec.d.ts +7 -1
  42. package/dist/render/export-spec.js +51 -5
  43. package/dist/render/index.d.ts +3 -2
  44. package/dist/render/index.js +2 -2
  45. package/dist/render/roadmap-render.js +164 -60
  46. package/dist/render/scope-outlook.d.ts +5 -0
  47. package/dist/render/scope-outlook.js +3 -0
  48. package/dist/render/spec-render.d.ts +28 -2
  49. package/dist/render/spec-render.js +111 -14
  50. package/dist/render/text-utils.d.ts +6 -0
  51. package/dist/render/text-utils.js +23 -0
  52. package/dist/scm/binary.d.ts +3 -0
  53. package/dist/scm/binary.js +10 -2
  54. package/dist/scm/call.d.ts +5 -0
  55. package/dist/scm/call.js +5 -0
  56. package/dist/scm/index.d.ts +1 -0
  57. package/dist/scm/index.js +1 -0
  58. package/dist/scm/main.d.ts +5 -0
  59. package/dist/scm/main.js +27 -0
  60. package/dist/scm/readiness-cli.d.ts +24 -0
  61. package/dist/scm/readiness-cli.js +85 -0
  62. package/dist/scm/readiness.d.ts +104 -0
  63. package/dist/scm/readiness.js +421 -0
  64. package/dist/scope/batch-promote.d.ts +26 -0
  65. package/dist/scope/batch-promote.js +167 -0
  66. package/dist/scope/brief-io.d.ts +3 -1
  67. package/dist/scope/brief-io.js +5 -3
  68. package/dist/scope/delivery-evidence.d.ts +112 -0
  69. package/dist/scope/delivery-evidence.js +419 -0
  70. package/dist/scope/index.d.ts +2 -0
  71. package/dist/scope/index.js +2 -0
  72. package/dist/scope/main.d.ts +7 -0
  73. package/dist/scope/main.js +155 -9
  74. package/dist/scope/registry-artifact-sync.js +4 -1
  75. package/dist/scope/transition.d.ts +11 -1
  76. package/dist/scope/transition.js +30 -4
  77. package/dist/scope/wip-cap-check.d.ts +6 -0
  78. package/dist/scope/wip-cap-check.js +14 -2
  79. package/dist/session/ritual-sentinel.js +21 -12
  80. package/dist/session/session-start-hook.d.ts +3 -0
  81. package/dist/session/session-start-hook.js +21 -0
  82. package/dist/session/session-start.d.ts +7 -0
  83. package/dist/session/session-start.js +85 -0
  84. package/dist/swarm/complete-cohort.d.ts +20 -1
  85. package/dist/swarm/complete-cohort.js +56 -9
  86. package/dist/swarm/finalize-cohort-cli.js +9 -0
  87. package/dist/swarm/finalize-cohort.d.ts +8 -0
  88. package/dist/swarm/finalize-cohort.js +217 -21
  89. package/dist/triage/welcome/index.d.ts +1 -1
  90. package/dist/triage/welcome/index.js +1 -1
  91. package/dist/triage/welcome/onboard.js +7 -1
  92. package/dist/triage/welcome/prior-state.d.ts +7 -0
  93. package/dist/triage/welcome/prior-state.js +16 -2
  94. package/dist/triage/welcome/writers.d.ts +10 -0
  95. package/dist/triage/welcome/writers.js +72 -2
  96. package/dist/user-config/experimental-rules.d.ts +43 -0
  97. package/dist/user-config/experimental-rules.js +162 -0
  98. package/dist/user-config/index.d.ts +1 -0
  99. package/dist/user-config/index.js +1 -0
  100. package/dist/verify-source/contained-writes.js +1 -1
  101. package/package.json +3 -3
@@ -2,7 +2,13 @@
2
2
  * Scoped staging + installer-managed allowlist for TS-native init/update (#1453).
3
3
  *
4
4
  * Mirrors cmd/deft-install/hygiene.go + deposit.go installerManagedMatchers.
5
- * Refs #1576, #1453, #1430.
5
+ *
6
+ * CRITICAL (#1430 / #3030): the allowlist MUST honor the SPEC consumer-path
7
+ * denylist (`CONSUMER_GUARD_MUST_FIRE`). Consumer-authored PROJECT-DEFINITION
8
+ * and scope briefs are never installer-managed; if they reappear in
9
+ * `installerManagedMatchers()`, unit tests and deposit-time assert fail closed.
10
+ *
11
+ * Refs #1576, #1453, #1430, #3029, #3030.
6
12
  */
7
13
  import { execFileSync } from "node:child_process";
8
14
  import { existsSync, readdirSync, rmSync } from "node:fs";
@@ -15,6 +21,26 @@ export const CORE_GUARD_WORKFLOW_REL = ".github/workflows/deft-core-guard.yml";
15
21
  // The lifecycle dir names are identical across the legacy `vbrief/` tree and the
16
22
  // post-#2034 / #2110 `xbrief/` tree, so both allowlist families reuse this list.
17
23
  const VBRIEF_LIFECYCLE_DIRS = ["proposed", "pending", "active", "completed", "cancelled"];
24
+ /**
25
+ * Consumer paths that MUST trip no-mixed-core-and-app when mixed with
26
+ * `.deft/core/**` (#1430 SPEC). These probe paths must never match
27
+ * `installerManagedMatchers()` / the deposited guard ERE.
28
+ *
29
+ * Legitimate installer scaffolding (xbrief/.deft-version, lifecycle .gitkeep,
30
+ * schemas/, migration/, xbrief.md) is NOT in this denylist — see #2277.
31
+ * Init may still *create* PROJECT-DEFINITION (#3013); create ≠ allowlist.
32
+ *
33
+ * Refs #3030, #3029, #1430.
34
+ */
35
+ export const CONSUMER_GUARD_MUST_FIRE = [
36
+ "xbrief/PROJECT-DEFINITION.xbrief.json",
37
+ "vbrief/PROJECT-DEFINITION.vbrief.json",
38
+ // Representative consumer scope briefs (not scaffolding markers).
39
+ "xbrief/active/example-scope.xbrief.json",
40
+ "vbrief/active/example-scope.vbrief.json",
41
+ "xbrief/proposed/example-scope.xbrief.json",
42
+ "vbrief/pending/example-scope.vbrief.json",
43
+ ];
18
44
  /** Single source of truth for installer-managed paths (#1440 / #1576). */
19
45
  export function installerManagedMatchers() {
20
46
  return [
@@ -45,6 +71,10 @@ export function installerManagedMatchers() {
45
71
  // `deft update` framework-deposit PR trips no-mixed-core-and-app (#2277).
46
72
  { exact: "xbrief/.deft-version" },
47
73
  { exact: "xbrief/xbrief.md" },
74
+ // CRITICAL (#1430 / #3029 / #3030): do NOT allowlist consumer-authored
75
+ // PROJECT-DEFINITION (xbrief/ or vbrief/) or consumer scope briefs.
76
+ // Init may still seed PD (#3013); the seed is app-owned for guard classification
77
+ // so core+PD mixed PRs fail no-mixed-core-and-app. See CONSUMER_GUARD_MUST_FIRE.
48
78
  { prefix: "xbrief/schemas/" },
49
79
  { prefix: "xbrief/migration/" },
50
80
  ...VBRIEF_LIFECYCLE_DIRS.map((sub) => ({ exact: `xbrief/${sub}/.gitkeep` })),
@@ -58,12 +88,6 @@ function matcherToEre(matcher) {
58
88
  return `^${escapeEre(matcher.exact)}$`;
59
89
  return `^${escapeEre(matcher.prefix ?? "")}`;
60
90
  }
61
- /** POSIX ERE alternation embedded in the deposited deft-core-guard workflow. */
62
- export function installerManagedGuardEre() {
63
- return installerManagedMatchers()
64
- .map((matcher) => matcherToEre(matcher))
65
- .join("|");
66
- }
67
91
  function matchesInstallerManaged(path, matchers) {
68
92
  for (const matcher of matchers) {
69
93
  if (matcher.exact && path === matcher.exact)
@@ -73,9 +97,90 @@ function matchesInstallerManaged(path, matchers) {
73
97
  }
74
98
  return false;
75
99
  }
100
+ /** Consumer scope-brief filenames under lifecycle dirs (not scaffolding). */
101
+ const CONSUMER_SCOPE_BRIEF_EXACT = /^(xbrief|vbrief)\/(proposed|pending|active|completed|cancelled)\/.+\.(x|v)brief\.json$/;
102
+ /** PROJECT-DEFINITION exact paths (xbrief or legacy vbrief). */
103
+ const CONSUMER_PROJECT_DEFINITION_EXACT = /^(xbrief|vbrief)\/PROJECT-DEFINITION\.(x|v)brief\.json$/;
104
+ /**
105
+ * Prefixes that would exempt entire consumer lifecycle trees or the whole
106
+ * xbrief/vbrief tree — never installer-managed (#1430).
107
+ */
108
+ const FORBIDDEN_CONSUMER_PREFIXES = new Set([
109
+ "xbrief/",
110
+ "vbrief/",
111
+ ...VBRIEF_LIFECYCLE_DIRS.flatMap((sub) => [`xbrief/${sub}/`, `vbrief/${sub}/`]),
112
+ ]);
113
+ /**
114
+ * Fail closed when the installer-managed allowlist would exempt consumer
115
+ * PROJECT-DEFINITION or scope briefs (#3030 / #1430). Pure over `matchers` so
116
+ * tests can inject a bad matcher without mutating production state.
117
+ *
118
+ * Checks both:
119
+ * 1. Explicit probe paths in {@link CONSUMER_GUARD_MUST_FIRE}
120
+ * 2. Structural patterns (any exact consumer scope brief / PD; forbidden prefixes)
121
+ * so an unlisted `xbrief/active/another-scope.xbrief.json` matcher still fails.
122
+ */
123
+ export function assertInstallerAllowlistHonors1430(matchers = installerManagedMatchers()) {
124
+ for (const path of CONSUMER_GUARD_MUST_FIRE) {
125
+ if (matchesInstallerManaged(path, matchers)) {
126
+ throw new Error(`#1430 violation: ${path} must not be installer-managed (SPEC consumer denylist; see CONSUMER_GUARD_MUST_FIRE / #3030)`);
127
+ }
128
+ }
129
+ for (const matcher of matchers) {
130
+ if (matcher.exact) {
131
+ if (CONSUMER_PROJECT_DEFINITION_EXACT.test(matcher.exact) ||
132
+ CONSUMER_SCOPE_BRIEF_EXACT.test(matcher.exact)) {
133
+ throw new Error(`#1430 violation: exact matcher ${matcher.exact} must not be installer-managed (consumer brief denylist / #3030)`);
134
+ }
135
+ }
136
+ if (matcher.prefix) {
137
+ const normalized = matcher.prefix.endsWith("/") ? matcher.prefix : `${matcher.prefix}/`;
138
+ if (FORBIDDEN_CONSUMER_PREFIXES.has(normalized)) {
139
+ throw new Error(`#1430 violation: prefix matcher ${matcher.prefix} must not cover consumer brief trees (#3030)`);
140
+ }
141
+ }
142
+ }
143
+ }
144
+ /** POSIX ERE alternation embedded in the deposited deft-core-guard workflow. */
145
+ export function installerManagedGuardEre() {
146
+ const matchers = installerManagedMatchers();
147
+ // Refuse to emit a guard workflow that would exempt consumer denylist paths.
148
+ assertInstallerAllowlistHonors1430(matchers);
149
+ return matchers.map((matcher) => matcherToEre(matcher)).join("|");
150
+ }
76
151
  export function isInstallerManagedPath(path) {
77
152
  return matchesInstallerManaged(path, installerManagedMatchers());
78
153
  }
154
+ /**
155
+ * TS twin of Go `classifyChangedPaths` / deposited shell guard (#1430).
156
+ * Core = `.deft/core/**`; installer-managed = allowlist; app = everything else.
157
+ * Guard fails iff both core and app are non-empty.
158
+ */
159
+ export function classifyMixedCoreAndApp(changedPaths, matchers = installerManagedMatchers()) {
160
+ const core = [];
161
+ const installerManaged = [];
162
+ const app = [];
163
+ for (const raw of changedPaths) {
164
+ const path = raw.replace(/\\/g, "/");
165
+ if (!path)
166
+ continue;
167
+ if (path === ".deft/core" || path.startsWith(".deft/core/")) {
168
+ core.push(path);
169
+ }
170
+ else if (matchesInstallerManaged(path, matchers)) {
171
+ installerManaged.push(path);
172
+ }
173
+ else {
174
+ app.push(path);
175
+ }
176
+ }
177
+ return {
178
+ core,
179
+ installerManaged,
180
+ app,
181
+ wouldFail: core.length > 0 && app.length > 0,
182
+ };
183
+ }
79
184
  export function frameworkStagePaths(projectDir, deftDir, options = {}) {
80
185
  const paths = [];
81
186
  const seen = new Set();
@@ -34,6 +34,11 @@ export declare function userConfigDir(): string;
34
34
  export declare function createUserConfigDir(io: InitDepositIo): string;
35
35
  export declare function buildInstallSummaryJson(result: InitDepositResult, options: InitDepositArgs): Record<string, unknown>;
36
36
  export declare function printNextSteps(result: InitDepositResult, io: InitDepositIo): void;
37
+ /**
38
+ * Ensure a minimal render-ready PROJECT-DEFINITION exists after lifecycle dirs
39
+ * are deposited (#3013 / epic #3009). Idempotent: never overwrites existing identity.
40
+ */
41
+ export declare function seedMinimalProjectDefinition(projectDir: string, io: InitDepositIo): boolean;
37
42
  export declare function runInitDeposit(args: InitDepositArgs, io: InitDepositIo, seams?: InitDepositSeams): Promise<InitDepositResult>;
38
43
  export interface RunInitDepositCliOptions extends InitDepositArgs {
39
44
  readonly writeOut: (text: string) => void;
@@ -7,12 +7,14 @@
7
7
  */
8
8
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
9
9
  import { homedir, platform } from "node:os";
10
- import { join, resolve } from "node:path";
10
+ import { basename, join, resolve } from "node:path";
11
11
  import { assertDepositContained } from "../deposit/contain.js";
12
12
  import { copyTree } from "../deposit/copy-tree.js";
13
13
  import { prunePythonArtifactsFromDeposit } from "../deposit/python-free.js";
14
14
  import { resolveInstalledContentRoot } from "../deposit/resolve-content.js";
15
15
  import { readCorePackageVersion } from "../engine-version.js";
16
+ import { renderProjectDefinition } from "../render/project-render.js";
17
+ import { removeStaleMigratedFrameworkNarrative } from "../xbrief-migrate/migrate-project.js";
16
18
  import { writeAgentHookDeposit } from "./agent-hooks.js";
17
19
  import { ensureInitGitignoreLines, reconstituteDepositFromContent } from "./gitignore.js";
18
20
  import { depositStagePaths, printCommitGuidance } from "./hygiene.js";
@@ -20,6 +22,7 @@ import { buildLegacyRefusalJson, buildLegacyRefusalMessage, detectLegacyLayout,
20
22
  import { printMigrateNudgeIfNeeded } from "./migrate.js";
21
23
  import { ensurePrettierIgnoreLines } from "./prettierignore.js";
22
24
  import { CANONICAL_INSTALL_ROOT, depositNeutralization, ensureTaskfile, writeAgentsMd, writeAgentsSkills, writeConsumerGitHooks, writeConsumerVbrief, writeInstallManifest, } from "./scaffold.js";
25
+ import { syncBareVersionMarker } from "./xbrief-projections.js";
23
26
  export function parseInitArgv(canonicalArgv, userArgv = []) {
24
27
  const args = [...canonicalArgv, ...userArgv];
25
28
  let projectDir = process.cwd();
@@ -113,10 +116,36 @@ export function printNextSteps(result, io) {
113
116
  io.printf(` 1. Open your AI coding assistant in ${result.projectDir}\n`);
114
117
  io.printf(" 2. Deft skill auto-discovery is partially implemented — if your agent doesn't\n");
115
118
  io.printf(' start setup automatically, tell it: "Use AGENTS.md"\n');
116
- io.printf(" 3. On first session, the agent will guide you through creating USER.md and PROJECT-DEFINITION.vbrief.json\n");
119
+ io.printf(" 3. On first session, the agent will guide you through USER.md (if missing).\n");
120
+ io.printf(" 4. PROJECT-DEFINITION is seeded at init (#3013) — run `task project:render` once to refresh items from lifecycle folders; do not multi-turn invent project identity.\n");
117
121
  printMigrateNudgeIfNeeded(result.projectDir, io);
118
122
  io.printf("\n");
119
123
  }
124
+ /**
125
+ * Ensure a minimal render-ready PROJECT-DEFINITION exists after lifecycle dirs
126
+ * are deposited (#3013 / epic #3009). Idempotent: never overwrites existing identity.
127
+ */
128
+ export function seedMinimalProjectDefinition(projectDir, io) {
129
+ const xbriefDir = join(projectDir, "xbrief");
130
+ const legacyDir = join(projectDir, "vbrief");
131
+ const root = existsSync(xbriefDir) ? xbriefDir : existsSync(legacyDir) ? legacyDir : null;
132
+ if (root === null) {
133
+ return false;
134
+ }
135
+ const isXbrief = basename(root) === "xbrief";
136
+ const projectDefPath = join(root, isXbrief ? "PROJECT-DEFINITION.xbrief.json" : "PROJECT-DEFINITION.vbrief.json");
137
+ if (existsSync(projectDefPath)) {
138
+ io.printf("PROJECT-DEFINITION already present — leaving identity intact (#3013).\n");
139
+ return false;
140
+ }
141
+ const [ok, message] = renderProjectDefinition(root);
142
+ if (ok) {
143
+ io.printf(`${message} (minimal seed for one-shot project:render; #3013)\n`);
144
+ return true;
145
+ }
146
+ io.printf(`PROJECT-DEFINITION seed skipped: ${message}\n`);
147
+ return false;
148
+ }
120
149
  export async function runInitDeposit(args, io, seams = {}) {
121
150
  const projectDir = args.projectDir;
122
151
  const deftDir = join(projectDir, CANONICAL_INSTALL_ROOT);
@@ -154,6 +183,12 @@ export async function runInitDeposit(args, io, seams = {}) {
154
183
  const skillsCreated = writeAgentsSkills(projectDir, io);
155
184
  await depositNeutralization(projectDir, io);
156
185
  await writeConsumerVbrief(projectDir, deftDir, io);
186
+ seedMinimalProjectDefinition(projectDir, io);
187
+ // Seeding PROJECT-DEFINITION.xbrief.json makes resolveLifecycleRoot succeed.
188
+ // Align the same consumer derivatives update repairs (#2595 / #2806) so
189
+ // init + git add -A is already clean under core.autocrlf=true (#2118 / #3013).
190
+ syncBareVersionMarker(projectDir, version);
191
+ removeStaleMigratedFrameworkNarrative(projectDir);
157
192
  writeConsumerGitHooks(projectDir, deftDir, io, seams.gitHooks);
158
193
  writeAgentHookDeposit(projectDir, io);
159
194
  let taskfileWired = false;
@@ -14,7 +14,7 @@ import { assertDestinationNotSymlink, ProjectionContainmentError, } from "../fs/
14
14
  import { agentsRefreshPlan } from "../platform/agents-md.js";
15
15
  import { MIGRATED_ARTIFACT_DIR } from "../xbrief-migrate/constants.js";
16
16
  import { CANONICAL_INSTALL_ROOT } from "./constants.js";
17
- import { installerManagedGuardEre } from "./hygiene.js";
17
+ import { assertInstallerAllowlistHonors1430, installerManagedGuardEre } from "./hygiene.js";
18
18
  import { syncConsumerXbriefSchemas } from "./xbrief-projections.js";
19
19
  export { CANONICAL_INSTALL_ROOT };
20
20
  export const CORE_GLOB = ".deft/core/**";
@@ -632,6 +632,8 @@ export function mergeCoreGuardWorkflowRefresh(existing, desired) {
632
632
  return desired.slice(0, idx) + existingCheckout + desired.slice(idx + desiredCheckout.length);
633
633
  }
634
634
  function coreGuardWorkflowContent() {
635
+ // Fail closed before emitting guard ERE if allowlist violates #1430 denylist (#3030).
636
+ assertInstallerAllowlistHonors1430();
635
637
  const baseSha = githubActionsExpr("github.event.pull_request.base.sha");
636
638
  const headSha = githubActionsExpr("github.event.pull_request.head.sha");
637
639
  return ("name: deft-core-guard\n\n" +
@@ -1,4 +1,4 @@
1
- import { type CompletedProcess } from "../scm/call.js";
1
+ import type { CompletedProcess } from "../scm/call.js";
2
2
  import { probeRuntimeCapabilities, type RuntimeCapabilityReport } from "./platform-capabilities.js";
3
3
  export declare const GITHUB_AUTH_MODE_INJECTED_TOKEN = "injected-token";
4
4
  export declare const GITHUB_AUTH_MODE_HOST_GH = "host-gh";
@@ -1,4 +1,4 @@
1
- import { call } from "../scm/call.js";
1
+ import { spawnSync } from "node:child_process";
2
2
  import { pyRepr } from "../scm/py-format.js";
3
3
  import { getPlatformCapabilities, probeRuntimeCapabilities, RUNTIME_MODE_CLOUD_HEADLESS, RUNTIME_MODE_CURSOR_NATIVE_SANDBOX, } from "./platform-capabilities.js";
4
4
  export const GITHUB_AUTH_MODE_INJECTED_TOKEN = "injected-token";
@@ -37,9 +37,31 @@ export function inferGithubAuthMode(runtimeReport) {
37
37
  }
38
38
  return GITHUB_AUTH_MODE_HOST_GH;
39
39
  }
40
+ /**
41
+ * Prefer live `gh` for auth/API validation. Do not route through scm.call /
42
+ * BINARY_PREFERENCE (ghx-first): ghx is a cached GET proxy and rejects multi-arg
43
+ * `api user --jq .login` forms used here (#2275 Greptile P1 / #954).
44
+ */
40
45
  function defaultRunGh(args, environ) {
41
- const verb = args[0];
42
- return call("github-issue", verb, args.slice(1), { env: environ, timeout: 30 });
46
+ const binary = "gh";
47
+ try {
48
+ const result = spawnSync(binary, [...args], {
49
+ env: environ,
50
+ encoding: "utf8",
51
+ timeout: 30_000,
52
+ stdio: ["ignore", "pipe", "pipe"],
53
+ });
54
+ return {
55
+ args: [binary, ...args],
56
+ returncode: result.status ?? 1,
57
+ stdout: typeof result.stdout === "string" ? result.stdout : "",
58
+ stderr: typeof result.stderr === "string" ? result.stderr : "",
59
+ };
60
+ }
61
+ catch (err) {
62
+ const message = err instanceof Error ? err.message : String(err);
63
+ return { args: [binary, ...args], returncode: 1, stdout: "", stderr: message };
64
+ }
43
65
  }
44
66
  function splitRepo(repo) {
45
67
  const idx = repo.indexOf("/");
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { fileURLToPath } from "node:url";
3
+ import { ScmStubError } from "../scm/errors.js";
4
+ import { requireScmReady } from "../scm/readiness.js";
3
5
  import { issueIngestMain } from "./issue-ingest.js";
4
6
  function parseArgs(argv) {
5
7
  const out = {};
@@ -25,6 +27,17 @@ function parseArgs(argv) {
25
27
  return out;
26
28
  }
27
29
  export function mainEntry(argv = process.argv.slice(2)) {
30
+ // #2275: fail loud when gh/auth is missing in this execution env.
31
+ try {
32
+ requireScmReady();
33
+ }
34
+ catch (err) {
35
+ if (err instanceof ScmStubError) {
36
+ process.stderr.write(`error: ${err.message}\n`);
37
+ return 2;
38
+ }
39
+ throw err;
40
+ }
28
41
  return issueIngestMain(parseArgs(argv));
29
42
  }
30
43
  if (process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1]) {
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { fileURLToPath } from "node:url";
3
+ import { ScmStubError } from "../scm/errors.js";
4
+ import { requireScmReady } from "../scm/readiness.js";
3
5
  import { reconcileMain } from "./reconcile-issues.js";
4
6
  function parseArgs(argv) {
5
7
  const out = {};
@@ -23,6 +25,17 @@ function parseArgs(argv) {
23
25
  return out;
24
26
  }
25
27
  export function mainEntry(argv = process.argv.slice(2)) {
28
+ // #2275: fail loud when gh/auth is missing in this execution env.
29
+ try {
30
+ requireScmReady();
31
+ }
32
+ catch (err) {
33
+ if (err instanceof ScmStubError) {
34
+ process.stderr.write(`error: ${err.message}\n`);
35
+ return 2;
36
+ }
37
+ throw err;
38
+ }
26
39
  return reconcileMain(parseArgs(argv));
27
40
  }
28
41
  if (process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1]) {
@@ -36,7 +36,11 @@ export declare function detectGitBranch(projectRoot: string, execGit?: (args: st
36
36
  }): string;
37
37
  /** Read probe session from project root. */
38
38
  export declare function readSession(projectRoot: string): ProbeSession | null;
39
- /** Atomically persist session to .deft/probe-session.json. */
39
+ /**
40
+ * Atomically persist session to .deft/probe-session.json.
41
+ * #3042: contain against projectRoot; refuse escaping `.deft` symlink parents
42
+ * (no bare open/write/rename).
43
+ */
40
44
  export declare function writeSession(projectRoot: string, session: ProbeSession): string;
41
45
  export declare function startSession(projectRoot: string, options: {
42
46
  target: string;
@@ -3,8 +3,10 @@
3
3
  */
4
4
  import { execFileSync } from "node:child_process";
5
5
  import { randomBytes } from "node:crypto";
6
- import { closeSync, existsSync, fdatasyncSync, mkdirSync, openSync, readFileSync, renameSync, writeSync, } from "node:fs";
6
+ import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
7
7
  import { join, resolve } from "node:path";
8
+ import { containedWrite } from "../fs/contained-write.js";
9
+ import { assertWriteTargetSafe } from "../fs/projection-containment.js";
8
10
  export const SCHEMA_VERSION = 1;
9
11
  export const SESSION_RELPATH = [".deft", "probe-session.json"];
10
12
  export const STATE_INTERROGATE = "interrogate";
@@ -188,11 +190,16 @@ export function readSession(projectRoot) {
188
190
  completed_at: completedAt,
189
191
  };
190
192
  }
191
- /** Atomically persist session to .deft/probe-session.json. */
193
+ /**
194
+ * Atomically persist session to .deft/probe-session.json.
195
+ * #3042: contain against projectRoot; refuse escaping `.deft` symlink parents
196
+ * (no bare open/write/rename).
197
+ */
192
198
  export function writeSession(projectRoot, session) {
193
- const sessionFile = sessionPath(projectRoot);
194
- mkdirSync(join(projectRoot, ".deft"), { recursive: true });
195
- const tmpName = join(projectRoot, ".deft", `.probe-session.${randomBytes(8).toString("hex")}.json.tmp`);
199
+ const root = resolve(projectRoot);
200
+ const sessionFile = sessionPath(root);
201
+ assertWriteTargetSafe(root, sessionFile);
202
+ const tmpName = join(root, ".deft", `.probe-session.${randomBytes(8).toString("hex")}.json.tmp`);
196
203
  const sortedPayload = sessionToDict(session);
197
204
  const sortedKeys = Object.keys(sortedPayload).sort();
198
205
  const sortedObj = {};
@@ -200,20 +207,24 @@ export function writeSession(projectRoot, session) {
200
207
  sortedObj[k] = sortedPayload[k];
201
208
  }
202
209
  const finalContent = `${JSON.stringify(sortedObj, null, 2)}\n`;
203
- const fd = openSync(tmpName, "w");
204
210
  try {
205
- writeSync(fd, finalContent, undefined, "utf8");
211
+ containedWrite({
212
+ root,
213
+ target: tmpName,
214
+ data: finalContent,
215
+ mode: "create",
216
+ });
217
+ renameSync(tmpName, sessionFile);
218
+ }
219
+ catch (err) {
206
220
  try {
207
- fdatasyncSync(fd);
221
+ rmSync(tmpName, { force: true });
208
222
  }
209
223
  catch {
210
- // best effort
224
+ /* best-effort cleanup */
211
225
  }
226
+ throw err;
212
227
  }
213
- finally {
214
- closeSync(fd);
215
- }
216
- renameSync(tmpName, sessionFile);
217
228
  return sessionFile;
218
229
  }
219
230
  export function startSession(projectRoot, options) {
@@ -324,7 +324,7 @@ export function applyAgentsRefresh(projectRoot, options = {}, seams = {}, lockDe
324
324
  const writable = AGENTS_REFRESH_WRITABLE_STATES.has(state) && typeof newContent === "string";
325
325
  return { state, path, wrote: false, writable };
326
326
  }
327
- atomicWriteText(path, newContent);
327
+ atomicWriteText(path, newContent, { projectRoot });
328
328
  return { state, path, wrote: true, writable: true };
329
329
  }, lockDeps);
330
330
  }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Temporary test/local kill-switch for Directive enforcement (#3039).
3
+ *
4
+ * Presence of root `.deft-directive-disable` means Directive **enforcement** is
5
+ * OFF (hooks, session ritual, automation) while the deposit may remain — **only
6
+ * when the file is not tracked by git**. A committed flag is misconfig: doctor
7
+ * warns and enforcement stays ON (repository-controlled content must not disable
8
+ * hooks for downstream clones).
9
+ *
10
+ * Distinct from `.no-deft-directive` (#2926 permanent opt-out): flag+deposit is
11
+ * **not** inconsistent here.
12
+ *
13
+ * Product choices (v1):
14
+ * - Flag is **root-only** (workspace root the tool opened).
15
+ * - Flag **must be gitignored** (canonical baseline); committed flag → doctor
16
+ * warns and does **not** short-circuit enforcement.
17
+ * - Deposit presence is **OK**.
18
+ * - Full re-enable requires: delete the file **and** start a **new** agent session.
19
+ * - Precedence: active kill-switch first, then `.no-deft-directive`, else normal.
20
+ */
21
+ /** Canonical root-only filename (lowercase). Presence = candidate flag. */
22
+ export declare const DEFT_DIRECTIVE_DISABLE_FLAG_NAME = ".deft-directive-disable";
23
+ /** Gitignore entry the deposit must ensure (same as flag name). */
24
+ export declare const DEFT_DIRECTIVE_DISABLE_GITIGNORE_LINE = ".deft-directive-disable";
25
+ /**
26
+ * Canonical recovery message when Directive is disabled by the test kill-switch.
27
+ * Surfaces (doctor, agent, CLI, hooks) share this wording.
28
+ */
29
+ export declare const DEFT_DIRECTIVE_DISABLE_RECOVERY_MESSAGE: string;
30
+ /** One-line summary for log lines and short CLI output. */
31
+ export declare const DEFT_DIRECTIVE_DISABLE_ONE_LINE = "Directive disabled via `.deft-directive-disable` (test/local kill-switch; deposit OK)";
32
+ /** Doctor/status label when the kill-switch is active (local / untracked). */
33
+ export declare const DEFT_DIRECTIVE_DISABLE_STATUS: "disabled-test-kill-switch";
34
+ /**
35
+ * Warning when the kill-switch file is tracked by git (should be gitignored).
36
+ * Tracked flags do **not** disable enforcement.
37
+ */
38
+ export declare const DEFT_DIRECTIVE_DISABLE_TRACKED_WARNING = "Misconfig: `.deft-directive-disable` is tracked by git. The flag must be gitignored (local kill-switch only). Untrack it and ensure `.gitignore` covers the path. Enforcement is NOT disabled while the flag is tracked.";
39
+ export interface DeftDirectiveDisableSeams {
40
+ readonly isFile?: (path: string) => boolean;
41
+ readonly isDir?: (path: string) => boolean;
42
+ /** Return true when `git ls-files` lists the flag (tracked). */
43
+ readonly isGitTracked?: (projectRoot: string, relPath: string) => boolean;
44
+ /** Skip tracked probe + cache (tests / doctor full re-check). */
45
+ readonly skipTrackedCache?: boolean;
46
+ }
47
+ export interface DeftDirectiveDisableState {
48
+ readonly present: boolean;
49
+ readonly flagPath: string;
50
+ readonly depositPresent: boolean;
51
+ /**
52
+ * True when the flag file is tracked by git (misconfig — should be gitignored).
53
+ * Always false when the flag is absent or when the probe cannot run.
54
+ */
55
+ readonly trackedByGit: boolean;
56
+ /**
57
+ * True when the kill-switch is **active** for enforcement short-circuit:
58
+ * present and **not** tracked by git.
59
+ */
60
+ readonly active: boolean;
61
+ }
62
+ /** Absolute path to the root kill-switch flag file. */
63
+ export declare function deftDirectiveDisableFlagPath(projectRoot: string): string;
64
+ /**
65
+ * Detect root `.deft-directive-disable`. Presence is file-existence only
66
+ * (empty or short comment OK). Deposit presence is reported but never treated
67
+ * as inconsistent (#3039 vs #2926).
68
+ *
69
+ * Enforcement short-circuit uses `state.active` (present && !trackedByGit).
70
+ */
71
+ export declare function detectDeftDirectiveDisable(projectRoot: string, seams?: DeftDirectiveDisableSeams): DeftDirectiveDisableState;
72
+ /**
73
+ * True when the local (untracked) kill-switch is active for enforcement
74
+ * short-circuit. Tracked flags return false so hooks stay enforced.
75
+ */
76
+ export declare function isDeftDirectiveDisableActive(projectRoot: string, seams?: DeftDirectiveDisableSeams): boolean;
77
+ /**
78
+ * Full operator-facing message for the kill-switch, optionally combined with
79
+ * permanent opt-out when both flags are present.
80
+ */
81
+ export declare function formatDeftDirectiveDisableMessage(options?: {
82
+ readonly permanentOptOutAlsoPresent?: boolean;
83
+ readonly trackedByGit?: boolean;
84
+ readonly oneLine?: boolean;
85
+ }): string;
86
+ //# sourceMappingURL=deft-directive-disable.d.ts.map