pm-graph 2026.10.20 → 2026.10.23

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.10.23 - 2026-10-23
4
+
5
+ ### Changed
6
+
7
+ - Adopt pm-github 2026.10.16: default-branch-safe gated sync and daily gated closeout PRs ([pm-graph-mupl](https://github.com/unbraind/pm-graph/blob/main/.agents/pm/chores/pm-graph-mupl.toon))
8
+ - Enable the gated GitHub issue sync and drop the braces chain ([pm-graph-maint1014](https://github.com/unbraind/pm-graph/blob/main/.agents/pm/chores/pm-graph-maint1014.toon))
9
+
10
+ ### Fixed
11
+
12
+ - A release run bumped and committed a new version every night while publishing was impossible, because the only externally-failable step runs last ([pm-graph-wos1](https://github.com/unbraind/pm-graph/blob/main/.agents/pm/issues/pm-graph-wos1.toon))
13
+ - Fix release publish ordering ahead of protected main push ([pm-graph-ihhe](https://github.com/unbraind/pm-graph/blob/main/.agents/pm/issues/pm-graph-ihhe.toon))
14
+ - Committed CHANGELOG.md drifts from deterministic regeneration after the pm-graph-3svp type classification change, breaking changelog:check on every PR ([pm-graph-brgk](https://github.com/unbraind/pm-graph/blob/main/.agents/pm/issues/pm-graph-brgk.toon))
15
+ - Refresh managed pm-github extension for project-local PM CLI 2026.9.27 ([pm-graph-ipvn](https://github.com/unbraind/pm-graph/blob/main/.agents/pm/issues/pm-graph-ipvn.toon))
16
+ - Graph export uses quadratic item and edge scans on large trackers ([pm-graph-3a1p](https://github.com/unbraind/pm-graph/blob/main/.agents/pm/issues/pm-graph-3a1p.toon))
17
+ - Remove vulnerable braces dependency from certification toolchain ([pm-graph-g8uc](https://github.com/unbraind/pm-graph/blob/main/.agents/pm/issues/pm-graph-g8uc.toon))
18
+ - The publish-attestation gate misses a publish routed through an unquoted shell variable ([pm-graph-ifqz](https://github.com/unbraind/pm-graph/blob/main/.agents/pm/issues/pm-graph-ifqz.toon))
19
+ - The changelog gate stamps an untagged version with the current date, so its verdict flips every midnight with no commit ([pm-graph-4glq](https://github.com/unbraind/pm-graph/blob/main/.agents/pm/issues/pm-graph-4glq.toon))
20
+ - The release job authenticated with a stored npm token that expired, so publishing stopped while every other gate stayed green ([pm-graph-ybzl](https://github.com/unbraind/pm-graph/blob/main/.agents/pm/issues/pm-graph-ybzl.toon))
21
+
22
+ ### Other
23
+
24
+ - Cover the output_format decline contract with real SDK harness tests instead of a registration-only assertion ([pm-graph-vgj0](https://github.com/unbraind/pm-graph/blob/main/.agents/pm/tasks/pm-graph-vgj0.toon))
25
+ - Bring pm-graph to real 100% line/branch/function coverage and raise the gate to 100 ([pm-graph-6zil](https://github.com/unbraind/pm-graph/blob/main/.agents/pm/tasks/pm-graph-6zil.toon))
26
+ - Render package visuals with build-time Triplicate tokens ([pm-graph-triplicate](https://github.com/unbraind/pm-graph/blob/main/.agents/pm/tasks/pm-graph-triplicate.toon))
27
+ - Certify pm-graph on PM CLI/SDK 2026.10.4 and consolidate pending dependency updates ([pm-graph-lcmc](https://github.com/unbraind/pm-graph/blob/main/.agents/pm/chores/pm-graph-lcmc.toon))
28
+
3
29
  ## 2026.10.14 - 2026-10-14
4
30
 
5
31
  ### Added
package/README.md CHANGED
@@ -304,11 +304,103 @@ Example output:
304
304
  }
305
305
  ```
306
306
 
307
+ ## Without git
308
+
309
+ All offline graph commands, including `blocker-cuts`, and the graph exporter
310
+ work in plain initialized trackers without Git. Neo4j commands need their
311
+ documented database configuration and connectivity; they also do not need a
312
+ Git repository.
313
+
307
314
  ## Offline Analytics
308
315
 
309
- These commands analyze the workspace dependency graph **entirely offline** — no Neo4j required. They operate on **structural edges only** (`BLOCKED_BY`, `CHILD_OF`, and dependency edges such as `BLOCKS`/`RELATED`) between real items; facet edges (type/status/assignee/sprint/release) and tag edges are deliberately excluded so cycle, path, and centrality results stay meaningful.
316
+ ### `pm pm-graph blocker-cuts <goal>`
310
317
 
311
- Closed/canceled items are excluded by default; pass `--include-closed` to keep them. `analyze`, `cycles`, `critical-path`, and `topo-sort` also accept `--root <id>` / `--depth <n>` to scope the analysis to a neighborhood. All analytics commands accept `--filter type=...|status=...` to keep only items matching the given type/status (same-key values are ORed; distinct keys are ANDed); non-item nodes (facets/tags) are always kept. `cycles` and `critical-path` additionally accept `--format <text|mermaid|graphml|dot>` (default `text`) to render the relevant subgraph as a diagram for docs — `dot` emits a Graphviz `digraph` consumable by `dot`, `neato`, etc.
318
+ Return alternative **inclusion-minimal blocker frontiers**, smallest cardinality
319
+ first, with separate source, graph and search completeness receipts. This is a
320
+ graph-view data operation; it complements core dominators/articulation/plan/impact
321
+ and this package's path/critical-path commands.
322
+
323
+ ```bash
324
+ pm pm-graph blocker-cuts goal-id
325
+ pm pm-graph blocker-cuts goal-id --max-cuts 16 --max-states 20000 --json
326
+ ```
327
+
328
+ Every upstream unresolved prerequisite path must meet a returned cut. All
329
+ prerequisites are conjunctive: for `goal -> a -> {b,c}`, the alternatives are
330
+ `[a]` and `[b,c]`. These are **plans**: resolving `b,c` clears the upstream
331
+ frontier, but `a` still needs completion. Each cut's `immediatelyUnblocked` is
332
+ true only when that set includes every immediate unresolved prerequisite. This
333
+ does not predict effort, duration, automatic task completion, or approvals.
334
+
335
+ The command uses an extension-free, strict, full, unbounded SDK read (including
336
+ terminal items), and exact goal ids. `BLOCKED_BY`, `DEPENDS_ON`, `REQUIRES` and
337
+ reversed `BLOCKS` are prerequisites; hierarchy, related links and facets are
338
+ excluded. Terminal statuses from the tracker registry stop traversal. A resolved
339
+ goal or a goal with no active blockers returns the empty cut `[[]]`.
340
+
341
+ `source` certifies the read, while `receipt.graph` describes the active goal cone
342
+ in that supplied snapshot. It cannot prove that missing real-world relationships
343
+ were recorded, nor provide transaction isolation from concurrent tracker edits.
344
+ Unknown referenced items/statuses refuse with `INCOMPLETE_GRAPH`; a relevant
345
+ cycle refuses with `CYCLE` and one closed cycle witness. Unrelated cycles and
346
+ missing references outside the active cone do not prevent a goal proof.
347
+ Missing-data receipts include total counts and at most 32 example ids per kind.
348
+
349
+ `receipt.search.complete` means **all** inclusion-minimal cuts were enumerated.
350
+ `completeThroughSize` certifies fully searched cardinalities;
351
+ `minimumCardinality` is the proven smallest size, or `null` if no cut was found.
352
+ Partial results remain individually minimal. Never interpret an empty partial
353
+ list as "unblocked". To act on displayed rows, also inspect the host's
354
+ `read_output` receipt: output compaction is independent of search completeness.
355
+
356
+ | Budget | Default | Hard maximum |
357
+ | --- | ---: | ---: |
358
+ | `--max-states` (candidate subsets) | 20000 | 1000000 |
359
+ | `--max-work` (search visits, edges, subset comparisons) | 2000000 | 20000000 |
360
+ | `--max-cuts` (returned alternatives) | 32 | 256 |
361
+ | `--max-size` (frontier cardinality) | 8 | 64 |
362
+
363
+ Enumeration stops deterministically at the first cap and reports its name;
364
+ raising that budget and rerunning restarts the search. Preflight scans are
365
+ input-capped at 100000 nodes / 500000 relationships and 512 active unresolved
366
+ vertices. Oversized snapshots refuse with `GRAPH_LIMIT` and a scoping remedy.
367
+ `INVALID_OPTIONS`, `INVALID_GRAPH`, `UNKNOWN_GOAL`, and `SOURCE_INCOMPLETE` also
368
+ include remedies. CLI refusals exit 2 (unknown goals exit 3); successful bounded
369
+ searches exit 0, with partiality explicit in the receipt.
370
+
371
+ The typed SDK has no filesystem, Git or Neo4j dependency:
372
+
373
+ ```ts
374
+ import { minimalBlockerCuts } from "pm-graph";
375
+ import type { BlockerCutResult } from "pm-graph";
376
+
377
+ const result: BlockerCutResult = minimalBlockerCuts(snapshot, "goal-id", {
378
+ maxCuts: 16,
379
+ // For custom workflows, pass disjoint resolvedStatuses/unresolvedStatuses.
380
+ });
381
+ if (!result.ok) console.error(result.refusal.code, result.refusal.remedy);
382
+ else renderFrontiers(result.cuts, result.receipt);
383
+ ```
384
+
385
+ SDK callers must certify their own complete source read; the pure function only
386
+ certifies the supplied goal cone. Successful results contain ids and receipts,
387
+ excluding item bodies, titles and local paths. `SOURCE_INCOMPLETE` remedies retain
388
+ the original source error message to distinguish invalid roots, strict-read
389
+ failures and bad settings; these diagnostics can include tracker paths.
390
+ See [the worked example](docs/blocker-cuts-example.md).
391
+
392
+ ### Other offline analytics
393
+
394
+ The following commands analyze the workspace dependency graph **entirely offline** — no Neo4j required. They operate on **structural edges only** (`BLOCKED_BY`, `CHILD_OF`, and dependency edges such as `BLOCKS`/`RELATED`) between real items; facet edges (type/status/assignee/sprint/release) and tag edges are deliberately excluded so cycle, path, and centrality results stay meaningful.
395
+
396
+ Closed/canceled items are excluded by default; pass `--include-closed` to keep them. `analyze`, `cycles`, `critical-path`, and `topo-sort` also accept `--root <id>` / `--depth <n>` to scope the analysis to a neighborhood. `analyze`, `cycles`, `critical-path`, `topo-sort`, `path`, `impact`, and `explain` accept `--filter type=...|status=...` to keep only items matching the given type/status (same-key values are ORed; distinct keys are ANDed); non-item nodes (facets/tags) are always kept. `cycles` and `critical-path` additionally accept `--format <text|mermaid|graphml|dot>` (default `text`) to render the relevant subgraph as a diagram for docs — `dot` emits a Graphviz `digraph` consumable by `dot`, `neato`, etc.
397
+
398
+ The host dependency contract stores `A --blocked_by--> B` and `B --blocks--> A`
399
+ as the same prerequisite: A waits for B. Offline analytics normalize `BLOCKS`
400
+ to item → blocker direction before computing paths, cycles, execution order,
401
+ impact, depth, and explanations. Graph exports and diagram relationships retain
402
+ their original stored endpoints and relation types. The edge-array SDK helpers
403
+ (such as `topoSort`) expect this normalized item → blocker direction.
312
404
 
313
405
  ### `pm pm-graph analyze`
314
406
 
@@ -458,6 +550,76 @@ pm pm-graph impact pm-vc4q --json
458
550
  { "ok": true, "id": "pm-vc4q", "count": 3, "impacted": ["pm-0wtl", "pm-8k1m", "pm-yol1"] }
459
551
  ```
460
552
 
553
+ ## Snapshot graph change impact
554
+
555
+ `compareGraphSnapshots(before, after, goal, options?)` compares two explicit
556
+ `Graph` SDK values. The CLI accepts regular JSON files containing those values
557
+ or the existing export command's `{ graph: ... }` envelope:
558
+
559
+ ```bash
560
+ pm pm-graph snapshot-impact before.json after.json goal-id --limit 16 --json
561
+ pm pm-graph snapshot-impact before.json after.json goal-id --limit 16 --offset 16
562
+ ```
563
+
564
+ The result reports gained/lost upstream prerequisites and downstream dependents,
565
+ added/removed minimal blocker frontiers, and explicit `SUPPORTS`/`EVIDENCE_FOR`
566
+ links into the goal or its upstream prerequisites. Removed facts carry the
567
+ earlier node title and status, even if that node was deleted. A deleted goal is
568
+ valid when it exists in the earlier snapshot. Reordering inputs, duplicate
569
+ edges and nonsemantic title/capture metadata edits produce no changes.
570
+
571
+ The gating policy is `BLOCKED_BY`, `DEPENDS_ON`, `REQUIRES`, and reversed
572
+ `BLOCKS`; hierarchy, facets and other relationships do not imply prerequisites.
573
+ Reachability is structural, including terminal items. Blocker frontiers stop
574
+ at terminal items and require all upstream paths to intersect a cut. Resolving
575
+ a frontier still leaves intermediate work; `immediatelyUnblocked` identifies
576
+ cuts containing every direct prerequisite. Both cut searches must finish before
577
+ differences are reported. Cycles, missing cone endpoints, unknown active statuses
578
+ and exhausted cut searches return typed refusals with remedies. A dangling
579
+ reference to a deleted node retains its earlier explanation in `missingNode`
580
+ while refusing to infer cut differences from incomplete input. For custom
581
+ workflows, pass `cutOptions.resolvedStatuses`/`unresolvedStatuses` in the SDK or
582
+ `--resolved-statuses done,canceled --unresolved-statuses ready,working` in the CLI.
583
+
584
+ `receipt.output` exposes the exact total, returned/omitted counts and
585
+ `nextOffset`. Keep both input files immutable while recovering pages.
586
+ Defaults are 32 rows and 2,000,000 traversal work units; `--limit` accepts
587
+ 1..256 and `--max-work` accepts 1..20,000,000. Cut budgets have separate
588
+ `--cut-max-states`, `--cut-max-work`, `--cut-max-cuts` and `--cut-max-size` flags
589
+ (defaults: 20,000 / 2,000,000 / 32 / 8). Each snapshot is limited to 10,000 nodes,
590
+ 50,000 relationships and, for CLI files, 16 MiB. The active blocker cone is
591
+ limited to 512 unresolved items including the goal; this is a preflight cap,
592
+ not a completion guarantee. Completion requires `--cut-max-size` (default 8,
593
+ hard cap 64) to be at least the number of unresolved prerequisites in the
594
+ active cone, excluding the goal. A chain of 9 unresolved prerequisites therefore
595
+ returns `CUT_SEARCH_INCOMPLETE` with defaults; more than 64 cannot complete
596
+ under the current search. Other cut budgets can stop even smaller searches.
597
+ Explanation titles/statuses are clipped to
598
+ 160/64 Unicode code points with `textClipped: true`; ids are limited to 256
599
+ characters. The host's JSON/TOON projection and token budgets also apply to the
600
+ declared `changes` rows. Refusals exit 2 (unknown goal: 3) and preserve
601
+ `refusal.code`, `refusal.remedy` and the failing side.
602
+
603
+ This is a comparison over the supplied snapshots. Core owns snapshot capture
604
+ and storage; pm-context owns resume packets. Certify backend reads separately
605
+ (for example with `listAllComplete`) before `graphFromItems` projection. The
606
+ projection includes item relationships and facets; evidence nodes and support
607
+ edges must be supplied explicitly by the caller's backend. Support is a linked
608
+ structural assertion, not verification of a document, file, test run or URL.
609
+ The comparison never accesses those resources or either live backend.
610
+
611
+ See the [SDK and CLI example](docs/snapshot-impact-example.md) and the
612
+ [public tracker validation](docs/snapshot-impact-validation.md).
613
+
614
+ ## Without git
615
+
616
+ Graph projection, export, offline analysis, path/impact/explain and snapshot
617
+ comparison work in plain trackers. Snapshot comparison reads only the two
618
+ explicit JSON files and also works without a tracker. Neo4j operations require
619
+ their configured database connection. None of these graph operations needs a
620
+ git repository; the no-git regression runs the installed CLI with git absent
621
+ from PATH.
622
+
461
623
  ## Graph Model
462
624
 
463
625
  - **`PmItem`** nodes for real pm items, labelled with item type (e.g. `task`, `bug`).
@@ -0,0 +1,21 @@
1
+ import { listAllComplete } from "@unbrained/pm-cli/sdk";
2
+ import type { PmCompleteListCertificate } from "@unbrained/pm-cli/sdk";
3
+ import type { BlockerCutReceipt, BlockerCutRefusalCode } from "./blocker-cuts.ts";
4
+ import type { CommandContext, ExtensionApi, Graph } from "./index.ts";
5
+ /** Structured CLI refusal; the host honors exit_code while retaining the full result. */
6
+ export type BlockerCutCommandRefusal = {
7
+ ok: false;
8
+ exit_code: number;
9
+ code: BlockerCutRefusalCode | "SOURCE_INCOMPLETE";
10
+ remediation: string;
11
+ refusal: {
12
+ code: BlockerCutCommandRefusal["code"];
13
+ remedy: string;
14
+ };
15
+ /** Declared row collection is present even when the host delivers a refusal. */
16
+ cuts: [];
17
+ receipt?: BlockerCutReceipt;
18
+ source?: PmCompleteListCertificate;
19
+ };
20
+ /** Register the offline command; source reads are strict, complete, and extension-free. */
21
+ export declare function registerBlockerCuts(api: ExtensionApi, resolveRoot: (context: CommandContext) => string, project: (items: Awaited<ReturnType<typeof listAllComplete>>["items"], root: string) => Graph): void;
@@ -0,0 +1,74 @@
1
+ import { listAllComplete, readSettings, resolveRuntimeStatusRegistry } from "@unbrained/pm-cli/sdk";
2
+ import { minimalBlockerCuts } from "./blocker-cuts.js";
3
+ /** Preserve refusal evidence in CLI JSON/TOON instead of losing it in error formatting. */
4
+ function commandRefusal(code, remedy, receipt, source) {
5
+ return { ok: false, code, remediation: remedy, refusal: { code, remedy }, cuts: [],
6
+ exit_code: code === "UNKNOWN_GOAL" ? 3 : 2, receipt, source };
7
+ }
8
+ /** Register the offline command; source reads are strict, complete, and extension-free. */
9
+ export function registerBlockerCuts(api, resolveRoot, project) {
10
+ api.registerCommand({
11
+ name: "pm-graph blocker-cuts",
12
+ description: "Enumerate alternative minimal blocker frontiers for a goal with bounded completeness receipts.",
13
+ read_output: { row_keys: ["cuts"] },
14
+ arguments: [{ name: "goal", required: true, description: "Exact goal item id" }],
15
+ examples: ["pm pm-graph blocker-cuts goal-id --max-cuts 16 --json"],
16
+ failure_hints: ["Reload the full tracker for missing nodes; break cycles; increase search limits only when the receipt is partial."],
17
+ run: async (context) => {
18
+ const args = context.args ?? [];
19
+ if (args.includes("--help") || args.includes("-h")) {
20
+ return {
21
+ cuts: [],
22
+ usage: "pm pm-graph blocker-cuts <goal> [--max-states 20000] [--max-work 2000000] [--max-cuts 32] [--max-size 8] [--json]",
23
+ semantics: "All upstream prerequisite paths must intersect a cut. Intermediate tasks still require completion; immediatelyUnblocked reports direct eligibility.",
24
+ completeness: "Graph/source and search receipts are separate. Partial cuts are minimal; a partial list is not exhaustive.",
25
+ };
26
+ }
27
+ const options = {};
28
+ const positionals = [];
29
+ const flags = new Map([
30
+ ["--max-states", "maxStates"], ["--max-work", "maxWork"], ["--max-cuts", "maxCuts"], ["--max-size", "maxSize"],
31
+ ]);
32
+ for (let i = 0; i < args.length; i++) {
33
+ const arg = args[i];
34
+ if (arg === "--json")
35
+ continue;
36
+ const key = flags.get(arg);
37
+ if (key) {
38
+ const value = args[++i];
39
+ if (!value || !/^\d+$/.test(value))
40
+ return commandRefusal("INVALID_OPTIONS", `Supply a positive integer after ${arg}.`);
41
+ options[key] = Number(value);
42
+ }
43
+ else if (arg.startsWith("-")) {
44
+ return commandRefusal("INVALID_OPTIONS", `Remove unsupported flag ${arg}; use blocker-cuts --help.`);
45
+ }
46
+ else
47
+ positionals.push(arg);
48
+ }
49
+ if (positionals.length !== 1)
50
+ return commandRefusal("INVALID_OPTIONS", "Use pm pm-graph blocker-cuts <exact-goal-id>; see --help for budgets.");
51
+ let source;
52
+ let snapshot;
53
+ let root;
54
+ try {
55
+ root = resolveRoot(context);
56
+ snapshot = await listAllComplete({}, { pmRoot: root, noExtensions: true });
57
+ source = snapshot.complete_list;
58
+ const registry = resolveRuntimeStatusRegistry((await readSettings(root)).schema);
59
+ options.resolvedStatuses = [...registry.terminal_statuses];
60
+ options.unresolvedStatuses = [...registry.by_id.keys()].filter((status) => !registry.terminal_statuses.has(status));
61
+ }
62
+ catch (error) {
63
+ const cause = error instanceof Error ? error.message : String(error);
64
+ return commandRefusal("SOURCE_INCOMPLETE", `Restore a readable complete tracker; run pm list --all --full --strict-read --output-limit unbounded --output-budget unbounded, then retry. Cause: ${cause}`);
65
+ }
66
+ const graph = project(snapshot.items, root);
67
+ const result = minimalBlockerCuts(graph, positionals[0], options);
68
+ if (!result.ok)
69
+ return { ...commandRefusal(result.refusal.code, result.refusal.remedy, result.receipt, source), goal: result.goal };
70
+ return { ...result, source };
71
+ },
72
+ });
73
+ }
74
+ //# sourceMappingURL=blocker-cuts-command.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blocker-cuts-command.js","sourceRoot":"","sources":["../src/blocker-cuts-command.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,4BAA4B,EAAE,MAAM,uBAAuB,CAAC;AAEpG,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAiBvD,2FAA2F;AAC3F,SAAS,cAAc,CAAC,IAAsC,EAAE,MAAc,EAAE,OAA2B,EAAE,MAAkC;IAC7I,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,EAAE;QAChF,SAAS,EAAE,IAAI,KAAK,cAAc,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC;AAClE,CAAC;AAED,2FAA2F;AAC3F,MAAM,UAAU,mBAAmB,CACjC,GAAiB,EACjB,WAAgD,EAChD,OAA6F;IAE7F,GAAG,CAAC,eAAe,CAAC;QAClB,IAAI,EAAE,uBAAuB;QAC7B,WAAW,EAAE,gGAAgG;QAC7G,WAAW,EAAE,EAAE,QAAQ,EAAE,CAAC,MAAM,CAAC,EAAE;QACnC,SAAS,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,oBAAoB,EAAE,CAAC;QAChF,QAAQ,EAAE,CAAC,uDAAuD,CAAC;QACnE,aAAa,EAAE,CAAC,mHAAmH,CAAC;QACpI,GAAG,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE;YACrB,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,EAAE,CAAC;YAChC,IAAI,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;gBACnD,OAAO;oBACL,IAAI,EAAE,EAAE;oBACR,KAAK,EAAE,mHAAmH;oBAC1H,SAAS,EAAE,qJAAqJ;oBAChK,YAAY,EAAE,4GAA4G;iBAC3H,CAAC;YACJ,CAAC;YACD,MAAM,OAAO,GAAsB,EAAE,CAAC;YACtC,MAAM,WAAW,GAAa,EAAE,CAAC;YACjC,MAAM,KAAK,GAAG,IAAI,GAAG,CAA0D;gBAC7E,CAAC,cAAc,EAAE,WAAW,CAAC,EAAE,CAAC,YAAY,EAAE,SAAS,CAAC,EAAE,CAAC,YAAY,EAAE,SAAS,CAAC,EAAE,CAAC,YAAY,EAAE,SAAS,CAAC;aAC/G,CAAC,CAAC;YACH,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;gBACrC,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;gBACpB,IAAI,GAAG,KAAK,QAAQ;oBAAE,SAAS;gBAC/B,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;gBAC3B,IAAI,GAAG,EAAE,CAAC;oBACR,MAAM,KAAK,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;oBACxB,IAAI,CAAC,KAAK,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC;wBAAE,OAAO,cAAc,CAAC,iBAAiB,EAAE,mCAAmC,GAAG,GAAG,CAAC,CAAC;oBACxH,OAAO,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;gBAC/B,CAAC;qBAAM,IAAI,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;oBAC/B,OAAO,cAAc,CAAC,iBAAiB,EAAE,2BAA2B,GAAG,4BAA4B,CAAC,CAAC;gBACvG,CAAC;;oBAAM,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAC/B,CAAC;YACD,IAAI,WAAW,CAAC,MAAM,KAAK,CAAC;gBAAE,OAAO,cAAc,CAAC,iBAAiB,EAAE,uEAAuE,CAAC,CAAC;YAChJ,IAAI,MAAiC,CAAC;YACtC,IAAI,QAAqD,CAAC;YAC1D,IAAI,IAAY,CAAC;YACjB,IAAI,CAAC;gBACH,IAAI,GAAG,WAAW,CAAC,OAAO,CAAC,CAAC;gBAC5B,QAAQ,GAAG,MAAM,eAAe,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,CAAC,CAAC;gBAC3E,MAAM,GAAG,QAAQ,CAAC,aAAa,CAAC;gBAChC,MAAM,QAAQ,GAAG,4BAA4B,CAAC,CAAC,MAAM,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;gBACjF,OAAO,CAAC,gBAAgB,GAAG,CAAC,GAAG,QAAQ,CAAC,iBAAiB,CAAC,CAAC;gBAC3D,OAAO,CAAC,kBAAkB,GAAG,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,QAAQ,CAAC,iBAAiB,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC;YACtH,CAAC;YAAC,OAAO,KAAc,EAAE,CAAC;gBACxB,MAAM,KAAK,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;gBACrE,OAAO,cAAc,CAAC,mBAAmB,EAAE,sJAAsJ,KAAK,EAAE,CAAC,CAAC;YAC5M,CAAC;YACD,MAAM,KAAK,GAAG,OAAO,CAAC,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;YAC5C,MAAM,MAAM,GAAG,kBAAkB,CAAC,KAAK,EAAE,WAAW,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;YAClE,IAAI,CAAC,MAAM,CAAC,EAAE;gBAAE,OAAO,EAAE,GAAG,cAAc,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,OAAO,CAAC,MAAM,EAAE,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,CAAC;YACpI,OAAO,EAAE,GAAG,MAAM,EAAE,MAAM,EAAE,CAAC;QAC/B,CAAC;KACF,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,87 @@
1
+ import type { Graph } from "./index.ts";
2
+ /** Deterministic limits; input size is capped and enumeration is work-budgeted. */
3
+ export type BlockerCutOptions = {
4
+ maxStates?: number;
5
+ maxWork?: number;
6
+ maxCuts?: number;
7
+ maxSize?: number;
8
+ /** Terminal statuses stop traversal; custom registries can supply their own sets. */
9
+ resolvedStatuses?: readonly string[];
10
+ /** Missing/unrecognized statuses refuse a proof instead of guessing actionability. */
11
+ unresolvedStatuses?: readonly string[];
12
+ };
13
+ /** One inclusion-minimal vertex separator, sorted by id. */
14
+ export type BlockerCut = {
15
+ blockers: string[];
16
+ /** True only if resolving this set also satisfies every immediate prerequisite. */
17
+ immediatelyUnblocked: boolean;
18
+ };
19
+ /** Independent receipts for the supplied graph and the bounded enumeration. */
20
+ export type BlockerCutReceipt = {
21
+ graph: {
22
+ scope: "supplied-snapshot";
23
+ edgePolicy: "BLOCKED_BY|DEPENDS_ON|REQUIRES|reverse:BLOCKS";
24
+ nodes: number;
25
+ relationships: number;
26
+ reachableUnresolved: number;
27
+ complete: boolean;
28
+ unknownNodes: string[];
29
+ unknownNodeCount: number;
30
+ unknownStatuses: string[];
31
+ unknownStatusCount: number;
32
+ cycle: string[];
33
+ };
34
+ search: {
35
+ complete: boolean;
36
+ reason: "exhausted" | "maxStates" | "maxWork" | "maxCuts" | "maxSize" | "refused";
37
+ states: number;
38
+ work: number;
39
+ completeThroughSize: number;
40
+ minimumCardinality: number | null;
41
+ limits: {
42
+ maxStates: number;
43
+ maxWork: number;
44
+ maxCuts: number;
45
+ maxSize: number;
46
+ };
47
+ };
48
+ };
49
+ /** Stable refusal codes with actionable remedies, usable by a graph view without parsing text. */
50
+ export type BlockerCutRefusalCode = "INVALID_OPTIONS" | "GRAPH_LIMIT" | "INVALID_GRAPH" | "UNKNOWN_GOAL" | "INCOMPLETE_GRAPH" | "CYCLE";
51
+ /** Compact result: frontier cuts describe plans, not automatic completion of intervening work. */
52
+ export type BlockerCutResult = {
53
+ ok: true;
54
+ goal: string;
55
+ semantics: "upstream-frontier";
56
+ goalResolved: boolean;
57
+ immediateBlockers: string[];
58
+ cuts: BlockerCut[];
59
+ receipt: BlockerCutReceipt;
60
+ } | {
61
+ ok: false;
62
+ goal: string;
63
+ refusal: {
64
+ code: BlockerCutRefusalCode;
65
+ remedy: string;
66
+ };
67
+ receipt: BlockerCutReceipt;
68
+ };
69
+ /**
70
+ * Enumerate inclusion-minimal unresolved vertex cuts between a goal and its
71
+ * upstream unresolved leaves. Every prerequisite is conjunctive. Resolving a
72
+ * frontier clears all upstream blocking paths; intermediate work still needs
73
+ * completion. `immediatelyUnblocked` distinguishes direct eligibility.
74
+ *
75
+ * Only BLOCKED_BY, DEPENDS_ON, REQUIRES and reversed BLOCKS edges are gating;
76
+ * hierarchy, facets and RELATED edges never imply prerequisites. Resolved
77
+ * nodes stop traversal. Cycles, dangling prerequisites and unknown statuses in
78
+ * the active goal cone refuse a proof. Completeness covers the supplied
79
+ * snapshot only; callers must separately certify their source read.
80
+ *
81
+ * Cardinality-ordered combination search proves minimality before emitting a
82
+ * cut, including when truncated. Work counts visited vertices/edges and subset
83
+ * comparisons; hard caps bound both output and exponential search. Preflight
84
+ * scans at most 100,000 nodes and 500,000 relationships; the active cone is
85
+ * limited to 512 unresolved vertices. No filesystem, git or service access.
86
+ */
87
+ export declare function minimalBlockerCuts(graph: Graph, goal: string, options?: BlockerCutOptions): BlockerCutResult;
@@ -0,0 +1,227 @@
1
+ const DEFAULT_LIMITS = { maxStates: 20_000, maxWork: 2_000_000, maxCuts: 32, maxSize: 8 };
2
+ const HARD_LIMITS = { maxStates: 1_000_000, maxWork: 20_000_000, maxCuts: 256, maxSize: 64 };
3
+ const FORWARD = new Set(["BLOCKED_BY", "DEPENDS_ON", "REQUIRES"]);
4
+ /** Find one closed directed cycle iteratively, without enumerating exponentially many paths. */
5
+ function cycleWitness(adjacency, root) {
6
+ const color = new Map();
7
+ const stack = [{ id: root, next: 0 }];
8
+ color.set(root, 1);
9
+ while (stack.length) {
10
+ const frame = stack[stack.length - 1];
11
+ const neighbors = adjacency.get(frame.id);
12
+ if (frame.next === neighbors.length) {
13
+ color.set(frame.id, 2);
14
+ stack.pop();
15
+ continue;
16
+ }
17
+ const id = neighbors[frame.next++];
18
+ if (color.get(id) === 1) {
19
+ return [...stack.slice(stack.findIndex((entry) => entry.id === id)).map((entry) => entry.id), id];
20
+ }
21
+ if (!color.has(id)) {
22
+ color.set(id, 1);
23
+ stack.push({ id, next: 0 });
24
+ }
25
+ }
26
+ return [];
27
+ }
28
+ /**
29
+ * Enumerate inclusion-minimal unresolved vertex cuts between a goal and its
30
+ * upstream unresolved leaves. Every prerequisite is conjunctive. Resolving a
31
+ * frontier clears all upstream blocking paths; intermediate work still needs
32
+ * completion. `immediatelyUnblocked` distinguishes direct eligibility.
33
+ *
34
+ * Only BLOCKED_BY, DEPENDS_ON, REQUIRES and reversed BLOCKS edges are gating;
35
+ * hierarchy, facets and RELATED edges never imply prerequisites. Resolved
36
+ * nodes stop traversal. Cycles, dangling prerequisites and unknown statuses in
37
+ * the active goal cone refuse a proof. Completeness covers the supplied
38
+ * snapshot only; callers must separately certify their source read.
39
+ *
40
+ * Cardinality-ordered combination search proves minimality before emitting a
41
+ * cut, including when truncated. Work counts visited vertices/edges and subset
42
+ * comparisons; hard caps bound both output and exponential search. Preflight
43
+ * scans at most 100,000 nodes and 500,000 relationships; the active cone is
44
+ * limited to 512 unresolved vertices. No filesystem, git or service access.
45
+ */
46
+ export function minimalBlockerCuts(graph, goal, options = {}) {
47
+ const limits = { ...DEFAULT_LIMITS };
48
+ const receipt = {
49
+ graph: { scope: "supplied-snapshot", edgePolicy: "BLOCKED_BY|DEPENDS_ON|REQUIRES|reverse:BLOCKS",
50
+ nodes: graph.nodes.length, relationships: graph.relationships.length,
51
+ reachableUnresolved: 0, complete: false, unknownNodes: [], unknownNodeCount: 0,
52
+ unknownStatuses: [], unknownStatusCount: 0, cycle: [] },
53
+ search: { complete: false, reason: "refused", states: 0, work: 0, completeThroughSize: 0,
54
+ minimumCardinality: null, limits },
55
+ };
56
+ const refuse = (code, remedy) => ({ ok: false, goal, refusal: { code, remedy }, receipt });
57
+ for (const key of Object.keys(limits)) {
58
+ const value = options[key] ?? limits[key];
59
+ if (!Number.isSafeInteger(value) || value < 1 || value > HARD_LIMITS[key]) {
60
+ return refuse("INVALID_OPTIONS", `Set ${key} to an integer in 1..${HARD_LIMITS[key]}.`);
61
+ }
62
+ limits[key] = value;
63
+ }
64
+ const resolved = new Set(options.resolvedStatuses ?? ["closed", "canceled"]);
65
+ const unresolved = new Set(options.unresolvedStatuses ?? ["open", "in_progress", "blocked"]);
66
+ if ([...resolved].some((status) => unresolved.has(status))) {
67
+ return refuse("INVALID_OPTIONS", "Supply disjoint resolvedStatuses and unresolvedStatuses.");
68
+ }
69
+ if (graph.nodes.length > 100_000 || graph.relationships.length > 500_000) {
70
+ return refuse("GRAPH_LIMIT", "Supply a certified goal-scoped snapshot below 100000 nodes and 500000 relationships, retaining every prerequisite.");
71
+ }
72
+ const nodes = new Map();
73
+ for (const node of graph.nodes) {
74
+ if (nodes.has(node.id))
75
+ return refuse("INVALID_GRAPH", "Supply unique node ids; reload the complete tracker snapshot.");
76
+ nodes.set(node.id, node);
77
+ }
78
+ if (!nodes.get(goal)?.labels.includes("PmItem")) {
79
+ return refuse("UNKNOWN_GOAL", "Select an exact PmItem id from the complete tracker snapshot.");
80
+ }
81
+ const allEdges = new Map();
82
+ for (const rel of graph.relationships) {
83
+ if (!FORWARD.has(rel.type) && rel.type !== "BLOCKS")
84
+ continue;
85
+ const from = rel.type === "BLOCKS" ? rel.to : rel.from;
86
+ const to = rel.type === "BLOCKS" ? rel.from : rel.to;
87
+ if (!allEdges.has(from))
88
+ allEdges.set(from, new Set());
89
+ allEdges.get(from).add(to);
90
+ }
91
+ const adjacency = new Map();
92
+ const pending = [goal];
93
+ const seen = new Set();
94
+ while (pending.length) {
95
+ const id = pending.pop();
96
+ if (seen.has(id))
97
+ continue;
98
+ seen.add(id);
99
+ const node = nodes.get(id);
100
+ if (!node?.labels.includes("PmItem")) {
101
+ receipt.graph.unknownNodeCount++;
102
+ if (receipt.graph.unknownNodes.length < 32)
103
+ receipt.graph.unknownNodes.push(id);
104
+ continue;
105
+ }
106
+ const status = String(node.properties.status);
107
+ if (resolved.has(status))
108
+ continue;
109
+ if (!unresolved.has(status)) {
110
+ receipt.graph.unknownStatusCount++;
111
+ if (receipt.graph.unknownStatuses.length < 32)
112
+ receipt.graph.unknownStatuses.push(id);
113
+ }
114
+ adjacency.set(id, []);
115
+ receipt.graph.reachableUnresolved = adjacency.size;
116
+ if (adjacency.size > 512)
117
+ return refuse("GRAPH_LIMIT", "Reduce the active goal cone below 513 unresolved items, retaining every prerequisite; inspect immediate blockers first.");
118
+ for (const next of allEdges.get(id) ?? []) {
119
+ const target = nodes.get(next);
120
+ if (target?.labels.includes("PmItem") && resolved.has(String(target.properties.status)))
121
+ continue;
122
+ adjacency.get(id).push(next);
123
+ }
124
+ adjacency.get(id).sort();
125
+ for (const next of adjacency.get(id))
126
+ pending.push(next);
127
+ }
128
+ receipt.graph.reachableUnresolved = adjacency.size;
129
+ receipt.graph.unknownNodes.sort();
130
+ receipt.graph.unknownStatuses.sort();
131
+ if (receipt.graph.unknownNodeCount || receipt.graph.unknownStatusCount) {
132
+ return refuse("INCOMPLETE_GRAPH", "Reload all referenced items with statuses; supply registry status sets for custom workflows. Do not filter or truncate the goal cone.");
133
+ }
134
+ const goalResolved = resolved.has(String(nodes.get(goal).properties.status));
135
+ if (!goalResolved)
136
+ receipt.graph.cycle = cycleWitness(adjacency, goal);
137
+ receipt.graph.complete = true;
138
+ if (receipt.graph.cycle.length)
139
+ return refuse("CYCLE", "Break or explicitly resolve the reported dependency cycle, then reload and retry.");
140
+ const immediateBlockers = adjacency.get(goal) ?? [];
141
+ const cuts = [];
142
+ const finish = (reason) => {
143
+ receipt.search.reason = reason;
144
+ receipt.search.complete = reason === "exhausted";
145
+ return { ok: true, goal, semantics: "upstream-frontier", goalResolved, immediateBlockers, cuts, receipt };
146
+ };
147
+ if (!immediateBlockers.length) {
148
+ cuts.push({ blockers: [], immediatelyUnblocked: true });
149
+ receipt.search.minimumCardinality = 0;
150
+ return finish("exhausted");
151
+ }
152
+ const candidates = [...adjacency.keys()].filter((id) => id !== goal).sort();
153
+ const leaves = new Set(candidates.filter((id) => adjacency.get(id).length === 0));
154
+ const spend = () => {
155
+ if (receipt.search.work === limits.maxWork)
156
+ return false;
157
+ receipt.search.work++;
158
+ return true;
159
+ };
160
+ for (let size = 1; size <= Math.min(limits.maxSize, candidates.length); size++) {
161
+ const indices = Array.from({ length: size }, (_, i) => i);
162
+ let more = true;
163
+ while (more) {
164
+ if (receipt.search.states === limits.maxStates)
165
+ return finish("maxStates");
166
+ receipt.search.states++;
167
+ const selected = indices.map((i) => candidates[i]);
168
+ const selectedSet = new Set(selected);
169
+ let redundant = false;
170
+ for (const cut of cuts) {
171
+ let contains = true;
172
+ for (const id of cut.blockers) {
173
+ if (!spend())
174
+ return finish("maxWork");
175
+ if (!selectedSet.has(id)) {
176
+ contains = false;
177
+ break;
178
+ }
179
+ }
180
+ if (contains) {
181
+ redundant = true;
182
+ break;
183
+ }
184
+ }
185
+ if (!redundant) {
186
+ const visit = [goal];
187
+ const visited = new Set();
188
+ let separated = true;
189
+ while (visit.length) {
190
+ if (!spend())
191
+ return finish("maxWork");
192
+ const id = visit.pop();
193
+ if (selectedSet.has(id) || visited.has(id))
194
+ continue;
195
+ visited.add(id);
196
+ if (leaves.has(id)) {
197
+ separated = false;
198
+ break;
199
+ }
200
+ for (const next of adjacency.get(id)) {
201
+ if (!spend())
202
+ return finish("maxWork");
203
+ visit.push(next);
204
+ }
205
+ }
206
+ if (separated) {
207
+ cuts.push({ blockers: selected, immediatelyUnblocked: immediateBlockers.every((id) => selectedSet.has(id)) });
208
+ receipt.search.minimumCardinality ??= size;
209
+ if (cuts.length === limits.maxCuts)
210
+ return finish("maxCuts");
211
+ }
212
+ }
213
+ let position = size - 1;
214
+ while (position >= 0 && indices[position] === candidates.length - size + position)
215
+ position--;
216
+ more = position >= 0;
217
+ if (more) {
218
+ indices[position]++;
219
+ for (let next = position + 1; next < size; next++)
220
+ indices[next] = indices[next - 1] + 1;
221
+ }
222
+ }
223
+ receipt.search.completeThroughSize = size;
224
+ }
225
+ return finish(limits.maxSize < candidates.length ? "maxSize" : "exhausted");
226
+ }
227
+ //# sourceMappingURL=blocker-cuts.js.map