@deftai/directive-core 0.79.0 → 0.79.2

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 (43) hide show
  1. package/dist/cache/fetch.js +14 -4
  2. package/dist/capacity/backfill.js +4 -2
  3. package/dist/category-b-namespace/index.js +31 -6
  4. package/dist/content-contracts/skills/skill-frontmatter.js +6 -2
  5. package/dist/eval/health.js +8 -1
  6. package/dist/eval/run.js +2 -0
  7. package/dist/fs/projection-containment.d.ts +11 -0
  8. package/dist/fs/projection-containment.js +62 -1
  9. package/dist/hooks/dispatcher.d.ts +14 -2
  10. package/dist/hooks/dispatcher.js +121 -7
  11. package/dist/init-deposit/hygiene.js +3 -0
  12. package/dist/intake/reconcile-issues.d.ts +1 -1
  13. package/dist/intake/reconcile-issues.js +31 -3
  14. package/dist/issue-sync/sync-from-xbrief-cli.d.ts +1 -0
  15. package/dist/issue-sync/sync-from-xbrief-cli.js +3 -0
  16. package/dist/issue-sync/sync-from-xbrief.d.ts +4 -0
  17. package/dist/issue-sync/sync-from-xbrief.js +12 -0
  18. package/dist/lifecycle/event.d.ts +15 -0
  19. package/dist/lifecycle/event.js +163 -0
  20. package/dist/lifecycle/index.d.ts +1 -0
  21. package/dist/lifecycle/index.js +1 -0
  22. package/dist/orchestration/verify-judgment-gates.js +18 -2
  23. package/dist/pr-merge-readiness/compute.d.ts +2 -1
  24. package/dist/pr-merge-readiness/compute.js +23 -6
  25. package/dist/pr-merge-readiness/evaluate.d.ts +2 -1
  26. package/dist/pr-merge-readiness/evaluate.js +46 -31
  27. package/dist/pr-merge-readiness/greptile-inline.d.ts +27 -0
  28. package/dist/pr-merge-readiness/greptile-inline.js +239 -0
  29. package/dist/pr-merge-readiness/index.d.ts +1 -0
  30. package/dist/pr-merge-readiness/index.js +1 -0
  31. package/dist/pr-merge-readiness/mergeability.d.ts +2 -1
  32. package/dist/pr-merge-readiness/mergeability.js +7 -1
  33. package/dist/pr-wait-mergeable/wrappers.d.ts +9 -0
  34. package/dist/pr-wait-mergeable/wrappers.js +31 -2
  35. package/dist/release/constants.js +2 -0
  36. package/dist/scope/demote.js +20 -0
  37. package/dist/swarm/worktrees.d.ts +3 -0
  38. package/dist/swarm/worktrees.js +18 -2
  39. package/dist/task-surface/index.js +3 -2
  40. package/dist/vitest-runner/win32-coverage-tmp-setup.d.ts +16 -8
  41. package/dist/vitest-runner/win32-coverage-tmp-setup.js +41 -14
  42. package/dist/xbrief-migrate/migration-containment.js +9 -25
  43. package/package.json +3 -3
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, resolve } from "node:path";
3
+ import { assertWriteTargetSafe, ProjectionContainmentError } from "../fs/projection-containment.js";
3
4
  import { GhRestError, InvalidRepoError, restIssueView, runGhApi, } from "../scm/gh-rest.js";
4
5
  import { DEFAULT_BATCH_SIZE, DEFAULT_DELAY_MS } from "./constants.js";
5
6
  import { CacheError, CacheFetchError } from "./errors.js";
@@ -569,8 +570,9 @@ function readSelfHealState(cacheRoot) {
569
570
  return null;
570
571
  }
571
572
  }
