fapony 0.3.5 → 0.3.6

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/src/debt/cli.ts CHANGED
@@ -6,7 +6,7 @@ import { existsSync, realpathSync, statSync } from "node:fs";
6
6
  import { dirname, isAbsolute, join, resolve } from "node:path";
7
7
  import { CONVENTIONS_FILE } from "../core/config.js";
8
8
  import { formatDebt } from "./format.js";
9
- import { loadConventions } from "./load.js";
9
+ import { resolveDebtScope } from "./load.js";
10
10
  import { findPromotions, formatPromotions } from "./promotion.js";
11
11
  import { debtForFile, debtScan } from "./scan.js";
12
12
  import { ZONE_CAP } from "./types.js";
@@ -119,12 +119,26 @@ export function cmdDebt(args: string[]): void {
119
119
  }
120
120
  }
121
121
 
122
- const worktree = worktreeOf(path);
123
- const loaded = loadConventions(worktree);
122
+ // Scan scope: the git root (convention `where` values are repo-relative)
123
+ // paired with the nearest conventions file — scanning from the app dir
124
+ // dropped repo-relative conventions and hid files outside the app
125
+ // (bug mucvfiv5). Evidence (mem/ledger) keeps the old neighborhood scope.
126
+ // Physical path (symlinks resolved) so --files resolution below compares
127
+ // like with like against scope.scanRoot (/var → /private/var on macOS).
128
+ let base = resolve(path ?? ".");
129
+ try {
130
+ base = realpathSync(base);
131
+ } catch {
132
+ // nonexistent — the not-found notes below handle it
133
+ }
134
+ const scope = resolveDebtScope(base);
135
+ const worktree = scope.scanRoot;
136
+ const loaded = scope.loaded;
137
+ const evidenceDir = worktreeOf(path);
124
138
 
