@deftai/directive-core 0.81.0 → 0.82.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 (51) hide show
  1. package/dist/content-contracts/skills/helpers.d.ts +6 -0
  2. package/dist/content-contracts/skills/helpers.js +47 -3
  3. package/dist/content-contracts/skills/skill-external-fetch-gate.d.ts +18 -0
  4. package/dist/content-contracts/skills/skill-external-fetch-gate.js +81 -0
  5. package/dist/eval/crud-telemetry.d.ts +2 -0
  6. package/dist/eval/crud-telemetry.js +30 -0
  7. package/dist/intake/github-body-cli.js +2 -0
  8. package/dist/intake/github-body.d.ts +11 -0
  9. package/dist/intake/github-body.js +95 -24
  10. package/dist/integration-e2e/helpers.js +1 -1
  11. package/dist/issue-sync/sync-from-xbrief.js +3 -0
  12. package/dist/orphan-active/evaluate.d.ts +25 -0
  13. package/dist/orphan-active/evaluate.js +275 -0
  14. package/dist/orphan-active/index.d.ts +3 -0
  15. package/dist/orphan-active/index.js +3 -0
  16. package/dist/orphan-active/refs.d.ts +16 -0
  17. package/dist/orphan-active/refs.js +105 -0
  18. package/dist/pr-wait-mergeable/cascade.d.ts +8 -0
  19. package/dist/pr-wait-mergeable/cascade.js +18 -0
  20. package/dist/pr-wait-mergeable/main.d.ts +4 -0
  21. package/dist/pr-wait-mergeable/main.js +44 -73
  22. package/dist/pr-wait-mergeable/result.d.ts +2 -1
  23. package/dist/pr-wait-mergeable/result.js +4 -0
  24. package/dist/pr-wait-mergeable/semantic-green.d.ts +27 -0
  25. package/dist/pr-wait-mergeable/semantic-green.js +202 -0
  26. package/dist/pr-wait-mergeable/types.d.ts +1 -0
  27. package/dist/pr-watch/constants.d.ts +4 -4
  28. package/dist/pr-watch/constants.js +4 -4
  29. package/dist/pr-watch/watch.js +5 -3
  30. package/dist/release/native-steps.js +11 -4
  31. package/dist/render/framework-commands.js +6 -0
  32. package/dist/scope/brief-io.d.ts +26 -0
  33. package/dist/scope/brief-io.js +77 -0
  34. package/dist/scope/decompose.js +2 -2
  35. package/dist/scope/decomposed-refs.js +3 -3
  36. package/dist/scope/demote.js +2 -2
  37. package/dist/scope/project-definition-sync.d.ts +2 -2
  38. package/dist/scope/project-definition-sync.js +15 -3
  39. package/dist/scope/registry-artifact-sync.d.ts +6 -1
  40. package/dist/scope/registry-artifact-sync.js +31 -13
  41. package/dist/scope/transition.js +23 -15
  42. package/dist/scope/undo.js +2 -2
  43. package/dist/scope/vbrief-json.d.ts +1 -2
  44. package/dist/scope/vbrief-json.js +1 -4
  45. package/dist/staleness-tickler/state.js +19 -2
  46. package/dist/triage/queue/cache.js +5 -0
  47. package/dist/verify-source/index.d.ts +1 -0
  48. package/dist/verify-source/index.js +1 -0
  49. package/dist/verify-source/skill-external-fetch-gate.d.ts +21 -0
  50. package/dist/verify-source/skill-external-fetch-gate.js +71 -0
  51. package/package.json +7 -3
@@ -8,6 +8,7 @@ export declare function repoPath(...segments: string[]): string;
8
8
  * first (SOURCE layout), then fall back to the repo root so root-resident
9
9
  * harness entries (AGENTS.md) and the flattened consumer layout still resolve.
10
10
  */
11
+ export declare function resolveContentPathFromRoot(projectRoot: string, relPath: string): string;
11
12
  export declare function resolveRepoPath(relPath: string): string;
12
13
  export declare function readRepoFile(relPath: string): string;
13
14
  export declare function repoFileExists(relPath: string): boolean;