572
- function writeSelfHealState(cacheRoot, when) {
573
- const path = join(cacheRoot, SELF_HEAL_STATE_FILENAME);
573
+ function writeSelfHealState(projectRoot, cacheRoot, when) {
574
+ const path = join(resolve(cacheRoot), SELF_HEAL_STATE_FILENAME);
575
+ assertWriteTargetSafe(projectRoot, path);
574
576
  writeFileSync(path, `${JSON.stringify({ last_reconcile_at: when.toISOString() })}\n`, "utf8");
575
577
  }
576
578
  /** TTL-bounded closed-reconcile for session ritual / triage:welcome self-healing (#1886). */
@@ -623,7 +625,15 @@ export function maybeSelfHealCache(projectRoot, options = {}) {
623
625
  try {
624
626
  const refresh = refreshFn({ source, repo, cacheRoot, openNumbers });
625
627
  if (options.writeState !== false && refresh.refreshFailed === 0) {
626
- writeSelfHealState(cacheRoot, now);
628
+ try {
629
+ writeSelfHealState(projectRoot, cacheRoot, now);
630
+ }
631
+ catch (err) {
632
+ if (err instanceof ProjectionContainmentError) {
633
+ return { skipped: true, skipReason: "containment-refused", drift, refresh: null };
634
+ }
635
+ throw err;
636
+ }
627
637
  }
628
638
  return { skipped: false, skipReason: null, drift, refresh };
629
639
  }
@@ -3,6 +3,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { referenceTypeMatches } from "@deftai/directive-types";
5
5
  import { sortedStringifyCompact } from "../codebase/json.js";
6
+ import { assertWriteTargetSafe } from "../fs/projection-containment.js";
6
7
  import { hasArtifactSuffix, resolveLifecycleFolder } from "../layout/resolve.js";
7
8
  import { classifyBucket, loadBucketMatchers, resolveCapacityAllocation, SOURCE_MATCH, } from "../policy/capacity.js";
8
9
  export const COMPLETED_FOLDER = "completed";
@@ -137,7 +138,8 @@ function inWindow(completedAt, windowDays, now) {
137
138
  const ageDays = (now.getTime() - parsed.getTime()) / (86400 * 1000);
138
139
  return ageDays >= 0 && ageDays <= windowDays;
139
140
  }
140
- function writeMetadata(path, data, plan, metadata, options) {
141
+ function writeMetadata(projectRoot, path, data, plan, metadata, options) {
142
+ assertWriteTargetSafe(projectRoot, path);
141
143
  if (typeof plan.metadata !== "object" || plan.metadata === null || Array.isArray(plan.metadata)) {
142
144
  plan.metadata = metadata;
143
145
  }
@@ -272,7 +274,7 @@ export async function backfill(projectRoot, options = {}) {
272
274
  result.items.push(item);
273
275
  if (!result.dry_run && (setBucket || setCompletedAt)) {
274
276
  try {
275
- writeMetadata(path, data, planRec, metadata, {
277
+ writeMetadata(projectRoot, path, data, planRec, metadata, {
276
278
  bucket: setBucket ? bucket : undefined,
277
279
  source: setBucket ? source : undefined,
278
280
  completedAt: setCompletedAt ? (gitCompletedAt ?? undefined) : undefined,
@@ -9,8 +9,9 @@
9
9
  * order-preserving (the namespaced key takes the legacy key's slot, keeping
10
10
  * artifact diffs minimal).
11
11
  */
12
- import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, } from "node:fs";
12
+ import { existsSync, lstatSync, readdirSync, readFileSync, writeFileSync, } from "node:fs";
13
13
  import { join, relative, resolve } from "node:path";
14
+ import { assertDirectoryNotSymlink, assertWriteTargetSafe } from "../fs/projection-containment.js";
14
15
  import { hasArtifactSuffix, resolveLifecycleRoot } from "../layout/resolve.js";
15
16
  import { LEGACY_PLAN_COMPLETED_NOTE_KEY, LEGACY_PLAN_POLICY_KEY, PLAN_COMPLETED_NOTE_KEY, PLAN_POLICY_KEY, } from "../policy/plan-extensions.js";
16
17
  /** The locked Category B renames (#1650): directive's own config, not xbrief data. */
@@ -69,10 +70,20 @@ function collectVbriefFiles(dir, acc = []) {
69
70
  }
70
71
  for (const entry of entries) {
71
72
  const full = join(dir, entry.name);
72
- if (entry.isDirectory()) {
73
+ let info;
74
+ try {
75
+ info = lstatSync(full);
76
+ }
77
+ catch {
78
+ continue;
79
+ }
80
+ if (info.isSymbolicLink()) {
81
+ continue;
82
+ }
83
+ if (info.isDirectory()) {
73
84
  collectVbriefFiles(full, acc);
74
85
  }
75
- else if (entry.isFile() && hasArtifactSuffix(entry.name)) {
86
+ else if (info.isFile() && hasArtifactSuffix(entry.name)) {
76
87
  acc.push(full);
77
88
  }
78
89
  }
@@ -100,13 +111,17 @@ export function migrateCategoryBCorpus(projectRoot) {
100
111
  return { scanned: 0, changed: [], conflicts: [] };
101
112
  }
102
113
  }
114
+ try {
115
+ assertDirectoryNotSymlink(root, vbriefDir, "lifecycle root");
116
+ }
117
+ catch (err) {
118
+ const message = err instanceof Error ? err.message : String(err);
119
+ return { scanned: 0, changed: [], conflicts: [{ path: relative(root, vbriefDir), message }] };
120
+ }
103
121
  let scanned = 0;
104
122
  const changed = [];
105
123
  const conflicts = [];
106
124
  for (const file of collectVbriefFiles(vbriefDir)) {
107
- if (!statSync(file).isFile()) {
108
- continue;
109
- }
110
125
  scanned += 1;
111
126
  let parsed;
112
127
  try {
@@ -125,6 +140,16 @@ export function migrateCategoryBCorpus(projectRoot) {
125
140
  continue;
126
141
  }
127
142
  if (result.changed) {
143
+ try {
144
+ assertWriteTargetSafe(root, file);
145
+ }
146
+ catch (err) {
147
+ conflicts.push({
148
+ path: relPath,
149
+ message: err instanceof Error ? err.message : String(err),
150
+ });
151
+ continue;
152
+ }
128
153
  writeFileSync(file, `${JSON.stringify(result.doc, null, 2)}\n`, "utf8");
129
154
  changed.push(relPath);
130
155
  }
@@ -34,8 +34,12 @@ function parseOsArray(fm) {
34
34
  const body = match.groups?.body ?? "";
35
35
  return [...body.matchAll(OS_TOKEN)].map((m) => m[1] ?? "");
36
36
  }
37
- function pathHasExcludedPart(filePath) {
38
- const parts = filePath.split(/[/\\]/);
37
+ function pathHasExcludedPart(filePath, repoRoot = REPO_ROOT) {
38
+ const rel = relative(repoRoot, filePath).replace(/\\/g, "/");
39
+ if (rel.startsWith("..") || rel.length === 0) {
40
+ return true;
41
+ }
42
+ const parts = rel.split("/");
39
43
  return parts.some((part) => EXCLUDED_PARTS.has(part));
40
44
  }
41
45
  function discoverSkillMdFiles(dir, results) {
@@ -1,8 +1,9 @@
1
1
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
- import { dirname, resolve } from "node:path";
2
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
3
3
  import { agentsRefreshPlan } from "../doctor/agents-md.js";
4
4
  import { evaluate as evaluateEncoding } from "../encoding/evaluate.js";
5
5
  import { readCorePackageVersion } from "../engine-version.js";
6
+ import { assertWriteTargetSafe } from "../fs/projection-containment.js";
6
7
  import { resolveProjectDefinitionPath } from "../layout/resolve.js";
7
8
  import { healthMetricsHistoryPath } from "../metrics/resolve-metrics-home.js";
8
9
  import { readPlanPolicy } from "../policy/plan-extensions.js";
@@ -162,6 +163,12 @@ export function persistHealthRun(projectRoot, report) {
162
163
  if (path === null) {
163
164
  return;
164
165
  }
166
+ const projectAbs = resolve(projectRoot);
167
+ const targetAbs = resolve(path);
168
+ const rel = relative(projectAbs, targetAbs);
169
+ if (rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel)) {
170
+ assertWriteTargetSafe(projectRoot, path);
171
+ }
165
172
  mkdirSync(dirname(path), { recursive: true });
166
173
  appendFileSync(path, `${JSON.stringify(report)}\n`, "utf8");
167
174
  }
package/dist/eval/run.js CHANGED
@@ -3,6 +3,7 @@ import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "n
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { readCorePackageVersion } from "../engine-version.js";
6
+ import { assertWriteTargetSafe } from "../fs/projection-containment.js";
6
7
  import { resolveEvalPath } from "../layout/resolve.js";
7
8
  import { BYTE_DIFF_WHOLE_FILE_THRESHOLD, InstrumentedVbriefCrud } from "./crud-telemetry.js";
8
9
  import { evaluateHealth } from "./health.js";
@@ -194,6 +195,7 @@ export function goldenRunsHistoryPath(projectRoot) {
194
195
  /** Append one golden run to the versioned ledger (#1703 Tier 2). */
195
196
  export function persistGoldenRun(projectRoot, record) {
196
197
  const path = goldenRunsHistoryPath(projectRoot);
198
+ assertWriteTargetSafe(projectRoot, path);
197
199
  mkdirSync(dirname(path), { recursive: true });
198
200
  appendFileSync(path, `${JSON.stringify(record)}\n`, "utf8");
199
201
  }
@@ -32,4 +32,15 @@ export declare class ProjectionContainmentError extends Error {
32
32
  * MUST be called BEFORE any projection read/write/mkdir.
33
33
  */
34
34
  export declare function assertProjectionContained(projectDir: string, targetPath: string): void;
35
+ /**
36
+ * Refuse writes when the resolved target already exists as a symlink (#2626 / #2632).
37
+ * Pair with {@link assertProjectionContained} before read/write/mkdir/append.
38
+ */
39
+ export declare function assertWriteTargetSafe(projectDir: string, targetPath: string): void;
40
+ /**
41
+ * Refuse when a lifecycle corpus root is itself a symlink (#2626 category-b).
42
+ */
43
+ export declare function assertDirectoryNotSymlink(projectDir: string, dirPath: string, label: string): void;
44
+ /** Walk a directory tree and refuse any symlink entry (#2601 / #2626). */
45
+ export declare function walkDirectoryRejectSymlinks(root: string): void;
35
46
  //# sourceMappingURL=projection-containment.d.ts.map
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * Refs #2413.
15
15
  */
16
- import { lstatSync, realpathSync } from "node:fs";
16
+ import { lstatSync, readdirSync, realpathSync } from "node:fs";
17
17
  import { isAbsolute, join, relative, resolve } from "node:path";
18
18
  /** Non-zero exit code for a projection-containment refusal (needs-action). */
19
19
  export const PROJECTION_CONTAINMENT_REFUSED_EXIT_CODE = 2;
@@ -115,4 +115,65 @@ export function assertProjectionContained(projectDir, targetPath) {
115
115
  `(${deepestExistingReal} is outside ${projectReal})`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: deepestExistingReal });
116
116
  }
117
117
  }
118
+ /**
119
+ * Refuse writes when the resolved target already exists as a symlink (#2626 / #2632).
120
+ * Pair with {@link assertProjectionContained} before read/write/mkdir/append.
121
+ */
122
+ export function assertWriteTargetSafe(projectDir, targetPath) {
123
+ assertProjectionContained(projectDir, targetPath);
124
+ const targetAbs = resolve(targetPath);
125
+ let info;
126
+ try {
127
+ info = lstatSync(targetAbs);
128
+ }
129
+ catch {
130
+ return;
131
+ }
132
+ if (info.isSymbolicLink()) {
133
+ throw new ProjectionContainmentError(`projection write refused: ${targetAbs} is a symlink on the write path`, { projectDir: resolve(projectDir), targetPath: targetAbs, offendingPath: targetAbs });
134
+ }
135
+ }
136
+ /**
137
+ * Refuse when a lifecycle corpus root is itself a symlink (#2626 category-b).
138
+ */
139
+ export function assertDirectoryNotSymlink(projectDir, dirPath, label) {
140
+ assertProjectionContained(projectDir, dirPath);
141
+ const dirAbs = resolve(dirPath);
142
+ let info;
143
+ try {
144
+ info = lstatSync(dirAbs);
145
+ }
146
+ catch {
147
+ throw new ProjectionContainmentError(`projection write refused: ${label} ${dirAbs} does not exist`, { projectDir: resolve(projectDir), targetPath: dirAbs, offendingPath: dirAbs });
148
+ }
149
+ if (info.isSymbolicLink()) {
150
+ throw new ProjectionContainmentError(`projection write refused: ${label} ${dirAbs} must not be a symlink`, { projectDir: resolve(projectDir), targetPath: dirAbs, offendingPath: dirAbs });
151
+ }
152
+ }
153
+ /** Walk a directory tree and refuse any symlink entry (#2601 / #2626). */
154
+ export function walkDirectoryRejectSymlinks(root) {
155
+ let entries;
156
+ try {
157
+ entries = readdirSync(root, { withFileTypes: true });
158
+ }
159
+ catch {
160
+ return;
161
+ }
162
+ for (const entry of entries) {
163
+ const full = join(root, entry.name);
164
+ let info;
165
+ try {
166
+ info = lstatSync(full);
167
+ }
168
+ catch {
169
+ continue;
170
+ }
171
+ if (info.isSymbolicLink()) {
172
+ throw new ProjectionContainmentError(`projection write refused: symlink on traversal path: ${full}`, { projectDir: resolve(root), targetPath: full, offendingPath: full });
173
+ }
174
+ if (info.isDirectory()) {
175
+ walkDirectoryRejectSymlinks(full);
176
+ }
177
+ }
178
+ }
118
179
  //# sourceMappingURL=projection-containment.js.map
@@ -6,7 +6,7 @@ export type HookHost = (typeof HOOK_HOSTS)[number];
6
6
  export declare const HOOK_EVENTS: readonly ["session.start", "tool.before"];
7
7
  export type HookEvent = (typeof HOOK_EVENTS)[number];
8
8
  export type HookVerdict = "allow" | "deny";
9
- export type HookDecisionCode = "session-start" | "session-start-degraded" | "not-direct-write" | "invalid-input" | "ritual-not-ready" | "scope-not-ready" | "write-ready";
9
+ export type HookDecisionCode = "session-start" | "session-start-degraded" | "not-direct-write" | "invalid-input" | "ritual-not-ready" | "scope-not-ready" | "write-propose-ready" | "write-ready";
10
10
  export interface HookDecision {
11
11
  readonly verdict: HookVerdict;
12
12
  readonly code: HookDecisionCode;
@@ -32,7 +32,19 @@ export interface HookPolicySeams {
32
32
  stderr: string;
33
33
  };
34
34
  }
35
- export declare function hookToolName(payload: unknown): string | null;
35
+ export declare function hookToolName(payload: unknown, host?: HookHost): string | null;
36
+ /**
37
+ * Best-effort write-target path from host PreToolUse payloads (#2625).
38
+ * Hosts disagree on nesting (`tool_input.file_path` vs top-level `path`).
39
+ */
40
+ export declare function hookWriteTargetPath(payload: unknown): string | null;
41
+ /** POSIX-ish project-relative path for lifecycle matching. */
42
+ export declare function toProjectRelativePosix(projectRoot: string, targetPath: string): string;
43
+ /**
44
+ * Proposing a scope under xbrief/proposed/ (or legacy vbrief/proposed/) is
45
+ * planning, not implementation dispatch — exempt from the active-scope gate (#2625).
46
+ */
47
+ export declare function isProposedLifecycleWrite(projectRoot: string, targetPath: string | null): boolean;
36
48
  export declare function projectRootFromHookPayload(payload: unknown, fallback: string): string;
37
49
  export declare function isHookHost(value: string): value is HookHost;
38
50
  export declare function isHookEvent(value: string): value is HookEvent;
@@ -1,4 +1,5 @@
1
- import { resolve } from "node:path";
1
+ import { relative, resolve, sep } from "node:path";
2
+ import { hasArtifactSuffix } from "../layout/resolve.js";
2
3
  import { runSessionStartHookWrite } from "../session/session-start-hook.js";
3
4
  import { inspectSessionRitual } from "../session/verify-session-ritual.js";
4
5
  import { inspectActiveScope } from "./scope.js";
@@ -18,11 +19,103 @@ function firstString(...values) {
18
19
  }
19
20
  return null;
20
21
  }
21
- export function hookToolName(payload) {
22
+ function fieldPresent(input, key) {
23
+ return key in input;
24
+ }
25
+ function fieldString(input, key) {
26
+ const value = input[key];
27
+ if (typeof value === "string" && value.trim().length > 0)
28
+ return value.trim();
29
+ return null;
30
+ }
31
+ function toolInputRecord(payload) {
32
+ return record(payload.tool_input) ?? record(payload.toolInput) ?? record(payload.input);
33
+ }
34
+ /**
35
+ * Cursor preToolUse payloads sometimes omit `tool_name` even when the hook matcher
36
+ * fired for a direct-write tool (#2628). Infer from nested tool input when possible.
37
+ */
38
+ function inferCursorDirectWriteToolName(payload) {
39
+ const toolInput = toolInputRecord(payload);
40
+ if (toolInput !== null) {
41
+ if (fieldPresent(toolInput, "new_string") ||
42
+ fieldPresent(toolInput, "newString") ||
43
+ fieldPresent(toolInput, "old_string") ||
44
+ fieldPresent(toolInput, "oldString")) {
45
+ return "StrReplace";
46
+ }
47
+ if (fieldString(toolInput, "contents") !== null ||
48
+ fieldString(toolInput, "content") !== null ||
49
+ fieldString(toolInput, "text") !== null) {
50
+ return "Write";
51
+ }
52
+ if (fieldString(toolInput, "patch") !== null ||
53
+ fieldString(toolInput, "unified_diff") !== null ||
54
+ fieldString(toolInput, "diff") !== null) {
55
+ return "ApplyPatch";
56
+ }
57
+ if (Array.isArray(toolInput.edits))
58
+ return "MultiEdit";
59
+ if (Array.isArray(toolInput.cells) || toolInput.cell_id != null) {
60
+ return "NotebookEdit";
61
+ }
62
+ }
63
+ // Cursor maps Claude Edit → Write; a write target without contents is still a direct write.
64
+ if (hookWriteTargetPath(payload) !== null)
65
+ return "Write";
66
+ return null;
67
+ }
68
+ export function hookToolName(payload, host) {
22
69
  const input = record(payload);
23
70
  if (input === null)
24
71
  return null;
25
- return firstString(input.tool_name, input.toolName, input.tool);
72
+ const direct = fieldString(input, "tool_name") ?? fieldString(input, "toolName") ?? fieldString(input, "tool");
73
+ if (direct !== null)
74
+ return direct;
75
+ if (host === "cursor")
76
+ return inferCursorDirectWriteToolName(input);
77
+ return null;
78
+ }
79
+ function missingToolNameMessage(host) {
80
+ if (host === "cursor") {
81
+ return ("Directive denied this Cursor preToolUse event because the host payload omitted a " +
82
+ "recognizable tool name (host-integration mismatch — not a session ritual or scope failure). " +
83
+ "If write tools should pass, update Directive or report the payload shape from Cursor.");
84
+ }
85
+ return "Directive denied this matched write event because the host payload omitted its tool name.";
86
+ }
87
+ /**
88
+ * Best-effort write-target path from host PreToolUse payloads (#2625).
89
+ * Hosts disagree on nesting (`tool_input.file_path` vs top-level `path`).
90
+ */
91
+ export function hookWriteTargetPath(payload) {
92
+ const input = record(payload);
93
+ if (input === null)
94
+ return null;
95
+ const toolInput = toolInputRecord(input);
96
+ return firstString(toolInput?.file_path, toolInput?.filePath, toolInput?.path, input.file_path, input.filePath, input.path);
97
+ }
98
+ /** POSIX-ish project-relative path for lifecycle matching. */
99
+ export function toProjectRelativePosix(projectRoot, targetPath) {
100
+ const abs = resolve(projectRoot, targetPath.replace(/\\/g, "/"));
101
+ const rel = relative(resolve(projectRoot), abs);
102
+ return rel.split(sep).join("/").replace(/\\/g, "/");
103
+ }
104
+ /**
105
+ * Proposing a scope under xbrief/proposed/ (or legacy vbrief/proposed/) is
106
+ * planning, not implementation dispatch — exempt from the active-scope gate (#2625).
107
+ */
108
+ export function isProposedLifecycleWrite(projectRoot, targetPath) {
109
+ if (targetPath === null || targetPath.trim().length === 0)
110
+ return false;
111
+ const posix = toProjectRelativePosix(projectRoot, targetPath);
112
+ // resolve()+relative() collapses mid-path `..`; only outside-root `..` remains.
113
+ if (posix.startsWith(".."))
114
+ return false;
115
+ const base = posix.includes("/") ? posix.slice(posix.lastIndexOf("/") + 1) : posix;
116
+ if (!hasArtifactSuffix(base))
117
+ return false;
118
+ return posix.startsWith("xbrief/proposed/") || posix.startsWith("vbrief/proposed/");
26
119
  }
27
120
  export function projectRootFromHookPayload(payload, fallback) {
28
121
  const input = record(payload);
@@ -94,9 +187,9 @@ export function decideHook(input, seams = {}) {
94
187
  scopePath: null,
95
188
  };
96
189
  }
97
- const toolName = hookToolName(input.payload);
190
+ const toolName = hookToolName(input.payload, input.host);
98
191
  if (toolName === null) {
99
- return deny(input, "invalid-input", null, "Directive denied this matched write event because the host payload omitted its tool name.");
192
+ return deny(input, "invalid-input", null, missingToolNameMessage(input.host));
100
193
  }
101
194
  if (!isDirectWriteTool(toolName)) {
102
195
  return {
@@ -124,6 +217,20 @@ export function decideHook(input, seams = {}) {
124
217
  "Recovery: run `deft session:start`, then " +
125
218
  "`deft verify:session-ritual -- --tier=gated`.");
126
219
  }
220
+ const writeTarget = hookWriteTargetPath(input.payload);
221
+ if (isProposedLifecycleWrite(projectRoot, writeTarget)) {
222
+ return {
223
+ verdict: "allow",
224
+ code: "write-propose-ready",
225
+ event: input.event,
226
+ host: input.host,
227
+ toolName,
228
+ projectRoot,
229
+ message: `Directive write gate allowed ${toolName} for a proposed lifecycle xBRIEF ` +
230
+ "(planning write; active scope not required).",
231
+ scopePath: null,
232
+ };
233
+ }
127
234
  let scope;
128
235
  try {
129
236
  scope = (seams.inspectScope ?? inspectActiveScope)(projectRoot);
@@ -132,8 +239,15 @@ export function decideHook(input, seams = {}) {
132
239
  scope = { ready: false, path: null, message: String(cause) };
133
240
  }
134
241
  if (!scope.ready) {
135
- return deny(input, "scope-not-ready", toolName, `Directive denied ${toolName}: ${scope.message} ` +
136
- "Recovery: run `deft scope:activate -- <path>` for the approved xBRIEF.");
242
+ const relTarget = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
243
+ const proposedPathHint = relTarget !== null &&
244
+ (relTarget.startsWith("xbrief/proposed/") || relTarget.startsWith("vbrief/proposed/"))
245
+ ? " For a new proposal under xbrief/proposed/, include a lifecycle artifact " +
246
+ "filename (*.xbrief.json) in the Write/Edit payload so the gate can exempt " +
247
+ "planning writes (#2625)."
248
+ : " Recovery: run `deft scope:activate -- <path>` for the approved xBRIEF, " +
249
+ "or Write a new proposal to xbrief/proposed/*.xbrief.json (planning exemption).";
250
+ return deny(input, "scope-not-ready", toolName, `Directive denied ${toolName}: ${scope.message}${proposedPathHint}`);
137
251
  }
138
252
  return {
139
253
  verdict: "allow",
@@ -27,6 +27,9 @@ export function installerManagedMatchers() {
27
27
  { exact: ".codex/hooks.json" },
28
28
  { exact: ".gitattributes" },
29
29
  { exact: ".gitignore" },
30
+ // Installer-deposited Prettier gate exclusion (#2534); must be allowlisted or
31
+ // framework-only deposit PRs trip no-mixed-core-and-app (#2629).
32
+ { exact: ".prettierignore" },
30
33
  { exact: "greptile.json" },
31
34
  { exact: CODEQL_CONFIG_REL },
32
35
  { exact: CORE_GUARD_WORKFLOW_REL },
@@ -56,7 +56,7 @@ export declare function formatJson(report: ReconcileReport): string;
56
56
  export declare function formatMarkdown(report: ReconcileReport): string;
57
57
  export declare function applyLifecycleFixes(vbriefDir: string, report: ReconcileReport, projectRoot?: string | null): [number, number, string[]];
58
58
  /** Repair completed/ files whose plan.status is not terminal (#2578). */
59
- export declare function repairCompletedStatusDrift(vbriefDir: string, drift: readonly CompletedStatusDriftEntry[]): [number, number, string[]];
59
+ export declare function repairCompletedStatusDrift(vbriefDir: string, drift: readonly CompletedStatusDriftEntry[], projectRoot?: string | null): [number, number, string[]];
60
60
  export declare function detectRepo(): string | null;
61
61
  export interface ReconcileCliArgs {
62
62
  vbriefDir?: string;
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
+ import { assertProjectionContained, assertWriteTargetSafe, ProjectionContainmentError, } from "../fs/projection-containment.js";
4
5
  import { hasArtifactSuffix, resolveLifecycleRoot } from "../layout/resolve.js";
5
6
  import { call } from "../scm/call.js";
6
7
  import { updateDecomposedChildBackReferences } from "../scope/decomposed-refs.js";
@@ -673,6 +674,17 @@ export function applyLifecycleFixes(vbriefDir, report, projectRoot = null) {
673
674
  failures.push(`vBRIEF file missing: ${relPath}`);
674
675
  continue;
675
676
  }
677
+ try {
678
+ assertWriteTargetSafe(cwd, src);
679
+ assertProjectionContained(cwd, join(vbriefDir, destFolder));
680
+ }
681
+ catch (err) {
682
+ if (err instanceof ProjectionContainmentError) {
683
+ failures.push(`${relPath}: ${err.message}`);
684
+ continue;
685
+ }
686
+ throw err;
687
+ }
676
688
  let data;
677
689
  try {
678
690
  data = JSON.parse(readFileSync(src, "utf8"));
@@ -684,7 +696,12 @@ export function applyLifecycleFixes(vbriefDir, report, projectRoot = null) {
684
696
  mkdirSync(join(vbriefDir, destFolder), { recursive: true });
685
697
  try {
686
698
  statSync(dst);
687
- failures.push(`target already exists in ${destFolder}/: ${filename}`);
699
+ // Destination already holds this basename (#2622). Treat as already-terminal
700
+ // and continue the sweep. Do not unlink the source — a same-basename collision
701
+ // can be a non-identical artifact; leave removal to the operator / git.
702
+ process.stderr.write(`reconcile: skipped ${relPath} — already present in ${destFolder}/ (#2622); ` +
703
+ "left source in place for manual cleanup\n");
704
+ skipped += 1;
688
705
  continue;
689
706
  }
690
707
  catch {
@@ -723,11 +740,12 @@ export function applyLifecycleFixes(vbriefDir, report, projectRoot = null) {
723
740
  return [moved, skipped, failures];
724
741
  }
725
742
  /** Repair completed/ files whose plan.status is not terminal (#2578). */
726
- export function repairCompletedStatusDrift(vbriefDir, drift) {
743
+ export function repairCompletedStatusDrift(vbriefDir, drift, projectRoot = null) {
727
744
  let repaired = 0;
728
745
  let skipped = 0;
729
746
  const failures = [];
730
747
  const completedDir = join(vbriefDir, "completed");
748
+ const cwd = projectRoot ?? resolve(vbriefDir, "..");
731
749
  for (const entry of drift) {
732
750
  const slash = entry.rel_path.indexOf("/");
733
751
  if (slash < 0) {
@@ -736,6 +754,16 @@ export function repairCompletedStatusDrift(vbriefDir, drift) {
736
754
  }
737
755
  const filename = entry.rel_path.slice(slash + 1);
738
756
  const src = join(completedDir, filename);
757
+ try {
758
+ assertWriteTargetSafe(cwd, src);
759
+ }
760
+ catch (err) {
761
+ if (err instanceof ProjectionContainmentError) {
762
+ failures.push(`${entry.rel_path}: ${err.message}`);
763
+ continue;
764
+ }
765
+ throw err;
766
+ }
739
767
  let data;
740
768
  try {
741
769
  data = JSON.parse(readFileSync(src, "utf8"));
@@ -871,7 +899,7 @@ export function reconcileMain(args) {
871
899
  }, 0);
872
900
  const [moved, skipped, failures] = applyLifecycleFixes(vbriefDir, applyReport, projectRoot);
873
901
  const driftAfterMove = scanCompletedStatusDrift(vbriefDir);
874
- const [driftRepaired, driftSkipped, driftFailures] = repairCompletedStatusDrift(vbriefDir, driftAfterMove);
902
+ const [driftRepaired, driftSkipped, driftFailures] = repairCompletedStatusDrift(vbriefDir, driftAfterMove, projectRoot);
875
903
  const allFailures = [...failures, ...driftFailures];
876
904
  process.stderr.write(`[${moved}/${candidates}] vBRIEFs reconciled (moved=${moved}, already-terminal=${skipped}, failures=${failures.length})\n`);
877
905
  process.stderr.write(`[${driftRepaired}/${completedDrift.length}] completed/ status drift repaired (repaired=${driftRepaired}, already-terminal=${driftSkipped}, failures=${driftFailures.length})\n`);
@@ -4,6 +4,7 @@ export interface ParsedSyncFromXbriefCliArgs {
4
4
  dryRun?: boolean;
5
5
  projectRoot?: string;
6
6
  repo?: string;
7
+ allowCrossRepo?: boolean;
7
8
  error?: string;
8
9
  }
9
10
  export declare function parseArgs(argv: readonly string[]): ParsedSyncFromXbriefCliArgs;
@@ -8,6 +8,9 @@ export function parseArgs(argv) {
8
8
  if (arg === "--dry-run") {
9
9
  out.dryRun = true;
10
10
  }
11
+ else if (arg === "--allow-cross-repo") {
12
+ out.allowCrossRepo = true;
13
+ }
11
14
  else if (arg === "--project-root") {
12
15
  const value = argv[i + 1];
13
16
  if (value === undefined) {
@@ -29,6 +29,8 @@ export interface SyncFromXbriefOptions {
29
29
  readonly dryRun?: boolean;
30
30
  readonly projectRoot?: string;
31
31
  readonly repo?: string;
32
+ readonly allowCrossRepo?: boolean;
33
+ readonly repoAllowlist?: readonly string[];
32
34
  readonly runFn?: RunGhApiFn;
33
35
  readonly writeErr?: (message: string) => void;
34
36
  readonly writeOut?: (message: string) => void;
@@ -41,6 +43,8 @@ export interface SyncFromXbriefCliArgs {
41
43
  readonly dryRun?: boolean;
42
44
  readonly projectRoot?: string;
43
45
  readonly repo?: string;
46
+ readonly allowCrossRepo?: boolean;
47
+ readonly repoAllowlist?: readonly string[];
44
48
  }
45
49
  export declare function syncFromXbriefMain(args: SyncFromXbriefCliArgs): number;
46
50
  //# sourceMappingURL=sync-from-xbrief.d.ts.map
@@ -6,6 +6,7 @@ import { provenanceIssueNumber, repoSlugFromUrl } from "../intake/issue-ingest.j
6
6
  import { extractReferencesFromVbrief, GITHUB_ISSUE_REF_TYPES, parseIssueNumber, } from "../intake/reconcile-issues.js";
7
7
  import { resolveProjectRoot } from "../scope/project-context.js";
8
8
  import { resolveProjectRepo } from "../slice/project-context.js";
9
+ import { isRepoMutationAllowed } from "../vbrief-reconcile/repo-guard.js";
9
10
  export const SYNC_COMMENT_HEADER = "## xBRIEF sync (deft issue:sync-from-xbrief)";
10
11
  export function resolveOriginIssue(data, options = {}) {
11
12
  const refs = extractReferencesFromVbrief(data);
@@ -161,6 +162,15 @@ export function syncFromXbrief(options) {
161
162
  writeOut(`issue:sync-from-xbrief: no material AC/status changes since last sync for #${origin.number}; nothing to post.`);
162
163
  return 0;
163
164
  }
165
+ const mutateGate = isRepoMutationAllowed(origin.repo, projectRoot, {
166
+ allowCrossRepo: options.allowCrossRepo,
167
+ allowlist: options.repoAllowlist,
168
+ explicitRepo: fallbackRepo,
169
+ });
170
+ if (!mutateGate.allowed) {
171
+ writeErr(`issue:sync-from-xbrief: ${mutateGate.reason ?? `refusing cross-repo mutation on ${origin.repo}`}`);
172
+ return 1;
173
+ }
164
174
  const relPath = options.xbriefPath.replace(/\\/g, "/");
165
175
  const comment = buildSyncComment(data, relPath);
166
176
  if (options.dryRun) {
@@ -203,6 +213,8 @@ export function syncFromXbriefMain(args) {
203
213
  dryRun: args.dryRun,
204
214
  projectRoot: args.projectRoot,
205
215
  repo: args.repo,
216
+ allowCrossRepo: args.allowCrossRepo,
217
+ repoAllowlist: args.repoAllowlist,
206
218
  });
207
219
  }
208
220
  //# sourceMappingURL=sync-from-xbrief.js.map