fapony 0.4.0 → 0.5.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.
@@ -23,6 +23,8 @@ export interface MemFindResult {
23
23
  filesFound: number;
24
24
  skipped: number;
25
25
  memDir: string | null;
26
+ /** Present only when a key query missed — every distinct key in the log. */
27
+ knownKeys?: string[];
26
28
  }
27
29
 
28
30
  export function memFind(args: {
@@ -33,6 +35,7 @@ export function memFind(args: {
33
35
  since?: string;
34
36
  limit?: number;
35
37
  open?: boolean;
38
+ key?: string;
36
39
  }): MemFindResult {
37
40
  // Query logic lives in the shared engine (src/mem/engine.ts) — this wrapper
38
41
  // owns only the read (readMemLog sees live + rotated archives via its loose
@@ -40,22 +43,24 @@ export function memFind(args: {
40
43
  // omitting kind returns every kind (locked by test). CLI cmdFind calls the
41
44
  // same engine with its own bookkeeping exclude.
42
45
  const read = readMemLog(args.worktree);
43
- const { rows: matched, total } = engineFind(read.rows, {
46
+ const found = engineFind(read.rows, {
44
47
  text: args.text,
45
48
  files: args.files,
46
49
  kind: args.kind,
47
50
  sinceIso: args.since,
51
+ key: args.key,
48
52
  limit: args.limit,
49
53
  open: args.open,
50
54
  });
51
55
  return {
52
- rows: matched,
53
- total,
56
+ rows: found.rows,
57
+ total: found.total,
54
58
  filesFound: read.filesFound,
55
59
  skipped: read.skipped,
56
60
  // memDir separates "no mem at all" from "nothing matched" (spec §3) and
57
61
  // makes the monorepo single-log limit visible (spec §5.1).
58
62
  memDir: resolveMemDir(args.worktree),
63
+ ...(found.knownKeys ? { knownKeys: found.knownKeys } : {}),
59
64
  };
60
65
  }
61
66
 
@@ -93,9 +98,18 @@ export function toolMemFind(args: Record<string, unknown>): ToolResult {
93
98
  }
94
99
  const limit = typeof args.limit === "number" ? args.limit : undefined;
95
100
  const open = typeof args.open === "boolean" ? args.open : undefined;
101
+ // Shape gate like mem_add — a non-string key must not coerce to undefined
102
+ // (silent drop = reject-without-saying). Pattern is NOT checked here: find
103
+ // answers a wrong-pattern key with knownKeys, not a reject (SPEC fail example).
104
+ if (args.key !== undefined && typeof args.key !== "string") {
105
+ return errorResult(
106
+ 'key must be a string matching [a-z0-9-]{3,40} — e.g. "fix-stop-dedupe"',
107
+ );
108
+ }
109
+ const key = typeof args.key === "string" ? args.key : undefined;
96
110
 
97
111
  return jsonResult(
98
- memFind({ worktree, files, text, kind, since, limit, open }),
112
+ memFind({ worktree, files, text, kind, since, limit, open, key }),
99
113
  );
100
114
  }
101
115
 
@@ -112,6 +126,7 @@ export function memAdd(args: {
112
126
  text: string;
113
127
  files: string[];
114
128
  spec?: string;
129
+ key?: string;
115
130
  }): MemAddResult {
116
131
  initStore(args.worktree);
117
132
  return engineAdd({
@@ -119,6 +134,7 @@ export function memAdd(args: {
119
134
  text: args.text,
120
135
  files: args.files,
121
136
  spec: args.spec,
137
+ key: args.key,
122
138
  });
123
139
  }
124
140
 
@@ -166,8 +182,17 @@ export function toolMemAdd(args: Record<string, unknown>): ToolResult {
166
182
  ? args.spec.trim()
167
183
  : undefined;
168
184
 
185
+ // Shape gate here, pattern check in engineAdd — a non-string key must not
186
+ // slip through as undefined (silent drop = reject-without-saying, SPEC §Validation).
187
+ if (args.key !== undefined && typeof args.key !== "string") {
188
+ return errorResult(
189
+ 'key must be a string matching [a-z0-9-]{3,40} — e.g. "fix-stop-dedupe"',
190
+ );
191
+ }
192
+ const key = typeof args.key === "string" ? args.key : undefined;
193
+
169
194
  try {
170
- const result = memAdd({ worktree, kind, text, files, spec });
195
+ const result = memAdd({ worktree, kind, text, files, spec, key });
171
196
  return jsonResult(result);
172
197
  } catch (e) {
173
198
  return errorResult(e instanceof Error ? e.message : String(e));
@@ -19,7 +19,7 @@ import { errorResult, type ToolResult } from "./types.js";
19
19
 
20
20
  const SERVER_INSTRUCTIONS = `fapony records decisions, bugs, and notes about this project so the next session (or the next agent) knows what happened and what to watch out for.
21
21
 
22
- When you finish a unit of work, record a mem row: fapony mem add <decision|bug|note> "what happened" --files <files> <path/to/PLAN.md>. files[] is required — a row without it is unfindable when you touch that file next session.
22
+ When you finish a unit of work, record a mem row: fapony mem add <decision|bug|note> "what happened" --files <files> <path/to/PLAN.md>. files[] is required — a row without it is unfindable when you touch that file next session. Name a known problem with --key <a-z0-9-id> and recall every row for it via mem_find key.
23
23
 
24
24
  worktree must be the absolute path (git rev-parse --show-toplevel): every query scopes by it, so a bare name or none files the row where nothing reads it, and nothing errors to say so. Write the note standalone — it is read months later with no access to this conversation.
25
25
 
@@ -30,7 +30,17 @@ export function hintLogPath(worktree: string): string {
30
30
  export interface HintFireRow {
31
31
  ts: string;
32
32
  worktree: string;
33
- surface: "read" | "debt" | "mem" | "commit" | "edit";
33
+ surface:
34
+ | "read"
35
+ | "debt"
36
+ | "mem"
37
+ | "commit"
38
+ | "edit"
39
+ | "open-bug"
40
+ | "handoff-would-block"
41
+ | "handoff-pass"
42
+ | "commit-block"
43
+ | "bug-block";
34
44
  file: string | null;
35
45
  count: number;
36
46
  ids?: string[];
@@ -62,6 +72,11 @@ export interface HintImpact {
62
72
  mem: number;
63
73
  commit: number;
64
74
  edit: number;
75
+ "open-bug": number;
76
+ "handoff-would-block": number;
77
+ "handoff-pass": number;
78
+ "commit-block": number;
79
+ "bug-block": number;
65
80
  };
66
81
  debt: { shown: number; resolved: number; unknown: number };
67
82
  window: string | null;
@@ -22,8 +22,22 @@ export interface MemRow {
22
22
  ref?: string;
23
23
  /** Files the row is about — written by `mem add --files` (PLAN-convention-debt chunk 3). */
24
24
  files?: string[];
25
+ /** Problem identity, distinct from kind (action) and files[] (place) — written by `mem add --key` (PLAN-mem-keys chunk 1). */
26
+ key?: string;
27
+ /** Row schema version: 2 = may carry `key`; absent = v:1 legacy row, read as-is. */
28
+ v?: number;
25
29
  }
26
30
 
31
+ /** One mem-row text budget for any hint/seed surface that shows a row (was context-data's private const). */
32
+ export const MEM_TEXT_MAX = 120;
33
+
34
+ /**
35
+ * The only accepted shape of a mem row `key` — problem identity, not action
36
+ * (kind) and not place (files[]). Lives here in core so CLI and MCP validate
37
+ * through one regex (PLAN-unify-mem-engine: one engine, both surfaces).
38
+ */
39
+ export const KEY_RE = /^[a-z0-9-]{3,40}$/;
40
+
27
41
  interface RawMemRow {
28
42
  ts?: string;
29
43
  agent?: string;
@@ -33,6 +47,8 @@ interface RawMemRow {
33
47
  id?: string;
34
48
  ref?: string;
35
49
  files?: unknown;
50
+ key?: unknown;
51
+ v?: unknown;
36
52
  }
37
53
 
38
54
  /**
@@ -319,6 +335,8 @@ export function readMemLog(
319
335
  ...(Array.isArray(parsed.files)
320
336
  ? { files: parsed.files.filter((f) => typeof f === "string") }
321
337
  : {}),
338
+ ...(typeof parsed.key === "string" ? { key: parsed.key } : {}),
339
+ ...(typeof parsed.v === "number" ? { v: parsed.v } : {}),
322
340
  });
323
341
  }
324
342
  }
package/src/debt/cli.ts CHANGED
@@ -13,7 +13,7 @@ import { ZONE_CAP } from "./types.js";
13
13
 
14
14
  const USAGE = `usage: fapony debt [path] [options]
15
15
  --files f1,f2 check specific files instead of scanning
16
- --id <conv> show only this convention
16
+ --id a,b show only these conventions
17
17
  --where <path> narrow scope to files under this path
18
18
  --all show all zones (default: cap at ${ZONE_CAP})
19
19
  --json output raw JSON
@@ -68,7 +68,7 @@ export function cmdDebt(args: string[]): void {
68
68
  let path: string | undefined;
69
69
  let filesMode: string[] | null = null;
70
70
  let json = false;
71
- let filterId: string | undefined;
71
+ let filterIds: string[] = [];
72
72
  let wherePath: string | undefined;
73
73
  let showAll = false;
74
74
  for (let i = 0; i < args.length; i++) {
@@ -80,14 +80,17 @@ export function cmdDebt(args: string[]): void {
80
80
  process.exit(1);
81
81
  }
82
82
  i++;
83
- filesMode = v
83
+ // Accumulate, never reassign: a repeated --files grows the set
84
+ // (PLAN-comma-x chunk 2 — last-wins was silent data loss).
85
+ const parts = v
84
86
  .split(",")
85
87
  .map((s) => s.trim())
86
88
  .filter(Boolean);
87
- if (filesMode.length === 0) {
89
+ if (parts.length === 0) {
88
90
  console.error(`fapony debt: --files needs at least one path\n${USAGE}`);
89
91
  process.exit(1);
90
92
  }
93
+ filesMode = [...(filesMode ?? []), ...parts];
91
94
  } else if (a === "--id") {
92
95
  const v = args[i + 1];
93
96
  if (!v || v.startsWith("--")) {
@@ -95,7 +98,17 @@ export function cmdDebt(args: string[]): void {
95
98
  process.exit(1);
96
99
  }
97
100
  i++;
98
- filterId = v;
101
+ // Comma list, same shape as --files: ids are slugs, an id can't hold
102
+ // a comma, so any comma splits (PLAN-comma-x). Repeats accumulate too.
103
+ const parts = v
104
+ .split(",")
105
+ .map((s) => s.trim())
106
+ .filter(Boolean);
107
+ if (parts.length === 0) {
108
+ console.error(`fapony debt: --id needs a convention id\n${USAGE}`);
109
+ process.exit(1);
110
+ }
111
+ filterIds = [...filterIds, ...parts];
99
112
  } else if (a === "--where") {
100
113
  const v = args[i + 1];
101
114
  if (!v || v.startsWith("--")) {
@@ -171,12 +184,14 @@ export function cmdDebt(args: string[]): void {
171
184
  }
172
185
  const report = debtScan(worktree, loaded);
173
186
 
174
- // --id filter: keep only the named convention
175
- if (filterId) {
176
- report.entries = report.entries.filter((e) => e.conv.id === filterId);
177
- report.declared = report.declared.filter((c) => c.id === filterId);
187
+ // --id filter: keep only the named conventions (comma list allowed)
188
+ if (filterIds.length > 0) {
189
+ report.entries = report.entries.filter((e) =>
190
+ filterIds.includes(e.conv.id),
191
+ );
192
+ report.declared = report.declared.filter((c) => filterIds.includes(c.id));
178
193
  report.checkedCount = 0; // not relevant when filtering
179
- report.dropped = report.dropped.filter((d) => d.id === filterId);
194
+ report.dropped = report.dropped.filter((d) => filterIds.includes(d.id));
180
195
  }
181
196
 
182
197
  // --where filter: narrow file lists to paths under the given prefix
package/src/hook.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  export {
9
9
  BUG_MARKERS,
10
+ bugDedupeKey,
10
11
  bugSignalFromTranscript,
11
12
  COMMIT_HINT_MIN_COMMITS,
12
13
  type CommitHintInput,
@@ -19,12 +20,15 @@ export {
19
20
  cmdHookStop,
20
21
  commitHintFor,
21
22
  computeHintImpact,
23
+ countTicks,
22
24
  cursorTranscriptPath,
25
+ decideHandoff,
23
26
  decideStop,
24
27
  type EditHintInput,
25
28
  type EditTrackRow,
26
29
  editHintFor,
27
30
  editTrackPath,
31
+ fileAtRevision,
28
32
  GIT_AUTONOMY_COMMIT_POLICY,
29
33
  GIT_AUTONOMY_PLUGIN_FILE,
30
34
  GIT_AUTONOMY_PLUGIN_NAME,
@@ -35,15 +39,24 @@ export {
35
39
  type GitAutonomyStatus,
36
40
  type GitAutonomyToolRewrite,
37
41
  gitAutonomyStatus,
42
+ type HandoffDecision,
43
+ type HandoffPlanFile,
38
44
  type HintFireRow,
39
45
  type HintImpact,
46
+ handoffBaseSha,
47
+ handoffBlockMessage,
48
+ handoffDedupeKey,
40
49
  hasBugMarker,
50
+ hasHandoffLiteral,
41
51
  hintLogDir,
42
52
  hintLogPath,
43
53
  hookTsMs,
44
54
  isBugfixCommit,
45
55
  isCodexPayload,
46
56
  isCursorPayload,
57
+ isPlanPath,
58
+ memHasHandoffForPlan,
59
+ mergeStopReasons,
47
60
  mvGuardDecision,
48
61
  type NormalizedStopInput,
49
62
  normalizeStopInput,
@@ -64,9 +77,11 @@ export {
64
77
  SESSION_START_MAX_CHARS,
65
78
  type StopClient,
66
79
  sessionKey,
80
+ sessionPlanFiles,
67
81
  sessionStartContext,
68
82
  stopBlockedBefore,
69
83
  stopBlockPath,
84
+ stopBlockSurface,
70
85
  stopOutput,
71
86
  utcStamp,
72
87
  worktreeKey,
@@ -5,9 +5,10 @@
5
5
  // Skills path). No hooks in the first phase — Antigravity's hook surface is
6
6
  // still evolving.
7
7
 
8
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { join } from "node:path";
11
+ import { defaultCheckCmd } from "../setup.js";
11
12
  import { agentsSkillsDir, linkSkills, reportSkills } from "./skills.js";
12
13
  import {
13
14
  CURSOR_MCP_ENTRY,
@@ -49,14 +50,20 @@ export function cmdInstallAntigravity(
49
50
  ): void {
50
51
  const exitFn = deps.exit ?? defaultExit;
51
52
  const getHome = deps.homedir ?? homedir;
52
- const geminiDir = findGeminiDir(getHome);
53
+ const checkCmd = deps.checkCmd ?? defaultCheckCmd;
54
+ let geminiDir = findGeminiDir(getHome);
53
55
 
54
56
  if (!geminiDir) {
55
- console.error(
56
- `Antigravity not found — open Antigravity at least once to create ~/.gemini`,
57
- );
58
- exitFn(1);
59
- return;
57
+ // Detect signal #2: the `agy` CLI on PATH — installed but never launched,
58
+ // so ~/.gemini doesn't exist yet; the write path below creates it.
59
+ if (!checkCmd("agy")) {
60
+ console.error(
61
+ `Antigravity not found — open Antigravity at least once to create ~/.gemini (or put the agy CLI on PATH)`,
62
+ );
63
+ exitFn(1);
64
+ return;
65
+ }
66
+ geminiDir = join(getHome(), ".gemini");
60
67
  }
61
68
 
62
69
  // --- 1. MCP server (mcpServers.fapony in ~/.gemini/config/mcp_config.json) ---
@@ -99,6 +106,10 @@ export function cmdInstallAntigravity(
99
106
  `── dry-run: would ${mcpIsNew ? "create" : "write"} ${mcpPath}${mcpIsNew ? "" : ` (mcpServers.${MCP_KEY})`} ──`,
100
107
  );
101
108
  } else {
109
+ // Escape hatch (plan §5): ~/.gemini/config may not exist yet — either
110
+ // the app never got as far as its config dir, or agy-on-PATH created
111
+ // nothing. Recursive mkdir is idempotent and non-destructive.
112
+ mkdirSync(configDir, { recursive: true });
102
113
  writeFileSync(mcpPath, `${JSON.stringify(after, null, 2)}\n`, "utf-8");
103
114
  console.error(`✓ added mcpServers.${MCP_KEY} to ${mcpPath}`);
104
115
  console.error(` restart Antigravity to load the MCP server`);
@@ -1,7 +1,7 @@
1
1
  // src/install/detect.ts — detect which MCP clients are installed on this machine.
2
2
  //
3
3
  // Signal per client:
4
- // antigravity = ~/.gemini exists (the app creates it on first run)
4
+ // antigravity = ~/.gemini exists (the app creates it on first run) OR `agy` on PATH
5
5
  // claude = `command -v claude` (CLI on PATH)
6
6
  // cursor = ~/.cursor exists (the app creates it on first run)
7
7
  // opencode = ~/.config/opencode/{opencode.json,opencode.jsonc} exists
@@ -28,14 +28,15 @@ export interface DetectedClient {
28
28
  * Returns one entry per platform, ordered: antigravity, claude, cursor, opencode, zcode, codex.
29
29
  *
30
30
  * Uses resolvers from each provider (file/dir-exists check) for
31
- * antigravity/cursor/opencode/zcode/codex, and `command -v claude` for claude — all
32
- * through injected deps for testability.
31
+ * antigravity/cursor/opencode/zcode/codex, and `command -v claude`/`command -v agy`
32
+ * for claude/antigravity — all through injected deps for testability.
33
33
  */
34
34
  export function detectClients(deps: InstallDeps = {}): DetectedClient[] {
35
35
  const getHome = deps.homedir ?? homedir;
36
36
  const checkCmd = deps.checkCmd ?? defaultCheckCmd;
37
37
 
38
38
  const claude = checkCmd("claude");
39
+ const agy = checkCmd("agy");
39
40
 
40
41
  const geminiDir = findGeminiDir(getHome);
41
42
  const cursorDir = findCursorDir(getHome);
@@ -46,10 +47,12 @@ export function detectClients(deps: InstallDeps = {}): DetectedClient[] {
46
47
  return [
47
48
  {
48
49
  platform: "antigravity",
49
- installed: geminiDir !== null,
50
+ installed: geminiDir !== null || agy,
50
51
  why: geminiDir
51
52
  ? `dir at ${geminiDir}`
52
- : "no ~/.gemini directory (open Antigravity once)",
53
+ : agy
54
+ ? "agy CLI on PATH"
55
+ : "no ~/.gemini directory (open Antigravity once)",
53
56
  },
54
57
  {
55
58
  platform: "claude",
@@ -271,6 +271,9 @@ export const FaponyReadHint = async ({ directory }) => {
271
271
  if (ctx.memLines.length > 0) {
272
272
  recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "mem", file: rel, count: ctx.memLines.length });
273
273
  }
274
+ if (ctx.openBugIds.length > 0) {
275
+ recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "open-bug", file: rel, count: ctx.openBugIds.length, ids: ctx.openBugIds });
276
+ }
274
277
  }
275
278
  } catch {
276
279
  // a hint must never break a read
@@ -412,6 +415,9 @@ export const FaponyEditHint = async ({ directory }) => {
412
415
  if (ctx?.memLines?.length) {
413
416
  recordHintFire({ ts: new Date().toISOString(), worktree, surface: "mem", file: rel, count: ctx.memLines.length });
414
417
  }
418
+ if (ctx?.openBugIds?.length) {
419
+ recordHintFire({ ts: new Date().toISOString(), worktree, surface: "open-bug", file: rel, count: ctx.openBugIds.length, ids: ctx.openBugIds });
420
+ }
415
421
  }
416
422
  } catch {
417
423
  // best-effort
package/src/install.ts CHANGED
@@ -1,4 +1,4 @@
1
- // src/install.ts — `fapony install --platform opencode|claude|cursor|zcode|codex` command.
1
+ // src/install.ts — `fapony install --platform antigravity|agy|opencode|claude|cursor|zcode|codex` command.
2
2
  // opencode: adds mcp.fapony config to ~/.config/opencode/opencode.json or opencode.jsonc.
3
3
  // claude: shells out to `claude mcp add` (never parses/writes ~/.claude.json directly).
4
4
  // cursor: writes mcpServers.fapony to ~/.cursor/mcp.json directly and merges the
@@ -59,7 +59,9 @@ export async function cmdInstall(
59
59
  args: string[],
60
60
  deps: InstallDeps = {},
61
61
  ): Promise<void> {
62
- const platform = args.find((a) => !a.startsWith("--"));
62
+ const platformArg = args.find((a) => !a.startsWith("--"));
63
+ // `agy` = Antigravity's CLI name — alias, same provider.
64
+ const platform = platformArg === "agy" ? "antigravity" : platformArg;
63
65
  const dryRun = args.includes("--dry-run");
64
66
  const installAll = args.includes("--all");
65
67
  // Opt-in only: the git-autonomy rewrite is an opinion (commit-as-you-go),
@@ -96,10 +98,10 @@ export async function cmdInstall(
96
98
  }
97
99
  if (platform !== undefined) {
98
100
  console.error(
99
- `usage: fapony install --platform antigravity|opencode|claude|cursor|zcode|codex [--dry-run] [--git-autonomy] [--plugins-only]`,
101
+ `usage: fapony install --platform antigravity|agy|opencode|claude|cursor|zcode|codex [--dry-run] [--git-autonomy] [--plugins-only]`,
100
102
  );
101
103
  console.error(
102
- ` supported platforms: antigravity, opencode, claude, cursor, zcode, codex`,
104
+ ` supported platforms: antigravity (agy), opencode, claude, cursor, zcode, codex`,
103
105
  );
104
106
  (deps.exit ?? defaultExit)(1);
105
107
  return;
@@ -190,6 +190,68 @@ export const collectDepIssues = (active: string[]): string[] => {
190
190
  return issues;
191
191
  };
192
192
 
193
+ // W1: status header says not-started but chunks are ticked.
194
+ // W2: all chunks ticked but header never marked shipped.
195
+ // WARN-level only — counted separately from issues, never changes exit code.
196
+ //
197
+ // Vocab measured from .fapony/{plan,done}/*.md (2026-09-23):
198
+ // in-progress 8 · done 4 · shipped/✅ shipped 6 · draft 1
199
+ // frontmatter status: shipped 8 · blocked 2
200
+ const NOT_STARTED =
201
+ /(?:^|\n)>\s*\*?\*?Status:?\*?\*?\s+.*(?:draft|drafted|not[\s-]started)/i;
202
+ const SHIPPED_RE =
203
+ /(?:^|\n)>\s*✅|(?:^|\n)>\s*\*?\*?Status:?\*?\*?\s+.*(?:shipped|done)/i;
204
+ const FM_SHIPPED = /^status:\s*shipped\b/m;
205
+
206
+ export const collectDriftWarns = (active: string[]): string[] => {
207
+ const warns: string[] = [];
208
+ for (const f of active) {
209
+ const fm = parsePlanFrontmatter(f);
210
+ // Skip plans whose frontmatter already says shipped/blocked/superseded or
211
+ // is a tracker — these have their own handling elsewhere.
212
+ if (FM_SHIPPED.test("") && fm.status === "shipped") continue;
213
+ if (fm.status === "blocked" || fm.status === "superseded") continue;
214
+ if (fm.kind === "tracker") continue;
215
+ // frontmatter status: shipped = already done
216
+ if (fm.status === "shipped") continue;
217
+
218
+ const text = readFileSync(f, "utf8");
219
+ const relPath = relative(planBase, f);
220
+ const { checked, unchecked } = countFirstSection(f);
221
+ const total = checked + unchecked;
222
+ if (total === 0) continue;
223
+
224
+ // W1: header says not-started but chunks are ticked
225
+ const head = text.slice(0, 2048);
226
+ if (
227
+ checked > 0 &&
228
+ NOT_STARTED.test(head) &&
229
+ !SHIPPED_RE.test(head) &&
230
+ !FM_SHIPPED.test(text)
231
+ ) {
232
+ // Extract the status value for the message
233
+ const statusMatch = />\s*\*?\*?Status:?\*?\*?\s+(.+)/i.exec(head);
234
+ const statusVal = statusMatch?.[1]?.replace(/\*\*/g, "").trim() ?? "?";
235
+ warns.push(
236
+ `${relPath} — status header says "${statusVal}" but ${checked} chunk(s) are ticked\n fix: update the header to 🚧 in-progress or ✅ shipped`,
237
+ );
238
+ }
239
+
240
+ // W2: all chunks ticked but header never marked shipped
241
+ if (
242
+ checked > 0 &&
243
+ unchecked === 0 &&
244
+ !SHIPPED_RE.test(head) &&
245
+ !FM_SHIPPED.test(text)
246
+ ) {
247
+ warns.push(
248
+ `${relPath} — all ${checked} chunk(s) ticked but header never marked shipped\n fix: add ✅ shipped to the header or run ${planSweepCmd} ${basename(f)} --apply`,
249
+ );
250
+ }
251
+ }
252
+ return warns;
253
+ };
254
+
193
255
  // status:blocked with every first-section chunk ticked = deferred doc debt:
194
256
  // the work reads done but the plan stays in plan/ forever (shippedNotMoved
195
257
  // never lists it — HELD excludes it). Trackers never finish, so they are out.
@@ -735,6 +797,10 @@ export const cmdPlanCheck = (a: string[]) => {
735
797
  // (HELD excludes it), so without this flag it sits in plan/ silently.
736
798
  for (const issue of collectBlockedTickedIssues(active)) issues.push(issue);
737
799
 
800
+ // 7) Drift warns — W1 (not-started header + ticks) and W2 (all ticked +
801
+ // not shipped). WARN-only: counted separately, never changes exit code.
802
+ const driftWarns = collectDriftWarns(active);
803
+
738
804
  if (!quiet) {
739
805
  console.log(
740
806
  `closed chunks: ${closed} · citing a commit: ${citing} · verified: ${verified}`,
@@ -759,6 +825,10 @@ export const cmdPlanCheck = (a: string[]) => {
759
825
  );
760
826
  }
761
827
  }
828
+ if (driftWarns.length > 0) {
829
+ console.log(`\n⚠ ${driftWarns.length} drift warning(s) (not blocking):`);
830
+ for (const w of driftWarns) console.log(`- ${w}`);
831
+ }
762
832
  }
763
833
 
764
834
  if (issues.length === 0) {