@deftai/directive-core 0.64.0 → 0.65.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.
@@ -63,6 +63,8 @@ const SKIP_DIRS = new Set([
63
63
  "dist",
64
64
  "tests",
65
65
  ".deft-cache",
66
+ // Swarm worktrees under .deft-scratch/ are not framework content (#1656).
67
+ ".deft-scratch",
66
68
  // node_modules holds third-party markdown (dependency READMEs) that is not
67
69
  // framework content; scanning it produced volatile, version-pinned cases that
68
70
  // broke CI when dependency versions drifted (#1993 follow-up).
@@ -6,7 +6,7 @@ import { resolveProjectRoot } from "../scope/project-context.js";
6
6
  import { resolveProjectRepo } from "../slice/project-context.js";
7
7
  import { slugify, TODAY } from "../vbrief-build/build.js";
8
8
  import { EMITTED_VBRIEF_VERSION } from "../vbrief-build/constants.js";
9
- import { findAcHeading, parseCheckboxItems, parseListItems, sliceAcSection, stripCodeBlocks, } from "./markdown-scanners.js";
9
+ import { findAcHeading, parseCheckboxItems, parseListItems, sliceAcSection, stripCodeBlocks, stripFencedCodeBlocks, } from "./markdown-scanners.js";
10
10
  import { detectRepo, extractReferencesFromVbrief, fetchOpenIssues, GITHUB_ISSUE_REF_TYPES, LIFECYCLE_FOLDERS, parseIssueNumber, } from "./reconcile-issues.js";
11
11
  export const INGEST_STATUSES = ["proposed", "pending", "active"];
12
12
  const STATUS_MAP = {
@@ -70,7 +70,7 @@ export function extractPlanItems(body) {
70
70
  if (body.length === 0) {
71
71
  return [];
72
72
  }
73
- const text = stripCodeBlocks(body);
73
+ const text = stripFencedCodeBlocks(body);
74
74
  const checkboxItems = parseCheckboxItems(text);
75
75
  if (checkboxItems.length > 0) {
76
76
  return checkboxItems.map((item) => ({ title: item.title, status: item.status }));
@@ -313,11 +313,11 @@ export function fetchSingleIssue(repo, number, options = {}) {
313
313
  }
314
314
  }
315
315
  export function fetchIssue(repo, number, options = {}) {
316
- const cached = fetchFromCache(repo, number, options);
317
- if (cached !== null) {
318
- return cached;
316
+ const live = fetchSingleIssue(repo, number, options);
317
+ if (live !== null) {
318
+ return live;
319
319
  }
320
- return fetchSingleIssue(repo, number, options);
320
+ return fetchFromCache(repo, number, options);
321
321
  }
322
322
  export function ingestOne(issue, options) {
323
323
  const number = Number(issue.number);
@@ -2,7 +2,9 @@
2
2
  * Linear-time markdown scanners for issue body parsing (#1784 / #1248).
3
3
  * Avoids nested/polynomial regex on issue bodies (ReDoS-safe).
4
4
  */
5
- /** Strip fenced and inline code spans before cross-ref / plan-item extraction. */
5
+ /** Strip fenced code blocks only (preserves inline backtick spans). */
6
+ export declare function stripFencedCodeBlocks(body: string): string;
7
+ /** Strip fenced and inline code spans before cross-ref extraction. */
6
8
  export declare function stripCodeBlocks(body: string): string;
7
9
  export interface CheckboxItem {
8
10
  readonly title: string;
@@ -2,16 +2,23 @@
2
2
  * Linear-time markdown scanners for issue body parsing (#1784 / #1248).
3
3
  * Avoids nested/polynomial regex on issue bodies (ReDoS-safe).
4
4
  */
5
- /** Strip fenced and inline code spans before cross-ref / plan-item extraction. */
5
+ /** Strip fenced code blocks only (preserves inline backtick spans). */
6
+ export function stripFencedCodeBlocks(body) {
7
+ if (body.length === 0) {
8
+ return "";
9
+ }
10
+ return stripFencedCodeBlocksInner(body);
11
+ }
12
+ /** Strip fenced and inline code spans before cross-ref extraction. */
6
13
  export function stripCodeBlocks(body) {
7
14
  if (body.length === 0) {
8
15
  return "";
9
16
  }
10
- let text = stripFencedCodeBlocks(body);
17
+ let text = stripFencedCodeBlocksInner(body);
11
18
  text = stripInlineCode(text);
12
19
  return text;
13
20
  }
14
- function stripFencedCodeBlocks(text) {
21
+ function stripFencedCodeBlocksInner(text) {
15
22
  let out = "";
16
23
  let i = 0;
17
24
  while (i < text.length) {
@@ -2,6 +2,7 @@ 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
4
  import { call } from "../scm/call.js";
5
+ import { updateDecomposedChildBackReferences } from "../scope/decomposed-refs.js";
5
6
  import { resolveProjectRoot } from "../scope/project-context.js";
6
7
  import { resolveProjectRepo } from "../slice/project-context.js";
7
8
  export const LIFECYCLE_FOLDERS = [
@@ -643,6 +644,7 @@ export function applyLifecycleFixes(vbriefDir, report, projectRoot = null) {
643
644
  failures.push(`failed to move ${relPath} -> ${destFolder}/`);
644
645
  continue;
645
646
  }
647
+ updateDecomposedChildBackReferences(data, src, dst, vbriefDir);
646
648
  moved += 1;
647
649
  }
648
650
  return [moved, skipped, failures];
@@ -14,6 +14,7 @@ import { execFileSync } from "node:child_process";
14
14
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
15
15
  import { join, relative } from "node:path";
16
16
  import { probeCacheDrift } from "../cache/fetch.js";
17
+ import { latestDecisionForIssue as auditLatestDecisionForIssue } from "../triage/actions/candidates-log.js";
17
18
  // ---------------------------------------------------------------------------
18
19
  // Public constants (mirror preflight_cache.py)
19
20
  // ---------------------------------------------------------------------------
@@ -258,43 +259,6 @@ function candidatesLogState(candidatesPath) {
258
259
  return "absent";
259
260
  }
260
261
  }
261
- function parseCandidates(candidatesPath) {
262
- if (!existsSync(candidatesPath))
263
- return [];
264
- try {
265
- const text = readFileSync(candidatesPath, "utf8");
266
- const entries = [];
267
- for (const line of text.split("\n")) {
268
- const trimmed = line.trim();
269
- if (!trimmed)
270
- continue;
271
- try {
272
- const data = JSON.parse(trimmed);
273
- if (data !== null && typeof data === "object" && !Array.isArray(data)) {
274
- const d = data;
275
- entries.push({
276
- issue: Number(d.issue ?? d.issue_number ?? 0),
277
- repo: String(d.repo ?? ""),
278
- decision: String(d.decision ?? ""),
279
- ts: String(d.ts ?? d.timestamp ?? ""),
280
- });
281
- }
282
- }
283
- catch {
284
- /* skip malformed lines */
285
- }
286
- }
287
- return entries;
288
- }
289
- catch {
290
- return [];
291
- }
292
- }
293
- function latestDecisionForIssue(candidates, repo, issueNumber) {
294
- // Take all entries matching this issue (most recent last)
295
- const matching = candidates.filter((c) => c.issue === issueNumber && c.repo === repo);
296
- return matching[matching.length - 1] ?? null;
297
- }
298
262
  // ---------------------------------------------------------------------------
299
263
  // Main evaluate function
300
264
  // ---------------------------------------------------------------------------
@@ -541,9 +505,8 @@ function evaluateForIssue(repo, issueNumber, candidatesPath, cacheRoot, source,
541
505
  }
542
506
  }
543
507
  }
544
- // Latest-decision check
545
- const candidates = parseCandidates(candidatesPath);
546
- const latest = latestDecisionForIssue(candidates, repo, issueNumber);
508
+ // Latest-decision check — shared audit-log reader (#1887 / candidates-log.ts)
509
+ const latest = auditLatestDecisionForIssue(issueNumber, repo, candidatesPath);
547
510
  if (latest === null) {
548
511
  return {
549
512
  code: 1,
@@ -1,5 +1,6 @@
1
1
  import { GROUP_ORDER } from "./constants.js";
2
2
  import { deriveGroup } from "./derive-group.js";
3
+ import { hasActiveScopeIgnores, isCachedIssueScopeIgnored, } from "./scope-ignores-filter.js";
3
4
  import { compareSelectionKeys, matchedLabelFor, withinGroupSortKey } from "./selection.js";
4
5
  function resolveRank(issue, number, rankByNumber) {
5
6
  let candidate = rankByNumber.get(number);
@@ -71,6 +72,8 @@ export function buildQueue(issues, auditEntries, options) {
71
72
  const dropNetNew = Boolean(opts.finishBeforeStart && opts.wipAtCap);
72
73
  const includeBlocked = Boolean(opts.includeBlocked);
73
74
  const limit = opts.limit;
75
+ const scopeIgnores = opts.scopeIgnores;
76
+ const filterScopeIgnores = scopeIgnores !== undefined && hasActiveScopeIgnores(scopeIgnores);
74
77
  const decisions = latestDecisionsByIssue(auditEntries);
75
78
  const grouped = new Map();
76
79
  for (const group of GROUP_ORDER) {
@@ -81,6 +84,9 @@ export function buildQueue(issues, auditEntries, options) {
81
84
  if (typeof n !== "number") {
82
85
  continue;
83
86
  }
87
+ if (filterScopeIgnores && isCachedIssueScopeIgnored(issue, scopeIgnores)) {
88
+ continue;
89
+ }
84
90
  const isContinuation = resolveContinuation(issue, n, continuationNumbers);
85
91
  const isOrphan = orphanIssueNumbers.has(n);
86
92
  if (dropNetNew && !isContinuation && !isOrphan) {
@@ -1,6 +1,8 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
+ import { extractAuthor, extractMilestone } from "../scope-drift/cache-walker.js";
3
4
  import { CACHE_DIR_NAME, CACHE_SOURCE_GITHUB_ISSUE, DEFAULT_SLICES_LOG_REL_PATH, } from "./constants.js";
5
+ import { hasActiveScopeIgnores, isRawIssueScopeIgnored, resolveScopeIgnores, } from "./scope-ignores-filter.js";
4
6
  import { blockedByIssueNumber, rankByIssueNumber } from "./scope-walk.js";
5
7
  function cachedState(issue) {
6
8
  if (issue === undefined) {
@@ -116,6 +118,8 @@ export function loadCachedIssues(repo, options) {
116
118
  }
117
119
  const rankMap = rankByIssueNumber(root);
118
120
  const blockedSet = blockedByIssueNumber(root);
121
+ const scopeIgnores = resolveScopeIgnores(root);
122
+ const filterScopeIgnores = hasActiveScopeIgnores(scopeIgnores);
119
123
  const issues = [];
120
124
  for (const entryName of readdirSync(base)) {
121
125
  if (!/^\d+$/.test(entryName)) {
@@ -150,11 +154,16 @@ export function loadCachedIssues(repo, options) {
150
154
  if (state !== "open" && !options.includeClosed) {
151
155
  continue;
152
156
  }
157
+ if (filterScopeIgnores && isRawIssueScopeIgnored(payload, scopeIgnores)) {
158
+ continue;
159
+ }
153
160
  issues.push({
154
161
  number: n,
155
162
  title: typeof payload.title === "string" ? payload.title : "",
156
163
  state,
157
164
  labels: parseLabels(payload.labels),
165
+ author: extractAuthor(payload),
166
+ milestone: extractMilestone(payload),
158
167
  updatedAt: typeof payload.updated_at === "string" ? payload.updated_at : "",
159
168
  createdAt: typeof payload.created_at === "string" ? payload.created_at : "",
160
169
  metadataRank: rankMap.get(n) ?? null,
@@ -6,6 +6,7 @@ export * from "./derive-group.js";
6
6
  export * from "./ranking-labels.js";
7
7
  export * from "./render.js";
8
8
  export * from "./repo.js";
9
+ export * from "./scope-ignores-filter.js";
9
10
  export * from "./scope-walk.js";
10
11
  export * from "./selection.js";
11
12
  export * from "./types.js";
@@ -6,6 +6,7 @@ export * from "./derive-group.js";
6
6
  export * from "./ranking-labels.js";
7
7
  export * from "./render.js";
8
8
  export * from "./repo.js";
9
+ export * from "./scope-ignores-filter.js";
9
10
  export * from "./scope-walk.js";
10
11
  export * from "./selection.js";
11
12
  export * from "./types.js";
@@ -0,0 +1,12 @@
1
+ import { resolveScopeIgnores, type ScopeIgnores } from "../scope-drift/scope-rules.js";
2
+ export { resolveScopeIgnores, type ScopeIgnores };
3
+ export declare function hasActiveScopeIgnores(ignores: ScopeIgnores): boolean;
4
+ /** True when a cached raw.json payload matches any triageScopeIgnores entry. */
5
+ export declare function isRawIssueScopeIgnored(raw: Record<string, unknown>, ignores: ScopeIgnores): boolean;
6
+ /** True when a queue CachedIssue matches any triageScopeIgnores entry. */
7
+ export declare function isCachedIssueScopeIgnored(issue: {
8
+ readonly labels: readonly string[];
9
+ readonly author?: string;
10
+ readonly milestone?: string;
11
+ }, ignores: ScopeIgnores): boolean;
12
+ //# sourceMappingURL=scope-ignores-filter.d.ts.map
@@ -0,0 +1,50 @@
1
+ import { extractAuthor, extractLabels, extractMilestone } from "../scope-drift/cache-walker.js";
2
+ import { resolveScopeIgnores } from "../scope-drift/scope-rules.js";
3
+ export { resolveScopeIgnores };
4
+ export function hasActiveScopeIgnores(ignores) {
5
+ return ignores.labels.size > 0 || ignores.milestones.size > 0 || ignores.authors.size > 0;
6
+ }
7
+ /** True when a cached raw.json payload matches any triageScopeIgnores entry. */
8
+ export function isRawIssueScopeIgnored(raw, ignores) {
9
+ if (ignores.authors.size > 0 && ignores.authors.has(extractAuthor(raw))) {
10
+ return true;
11
+ }
12
+ if (ignores.labels.size > 0) {
13
+ for (const label of extractLabels(raw)) {
14
+ if (ignores.labels.has(label)) {
15
+ return true;
16
+ }
17
+ }
18
+ }
19
+ if (ignores.milestones.size > 0) {
20
+ const milestone = extractMilestone(raw);
21
+ if (milestone && ignores.milestones.has(milestone)) {
22
+ return true;
23
+ }
24
+ }
25
+ return false;
26
+ }
27
+ /** True when a queue CachedIssue matches any triageScopeIgnores entry. */
28
+ export function isCachedIssueScopeIgnored(issue, ignores) {
29
+ if (ignores.authors.size > 0 &&
30
+ issue.author !== undefined &&
31
+ issue.author.length > 0 &&
32
+ ignores.authors.has(issue.author)) {
33
+ return true;
34
+ }
35
+ if (ignores.labels.size > 0) {
36
+ for (const label of issue.labels) {
37
+ if (ignores.labels.has(label)) {
38
+ return true;
39
+ }
40
+ }
41
+ }
42
+ if (ignores.milestones.size > 0 &&
43
+ issue.milestone !== undefined &&
44
+ issue.milestone.length > 0 &&
45
+ ignores.milestones.has(issue.milestone)) {
46
+ return true;
47
+ }
48
+ return false;
49
+ }
50
+ //# sourceMappingURL=scope-ignores-filter.js.map
@@ -1,4 +1,5 @@
1
1
  import type { QueueGroup } from "./constants.js";
2
+ import type { ScopeIgnores } from "./scope-ignores-filter.js";
2
3
  /** One ranked row in buildQueue. */
3
4
  export interface QueueItem {
4
5
  readonly number: number;
@@ -25,6 +26,7 @@ export interface QueueBuildOptions {
25
26
  readonly blockedIssueNumbers?: ReadonlySet<number>;
26
27
  readonly includeBlocked?: boolean;
27
28
  readonly limit?: number | null;
29
+ readonly scopeIgnores?: ScopeIgnores;
28
30
  }
29
31
  /** Cached issue row produced by loadCachedIssues. */
30
32
  export interface CachedIssue {
@@ -32,6 +34,8 @@ export interface CachedIssue {
32
34
  readonly title: string;
33
35
  readonly state: string;
34
36
  readonly labels: readonly string[];
37
+ readonly author?: string;
38
+ readonly milestone?: string;
35
39
  readonly updatedAt: string;
36
40
  readonly createdAt: string;
37
41
  readonly metadataRank: number | null;
@@ -298,6 +298,6 @@ export function subscribe(projectRoot, options = {}) {
298
298
  export function unsubscribe(projectRoot, options = {}) {
299
299
  return mutate(projectRoot, { op: "unsubscribe", ...options });
300
300
  }
301
- export const RECONCILE_HINT = " Reconciliation: run `task triage:bootstrap -- --resume` to " +
301
+ export const RECONCILE_HINT = " Reconciliation: run `task triage:bootstrap` to " +
302
302
  "backfill / mark out-of-scope cached entries.";
303
303
  //# sourceMappingURL=index.js.map
@@ -8,6 +8,8 @@ export declare const CANDIDATES_LOG_REL_PATH = "vbrief/.eval/candidates.jsonl";
8
8
  export { latestDecisions, readAuditLog } from "../actions/candidates-log.js";
9
9
  export declare const SUMMARY_HISTORY_REL_PATH = "vbrief/.eval/summary-history.jsonl";
10
10
  export declare const SUMMARY_HISTORY_SCHEMA = "deft.triage.summary.v1";
11
+ /** D2 repeat-suppression window for session-start triage one-liner (#1122 / #1279). */
12
+ export declare const D2_SUPPRESSION_WINDOW_HOURS = 4;
11
13
  export declare const EMPTY_CACHE_LINE = "[triage] cache empty -- run task triage:bootstrap";
12
14
  export declare const WIP_LIFECYCLE_DIRS: readonly ["pending", "active"];
13
15
  export declare const FILESYSTEM_IN_FLIGHT_FOLDER = "active";
@@ -45,6 +47,10 @@ export declare function countFilesystemInFlight(projectRoot: string): number;
45
47
  export declare function isTriageScopeExplicitlyConfigured(projectRoot: string): boolean;
46
48
  /** Read `plan.policy.wipCap`; always returns an integer cap. */
47
49
  export declare function resolveWipCapInt(projectRoot: string): number;
50
+ /** Read `raw.json` state for a cached github-issue entry; defaults to open when absent. */
51
+ export declare function readCachedIssueState(cacheRoot: string, repo: string, issueNumber: number): string;
52
+ /** True when a cached issue should participate in summary classification counts. */
53
+ export declare function isCachedIssueOpen(cacheRoot: string, repo: string, issueNumber: number): boolean;
48
54
  export declare function computeSummary(projectRoot: string, options?: ComputeSummaryOptions): SummaryResult;
49
55
  export declare function formatOneLiner(result: SummaryResult, options?: {
50
56
  maxChars?: number;
@@ -58,6 +64,33 @@ export declare function summaryResultToRecord(result: SummaryResult, options: {
58
64
  emittedAt: string;
59
65
  line: string;
60
66
  }): Record<string, unknown>;
67
+ /** Structured fields that drive operator-visible triage summary output (#1279). */
68
+ export type SuppressionKeyFields = {
69
+ readonly cache_empty: boolean;
70
+ readonly untriaged: number;
71
+ readonly stale_defer: number;
72
+ readonly in_flight: number;
73
+ readonly in_flight_filesystem: number;
74
+ readonly in_flight_cache_scoped: number;
75
+ readonly triage_scope_configured: boolean;
76
+ readonly wip_count: number;
77
+ readonly wip_cap: number;
78
+ readonly repos: readonly string[];
79
+ readonly scope_drift: number;
80
+ readonly reconcilable: number;
81
+ };
82
+ export declare function suppressionKeyFieldsFromResult(result: SummaryResult): SuppressionKeyFields;
83
+ /** Extract D2 suppression key fields from a summary-history JSONL record. */
84
+ export declare function suppressionKeyFieldsFromRecord(record: Record<string, unknown>): SuppressionKeyFields | null;
85
+ /** Canonical stable hash for D2 suppression comparison (#1279). */
86
+ export declare function suppressionKey(result: SummaryResult): string;
87
+ export declare function suppressionKeyFromRecord(record: Record<string, unknown>): string | null;
88
+ /** Read the most recent JSONL record from summary-history.jsonl. */
89
+ export declare function readLastHistoryRecord(historyPath: string): Record<string, unknown> | null;
90
+ /** True when D2 should skip stdout emission (key unchanged within 4h). */
91
+ export declare function shouldSuppressD2Emission(result: SummaryResult, historyPath: string, options?: {
92
+ now?: Date;
93
+ }): boolean;
61
94
  /** Match Python `json.dumps(..., sort_keys=True, ensure_ascii=False)` spacing. */
62
95
  export declare function pythonStyleStringify(value: unknown): string;
63
96
  /** Append a single JSONL record to summary-history.jsonl. Never throws. */
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
2
3
  import { join, resolve as pathResolve } from "node:path";
3
4
  import { loadProjectDefinition, PROJECT_DEFINITION_REL_PATH } from "../../policy/resolve.js";
@@ -16,6 +17,8 @@ export const CANDIDATES_LOG_REL_PATH = AUDIT_LOG_REL_PATH;
16
17
  export { latestDecisions, readAuditLog } from "../actions/candidates-log.js";
17
18
  export const SUMMARY_HISTORY_REL_PATH = "vbrief/.eval/summary-history.jsonl";
18
19
  export const SUMMARY_HISTORY_SCHEMA = "deft.triage.summary.v1";
20
+ /** D2 repeat-suppression window for session-start triage one-liner (#1122 / #1279). */
21
+ export const D2_SUPPRESSION_WINDOW_HOURS = 4;
19
22
  export const EMPTY_CACHE_LINE = "[triage] cache empty -- run task triage:bootstrap";
20
23
  export const WIP_LIFECYCLE_DIRS = ["pending", "active"];
21
24
  export const FILESYSTEM_IN_FLIGHT_FOLDER = "active";
@@ -155,6 +158,29 @@ export function resolveWipCapInt(projectRoot) {
155
158
  function decisionKey(repo, issueNumber) {
156
159
  return `${repo}\0${issueNumber}`;
157
160
  }
161
+ /** Read `raw.json` state for a cached github-issue entry; defaults to open when absent. */
162
+ export function readCachedIssueState(cacheRoot, repo, issueNumber) {
163
+ const [owner, name] = repo.split("/", 2);
164
+ const rawPath = join(cacheRoot, CACHE_SOURCE, owner ?? "", name ?? "", String(issueNumber), "raw.json");
165
+ if (!existsSync(rawPath)) {
166
+ return "open";
167
+ }
168
+ try {
169
+ const raw = JSON.parse(readFileSync(rawPath, { encoding: "utf8" }));
170
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
171
+ return "open";
172
+ }
173
+ const stateRaw = raw.state ?? "open";
174
+ return typeof stateRaw === "string" ? stateRaw.toLowerCase() : "open";
175
+ }
176
+ catch {
177
+ return "open";
178
+ }
179
+ }
180
+ /** True when a cached issue should participate in summary classification counts. */
181
+ export function isCachedIssueOpen(cacheRoot, repo, issueNumber) {
182
+ return readCachedIssueState(cacheRoot, repo, issueNumber) !== "closed";
183
+ }
158
184
  // ---------------------------------------------------------------------------
159
185
  // compute / format / persist
160
186
  // ---------------------------------------------------------------------------
@@ -191,6 +217,9 @@ export function computeSummary(projectRoot, options = {}) {
191
217
  let staleDefer = 0;
192
218
  const noDecisionKeys = [];
193
219
  for (const [repo, issueNumber] of cached) {
220
+ if (!isCachedIssueOpen(resolvedCacheRoot, repo, issueNumber)) {
221
+ continue;
222
+ }
194
223
  const decision = decisions.get(decisionKey(repo, issueNumber));
195
224
  if (decision === undefined || decision === "reset" || !TRIAGED_DECISIONS.has(decision)) {
196
225
  untriaged += 1;
@@ -319,6 +348,11 @@ export function summaryResultToRecord(result, options) {
319
348
  schema: SUMMARY_HISTORY_SCHEMA,
320
349
  emitted_at: options.emittedAt,
321
350
  line: options.line,
351
+ ...suppressionKeyFieldsFromResult(result),
352
+ };
353
+ }
354
+ export function suppressionKeyFieldsFromResult(result) {
355
+ return {
322
356
  cache_empty: result.cacheEmpty,
323
357
  untriaged: result.untriaged,
324
358
  stale_defer: result.staleDefer,
@@ -328,11 +362,149 @@ export function summaryResultToRecord(result, options) {
328
362
  triage_scope_configured: result.triageScopeConfigured,
329
363
  wip_count: result.wipCount,
330
364
  wip_cap: result.wipCap,
331
- repos: [...result.repos],
365
+ repos: [...result.repos].sort(),
332
366
  scope_drift: result.scopeDrift,
333
367
  reconcilable: result.reconcilable,
334
368
  };
335
369
  }
370
+ function readNumericField(record, key) {
371
+ const value = record[key];
372
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
373
+ }
374
+ function readBooleanField(record, key) {
375
+ const value = record[key];
376
+ return typeof value === "boolean" ? value : null;
377
+ }
378
+ function readStringArrayField(record, key) {
379
+ const value = record[key];
380
+ if (!Array.isArray(value)) {
381
+ return null;
382
+ }
383
+ if (!value.every((entry) => typeof entry === "string")) {
384
+ return null;
385
+ }
386
+ return [...value].sort();
387
+ }
388
+ /** Extract D2 suppression key fields from a summary-history JSONL record. */
389
+ export function suppressionKeyFieldsFromRecord(record) {
390
+ const cacheEmpty = readBooleanField(record, "cache_empty");
391
+ const untriaged = readNumericField(record, "untriaged");
392
+ const staleDefer = readNumericField(record, "stale_defer");
393
+ const inFlight = readNumericField(record, "in_flight");
394
+ const inFlightFilesystem = readNumericField(record, "in_flight_filesystem");
395
+ const inFlightCacheScoped = readNumericField(record, "in_flight_cache_scoped");
396
+ const triageScopeConfigured = readBooleanField(record, "triage_scope_configured");
397
+ const wipCount = readNumericField(record, "wip_count");
398
+ const wipCap = readNumericField(record, "wip_cap");
399
+ const repos = readStringArrayField(record, "repos");
400
+ const scopeDrift = readNumericField(record, "scope_drift");
401
+ const reconcilable = readNumericField(record, "reconcilable");
402
+ if (cacheEmpty === null ||
403
+ untriaged === null ||
404
+ staleDefer === null ||
405
+ inFlight === null ||
406
+ inFlightFilesystem === null ||
407
+ inFlightCacheScoped === null ||
408
+ triageScopeConfigured === null ||
409
+ wipCount === null ||
410
+ wipCap === null ||
411
+ repos === null ||
412
+ scopeDrift === null ||
413
+ reconcilable === null) {
414
+ return null;
415
+ }
416
+ return {
417
+ cache_empty: cacheEmpty,
418
+ untriaged,
419
+ stale_defer: staleDefer,
420
+ in_flight: inFlight,
421
+ in_flight_filesystem: inFlightFilesystem,
422
+ in_flight_cache_scoped: inFlightCacheScoped,
423
+ triage_scope_configured: triageScopeConfigured,
424
+ wip_count: wipCount,
425
+ wip_cap: wipCap,
426
+ repos,
427
+ scope_drift: scopeDrift,
428
+ reconcilable,
429
+ };
430
+ }
431
+ /** Canonical stable hash for D2 suppression comparison (#1279). */
432
+ export function suppressionKey(result) {
433
+ return createHash("sha256")
434
+ .update(pythonStyleStringify(suppressionKeyFieldsFromResult(result)))
435
+ .digest("hex");
436
+ }
437
+ export function suppressionKeyFromRecord(record) {
438
+ const fields = suppressionKeyFieldsFromRecord(record);
439
+ if (fields === null) {
440
+ return null;
441
+ }
442
+ return createHash("sha256").update(pythonStyleStringify(fields)).digest("hex");
443
+ }
444
+ function parseHistoryEmittedAt(record) {
445
+ const raw = record.emitted_at;
446
+ if (typeof raw !== "string" || raw.trim().length === 0) {
447
+ return null;
448
+ }
449
+ let candidate = raw.trim();
450
+ if (candidate.endsWith("Z")) {
451
+ candidate = `${candidate.slice(0, -1)}+00:00`;
452
+ }
453
+ const parsed = new Date(candidate);
454
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
455
+ }
456
+ /** Read the most recent JSONL record from summary-history.jsonl. */
457
+ export function readLastHistoryRecord(historyPath) {
458
+ if (!existsSync(historyPath)) {
459
+ return null;
460
+ }
461
+ let text;
462
+ try {
463
+ text = readFileSync(historyPath, { encoding: "utf8" });
464
+ }
465
+ catch {
466
+ return null;
467
+ }
468
+ const lines = text.split("\n").filter((line) => line.trim().length > 0);
469
+ if (lines.length === 0) {
470
+ return null;
471
+ }
472
+ const last = lines[lines.length - 1];
473
+ if (last === undefined) {
474
+ return null;
475
+ }
476
+ try {
477
+ const parsed = JSON.parse(last);
478
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
479
+ return parsed;
480
+ }
481
+ }
482
+ catch {
483
+ return null;
484
+ }
485
+ return null;
486
+ }
487
+ /** True when D2 should skip stdout emission (key unchanged within 4h). */
488
+ export function shouldSuppressD2Emission(result, historyPath, options = {}) {
489
+ const prior = readLastHistoryRecord(historyPath);
490
+ if (prior === null) {
491
+ return false;
492
+ }
493
+ const emittedAt = parseHistoryEmittedAt(prior);
494
+ if (emittedAt === null) {
495
+ return false;
496
+ }
497
+ const now = options.now ?? new Date();
498
+ const ageMs = now.getTime() - emittedAt.getTime();
499
+ if (ageMs < 0 || ageMs >= D2_SUPPRESSION_WINDOW_HOURS * 3_600_000) {
500
+ return false;
501
+ }
502
+ const priorKey = suppressionKeyFromRecord(prior);
503
+ if (priorKey === null) {
504
+ return false;
505
+ }
506
+ return priorKey === suppressionKey(result);
507
+ }
336
508
  function sortKeysDeep(value) {
337
509
  if (value === null || typeof value !== "object") {
338
510
  return value;
@@ -16,6 +16,7 @@ export interface DefaultModeOptions {
16
16
  readonly writeHistory?: boolean;
17
17
  readonly taskPrefix?: string | null;
18
18
  readonly selfHealFn?: (projectRoot: string) => void;
19
+ readonly now?: Date;
19
20
  }
20
21
  /** Non-interactive default mode (#1309). */
21
22
  export declare function runDefaultMode(projectRoot: string, options?: DefaultModeOptions): WelcomeOutcome;
@@ -37,6 +37,7 @@ export function runDefaultMode(projectRoot, options = {}) {
37
37
  heal(projectRoot);
38
38
  emitOneliner(projectRoot, {
39
39
  writeHistory: options.writeHistory !== false,
40
+ now: options.now,
40
41
  output: out,
41
42
  });
42
43
  const state = detectPriorState(projectRoot);
@@ -19,5 +19,7 @@ export declare function appendHistory(historyPath: string, result: SummaryResult
19
19
  export declare function emitOneliner(projectRoot: string, options?: {
20
20
  writeHistory?: boolean;
21
21
  output?: (line: string) => void;
22
+ now?: Date;
23
+ applyD2Suppression?: boolean;
22
24
  }): string;
23
25
  //# sourceMappingURL=summary.d.ts.map
@@ -3,7 +3,8 @@ import { join, resolve } from "node:path";
3
3
  import { countVbriefWip, DEFAULT_WIP_CAP, resolveWipCap } from "../../policy/wip.js";
4
4
  import { countReconcilable } from "../reconcile/reconcile.js";
5
5
  import { computeDrift } from "../scope-drift/compute.js";
6
- import { CACHE_DIR_NAME, CACHE_SOURCE, EMPTY_CACHE_LINE, MAX_LINE_CHARS, SUMMARY_HISTORY_REL_PATH, SUMMARY_HISTORY_SCHEMA, WIP_WARN_GLYPH, } from "./constants.js";
6
+ import { SUMMARY_HISTORY_REL_PATH, shouldSuppressD2Emission } from "../summary/index.js";
7
+ import { CACHE_DIR_NAME, CACHE_SOURCE, EMPTY_CACHE_LINE, MAX_LINE_CHARS, SUMMARY_HISTORY_SCHEMA, WIP_WARN_GLYPH, } from "./constants.js";
7
8
  const CANDIDATES_LOG_REL_PATH = "vbrief/.eval/candidates.jsonl";
8
9
  const FILESYSTEM_IN_FLIGHT_STATUS = "running";
9
10
  const TRIAGED_DECISIONS = new Set([
@@ -291,12 +292,17 @@ export function appendHistory(historyPath, result, line) {
291
292
  export function emitOneliner(projectRoot, options = {}) {
292
293
  const result = computeSummary(projectRoot);
293
294
  const line = formatSummary(result);
295
+ const historyPath = join(resolve(projectRoot), SUMMARY_HISTORY_REL_PATH);
296
+ const applySuppression = options.applyD2Suppression !== false;
297
+ if (applySuppression && shouldSuppressD2Emission(result, historyPath, { now: options.now })) {
298
+ return line;
299
+ }
294
300
  const out = options.output ?? ((l) => process.stdout.write(`${l}\n`));
295
301
  for (const physicalLine of line.split("\n")) {
296
302
  out(physicalLine);
297
303
  }
298
304
  if (options.writeHistory !== false) {
299
- appendHistory(join(resolve(projectRoot), SUMMARY_HISTORY_REL_PATH), result, line);
305
+ appendHistory(historyPath, result, line);
300
306
  }
301
307
  return line;
302
308
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * contract-drift.ts — deterministic gate for the public contract layer (#1799).
3
+ *
4
+ * Ensures:
5
+ * 1. packages/types/schemas/vbrief-core-0.6.schema.json matches the canonical
6
+ * content/vbrief/schemas/vbrief-core.schema.json byte-for-byte.
7
+ * 2. @deftai/directive-types Status enum matches the schema Status enum.
8
+ * 3. @deftai/directive-types VBRIEF_VERSION matches the schema version const.
9
+ *
10
+ * Exit codes: 0 clean / 1 drift / 2 config error.
11
+ */
12
+ import { type GateExitCode } from "@deftai/directive-types";
13
+ export declare const EXIT_OK = 0;
14
+ export declare const EXIT_DRIFT = 1;
15
+ export declare const EXIT_CONFIG_ERROR = 2;
16
+ export declare const CANONICAL_SCHEMA_REL = "content/vbrief/schemas/vbrief-core.schema.json";
17
+ export declare const PUBLISHED_SCHEMA_REL = "packages/types/schemas/vbrief-core-0.6.schema.json";
18
+ export interface ContractDriftResult {
19
+ readonly code: GateExitCode;
20
+ readonly message: string;
21
+ readonly stream: "stdout" | "stderr";
22
+ }
23
+ export interface ContractDriftOptions {
24
+ readonly root: string;
25
+ readonly readText?: (path: string) => string;
26
+ }
27
+ /** Evaluate contract drift for the directive source tree. */
28
+ export declare function evaluateContractDrift(projectRoot: string, options?: Partial<ContractDriftOptions>): ContractDriftResult;
29
+ //# sourceMappingURL=contract-drift.d.ts.map
@@ -0,0 +1,152 @@
1
+ /**
2
+ * contract-drift.ts — deterministic gate for the public contract layer (#1799).
3
+ *
4
+ * Ensures:
5
+ * 1. packages/types/schemas/vbrief-core-0.6.schema.json matches the canonical
6
+ * content/vbrief/schemas/vbrief-core.schema.json byte-for-byte.
7
+ * 2. @deftai/directive-types Status enum matches the schema Status enum.
8
+ * 3. @deftai/directive-types VBRIEF_VERSION matches the schema version const.
9
+ *
10
+ * Exit codes: 0 clean / 1 drift / 2 config error.
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+ import { join, resolve } from "node:path";
14
+ import { VALID_STATUSES, VBRIEF_VERSION } from "@deftai/directive-types";
15
+ export const EXIT_OK = 0;
16
+ export const EXIT_DRIFT = 1;
17
+ export const EXIT_CONFIG_ERROR = 2;
18
+ export const CANONICAL_SCHEMA_REL = "content/vbrief/schemas/vbrief-core.schema.json";
19
+ export const PUBLISHED_SCHEMA_REL = "packages/types/schemas/vbrief-core-0.6.schema.json";
20
+ function defaultReadText(path) {
21
+ return readFileSync(path, "utf8");
22
+ }
23
+ function loadSchemaJson(text, label) {
24
+ try {
25
+ const parsed = JSON.parse(text);
26
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
27
+ throw new Error(`${label} is not a JSON object`);
28
+ }
29
+ return parsed;
30
+ }
31
+ catch (error) {
32
+ const detail = error instanceof Error ? error.message : String(error);
33
+ throw new Error(`${label} is not valid JSON: ${detail}`);
34
+ }
35
+ }
36
+ function schemaStatusEnum(schema) {
37
+ const defs = schema.$defs;
38
+ if (typeof defs !== "object" || defs === null || Array.isArray(defs)) {
39
+ throw new Error("schema missing $defs");
40
+ }
41
+ const status = defs.Status;
42
+ if (typeof status !== "object" || status === null || Array.isArray(status)) {
43
+ throw new Error("schema missing $defs.Status");
44
+ }
45
+ const enumValues = status.enum;
46
+ if (!Array.isArray(enumValues) || enumValues.some((v) => typeof v !== "string")) {
47
+ throw new Error("schema $defs.Status.enum must be a string array");
48
+ }
49
+ return [...enumValues];
50
+ }
51
+ function schemaVersionConst(schema) {
52
+ const defs = schema.$defs;
53
+ if (typeof defs !== "object" || defs === null || Array.isArray(defs)) {
54
+ throw new Error("schema missing $defs");
55
+ }
56
+ const info = defs.vBRIEFInfo;
57
+ if (typeof info !== "object" || info === null || Array.isArray(info)) {
58
+ throw new Error("schema missing $defs.vBRIEFInfo");
59
+ }
60
+ const properties = info.properties;
61
+ if (typeof properties !== "object" || properties === null || Array.isArray(properties)) {
62
+ throw new Error("schema missing $defs.vBRIEFInfo.properties");
63
+ }
64
+ const version = properties.version;
65
+ if (typeof version !== "object" || version === null || Array.isArray(version)) {
66
+ throw new Error("schema missing $defs.vBRIEFInfo.properties.version");
67
+ }
68
+ const constValue = version.const;
69
+ if (typeof constValue !== "string") {
70
+ throw new Error("schema $defs.vBRIEFInfo.properties.version.const must be a string");
71
+ }
72
+ return constValue;
73
+ }
74
+ function sortedStrings(values) {
75
+ return [...values].sort();
76
+ }
77
+ /** Evaluate contract drift for the directive source tree. */
78
+ export function evaluateContractDrift(projectRoot, options = {}) {
79
+ const root = resolve(projectRoot);
80
+ const readText = options.readText ?? defaultReadText;
81
+ const canonicalPath = join(root, CANONICAL_SCHEMA_REL);
82
+ const publishedPath = join(root, PUBLISHED_SCHEMA_REL);
83
+ let canonicalText;
84
+ let publishedText;
85
+ try {
86
+ canonicalText = readText(canonicalPath);
87
+ publishedText = readText(publishedPath);
88
+ }
89
+ catch (error) {
90
+ const detail = error instanceof Error ? error.message : String(error);
91
+ return {
92
+ code: EXIT_CONFIG_ERROR,
93
+ message: `contract-drift: config error — could not read schema files (${detail}). Run pnpm --prefix packages/types run prebuild.`,
94
+ stream: "stderr",
95
+ };
96
+ }
97
+ if (canonicalText !== publishedText) {
98
+ return {
99
+ code: EXIT_DRIFT,
100
+ message: `contract-drift: ${PUBLISHED_SCHEMA_REL} is out of sync with ${CANONICAL_SCHEMA_REL}. ` +
101
+ "Run: pnpm --prefix packages/types run prebuild",
102
+ stream: "stderr",
103
+ };
104
+ }
105
+ let schema;
106
+ try {
107
+ schema = loadSchemaJson(canonicalText, CANONICAL_SCHEMA_REL);
108
+ }
109
+ catch (error) {
110
+ const detail = error instanceof Error ? error.message : String(error);
111
+ return {
112
+ code: EXIT_CONFIG_ERROR,
113
+ message: `contract-drift: config error — ${detail}`,
114
+ stream: "stderr",
115
+ };
116
+ }
117
+ try {
118
+ const schemaStatuses = sortedStrings(schemaStatusEnum(schema));
119
+ const typeStatuses = sortedStrings(VALID_STATUSES);
120
+ if (schemaStatuses.join("|") !== typeStatuses.join("|")) {
121
+ return {
122
+ code: EXIT_DRIFT,
123
+ message: "contract-drift: VALID_STATUSES in @deftai/directive-types diverges from " +
124
+ `$defs.Status.enum in ${CANONICAL_SCHEMA_REL}.`,
125
+ stream: "stderr",
126
+ };
127
+ }
128
+ const schemaVersion = schemaVersionConst(schema);
129
+ if (schemaVersion !== VBRIEF_VERSION) {
130
+ return {
131
+ code: EXIT_DRIFT,
132
+ message: "contract-drift: VBRIEF_VERSION in @deftai/directive-types diverges from " +
133
+ "schema vBRIEFInfo.version const.",
134
+ stream: "stderr",
135
+ };
136
+ }
137
+ }
138
+ catch (error) {
139
+ const detail = error instanceof Error ? error.message : String(error);
140
+ return {
141
+ code: EXIT_CONFIG_ERROR,
142
+ message: `contract-drift: config error — ${detail}`,
143
+ stream: "stderr",
144
+ };
145
+ }
146
+ return {
147
+ code: EXIT_OK,
148
+ message: "contract-drift: canonical schema, published copy, and TS contract constants are in sync.",
149
+ stream: "stdout",
150
+ };
151
+ }
152
+ //# sourceMappingURL=contract-drift.js.map
@@ -1,5 +1,6 @@
1
1
  export * from "./code-structure-validate.js";
2
2
  export * from "./content-manifest.js";
3
+ export { CANONICAL_SCHEMA_REL, type ContractDriftOptions, type ContractDriftResult, evaluateContractDrift, PUBLISHED_SCHEMA_REL, } from "./contract-drift.js";
3
4
  export * from "./cursor-tier1.js";
4
5
  export * from "./python-call-scan.js";
5
6
  export * from "./rule-ownership-lint.js";
@@ -1,5 +1,6 @@
1
1
  export * from "./code-structure-validate.js";
2
2
  export * from "./content-manifest.js";
3
+ export { CANONICAL_SCHEMA_REL, evaluateContractDrift, PUBLISHED_SCHEMA_REL, } from "./contract-drift.js";
3
4
  export * from "./cursor-tier1.js";
4
5
  export * from "./python-call-scan.js";
5
6
  export * from "./rule-ownership-lint.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deftai/directive-core",
3
- "version": "0.64.0",
3
+ "version": "0.65.0",
4
4
  "description": "TypeScript engine core for the Directive framework.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -229,8 +229,8 @@
229
229
  "provenance": true
230
230
  },
231
231
  "dependencies": {
232
- "@deftai/directive-content": "^0.64.0",
233
- "@deftai/directive-types": "^0.64.0",
232
+ "@deftai/directive-content": "^0.65.0",
233
+ "@deftai/directive-types": "^0.65.0",
234
234
  "archiver": "^8.0.0"
235
235
  },
236
236
  "scripts": {