@deftai/directive-core 0.80.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 (82) 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/doctor/checks.d.ts +6 -0
  6. package/dist/doctor/checks.js +139 -0
  7. package/dist/doctor/main.js +2 -1
  8. package/dist/eval/crud-telemetry.d.ts +2 -0
  9. package/dist/eval/crud-telemetry.js +30 -0
  10. package/dist/intake/github-body-cli.js +2 -0
  11. package/dist/intake/github-body.d.ts +11 -0
  12. package/dist/intake/github-body.js +95 -24
  13. package/dist/integration-e2e/helpers.js +1 -1
  14. package/dist/issue-sync/sync-from-xbrief.js +3 -0
  15. package/dist/orphan-active/evaluate.d.ts +25 -0
  16. package/dist/orphan-active/evaluate.js +275 -0
  17. package/dist/orphan-active/index.d.ts +3 -0
  18. package/dist/orphan-active/index.js +3 -0
  19. package/dist/orphan-active/refs.d.ts +16 -0
  20. package/dist/orphan-active/refs.js +105 -0
  21. package/dist/policy/index.d.ts +1 -0
  22. package/dist/policy/index.js +19 -5
  23. package/dist/policy/product-signal.d.ts +45 -0
  24. package/dist/policy/product-signal.js +210 -0
  25. package/dist/pr-wait-mergeable/cascade.d.ts +8 -0
  26. package/dist/pr-wait-mergeable/cascade.js +18 -0
  27. package/dist/pr-wait-mergeable/main.d.ts +4 -0
  28. package/dist/pr-wait-mergeable/main.js +44 -73
  29. package/dist/pr-wait-mergeable/result.d.ts +2 -1
  30. package/dist/pr-wait-mergeable/result.js +4 -0
  31. package/dist/pr-wait-mergeable/semantic-green.d.ts +27 -0
  32. package/dist/pr-wait-mergeable/semantic-green.js +202 -0
  33. package/dist/pr-wait-mergeable/types.d.ts +1 -0
  34. package/dist/pr-watch/constants.d.ts +4 -4
  35. package/dist/pr-watch/constants.js +4 -4
  36. package/dist/pr-watch/watch.js +5 -3
  37. package/dist/product-signal/actor-name.d.ts +18 -0
  38. package/dist/product-signal/actor-name.js +94 -0
  39. package/dist/product-signal/consent.d.ts +30 -0
  40. package/dist/product-signal/consent.js +116 -0
  41. package/dist/product-signal/gates.d.ts +17 -0
  42. package/dist/product-signal/gates.js +66 -0
  43. package/dist/product-signal/github-private-sink-adapter.d.ts +28 -0
  44. package/dist/product-signal/github-private-sink-adapter.js +274 -0
  45. package/dist/product-signal/headless.d.ts +8 -0
  46. package/dist/product-signal/headless.js +32 -0
  47. package/dist/product-signal/install-context.d.ts +13 -0
  48. package/dist/product-signal/install-context.js +41 -0
  49. package/dist/product-signal/local-signal-summary.d.ts +7 -0
  50. package/dist/product-signal/local-signal-summary.js +173 -0
  51. package/dist/product-signal/payload.d.ts +90 -0
  52. package/dist/product-signal/payload.js +124 -0
  53. package/dist/product-signal/sink-bootstrap.d.ts +29 -0
  54. package/dist/product-signal/sink-bootstrap.js +108 -0
  55. package/dist/product-signal/submit-adapter.d.ts +14 -0
  56. package/dist/product-signal/submit-adapter.js +2 -0
  57. package/dist/product-signal/submit.d.ts +54 -0
  58. package/dist/product-signal/submit.js +290 -0
  59. package/dist/release/native-steps.js +11 -4
  60. package/dist/render/framework-commands.js +6 -0
  61. package/dist/scm/gh-rest.d.ts +3 -1
  62. package/dist/scm/gh-rest.js +22 -0
  63. package/dist/scope/brief-io.d.ts +26 -0
  64. package/dist/scope/brief-io.js +77 -0
  65. package/dist/scope/decompose.js +2 -2
  66. package/dist/scope/decomposed-refs.js +3 -3
  67. package/dist/scope/demote.js +2 -2
  68. package/dist/scope/project-definition-sync.d.ts +2 -2
  69. package/dist/scope/project-definition-sync.js +15 -3
  70. package/dist/scope/registry-artifact-sync.d.ts +6 -1
  71. package/dist/scope/registry-artifact-sync.js +31 -13
  72. package/dist/scope/transition.js +23 -15
  73. package/dist/scope/undo.js +2 -2
  74. package/dist/scope/vbrief-json.d.ts +1 -2
  75. package/dist/scope/vbrief-json.js +1 -4
  76. package/dist/staleness-tickler/state.js +19 -2
  77. package/dist/triage/queue/cache.js +5 -0
  78. package/dist/verify-source/index.d.ts +1 -0
  79. package/dist/verify-source/index.js +1 -0
  80. package/dist/verify-source/skill-external-fetch-gate.d.ts +21 -0
  81. package/dist/verify-source/skill-external-fetch-gate.js +71 -0
  82. 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