@@ -15,7 +16,12 @@ export declare function readSkill(relPath: string): string;
15
16
  export declare function readAgentsMd(): string;
16
17
  /** Slice the first `## Returning Sessions` section body out of AGENTS.md. */
17
18
  export declare function returningSessionsSection(): string;
19
+ export declare function listSkillMdFilesFromRoot(projectRoot: string): string[];
18
20
  export declare function listSkillMdFiles(): string[];
21
+ export declare function listSkillMdEntriesFromRoot(projectRoot: string): ReadonlyArray<{
22
+ path: string;
23
+ text: string;
24
+ }>;
19
25
  export declare const RFC2119_LEGEND = "!=MUST, ~=SHOULD";
20
26
  export declare const PLATFORM_DETECTION_HEADING = "## Platform Detection";
21
27
  export declare const USER_MD_GATE_HEADING = "## USER.md Gate";
@@ -13,12 +13,15 @@ export function repoPath(...segments) {
13
13
  * first (SOURCE layout), then fall back to the repo root so root-resident
14
14
  * harness entries (AGENTS.md) and the flattened consumer layout still resolve.
15
15
  */
16
- export function resolveRepoPath(relPath) {
17
- const underContent = join(REPO_ROOT, "content", relPath);
16
+ export function resolveContentPathFromRoot(projectRoot, relPath) {
17
+ const underContent = join(projectRoot, "content", relPath);
18
18
  if (existsSync(underContent)) {
19
19
  return underContent;
20
20
  }
21
- return repoPath(relPath);
21
+ return join(projectRoot, relPath);
22
+ }
23
+ export function resolveRepoPath(relPath) {
24
+ return resolveContentPathFromRoot(REPO_ROOT, relPath);
22
25
  }
23
26
  export function readRepoFile(relPath) {
24
27
  return readFileSync(resolveRepoPath(relPath), "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
@@ -43,6 +46,26 @@ export function returningSessionsSection() {
43
46
  const nextHeading = rest.indexOf("\n## ");
44
47
  return nextHeading === -1 ? rest : rest.slice(0, nextHeading);
45
48
  }
49
+ function skillsDirFromRoot(projectRoot) {
50
+ const resolved = resolveContentPathFromRoot(projectRoot, "skills");
51
+ return existsSync(resolved) ? resolved : null;
52
+ }
53
+ export function listSkillMdFilesFromRoot(projectRoot) {
54
+ const skillsDir = skillsDirFromRoot(projectRoot);
55
+ if (skillsDir === null) {
56
+ return [];
57
+ }
58
+ const results = [];
59
+ for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
60
+ if (entry.isDirectory()) {
61
+ const skillMd = join(skillsDir, entry.name, "SKILL.md");
62
+ if (existsSync(skillMd)) {
63
+ results.push(join("skills", entry.name, "SKILL.md"));
64
+ }
65
+ }
66
+ }
67
+ return results.sort();
68
+ }
46
69
  export function listSkillMdFiles() {
47
70
  const skillsDir = resolveRepoPath("skills");
48
71
  const results = [];
@@ -56,6 +79,27 @@ export function listSkillMdFiles() {
56
79
  }
57
80
  return results.sort();
58
81
  }
82
+ export function listSkillMdEntriesFromRoot(projectRoot) {
83
+ const skillsDir = skillsDirFromRoot(projectRoot);
84
+ if (skillsDir === null) {
85
+ return [];
86
+ }
87
+ const entries = [];
88
+ for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
89
+ if (!entry.isDirectory()) {
90
+ continue;
91
+ }
92
+ const skillMd = join(skillsDir, entry.name, "SKILL.md");
93
+ if (!existsSync(skillMd)) {
94
+ continue;
95
+ }
96
+ entries.push({
97
+ path: join("skills", entry.name, "SKILL.md"),
98
+ text: readFileSync(skillMd, "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n"),
99
+ });
100
+ }
101
+ return entries.sort((a, b) => a.path.localeCompare(b.path));
102
+ }
59
103
  export const RFC2119_LEGEND = "!=MUST, ~=SHOULD";
60
104
  export const PLATFORM_DETECTION_HEADING = "## Platform Detection";
61
105
  export const USER_MD_GATE_HEADING = "## USER.md Gate";
@@ -0,0 +1,18 @@
1
+ /** Skill-validation gate: external fetch must not pair with execute/install without mitigation (#1532 / #1936). */
2
+ export type ExternalFetchViolation = {
3
+ skillPath: string;
4
+ detail: string;
5
+ };
6
+ /** Strip YAML frontmatter and HTML comment banners before scanning skill prose. */
7
+ export declare function skillProse(raw: string): string;
8
+ export declare function hasExternalFetchSignal(prose: string): boolean;
9
+ export declare function hasRiskyExternalFetchPattern(prose: string): boolean;
10
+ export declare function hasExecuteFromExternalSignal(prose: string): boolean;
11
+ export declare function hasUntrustedFetchMitigation(prose: string): boolean;
12
+ export declare function analyzeSkillExternalFetch(skillPath: string, rawText: string): ExternalFetchViolation | null;
13
+ /** Collect violations across skill entries (used by verify-source gate + tests). */
14
+ export declare function collectExternalFetchViolations(entries: ReadonlyArray<{
15
+ path: string;
16
+ text: string;
17
+ }>): ExternalFetchViolation[];
18
+ //# sourceMappingURL=skill-external-fetch-gate.d.ts.map
@@ -0,0 +1,81 @@
1
+ /** Skill-validation gate: external fetch must not pair with execute/install without mitigation (#1532 / #1936). */
2
+ const EXTERNAL_FETCH_RE = /\b(fetch(?:ing)?(?:\s+\w+){0,6}\s+(?:the\s+)?(?:url|content|article|related\s+urls?|http|https|web\s+page)|fetch\s+and\s+read|webfetch|mcp.*fetch)/i;
3
+ const EXECUTE_FROM_EXTERNAL_RE = /\b(download|install|run|execute)\b.{0,40}\b(?:found|inside|within|in)\b.{0,40}\b(?:fetched|external|url|content|page|article)\b|\bfollow\b.{0,40}\b(?:instructions?|commands?)\b.{0,40}\b(?:download|install|run|execute)\b|\bexecute\b.{0,40}\b(?:commands?|scripts?|instructions?)\b.{0,40}\b(?:found|inside|within)\b/i;
4
+ const FETCH_THEN_EXECUTE_RE = /\bfetch\b.{0,120}\b(?:then|and)\b.{0,60}\b(?:run|execute|install|download)\b/i;
5
+ const RUN_FETCHED_ARTIFACT_RE = /\b(?:run|execute|install)\b.{0,40}\b(?:downloaded|fetched)\b/i;
6
+ const FETCHED_ARTIFACT_RE = /\b(?:downloaded|fetched)\b.{0,40}\b(?:script|binary|executable|tool|installer|package|file)\b/i;
7
+ const FOLLOW_RELATED_URLS_RE = /\bfetch(?:ing)?\s+related\s+urls?\b/i;
8
+ const SECURITY_CONTEXT_HEADING = /## Security context/i;
9
+ const UNTRUSTED_DOCTRINE_RE = /untrusted\s+(?:external\s+)?(?:content|data)/i;
10
+ const FORBID_EXTERNAL_EXECUTE_RE = /⊗[\s\S]{0,200}\b(?:download|install|execute|run)\b[\s\S]{0,200}\b(?:external|fetched|externally|found inside)\b/i;
11
+ /** Strip HTML comment blocks; repeat until stable for CodeQL multi-char sanitization. */
12
+ function stripHtmlComments(text) {
13
+ let body = text;
14
+ let prev = "";
15
+ while (prev !== body) {
16
+ prev = body;
17
+ body = body.replace(/<!--[\s\S]*?-->/g, "");
18
+ }
19
+ return body;
20
+ }
21
+ /** Strip YAML frontmatter and HTML comment banners before scanning skill prose. */
22
+ export function skillProse(raw) {
23
+ let body = raw.replace(/\r\n/g, "\n");
24
+ if (body.startsWith("---")) {
25
+ const end = body.indexOf("\n---", 3);
26
+ if (end !== -1) {
27
+ body = body.slice(end + 4);
28
+ }
29
+ }
30
+ return stripHtmlComments(body);
31
+ }
32
+ export function hasExternalFetchSignal(prose) {
33
+ return EXTERNAL_FETCH_RE.test(prose);
34
+ }
35
+ export function hasRiskyExternalFetchPattern(prose) {
36
+ if (!hasExternalFetchSignal(prose)) {
37
+ return false;
38
+ }
39
+ return (hasExecuteFromExternalSignal(prose) ||
40
+ FOLLOW_RELATED_URLS_RE.test(prose) ||
41
+ /\bfetch\b.{0,40}\bfollow\b/i.test(prose));
42
+ }
43
+ export function hasExecuteFromExternalSignal(prose) {
44
+ return (EXECUTE_FROM_EXTERNAL_RE.test(prose) ||
45
+ FETCH_THEN_EXECUTE_RE.test(prose) ||
46
+ RUN_FETCHED_ARTIFACT_RE.test(prose) ||
47
+ FETCHED_ARTIFACT_RE.test(prose));
48
+ }
49
+ export function hasUntrustedFetchMitigation(prose) {
50
+ return (SECURITY_CONTEXT_HEADING.test(prose) &&
51
+ UNTRUSTED_DOCTRINE_RE.test(prose) &&
52
+ FORBID_EXTERNAL_EXECUTE_RE.test(prose));
53
+ }
54
+ export function analyzeSkillExternalFetch(skillPath, rawText) {
55
+ const prose = skillProse(rawText);
56
+ if (!hasExternalFetchSignal(prose)) {
57
+ return null;
58
+ }
59
+ if (!hasRiskyExternalFetchPattern(prose)) {
60
+ return null;
61
+ }
62
+ if (hasUntrustedFetchMitigation(prose)) {
63
+ return null;
64
+ }
65
+ return {
66
+ skillPath,
67
+ detail: "external fetch or follow-through language without Security context untrusted-data / forbid-execute mitigation (#1936)",
68
+ };
69
+ }
70
+ /** Collect violations across skill entries (used by verify-source gate + tests). */
71
+ export function collectExternalFetchViolations(entries) {
72
+ const violations = [];
73
+ for (const { path, text } of entries) {
74
+ const finding = analyzeSkillExternalFetch(path, text);
75
+ if (finding !== null) {
76
+ violations.push(finding);
77
+ }
78
+ }
79
+ return violations;
80
+ }
81
+ //# sourceMappingURL=skill-external-fetch-gate.js.map
@@ -54,6 +54,8 @@ export declare class InstrumentedVbriefCrud {
54
54
  update(path: string, content: string, options?: {
55
55
  trustedWrite?: boolean;
56
56
  }): CrudResult;
57
+ /** Record update metrics without persisting (caller owns validated atomic write). */
58
+ recordTrustedUpdate(path: string, content: string): void;
57
59
  delete(path: string): CrudResult;
58
60
  private recordMetric;
59
61
  }
@@ -248,6 +248,36 @@ export class InstrumentedVbriefCrud {
248
248
  writeFileSync(path, content, "utf8");
249
249
  return { ok: true };
250
250
  }
251
+ /** Record update metrics without persisting (caller owns validated atomic write). */
252
+ recordTrustedUpdate(path, content) {
253
+ let parsed;
254
+ try {
255
+ parsed = JSON.parse(content);
256
+ }
257
+ catch (err) {
258
+ const message = sanitizeInlineMessage(err instanceof Error ? err.message : String(err));
259
+ this.recordMetric({
260
+ operation: "update",
261
+ path,
262
+ schemaValid: false,
263
+ schemaErrors: [`${path}: invalid JSON -- ${message}`],
264
+ inventedKeys: [],
265
+ byteDiffMinimality: null,
266
+ byteDiffChangedRatio: null,
267
+ });
268
+ return;
269
+ }
270
+ const assessment = assessDocument(path, parsed);
271
+ this.recordMetric({
272
+ operation: "update",
273
+ path,
274
+ schemaValid: assessment.schemaValid,
275
+ schemaErrors: assessment.schemaErrors,
276
+ inventedKeys: assessment.inventedKeys,
277
+ byteDiffMinimality: null,
278
+ byteDiffChangedRatio: null,
279
+ });
280
+ }
251
281
  delete(path) {
252
282
  if (!existsSync(path)) {
253
283
  this.recordMetric({
@@ -17,6 +17,8 @@ function parseArgs(argv) {
17
17
  out.pr = Number.parseInt(argv[++i], 10);
18
18
  else if (arg === "--body-file")
19
19
  out.bodyFile = argv[++i];
20
+ else if (arg === "--out-file")
21
+ out.outFile = argv[++i];
20
22
  }
21
23
  return out;
22
24
  }
@@ -9,6 +9,8 @@ export type RunGhApiFn = (args: readonly string[], options?: {
9
9
  /** Resolve the live GitHub CLI used for writes and mutation read-back. */
10
10
  export declare function resolveLiveGh(): string;
11
11
  export declare function readBody(bodyFile: string, stdinText?: string | null): string;
12
+ /** Fail closed when live read-back body is flattened or mojibaked vs intended payload (#2607). */
13
+ export declare function verifyBodyPostcondition(intended: string, live: string): void;
12
14
  export declare function createIssue(repo: string, options: {
13
15
  title: string;
14
16
  body: string;
@@ -35,6 +37,14 @@ export declare function editPrBody(repo: string, pr: number, options: {
35
37
  binary?: string | null;
36
38
  runFn?: RunGhApiFn;
37
39
  }): Record<string, unknown>;
40
+ export declare function fetchIssueBody(repo: string, issue: number, options?: {
41
+ binary?: string | null;
42
+ runFn?: RunGhApiFn;
43
+ }): string;
44
+ export declare function writeIssueBodyToFile(repo: string, issue: number, outFile: string, options?: {
45
+ binary?: string | null;
46
+ runFn?: RunGhApiFn;
47
+ }): string;
38
48
  export interface GitHubBodyCliArgs {
39
49
  command: string;
40
50
  repo?: string;
@@ -43,6 +53,7 @@ export interface GitHubBodyCliArgs {
43
53
  comment?: number;
44
54
  pr?: number;
45
55
  bodyFile?: string;
56
+ outFile?: string;
46
57
  }
47
58
  export declare function githubBodyMain(args: GitHubBodyCliArgs): number;
48
59
  //# sourceMappingURL=github-body.d.ts.map
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { defaultWhich } from "../scm/binary.js";
3
3
  import { call } from "../scm/call.js";
4
4
  export const DEFAULT_TIMEOUT_SECONDS = 60;
@@ -73,14 +73,62 @@ function requireIntField(obj, field) {
73
73
  }
74
74
  return value;
75
75
  }
76
+ function requireStringField(obj, field) {
77
+ const value = obj[field];
78
+ if (typeof value !== "string") {
79
+ throw new GitHubBodyError(`response did not include string field ${JSON.stringify(field)}`);
80
+ }
81
+ return value;
82
+ }
83
+ function countNewlines(text) {
84
+ let count = 0;
85
+ for (const ch of text) {
86
+ if (ch === "\n")
87
+ count += 1;
88
+ }
89
+ return count;
90
+ }
91
+ function normalizeNewlines(text) {
92
+ return text.replace(/\r\n/g, "\n");
93
+ }
94
+ /** Fail closed when live read-back body is flattened or mojibaked vs intended payload (#2607). */
95
+ export function verifyBodyPostcondition(intended, live) {
96
+ if (intended === live) {
97
+ return;
98
+ }
99
+ if (normalizeNewlines(intended) === normalizeNewlines(live)) {
100
+ return;
101
+ }
102
+ const diagnoses = [];
103
+ if (live.includes("\uFFFD") && !intended.includes("\uFFFD")) {
104
+ diagnoses.push("live body contains U+FFFD replacement character not present in intended payload");
105
+ }
106
+ const intendedNl = countNewlines(intended);
107
+ const liveNl = countNewlines(live);
108
+ if (intendedNl > 0 && liveNl < intendedNl) {
109
+ diagnoses.push(`newline count mismatch (intended ${intendedNl}, live ${liveNl})`);
110
+ }
111
+ if (intended.includes("\n") && intended.replace(/\n/g, " ") === live) {
112
+ diagnoses.push("live body looks like intended newlines were collapsed to spaces (PowerShell string[] join)");
113
+ }
114
+ if (diagnoses.length > 0) {
115
+ throw new GitHubBodyError(`body postcondition failed: ${diagnoses.join("; ")}`);
116
+ }
117
+ throw new GitHubBodyError(`body postcondition failed: re-fetched body does not match intended payload (length intended=${intended.length}, live=${live.length})`);
118
+ }
76
119
  function mutateWithReadback(mutationEndpoint, method, payload, readbackEndpoint, options = {}) {
120
+ const intendedBody = typeof payload.body === "string" ? payload.body : undefined;
77
121
  const mutation = runGhApiJson([mutationEndpoint, "--method", method, "--input", "-"], {
78
122
  inputText: jsonInput(payload),
79
123
  binary: options.binary,
80
124
  runFn: options.runFn,
81
125
  });
82
126
  const endpoint = typeof readbackEndpoint === "function" ? readbackEndpoint(mutation) : readbackEndpoint;
83
- return runGhApiJson([endpoint], { binary: options.binary, runFn: options.runFn });
127
+ const readback = runGhApiJson([endpoint], { binary: options.binary, runFn: options.runFn });
128
+ if (intendedBody !== undefined) {
129
+ verifyBodyPostcondition(intendedBody, requireStringField(readback, "body"));
130
+ }
131
+ return readback;
84
132
  }
85
133
  export function createIssue(repo, options) {
86
134
  const [owner, name] = splitRepo(repo);
@@ -116,32 +164,55 @@ export function editPrBody(repo, pr, options) {
116
164
  runFn: options.runFn,
117
165
  });
118
166
  }
167
+ export function fetchIssueBody(repo, issue, options = {}) {
168
+ const [owner, name] = splitRepo(repo);
169
+ const endpoint = `repos/${owner}/${name}/issues/${issue}`;
170
+ const response = runGhApiJson([endpoint], { binary: options.binary, runFn: options.runFn });
171
+ return requireStringField(response, "body");
172
+ }
173
+ export function writeIssueBodyToFile(repo, issue, outFile, options = {}) {
174
+ const body = fetchIssueBody(repo, issue, options);
175
+ writeFileSync(outFile, body, { encoding: "utf8" });
176
+ return body;
177
+ }
119
178
  export function githubBodyMain(args) {
120
179
  try {
121
- const body = readBody(args.bodyFile ?? "-");
122
- let result;
123
180
  switch (args.command) {
124
- case "issue-create":
125
- result = createIssue(args.repo, { title: args.title, body });
126
- break;
127
- case "issue-edit":
128
- result = editIssueBody(args.repo, args.issue, { body });
129
- break;
130
- case "comment-create":
131
- result = createIssueComment(args.repo, args.issue, { body });
132
- break;
133
- case "comment-edit":
134
- result = editIssueCommentBody(args.repo, args.comment, { body });
135
- break;
136
- case "pr-edit":
137
- result = editPrBody(args.repo, args.pr, { body });
138
- break;
139
- default:
140
- process.stderr.write(`error: unknown command ${JSON.stringify(args.command)}\n`);
141
- return 1;
181
+ case "issue-fetch": {
182
+ if (args.repo === undefined || args.issue === undefined || args.outFile === undefined) {
183
+ process.stderr.write("error: issue-fetch requires --repo, --issue, and --out-file\n");
184
+ return 1;
185
+ }
186
+ writeIssueBodyToFile(args.repo, args.issue, args.outFile);
187
+ return 0;
188
+ }
189
+ default: {
190
+ const body = readBody(args.bodyFile ?? "-");
191
+ let result;
192
+ switch (args.command) {
193
+ case "issue-create":
194
+ result = createIssue(args.repo, { title: args.title, body });
195
+ break;
196
+ case "issue-edit":
197
+ result = editIssueBody(args.repo, args.issue, { body });
198
+ break;
199
+ case "comment-create":
200
+ result = createIssueComment(args.repo, args.issue, { body });
201
+ break;
202
+ case "comment-edit":
203
+ result = editIssueCommentBody(args.repo, args.comment, { body });
204
+ break;
205
+ case "pr-edit":
206
+ result = editPrBody(args.repo, args.pr, { body });
207
+ break;
208
+ default:
209
+ process.stderr.write(`error: unknown command ${JSON.stringify(args.command)}\n`);
210
+ return 1;
211
+ }
212
+ process.stdout.write(`${JSON.stringify(result)}\n`);
213
+ return 0;
214
+ }
142
215
  }
143
- process.stdout.write(`${JSON.stringify(result)}\n`);
144
- return 0;
145
216
  }
146
217
  catch (exc) {
147
218
  process.stderr.write(`error: ${String(exc)}\n`);
@@ -98,7 +98,7 @@ export function createConsumerProject(parent) {
98
98
  export function writeScopeVbrief(projectRoot, folder, filename, status = "proposed") {
99
99
  const target = join(projectRoot, "xbrief", folder, filename);
100
100
  writeFileSync(target, `${JSON.stringify({
101
- vBRIEFInfo: { version: "0.5" },
101
+ xBRIEFInfo: { version: "0.8" },
102
102
  plan: {
103
103
  title: "Consumer fixture scope",
104
104
  status,
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFileSync, writeFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
+ import { assertWriteTargetSafe } from "../fs/projection-containment.js";
4
5
  import { createIssueComment } from "../intake/github-body.js";
5
6
  import { provenanceIssueNumber, repoSlugFromUrl } from "../intake/issue-ingest.js";
6
7
  import { extractReferencesFromVbrief, GITHUB_ISSUE_REF_TYPES, parseIssueNumber, } from "../intake/reconcile-issues.js";
@@ -192,9 +193,11 @@ export function syncFromXbrief(options) {
192
193
  writeOut(`issue:sync-from-xbrief: posted comment to ${origin.repo}#${origin.number} (id: ${commentResult.id}).`);
193
194
  const writeFingerprint = options.writeFingerprint ??
194
195
  ((targetPath, stamped) => {
196
+ assertWriteTargetSafe(projectRoot, targetPath);
195
197
  writeFileSync(targetPath, `${JSON.stringify(stamped, null, 2)}\n`, "utf8");
196
198
  });
197
199
  try {
200
+ assertWriteTargetSafe(projectRoot, absPath);
198
201
  writeFingerprint(absPath, stampIssueSyncFingerprint(data, origin));
199
202
  }
200
203
  catch (exc) {
@@ -0,0 +1,25 @@
1
+ import type { RunGhFn } from "../pr-protected-issues/types.js";
2
+ export type OutputStream = "stdout" | "stderr" | "none";
3
+ export interface OrphanActiveBrief {
4
+ readonly path: string;
5
+ readonly reason: string;
6
+ }
7
+ export interface EvaluateResult {
8
+ readonly code: 0 | 1 | 2;
9
+ readonly message: string;
10
+ readonly stream: OutputStream;
11
+ readonly orphans: readonly OrphanActiveBrief[];
12
+ }
13
+ export interface EvaluateOptions {
14
+ readonly quiet?: boolean;
15
+ readonly repo?: string | null;
16
+ readonly runGh?: RunGhFn;
17
+ readonly skipGh?: boolean;
18
+ }
19
+ /**
20
+ * Pure evaluator for orphaned active/running xBRIEF detection (#2321).
21
+ * Fails when active/ briefs with plan.status==running reference only closed
22
+ * issues and/or a merged PR — the stop-at:pr-open orphan signature.
23
+ */
24
+ export declare function evaluate(projectRoot: string, options?: EvaluateOptions): EvaluateResult;
25
+ //# sourceMappingURL=evaluate.d.ts.map