fapony 0.3.6 → 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.
@@ -2,7 +2,13 @@
2
2
  //
3
3
  // Adds mcp.fapony config to ~/.config/opencode/opencode.json or opencode.jsonc.
4
4
 
5
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import {
6
+ existsSync,
7
+ mkdirSync,
8
+ readdirSync,
9
+ readFileSync,
10
+ writeFileSync,
11
+ } from "node:fs";
6
12
  import { homedir } from "node:os";
7
13
  import { join } from "node:path";
8
14
  import { claudeSkillsDir, linkSkills, reportSkills } from "./skills.js";
@@ -84,6 +90,11 @@ function isConfigured(mcp: Record<string, unknown> | undefined): boolean {
84
90
  export interface OpencodeInstallOpts {
85
91
  /** Opt-in only: write the git-autonomy rewrite plugin. Never default. */
86
92
  gitAutonomy?: boolean;
93
+ /** Refresh only the generated plugin bodies — skip the opencode.json write
94
+ * and the skills symlink entirely. This is `fapony update`'s post-pull
95
+ * refresh: it must touch only fapony-owned plugin files, never the user's
96
+ * config (rule 6c — overwriting what exists = ask first, or refuse). */
97
+ pluginsOnly?: boolean;
87
98
  }
88
99
 
89
100
  export function cmdInstallOpencode(
@@ -94,6 +105,21 @@ export function cmdInstallOpencode(
94
105
  const exitFn = deps.exit ?? defaultExit;
95
106
  const getHome = deps.homedir ?? homedir;
96
107
 
108
+ const installPlugins = (): void => {
109
+ installReadHintPlugin(dryRun, getHome);
110
+ installCommitHintPlugin(dryRun, getHome);
111
+ installEditHintPlugin(dryRun, getHome);
112
+ installSessionStartPlugin(dryRun, getHome);
113
+ if (opts.gitAutonomy) installGitAutonomyPlugin(dryRun, getHome);
114
+ };
115
+
116
+ // Refresh path: plugin bodies only — returns before findOpencodeConfig, so
117
+ // neither opencode.json nor the skills symlink is read or written.
118
+ if (opts.pluginsOnly) {
119
+ installPlugins();
120
+ return;
121
+ }
122
+
97
123
  const configPath = findOpencodeConfig(getHome);
98
124
  let before: Record<string, unknown>;
99
125
  let isNew = false;
@@ -117,11 +143,7 @@ export function cmdInstallOpencode(
117
143
  if (configPath) console.error(` (${configPath})`);
118
144
  const skillsDir = claudeSkillsDir(getHome);
119
145
  reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
120
- installReadHintPlugin(dryRun, getHome);
121
- installCommitHintPlugin(dryRun, getHome);
122
- installEditHintPlugin(dryRun, getHome);
123
- installSessionStartPlugin(dryRun, getHome);
124
- if (opts.gitAutonomy) installGitAutonomyPlugin(dryRun, getHome);
146
+ installPlugins();
125
147
  return;
126
148
  }
127
149
 
@@ -143,11 +165,7 @@ export function cmdInstallOpencode(
143
165
  console.error(` (${configPath})`);
144
166
  }
145
167
  console.log(computeDiff(before, after));
146
- installReadHintPlugin(dryRun, getHome);
147
- installCommitHintPlugin(dryRun, getHome);
148
- installEditHintPlugin(dryRun, getHome);
149
- installSessionStartPlugin(dryRun, getHome);
150
- if (opts.gitAutonomy) installGitAutonomyPlugin(dryRun, getHome);
168
+ installPlugins();
151
169
  return;
152
170
  }
153
171
 
@@ -167,11 +185,7 @@ export function cmdInstallOpencode(
167
185
  console.error(` restart opencode to load the MCP server`);
168
186
  const skillsDir = claudeSkillsDir(getHome);
169
187
  reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
170
- installReadHintPlugin(dryRun, getHome);
171
- installCommitHintPlugin(dryRun, getHome);
172
- installEditHintPlugin(dryRun, getHome);
173
- installSessionStartPlugin(dryRun, getHome);
174
- if (opts.gitAutonomy) installGitAutonomyPlugin(dryRun, getHome);
188
+ installPlugins();
175
189
  }
176
190
 
177
191
  /**
@@ -257,6 +271,9 @@ export const FaponyReadHint = async ({ directory }) => {
257
271
  if (ctx.memLines.length > 0) {
258
272
  recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "mem", file: rel, count: ctx.memLines.length });
259
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
+ }
260
277
  }
261
278
  } catch {
262
279
  // a hint must never break a read
@@ -398,6 +415,9 @@ export const FaponyEditHint = async ({ directory }) => {
398
415
  if (ctx?.memLines?.length) {
399
416
  recordHintFire({ ts: new Date().toISOString(), worktree, surface: "mem", file: rel, count: ctx.memLines.length });
400
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
+ }
401
421
  }
402
422
  } catch {
403
423
  // best-effort
@@ -412,102 +432,102 @@ export const FaponyEditHint = async ({ directory }) => {
412
432
  `;
413
433
  }
414
434
 
415
- function installReadHintPlugin(dryRun: boolean, getHome: () => string): void {
435
+ /**
436
+ * Write one generated OpenCode plugin file, refreshing our own older copy.
437
+ *
438
+ * Ownership is the exported plugin name (`FaponyReadHint`, …) — the one token
439
+ * every generated body carries. A file without it is someone's own plugin and
440
+ * is never touched, same rule as the skill symlinks. A file *with* it but
441
+ * different content is ours and stale: the plugin body is baked at install
442
+ * time, so a `git pull` updates the imported logic (src/hook.ts) but never this
443
+ * file. Refusing to overwrite it is how an old hook stays reachable forever —
444
+ * the Thai-only bug marker survived exactly that way — so ours gets rewritten
445
+ * in place, and only a foreign file is reported and left alone.
446
+ */
447
+ function installPluginFile(
448
+ dryRun: boolean,
449
+ getHome: () => string,
450
+ fileName: string,
451
+ label: string,
452
+ ownedName: string,
453
+ desired: string,
454
+ ): void {
416
455
  const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
417
- const pluginPath = join(pluginsDir, "fapony-read-hint.ts");
418
- if (existsSync(pluginPath)) {
419
- let current = "";
456
+ const pluginPath = join(pluginsDir, fileName);
457
+ const write = (): boolean => {
458
+ if (dryRun) return true;
420
459
  try {
421
- current = readFileSync(pluginPath, "utf-8");
422
- } catch {
423
- current = "";
460
+ mkdirSync(pluginsDir, { recursive: true });
461
+ writeFileSync(pluginPath, desired, "utf-8");
462
+ return true;
463
+ } catch (e) {
464
+ console.error(` ${label}: failed to write — ${(e as Error).message}`);
465
+ return false;
424
466
  }
425
- if (current.includes("readHintFor")) {
426
- console.error(` read hint: already installed — no change`);
427
- return;
467
+ };
468
+
469
+ if (!existsSync(pluginPath)) {
470
+ if (write()) {
471
+ console.error(
472
+ ` ${label}: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
473
+ );
428
474
  }
475
+ return;
476
+ }
477
+
478
+ let current = "";
479
+ try {
480
+ current = readFileSync(pluginPath, "utf-8");
481
+ } catch {
482
+ current = "";
483
+ }
484
+ if (current === desired) {
485
+ console.error(` ${label}: already installed — no change`);
486
+ return;
487
+ }
488
+ if (!current.includes(ownedName)) {
429
489
  console.error(
430
- ` read hint: ${pluginPath} exists but isn't fapony's — not overwriting.`,
490
+ ` ${label}: ${pluginPath} exists but isn't fapony's — not overwriting.`,
431
491
  );
432
492
  return;
433
493
  }
434
- if (!dryRun) {
435
- try {
436
- mkdirSync(pluginsDir, { recursive: true });
437
- writeFileSync(pluginPath, readHintPluginSource(INSTALL_ROOT), "utf-8");
438
- } catch (e) {
439
- console.error(` read hint: failed to write — ${(e as Error).message}`);
440
- return;
441
- }
494
+ if (write()) {
495
+ console.error(
496
+ ` ${label}: ${dryRun ? "would update" : "updated"} ${pluginPath}`,
497
+ );
442
498
  }
443
- console.error(
444
- ` read hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
499
+ }
500
+
501
+ function installReadHintPlugin(dryRun: boolean, getHome: () => string): void {
502
+ installPluginFile(
503
+ dryRun,
504
+ getHome,
505
+ "fapony-read-hint.ts",
506
+ "read hint",
507
+ "FaponyReadHint",
508
+ readHintPluginSource(INSTALL_ROOT),
445
509
  );
446
510
  }
447
511
 
448
512
  function installCommitHintPlugin(dryRun: boolean, getHome: () => string): void {
449
- const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
450
- const pluginPath = join(pluginsDir, "fapony-commit-hint.ts");
451
- if (existsSync(pluginPath)) {
452
- let current = "";
453
- try {
454
- current = readFileSync(pluginPath, "utf-8");
455
- } catch {
456
- current = "";
457
- }
458
- if (current.includes("commitHintFor")) {
459
- console.error(` commit hint: already installed — no change`);
460
- return;
461
- }
462
- console.error(
463
- ` commit hint: ${pluginPath} exists but isn't fapony's — not overwriting.`,
464
- );
465
- return;
466
- }
467
- if (!dryRun) {
468
- try {
469
- mkdirSync(pluginsDir, { recursive: true });
470
- writeFileSync(pluginPath, commitHintPluginSource(INSTALL_ROOT), "utf-8");
471
- } catch (e) {
472
- console.error(` commit hint: failed to write — ${(e as Error).message}`);
473
- return;
474
- }
475
- }
476
- console.error(
477
- ` commit hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
513
+ installPluginFile(
514
+ dryRun,
515
+ getHome,
516
+ "fapony-commit-hint.ts",
517
+ "commit hint",
518
+ "FaponyCommitHint",
519
+ commitHintPluginSource(INSTALL_ROOT),
478
520
  );
479
521
  }
480
522
 
481
523
  function installEditHintPlugin(dryRun: boolean, getHome: () => string): void {
482
- const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
483
- const pluginPath = join(pluginsDir, "fapony-edit-hint.ts");
484
- if (existsSync(pluginPath)) {
485
- let current = "";
486
- try {
487
- current = readFileSync(pluginPath, "utf-8");
488
- } catch {
489
- current = "";
490
- }
491
- if (current.includes("editHintFor")) {
492
- console.error(` edit hint: already installed — no change`);
493
- return;
494
- }
495
- console.error(
496
- ` edit hint: ${pluginPath} exists but isn't fapony's — not overwriting.`,
497
- );
498
- return;
499
- }
500
- if (!dryRun) {
501
- try {
502
- mkdirSync(pluginsDir, { recursive: true });
503
- writeFileSync(pluginPath, editHintPluginSource(INSTALL_ROOT), "utf-8");
504
- } catch (e) {
505
- console.error(` edit hint: failed to write — ${(e as Error).message}`);
506
- return;
507
- }
508
- }
509
- console.error(
510
- ` edit hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
524
+ installPluginFile(
525
+ dryRun,
526
+ getHome,
527
+ "fapony-edit-hint.ts",
528
+ "edit hint",
529
+ "FaponyEditHint",
530
+ editHintPluginSource(INSTALL_ROOT),
511
531
  );
512
532
  }
513
533
 
@@ -571,50 +591,13 @@ function installSessionStartPlugin(
571
591
  dryRun: boolean,
572
592
  getHome: () => string,
573
593
  ): void {
574
- const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
575
- const pluginPath = join(pluginsDir, "fapony-session-start.ts");
576
- if (existsSync(pluginPath)) {
577
- let current = "";
578
- try {
579
- current = readFileSync(pluginPath, "utf-8");
580
- } catch {
581
- current = "";
582
- }
583
- if (current.includes("FaponySessionStart")) {
584
- // Ours — and generated, so never overwritten. A file baked by an older
585
- // fapony differs from what we generate now: say so instead of "no change",
586
- // which is how the pre-hook plugin (mub2ezhi) would have been kept.
587
- if (current !== sessionStartPluginSource(INSTALL_ROOT)) {
588
- console.error(
589
- ` session start: stale fapony plugin — delete ${pluginPath} and re-run install`,
590
- );
591
- return;
592
- }
593
- console.error(` session start: already installed — no change`);
594
- return;
595
- }
596
- console.error(
597
- ` session start: ${pluginPath} exists but isn't fapony's — not overwriting.`,
598
- );
599
- return;
600
- }
601
- if (!dryRun) {
602
- try {
603
- mkdirSync(pluginsDir, { recursive: true });
604
- writeFileSync(
605
- pluginPath,
606
- sessionStartPluginSource(INSTALL_ROOT),
607
- "utf-8",
608
- );
609
- } catch (e) {
610
- console.error(
611
- ` session start: failed to write — ${(e as Error).message}`,
612
- );
613
- return;
614
- }
615
- }
616
- console.error(
617
- ` session start: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
594
+ installPluginFile(
595
+ dryRun,
596
+ getHome,
597
+ "fapony-session-start.ts",
598
+ "session start",
599
+ "FaponySessionStart",
600
+ sessionStartPluginSource(INSTALL_ROOT),
618
601
  );
619
602
  }
620
603
 
@@ -680,45 +663,27 @@ function installGitAutonomyPlugin(
680
663
  dryRun: boolean,
681
664
  getHome: () => string,
682
665
  ): void {
666
+ installPluginFile(
667
+ dryRun,
668
+ getHome,
669
+ "fapony-git-autonomy.ts",
670
+ "git-autonomy",
671
+ "FaponyGitAutonomy",
672
+ gitAutonomyPluginSource(INSTALL_ROOT),
673
+ );
674
+ }
675
+
676
+ /** The fapony-generated plugin files present in an OpenCode install. Empty =
677
+ * OpenCode has none, so `fapony update` must not install into a client the user
678
+ * never opted into. Callers also use it to tell whether the opt-in git-autonomy
679
+ * plugin is installed, so an update can refresh it without ever creating it. */
680
+ export function opencodePluginFiles(getHome: () => string = homedir): string[] {
683
681
  const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
684
- const pluginPath = join(pluginsDir, "fapony-git-autonomy.ts");
685
- if (existsSync(pluginPath)) {
686
- let current = "";
687
- try {
688
- current = readFileSync(pluginPath, "utf-8");
689
- } catch {
690
- current = "";
691
- }
692
- if (current.includes("FaponyGitAutonomy")) {
693
- // Ours — and generated, so never overwritten. Same stale-check shape
694
- // as the session-start plugin: an older generation must say so instead
695
- // of "no change", or a fixed rewrite never reaches the client.
696
- if (current !== gitAutonomyPluginSource(INSTALL_ROOT)) {
697
- console.error(
698
- ` git-autonomy: stale fapony plugin — delete ${pluginPath} and re-run install --git-autonomy`,
699
- );
700
- return;
701
- }
702
- console.error(` git-autonomy: already installed — no change`);
703
- return;
704
- }
705
- console.error(
706
- ` git-autonomy: ${pluginPath} exists but isn't fapony's — not overwriting.`,
682
+ try {
683
+ return readdirSync(pluginsDir).filter(
684
+ (f) => f.startsWith("fapony-") && f.endsWith(".ts"),
707
685
  );
708
- return;
686
+ } catch {
687
+ return [];
709
688
  }
710
- if (!dryRun) {
711
- try {
712
- mkdirSync(pluginsDir, { recursive: true });
713
- writeFileSync(pluginPath, gitAutonomyPluginSource(INSTALL_ROOT), "utf-8");
714
- } catch (e) {
715
- console.error(
716
- ` git-autonomy: failed to write — ${(e as Error).message}`,
717
- );
718
- return;
719
- }
720
- }
721
- console.error(
722
- ` git-autonomy: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
723
- );
724
689
  }
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
@@ -39,7 +39,9 @@ export {
39
39
  editHintPluginSource,
40
40
  gitAutonomyPluginSource,
41
41
  type OpencodeInstallOpts,
42
+ opencodePluginFiles,
42
43
  readHintPluginSource,
44
+ sessionStartPluginSource,
43
45
  } from "./install/opencode.js";
44
46
  export {
45
47
  agentsSkillsDir,
@@ -57,13 +59,17 @@ export async function cmdInstall(
57
59
  args: string[],
58
60
  deps: InstallDeps = {},
59
61
  ): Promise<void> {
60
- 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;
61
65
  const dryRun = args.includes("--dry-run");
62
66
  const installAll = args.includes("--all");
63
67
  // Opt-in only: the git-autonomy rewrite is an opinion (commit-as-you-go),
64
68
  // not a utility — never installed unless this flag rides along, including
65
69
  // via --all. Opencode-only; other platforms ignore it.
66
70
  const gitAutonomy = args.includes("--git-autonomy");
71
+ // Refresh-only seam for `fapony update`: plugin bodies, never opencode.json.
72
+ const pluginsOnly = args.includes("--plugins-only");
67
73
 
68
74
  // --- explicit platform: original behavior, unchanged ---
69
75
  if (platform === "antigravity") {
@@ -87,15 +93,15 @@ export async function cmdInstall(
87
93
  return;
88
94
  }
89
95
  if (platform === "opencode") {
90
- cmdInstallOpencode(dryRun, deps, { gitAutonomy });
96
+ cmdInstallOpencode(dryRun, deps, { gitAutonomy, pluginsOnly });
91
97
  return;
92
98
  }
93
99
  if (platform !== undefined) {
94
100
  console.error(
95
- `usage: fapony install --platform antigravity|opencode|claude|cursor|zcode|codex [--dry-run] [--git-autonomy]`,
101
+ `usage: fapony install --platform antigravity|agy|opencode|claude|cursor|zcode|codex [--dry-run] [--git-autonomy] [--plugins-only]`,
96
102
  );
97
103
  console.error(
98
- ` supported platforms: antigravity, opencode, claude, cursor, zcode, codex`,
104
+ ` supported platforms: antigravity (agy), opencode, claude, cursor, zcode, codex`,
99
105
  );
100
106
  (deps.exit ?? defaultExit)(1);
101
107
  return;
@@ -131,7 +137,10 @@ export async function cmdInstall(
131
137
  if (installAll) {
132
138
  console.error();
133
139
  for (const client of found) {
134
- installPlatform(client.platform, dryRun, deps, { gitAutonomy });
140
+ installPlatform(client.platform, dryRun, deps, {
141
+ gitAutonomy,
142
+ pluginsOnly,
143
+ });
135
144
  }
136
145
  return;
137
146
  }
@@ -160,7 +169,10 @@ export async function cmdInstall(
160
169
  for (const client of found) {
161
170
  const answer = await askFn(`install into ${client.platform}?`, "Y");
162
171
  if (isAffirmative(answer)) {
163
- installPlatform(client.platform, dryRun, deps, { gitAutonomy });
172
+ installPlatform(client.platform, dryRun, deps, {
173
+ gitAutonomy,
174
+ pluginsOnly,
175
+ });
164
176
  }
165
177
  }
166
178
  } finally {
@@ -172,7 +184,7 @@ function installPlatform(
172
184
  platform: string,
173
185
  dryRun: boolean,
174
186
  deps: InstallDeps,
175
- opts: { gitAutonomy?: boolean } = {},
187
+ opts: { gitAutonomy?: boolean; pluginsOnly?: boolean } = {},
176
188
  ): void {
177
189
  switch (platform) {
178
190
  case "antigravity":
@@ -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) {