@@ -39,6 +39,12 @@ export declare const STALE_VBRIEF_CORE_SCHEMA_REL: string;
39
39
  * `directive update` instead. Advisory only (exit-exempt).
40
40
  */
41
41
  export declare function checkStaleXbriefSchemaDeposit(projectRoot: string, seams?: CheckSeams): CheckResult;
42
+ /**
43
+ * #2591: advisory hint when a TypeScript project pins bare `typescript@7` alongside
44
+ * typescript-eslint without the `@typescript/typescript6` alias. Exit-exempt (like
45
+ * gitignore-coverage) — adoption guidance, not a broken install.
46
+ */
47
+ export declare function checkTypescript7SideBySide(projectRoot: string, seams?: CheckSeams): CheckResult;
42
48
  /**
43
49
  * #2206: check that the consumer `.gitignore` carries the canonical Deft baseline
44
50
  * entries. Advisory (exit-exempt) because missing entries are an adoption risk, not
@@ -523,6 +523,142 @@ export function checkStaleXbriefSchemaDeposit(projectRoot, seams = {}) {
523
523
  },
524
524
  };
525
525
  }
526
+ const TS7_SIDE_BY_SIDE_CHECK = "typescript-7-side-by-side";
527
+ function depKeyIsTypescriptEslint(key) {
528
+ return key === "typescript-eslint" || key.startsWith("@typescript-eslint/");
529
+ }
530
+ function depKeysIncludeTypescriptEslint(keys) {
531
+ return keys.some(depKeyIsTypescriptEslint);
532
+ }
533
+ function depKeysIncludeEslint(keys) {
534
+ return keys.includes("eslint");
535
+ }
536
+ function typescriptValueIs7Bound(value) {
537
+ const v = value.trim();
538
+ if (!v || v.includes("@typescript/typescript6")) {
539
+ return false;
540
+ }
541
+ if (/npm:typescript@(?:[\^~>=<]*|\d*)7/i.test(v)) {
542
+ return true;
543
+ }
544
+ if (/^[\^~>=<]*7(?:\.\d|$)/.test(v)) {
545
+ return true;
546
+ }
547
+ if (/^7\.\d/.test(v)) {
548
+ return true;
549
+ }
550
+ return false;
551
+ }
552
+ function resolveTypescriptDepValue(deps, devDeps) {
553
+ if (typeof devDeps.typescript === "string") {
554
+ return devDeps.typescript;
555
+ }
556
+ if (typeof deps.typescript === "string") {
557
+ return deps.typescript;
558
+ }
559
+ return null;
560
+ }
561
+ /**
562
+ * #2591: advisory hint when a TypeScript project pins bare `typescript@7` alongside
563
+ * typescript-eslint without the `@typescript/typescript6` alias. Exit-exempt (like
564
+ * gitignore-coverage) — adoption guidance, not a broken install.
565
+ */
566
+ export function checkTypescript7SideBySide(projectRoot, seams = {}) {
567
+ const packageJsonPath = join(projectRoot, "package.json");
568
+ const text = readText(packageJsonPath, seams);
569
+ if (text === null) {
570
+ return {
571
+ name: TS7_SIDE_BY_SIDE_CHECK,
572
+ status: "skip",
573
+ detail: "package.json not found; nothing to inspect for TypeScript side-by-side layout.",
574
+ data: { package_json_path: packageJsonPath },
575
+ };
576
+ }
577
+ let parsed;
578
+ try {
579
+ parsed = JSON.parse(text);
580
+ }
581
+ catch {
582
+ return {
583
+ name: TS7_SIDE_BY_SIDE_CHECK,
584
+ status: "skip",
585
+ detail: "package.json is unreadable; skipping TypeScript side-by-side check.",
586
+ data: { package_json_path: packageJsonPath },
587
+ };
588
+ }
589
+ if (typeof parsed !== "object" || parsed === null) {
590
+ return {
591
+ name: TS7_SIDE_BY_SIDE_CHECK,
592
+ status: "skip",
593
+ detail: "package.json has no dependency sections; skipping TypeScript side-by-side check.",
594
+ data: { package_json_path: packageJsonPath },
595
+ };
596
+ }
597
+ const record = parsed;
598
+ const deps = typeof record.dependencies === "object" && record.dependencies !== null
599
+ ? record.dependencies
600
+ : {};
601
+ const devDeps = typeof record.devDependencies === "object" && record.devDependencies !== null
602
+ ? record.devDependencies
603
+ : {};
604
+ const depKeys = [...Object.keys(deps), ...Object.keys(devDeps)];
605
+ if (depKeys.length === 0) {
606
+ return {
607
+ name: TS7_SIDE_BY_SIDE_CHECK,
608
+ status: "skip",
609
+ detail: "package.json has no dependency sections; skipping TypeScript side-by-side check.",
610
+ data: { package_json_path: packageJsonPath },
611
+ };
612
+ }
613
+ if (!depKeysIncludeTypescriptEslint(depKeys)) {
614
+ return {
615
+ name: TS7_SIDE_BY_SIDE_CHECK,
616
+ status: "pass",
617
+ detail: "No typescript-eslint packages declared; TypeScript 7 side-by-side alias not required.",
618
+ data: { package_json_path: packageJsonPath },
619
+ };
620
+ }
621
+ if (!depKeysIncludeEslint(depKeys)) {
622
+ return {
623
+ name: TS7_SIDE_BY_SIDE_CHECK,
624
+ status: "pass",
625
+ detail: "eslint is not declared; TypeScript 7 side-by-side alias not required.",
626
+ data: { package_json_path: packageJsonPath },
627
+ };
628
+ }
629
+ const typescriptValue = resolveTypescriptDepValue(deps, devDeps);
630
+ if (typescriptValue === null) {
631
+ return {
632
+ name: TS7_SIDE_BY_SIDE_CHECK,
633
+ status: "pass",
634
+ detail: "No typescript dependency declared; TypeScript 7 side-by-side alias not required.",
635
+ data: { package_json_path: packageJsonPath },
636
+ };
637
+ }
638
+ if (!typescriptValueIs7Bound(typescriptValue)) {
639
+ return {
640
+ name: TS7_SIDE_BY_SIDE_CHECK,
641
+ status: "pass",
642
+ detail: "typescript is not pinned to 7.x (or uses the @typescript/typescript6 alias); side-by-side layout not required.",
643
+ data: {
644
+ package_json_path: packageJsonPath,
645
+ typescript: typescriptValue,
646
+ },
647
+ };
648
+ }
649
+ return {
650
+ name: TS7_SIDE_BY_SIDE_CHECK,
651
+ status: "fail",
652
+ detail: "typescript resolves to 7.x without the @typescript/typescript6 alias while typescript-eslint and eslint are present. " +
653
+ "Until TS 7.1, use the side-by-side alias pattern in languages/typescript.md § TypeScript 7 side-by-side (pre-7.1) " +
654
+ "(Cartograph #111: keep typescript on npm:@typescript/typescript6 and install TS 7 under @typescript/native).",
655
+ data: {
656
+ package_json_path: packageJsonPath,
657
+ typescript: typescriptValue,
658
+ suggested_fix: "languages/typescript.md#typescript-7-side-by-side-pre-71",
659
+ },
660
+ };
661
+ }
526
662
  /**
527
663
  * #2206: check that the consumer `.gitignore` carries the canonical Deft baseline
528
664
  * entries. Advisory (exit-exempt) because missing entries are an adoption risk, not
@@ -580,6 +716,7 @@ export function deriveExitCode(checks, errors) {
580
716
  "manifest-version-reportable",
581
717
  "gitignore-coverage",
582
718
  "stale-xbrief-schema-deposit",
719
+ "typescript-7-side-by-side",
583
720
  ]);
584
721
  if (errors.length > 0 || checks.some((c) => c.status === "error")) {
585
722
  return 2;
@@ -621,6 +758,7 @@ export function runChecksImpl(projectRoot, seams = {}) {
621
758
  checks.push(checkCanonicalVendoredNpmSignpost(projectRoot, seams));
622
759
  checks.push(checkStaleXbriefSchemaDeposit(projectRoot, seams));
623
760
  checks.push(checkGitignoreCoverage(projectRoot, seams));
761
+ checks.push(checkTypescript7SideBySide(projectRoot, seams));
624
762
  return {
625
763
  projectRoot,
626
764
  installRoot: null,
@@ -638,6 +776,7 @@ export function runChecksImpl(projectRoot, seams = {}) {
638
776
  checks.push(checkCanonicalVendoredNpmSignpost(projectRoot, seams));
639
777
  checks.push(checkStaleXbriefSchemaDeposit(projectRoot, seams));
640
778
  checks.push(checkGitignoreCoverage(projectRoot, seams));
779
+ checks.push(checkTypescript7SideBySide(projectRoot, seams));
641
780
  return {
642
781
  projectRoot,
643
782
  installRoot,
@@ -424,7 +424,8 @@ function runInstallIntegrityChecks(projectRoot, sink, addFinding, seams) {
424
424
  name === "canonical-vendored-npm-signpost" ||
425
425
  name === "manifest-version-reportable" ||
426
426
  name === "gitignore-coverage" ||
427
- name === "stale-xbrief-schema-deposit") &&
427
+ name === "stale-xbrief-schema-deposit" ||
428
+ name === "typescript-7-side-by-side") &&
428
429
  status === "fail") {
429
430
  sink.warn(`${name}: ${detail}`);
430
431
  addFinding({
@@ -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) {