125
139
  if (filesMode) {
126
140
  const out = filesMode.map((f) => {
127
- const abs = isAbsolute(f) ? f : resolve(worktree, f);
141
+ const abs = isAbsolute(f) ? f : resolve(base, f);
128
142
  if (!existsSync(abs) || !statSync(abs).isFile()) {
129
143
  return { file: f, debt: [], note: "not found" as const };
130
144
  }
@@ -178,7 +192,7 @@ export function cmdDebt(args: string[]): void {
178
192
  if (json) {
179
193
  console.log(
180
194
  JSON.stringify(
181
- { ...report, promotions: findPromotions(worktree, report) },
195
+ { ...report, promotions: findPromotions(evidenceDir, report) },
182
196
  null,
183
197
  2,
184
198
  ),
@@ -187,7 +201,7 @@ export function cmdDebt(args: string[]): void {
187
201
  }
188
202
  console.log(formatDebt(report, showAll));
189
203
  for (const w of loaded.warnings) console.log(`⚠ ${w}`);
190
- for (const l of formatPromotions(findPromotions(worktree, report))) {
204
+ for (const l of formatPromotions(findPromotions(evidenceDir, report))) {
191
205
  console.log(l);
192
206
  }
193
207
  }
package/src/debt/load.ts CHANGED
@@ -4,8 +4,14 @@
4
4
  // (<repo>/.fapony/conventions.json — via the same resolver as the mem log).
5
5
  // Missing file = empty + no error.
6
6
 
7
- import { existsSync, readFileSync } from "node:fs";
8
- import { join } from "node:path";
7
+ import {
8
+ existsSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ statSync,
13
+ } from "node:fs";
14
+ import { dirname, join, resolve } from "node:path";
9
15
  import {
10
16
  CONVENTIONS_FILE,
11
17
  CONVENTIONS_FILENAME,
@@ -28,6 +34,134 @@ export function resolveConventionsPath(worktree: string): string | null {
28
34
  return existsSync(root) ? root : null;
29
35
  }
30
36
 
37
+ export interface DebtScope {
38
+ /**
39
+ * The dir `where` clauses and debt rel-paths are measured against — the git
40
+ * root inside a repo (convention `where` values are repo-relative), the
41
+ * resolved start dir outside one.
42
+ */
43
+ scanRoot: string;
44
+ /** Conventions in scope: nearest file walking up, else the lone file under the root. */
45
+ loaded: LoadedConventions;
46
+ }
47
+
48
+ // Dirs that never hold a conventions.json and are expensive to walk — same
49
+ // prune set as the mem-log ambiguity scan (src/core/mem-log.ts).
50
+ const SCOPE_SKIP = new Set([
51
+ "node_modules",
52
+ ".git",
53
+ "dist",
54
+ "build",
55
+ "coverage",
56
+ "vendor",
57
+ "out",
58
+ "target",
59
+ ".next",
60
+ ".turbo",
61
+ ".cache",
62
+ ]);
63
+
64
+ /** Every `.fapony/conventions.json` under root — bounded walk, pruned dirs skipped. */
65
+ function findConventionsUnder(root: string): string[] {
66
+ const found: string[] = [];
67
+ const walk = (dir: string, depth: number): void => {
68
+ if (depth > 6) return;
69
+ let entries: import("node:fs").Dirent[];
70
+ try {
71
+ entries = readdirSync(dir, { withFileTypes: true });
72
+ } catch {
73
+ return;
74
+ }
75
+ for (const e of entries) {
76
+ if (!e.isDirectory()) continue;
77
+ if (e.name === FAPONY_DIR) {
78
+ const f = join(dir, FAPONY_DIR, CONVENTIONS_FILENAME);
79
+ if (existsSync(f)) found.push(f);
80
+ continue;
81
+ }
82
+ if (e.name.startsWith(".") || SCOPE_SKIP.has(e.name)) continue;
83
+ walk(join(dir, e.name), depth + 1);
84
+ }
85
+ };
86
+ walk(root, 0);
87
+ return found;
88
+ }
89
+
90
+ /**
91
+ * The single debt-scope resolver — hint (`readContextData`), the precision
92
+ * instrument (`computeHintImpact`), and `fapony debt` all pair the same
93
+ * scan root with the same conventions (bugs mucvfaxk + mucvfiv5).
94
+ *
95
+ * Each caller previously resolved differently: the hint and the instrument
96
+ * anchored `loadConventions` at the git root (silent whenever conventions are
97
+ * app-scoped), while the CLI scanned from the app dir (so repo-relative
98
+ * `where` values never matched and files outside the app were invisible).
99
+ * The scan root here is always the git root — `where` is repo-relative —
100
+ * and the conventions are the nearest file walking up from `fromDir`
101
+ * (the app case); when the walk misses — a file outside any app dir, or the
102
+ * repo root itself — the lone `conventions.json` under the root wins.
103
+ * Two or more files under the root stay ambiguous (null, don't guess).
104
+ */
105
+ export function resolveDebtScope(fromDir: string): DebtScope {
106
+ let start = resolve(fromDir);
107
+ try {
108
+ if (!statSync(start).isDirectory()) start = dirname(start);
109
+ } catch {
110
+ // nonexistent path — keep the lexical form, resolution below still applies
111
+ }
112
+ try {
113
+ // Physical path so the walk, the boundary check, and scanRoot compare
114
+ // like with like (/var → /private/var on macOS).
115
+ start = realpathSync(start);
116
+ } catch {
117
+ // vanished mid-call — keep the lexical form
118
+ }
119
+ // `git rev-parse` returns a physical path (/var → /private/var on macOS),
120
+ // so the boundary check compares realpaths, same as worktreeOf in cli.ts.
121
+ const physical = (d: string): string => {
122
+ try {
123
+ return realpathSync(d);
124
+ } catch {
125
+ return d;
126
+ }
127
+ };
128
+ let gitRoot: string | null = null;
129
+ try {
130
+ const p = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
131
+ cwd: start,
132
+ stdout: "pipe",
133
+ stderr: "pipe",
134
+ });
135
+ if (p.exitCode === 0) gitRoot = p.stdout.toString().trim() || null;
136
+ } catch {
137
+ // not a repo — start is all we have
138
+ }
139
+ const scanRoot = gitRoot ? physical(gitRoot) : start;
140
+ const boundary = gitRoot ? physical(gitRoot) : null;
141
+
142
+ // Nearest conventions.json walking up (the app case) — bounded by the root.
143
+ let dir = start;
144
+ while (true) {
145
+ if (existsSync(join(dir, CONVENTIONS_FILE))) {
146
+ return { scanRoot, loaded: loadConventions(dir) };
147
+ }
148
+ if (boundary && physical(dir) === boundary) break;
149
+ const parent = dirname(dir);
150
+ if (parent === dir) break;
151
+ dir = parent;
152
+ }
153
+
154
+ // Sibling-app case: the file sits outside any app dir (e.g. packages/*),
155
+ // but the repo holds exactly one conventions file — use it.
156
+ if (gitRoot) {
157
+ const found = findConventionsUnder(scanRoot);
158
+ if (found.length === 1) {
159
+ return { scanRoot, loaded: loadConventions(dirname(dirname(found[0]))) };
160
+ }
161
+ }
162
+ return { scanRoot, loaded: loadConventions(scanRoot) };
163
+ }
164
+
31
165
  function asString(v: unknown): string | undefined {
32
166
  return typeof v === "string" && v.length > 0 ? v : undefined;
33
167
  }
@@ -14,6 +14,7 @@ import {
14
14
  type Run,
15
15
  } from "../core/config.js";
16
16
  import { type HintImpact, hintLogPath } from "../core/hint-log.js";
17
+ import { parseSince } from "../core/since.js";
17
18
  import { openDb } from "../db/store.js";
18
19
  import { computeHintImpact } from "../hook.js";
19
20
  import { type MemRow, readMemLog } from "../memory.js";
@@ -100,25 +101,6 @@ export interface CollectOpts {
100
101
 
101
102
  // --- helpers ---
102
103
 
103
- function parseSince(
104
- raw: string | undefined,
105
- now?: number,
106
- ): { iso: string; label: string } {
107
- const def = "7d";
108
- const s = raw ?? def;
109
- const dMatch = /^(\d+)d$/.exec(s);
110
- if (dMatch) {
111
- const days = Number(dMatch[1]);
112
- const dt = new Date((now ?? Date.now()) - days * 86400000);
113
- return { iso: dt.toISOString(), label: `${days}d` };
114
- }
115
- const dateMatch = /^\d{4}-\d{2}-\d{2}$/.exec(s);
116
- if (dateMatch) {
117
- return { iso: `${s}T00:00:00.000Z`, label: s };
118
- }
119
- throw new Error(`invalid --since format: "${s}" — use <N>d or YYYY-MM-DD`);
120
- }
121
-
122
104
  function resolveWorktree(override?: string): string {
123
105
  if (override) return override;
124
106
  try {
package/src/hook.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // from this path.
7
7
 
8
8
  export {
9
+ BUG_MARKERS,
9
10
  bugSignalFromTranscript,
10
11
  COMMIT_HINT_MIN_COMMITS,
11
12
  type CommitHintInput,
@@ -36,9 +37,11 @@ export {
36
37
  gitAutonomyStatus,
37
38
  type HintFireRow,
38
39
  type HintImpact,
40
+ hasBugMarker,
39
41
  hintLogDir,
40
42
  hintLogPath,
41
43
  hookTsMs,
44
+ isBugfixCommit,
42
45
  isCodexPayload,
43
46
  isCursorPayload,
44
47
  mvGuardDecision,
@@ -31,6 +31,183 @@ import {
31
31
  const SHIPPED = /^>\s*✅/m;
32
32
  const FRONT = /^---\r?\n([\s\S]*?)\r?\n---/;
33
33
  const HELD = /^status:\s*(blocked|superseded)\b/m;
34
+ const PLAN_REF_RE = /\bPLAN-[\w-]+\.md\b/g;
35
+
36
+ export type PlanFrontmatter = {
37
+ status: string | null;
38
+ kind: string | null;
39
+ blockedByRaw: string | null;
40
+ blocksRaw: string | null;
41
+ supersededBy: string | null;
42
+ };
43
+
44
+ // frontmatter is the only dep-graph source — no new schema, parse what agents
45
+ // already write per templates/PLAN.md (keys EN, values EN). Sentence values
46
+ // ("waiting on support email") carry no PLAN-*.md token and are skipped by
47
+ // the file checks, never flagged.
48
+ export const parsePlanFrontmatter = (file: string): PlanFrontmatter => {
49
+ const out: PlanFrontmatter = {
50
+ status: null,
51
+ kind: null,
52
+ blockedByRaw: null,
53
+ blocksRaw: null,
54
+ supersededBy: null,
55
+ };
56
+ try {
57
+ const head = readFileSync(file, "utf8").slice(0, 4096);
58
+ const m = FRONT.exec(head);
59
+ if (!m) return out;
60
+ for (const line of m[1].split("\n")) {
61
+ const kv =
62
+ /^\s*(status|kind|blocked_by|blocks|superseded_by)\s*:\s*(.+?)\s*$/.exec(
63
+ line,
64
+ );
65
+ if (!kv) continue;
66
+ const v = kv[2].trim() || null;
67
+ if (kv[1] === "status") out.status = v;
68
+ else if (kv[1] === "kind") out.kind = v;
69
+ else if (kv[1] === "blocked_by") out.blockedByRaw = v;
70
+ else if (kv[1] === "blocks") out.blocksRaw = v;
71
+ else out.supersededBy = v;
72
+ }
73
+ } catch {
74
+ // unreadable file — callers treat nulls as "no frontmatter"
75
+ }
76
+ return out;
77
+ };
78
+
79
+ // every PLAN-*.md token inside the raw value (comma list or a sentence that
80
+ // names a plan). Deduped basenames — resolution tries planDir then doneDir.
81
+ export const extractPlanRefs = (raw: string | null): string[] => {
82
+ if (!raw) return [];
83
+ return [...new Set(raw.match(PLAN_REF_RE) ?? [])].map((p) => basename(p));
84
+ };
85
+
86
+ export const planLocation = (base: string): "plan" | "done" | null => {
87
+ try {
88
+ if (existsSync(join(planDir, base))) return "plan";
89
+ if (existsSync(join(doneDir, base))) return "done";
90
+ } catch {
91
+ // planDir/doneDir uninitialised in unit context — treat as unknown
92
+ }
93
+ return null;
94
+ };
95
+
96
+ // checkbox tally of the first ## section only — same contract as kickoff's
97
+ // readPlanSectionItems (kept local: read.ts imports from this file, so an
98
+ // import back would be a cycle). Counts only, no text.
99
+ export const countFirstSection = (
100
+ file: string,
101
+ ): { checked: number; unchecked: number } => {
102
+ try {
103
+ const text = readFileSync(file, "utf8");
104
+ const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---/, "");
105
+ const start = body.search(/^##\s+/m);
106
+ if (start < 0) return { checked: 0, unchecked: 0 };
107
+ const rest = body.slice(start);
108
+ const next = rest.slice(3).search(/^##\s+/m);
109
+ const block = next < 0 ? rest : rest.slice(0, next + 3);
110
+ let checked = 0;
111
+ let unchecked = 0;
112
+ for (const line of block.split("\n")) {
113
+ if (/^\s*[-*]\s+\[\s\]\s+.+$/.test(line)) unchecked++;
114
+ else if (/^\s*[-*]\s+\[[xX]\]\s+.+$/.test(line)) checked++;
115
+ }
116
+ return { checked, unchecked };
117
+ } catch {
118
+ return { checked: 0, unchecked: 0 };
119
+ }
120
+ };
121
+
122
+ // dep-graph issues over active plan files: dangling blocked_by/blocks refs,
123
+ // "blocker shipped but dependent still blocked", and cycles. Pure (no
124
+ // console/exit) so tests call it directly; cmdPlanCheck prints what it returns.
125
+ export const collectDepIssues = (active: string[]): string[] => {
126
+ const issues: string[] = [];
127
+ const activeBases = new Set(active.map((f) => basename(f)));
128
+ // waiter -> blocker edges among active files only (done/ blockers are the
129
+ // unblocked-ready case above, not a cycle).
130
+ const edges = new Map<string, string[]>();
131
+
132
+ for (const f of active) {
133
+ const relPath = relative(planBase, f);
134
+ const fm = parsePlanFrontmatter(f);
135
+ const base = basename(f);
136
+
137
+ for (const ref of extractPlanRefs(fm.blockedByRaw)) {
138
+ const loc = planLocation(ref);
139
+ if (!loc) {
140
+ issues.push(
141
+ `${relPath} — blocked_by points at ${ref} but no such file is in plan/ or done/\n fix: correct the filename or keep blocked_by as a plain sentence`,
142
+ );
143
+ } else if (loc === "done") {
144
+ issues.push(
145
+ `${relPath} — blocker ${ref} already shipped to done/ but this plan is still status:blocked\n fix: clear status:blocked or tick the remaining chunk`,
146
+ );
147
+ }
148
+ if (activeBases.has(ref)) {
149
+ if (!edges.has(base)) edges.set(base, []);
150
+ edges.get(base)?.push(ref);
151
+ }
152
+ }
153
+
154
+ for (const ref of extractPlanRefs(fm.blocksRaw)) {
155
+ const loc = planLocation(ref);
156
+ if (!loc) {
157
+ issues.push(
158
+ `${relPath} — blocks points at ${ref} but no such file is in plan/ or done/\n fix: correct the filename or drop it`,
159
+ );
160
+ }
161
+ // "F blocks G" = G waits on F → edge G -> F for cycle detection.
162
+ if (activeBases.has(ref)) {
163
+ if (!edges.has(ref)) edges.set(ref, []);
164
+ edges.get(ref)?.push(base);
165
+ }
166
+ }
167
+ }
168
+
169
+ // DFS cycle detection over the active-only edges.
170
+ const state = new Map<string, number>(); // 1 = on stack, 2 = done
171
+ const stack: string[] = [];
172
+ const visit = (n: string) => {
173
+ state.set(n, 1);
174
+ stack.push(n);
175
+ for (const t of edges.get(n) ?? []) {
176
+ if (state.get(t) === 1) {
177
+ const cyc = [...stack.slice(stack.indexOf(t)), t].join(" → ");
178
+ issues.push(
179
+ `cycle: ${cyc} — plans wait on each other, nothing can unblock\n fix: drop one side of the blocked_by/blocks link`,
180
+ );
181
+ continue;
182
+ }
183
+ if (!state.get(t)) visit(t);
184
+ }
185
+ stack.pop();
186
+ state.set(n, 2);
187
+ };
188
+ for (const n of edges.keys()) if (!state.get(n)) visit(n);
189
+
190
+ return issues;
191
+ };
192
+
193
+ // status:blocked with every first-section chunk ticked = deferred doc debt:
194
+ // the work reads done but the plan stays in plan/ forever (shippedNotMoved
195
+ // never lists it — HELD excludes it). Trackers never finish, so they are out.
196
+ export const collectBlockedTickedIssues = (active: string[]): string[] => {
197
+ const issues: string[] = [];
198
+ for (const f of active) {
199
+ const fm = parsePlanFrontmatter(f);
200
+ if (fm.status !== "blocked") continue;
201
+ if (fm.kind === "tracker") continue;
202
+ const { checked, unchecked } = countFirstSection(f);
203
+ if (checked > 0 && unchecked === 0) {
204
+ issues.push(
205
+ `${relative(planBase, f)} — status:blocked but all ${checked} chunk(s) ticked (deferred doc debt?)\n fix: ship via ${planSweepCmd} ${basename(f)} --apply or add the remaining chunk`,
206
+ );
207
+ }
208
+ }
209
+ return issues;
210
+ };
34
211
 
35
212
  // the ✅ shipped header is no longer on the first line — the current plan format starts with frontmatter
36
213
  // then `# title` (see templates/PLAN.md); check the file's head rather than a single first line
@@ -172,25 +349,45 @@ export const cmdPlanSweep = (a: string[]) => {
172
349
  const apply = a.includes("--apply");
173
350
 
174
351
  if (!target) {
175
- if (!candidates.length) {
352
+ const all = rows();
353
+ if (candidates.length) {
354
+ console.log(
355
+ `# plan-sweep — ${candidates.length} file(s) marked shipped but not archived\n`,
356
+ );
357
+ for (const name of candidates) {
358
+ const spec = `${rel(dir)}/${name}`;
359
+ const openN = openRows(all).filter((r) => r.spec === spec).length;
360
+ const warn = openN
361
+ ? ` ⚠ ${openN} open row(s) (next/bug/hold/decision/note) — check before moving`
362
+ : "";
363
+ console.log(`- ${rel(dir)}/${name}${warn}`);
364
+ }
365
+ console.log(`\nmove: ${planSweepCmd} <file.md> --apply`);
366
+ } else {
176
367
  console.log(
177
368
  `no PLAN with a ✅ shipped header is sitting outside ${rel(doneDir)}/`,
178
369
  );
179
- return;
180
370
  }
181
- const all = rows();
182
- console.log(
183
- `# plan-sweep — ${candidates.length} file(s) marked shipped but not archived\n`,
371
+ // blocked plans are never move candidates (HELD excludes them from
372
+ // shippedNotMoved) — list them with progress + open rows so the debt on
373
+ // a waiting plan is visible instead of silent.
374
+ const blocked = mdFiles(dir).filter(
375
+ (f) => parsePlanFrontmatter(f).status === "blocked",
184
376
  );
185
- for (const name of candidates) {
186
- const spec = `${rel(dir)}/${name}`;
187
- const openN = openRows(all).filter((r) => r.spec === spec).length;
188
- const warn = openN
189
- ? ` ⚠ ${openN} open row(s) (next/bug/hold/decision/note) — check before moving`
190
- : "";
191
- console.log(`- ${rel(dir)}/${name}${warn}`);
377
+ if (blocked.length) {
378
+ console.log(
379
+ `\n# blocked plans (${blocked.length}) — not move candidates\n`,
380
+ );
381
+ for (const f of blocked) {
382
+ const { checked, unchecked } = countFirstSection(f);
383
+ const spec = rel(f);
384
+ const openN = openRows(all).filter((r) => r.spec === spec).length;
385
+ const by = parsePlanFrontmatter(f).blockedByRaw ?? "?";
386
+ console.log(
387
+ `- ${spec} — ${checked}/${checked + unchecked} chunks · blocked_by: ${by}${openN ? ` · ⚠ ${openN} open row(s)` : ""}`,
388
+ );
389
+ }
192
390
  }
193
- console.log(`\nmove: ${planSweepCmd} <file.md> --apply`);
194
391
  return;
195
392
  }
196
393
 
@@ -210,6 +407,13 @@ export const cmdPlanSweep = (a: string[]) => {
210
407
  const srcDir = dirname(src);
211
408
  const shipped = hasShippedHeader(src);
212
409
  if (!apply) {
410
+ const fm = parsePlanFrontmatter(src);
411
+ if (fm.status === "blocked") {
412
+ console.log(
413
+ `${target}: status:blocked (blocked_by: ${fm.blockedByRaw ?? "?"}) — not a move candidate, stays in plan/`,
414
+ );
415
+ return;
416
+ }
213
417
  console.log(
214
418
  shipped
215
419
  ? `${target}: has a ✅ shipped header — ready to move (add --apply)`
@@ -289,6 +493,28 @@ export const cmdPlanSweep = (a: string[]) => {
289
493
  // immediately or staleReport shows "decision never made it into the spec" on every ship (seen in kickoff 2026-09-02)
290
494
  put({ kind: "synced", spec: doneSpec });
291
495
 
496
+ // the ship may unblock waiting plans — the dep graph lives in frontmatter,
497
+ // so say which active plans name this file as their blocker (or were named
498
+ // in this file's own blocks:). Detect-only: the dependent keeps
499
+ // status:blocked until its owner clears it (plan-check flags it meanwhile).
500
+ const unblockedByName = mdFiles(dir).filter((f) =>
501
+ extractPlanRefs(parsePlanFrontmatter(f).blockedByRaw).includes(name),
502
+ );
503
+ const unblockedByOwnBlocks = extractPlanRefs(
504
+ parsePlanFrontmatter(dst).blocksRaw,
505
+ ).filter((b) => existsSync(join(dir, b)));
506
+ const unblocked = [
507
+ ...new Set([
508
+ ...unblockedByName.map((f) => basename(f)),
509
+ ...unblockedByOwnBlocks,
510
+ ]),
511
+ ];
512
+ if (unblocked.length) {
513
+ console.log(
514
+ `🔓 ${name} shipped — ${unblocked.join(", ")} list(s) it as blocker, clear status:blocked?`,
515
+ );
516
+ }
517
+
292
518
  // plain-text mention detection (detect-only, no auto-fix)
293
519
  let plainTextTotal = 0;
294
520
  const plainTextFiles: string[] = [];
@@ -324,6 +550,8 @@ export const cmdPlanSweep = (a: string[]) => {
324
550
 
325
551
  // plan-check — list active PLANs + detect shipped-not-moved + broken links
326
552
  // + verify the commits ticked chunks cite (chunk 8, PLAN-seed-and-surface)
553
+ // + dep-graph (blocked_by/blocks: dangling, shipped-but-still-blocked, cycles)
554
+ // + blocked-with-all-chunks-ticked (deferred doc debt) + blocked view
327
555
  // exit 0 = clean, 1 = issues found
328
556
 
329
557
  // A ticked chunk cites the commit that closed it — a sha git cannot find on
@@ -496,10 +724,41 @@ export const cmdPlanCheck = (a: string[]) => {
496
724
  });
497
725
  }
498
726
 
727
+ // 5) Dep-graph check — frontmatter blocked_by/blocks already states the
728
+ // order, so verify it: dangling refs, blocker shipped but dependent still
729
+ // blocked, and waiter cycles. Sentence values carry no PLAN-*.md token
730
+ // and are skipped, never flagged.
731
+ for (const issue of collectDepIssues(active)) issues.push(issue);
732
+
733
+ // 6) Blocked-but-ticked check — status:blocked with every first-section
734
+ // chunk ticked is deferred doc debt: shippedNotMoved never lists it
735
+ // (HELD excludes it), so without this flag it sits in plan/ silently.
736
+ for (const issue of collectBlockedTickedIssues(active)) issues.push(issue);
737
+
499
738
  if (!quiet) {
500
739
  console.log(
501
740
  `closed chunks: ${closed} · citing a commit: ${citing} · verified: ${verified}`,
502
741
  );
742
+ // blocked view: waiting plans are never move candidates, but their debt
743
+ // (progress + open mem rows) must be visible somewhere — plan-check is it.
744
+ const blocked = active.filter(
745
+ (f) => parsePlanFrontmatter(f).status === "blocked",
746
+ );
747
+ if (blocked.length) {
748
+ const all = rows();
749
+ console.log(
750
+ `\nblocked plans (${blocked.length}) — waiting, not candidates:`,
751
+ );
752
+ for (const f of blocked) {
753
+ const { checked, unchecked } = countFirstSection(f);
754
+ const spec = rel(f);
755
+ const openN = openRows(all).filter((r) => r.spec === spec).length;
756
+ const by = parsePlanFrontmatter(f).blockedByRaw ?? "?";
757
+ console.log(
758
+ `- ${spec} — ${checked}/${checked + unchecked} chunks · blocked_by: ${by}${openN ? ` · ⚠ ${openN} open row(s)` : ""}`,
759
+ );
760
+ }
761
+ }
503
762
  }
504
763
 
505
764
  if (issues.length === 0) {