fapony 0.6.2 → 0.7.1

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.
Files changed (63) hide show
  1. package/README.md +144 -156
  2. package/package.json +5 -6
  3. package/skill/define-convention/SKILL.md +3 -3
  4. package/skill/lookup-before-edit/SKILL.md +2 -1
  5. package/skill/move-to-done/SKILL.md +18 -22
  6. package/skill/plan-with-pony/SKILL.md +4 -4
  7. package/skill/review-pony/SKILL.md +10 -12
  8. package/src/adapters/cli.ts +11 -52
  9. package/src/adapters/hooks/index.ts +0 -67
  10. package/src/adapters/hooks/mv-guard.ts +2 -2
  11. package/src/analyze/discover.ts +1 -1
  12. package/src/analyze/index.ts +0 -1
  13. package/src/commands.ts +8 -39
  14. package/src/core/config.ts +0 -6
  15. package/src/core/fapony-dir.ts +71 -0
  16. package/src/core/hook-helpers.ts +1 -2
  17. package/src/debt/load.ts +3 -5
  18. package/src/debt/promotion.ts +2 -2
  19. package/src/debt/scan.ts +1 -1
  20. package/src/digest/collect.ts +6 -7
  21. package/src/fael.ts +139 -0
  22. package/src/hook.ts +1 -81
  23. package/src/init.ts +30 -90
  24. package/src/install/antigravity.ts +8 -99
  25. package/src/install/claude.ts +66 -200
  26. package/src/install/codex.ts +6 -170
  27. package/src/install/detect.ts +4 -14
  28. package/src/install/opencode.ts +50 -486
  29. package/src/install/types.ts +1 -39
  30. package/src/install/zcode.ts +5 -91
  31. package/src/install.ts +9 -35
  32. package/src/memory.ts +10 -69
  33. package/src/plan/index.ts +34 -0
  34. package/src/plan/next.ts +181 -0
  35. package/src/plan/store.ts +41 -0
  36. package/src/{mem/commands/plan.ts → plan/sweep.ts} +66 -46
  37. package/src/seed/plan-seed.ts +10 -9
  38. package/src/setup.ts +4 -22
  39. package/src/update.ts +1 -1
  40. package/templates/PLAN.md +1 -1
  41. package/src/adapters/hooks/bug-markers.ts +0 -60
  42. package/src/adapters/hooks/context-data.ts +0 -139
  43. package/src/adapters/hooks/edit-hint.ts +0 -215
  44. package/src/adapters/hooks/read-hint.ts +0 -410
  45. package/src/adapters/hooks/session-start.ts +0 -112
  46. package/src/adapters/hooks/stop.ts +0 -768
  47. package/src/adapters/mcp/tools/index.ts +0 -149
  48. package/src/adapters/mcp/tools/mem.ts +0 -252
  49. package/src/adapters/mcp/transport.ts +0 -147
  50. package/src/analyze/cache.ts +0 -162
  51. package/src/core/mem-log.ts +0 -375
  52. package/src/init-mem.ts +0 -143
  53. package/src/install/cursor.ts +0 -167
  54. package/src/install/utils.ts +0 -29
  55. package/src/mem/commands/read.ts +0 -738
  56. package/src/mem/commands/rotate.ts +0 -59
  57. package/src/mem/commands/where.ts +0 -56
  58. package/src/mem/commands/write.ts +0 -273
  59. package/src/mem/engine.ts +0 -315
  60. package/src/mem/index.ts +0 -201
  61. package/src/mem/render.ts +0 -66
  62. package/src/mem/selectors.ts +0 -160
  63. package/src/mem/store.ts +0 -356
@@ -1,19 +1,12 @@
1
1
  // src/install/zcode.ts — ZCode install provider
2
2
  //
3
- // Reads/writes ~/.zcode/cli/config.json (fallback ~/.agents/mcp.json) directly.
4
- // ZCode has no `zcode mcp add` CLI.
3
+ // Symlinks skills into ~/.agents/skills/. Memory moved to fael.
5
4
 
6
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { existsSync } from "node:fs";
7
6
  import { homedir } from "node:os";
8
7
  import { join } from "node:path";
9
8
  import { agentsSkillsDir, linkSkills, reportSkills } from "./skills.js";
10
- import {
11
- defaultExit,
12
- type InstallDeps,
13
- MCP_KEY,
14
- ZCODE_MCP_CONFIG,
15
- } from "./types.js";
16
- import { computeDiff } from "./utils.js";
9
+ import { defaultExit, type InstallDeps } from "./types.js";
17
10
 
18
11
  /** Resolve the active ZCode config file. Primary: ~/.zcode/cli/config.json.
19
12
  * Fallback: ~/.agents/mcp.json (only when primary is absent). */
@@ -27,94 +20,15 @@ export function findZcodeConfig(
27
20
  return null;
28
21
  }
29
22
 
30
- function getZcodeServers(
31
- cfg: Record<string, unknown>,
32
- key: "mcp.servers" | "mcpServers",
33
- ): Record<string, unknown> | undefined {
34
- if (key === "mcpServers") {
35
- return cfg.mcpServers as Record<string, unknown> | undefined;
36
- }
37
- const mcp = cfg.mcp as Record<string, unknown> | undefined;
38
- return mcp?.servers as Record<string, unknown> | undefined;
39
- }
40
-
41
- function setZcodeServers(
42
- cfg: Record<string, unknown>,
43
- key: "mcp.servers" | "mcpServers",
44
- servers: Record<string, unknown>,
45
- ): Record<string, unknown> {
46
- if (key === "mcpServers") {
47
- return { ...cfg, mcpServers: servers };
48
- }
49
- const mcp = (cfg.mcp as Record<string, unknown>) ?? {};
50
- return { ...cfg, mcp: { ...mcp, servers } };
51
- }
52
-
53
- function isZcodeConfigured(
54
- servers: Record<string, unknown> | undefined,
55
- ): boolean {
56
- if (!servers) return false;
57
- const entry = servers[MCP_KEY];
58
- if (!entry || typeof entry !== "object") return false;
59
- const cfg = entry as Record<string, unknown>;
60
- if (cfg.type !== "stdio" && cfg.type !== undefined) return false;
61
- if (cfg.command !== ZCODE_MCP_CONFIG.command) return false;
62
- if (!Array.isArray(cfg.args)) return false;
63
- return JSON.stringify(cfg.args) === JSON.stringify(ZCODE_MCP_CONFIG.args);
64
- }
65
-
66
23
  export function cmdInstallZcode(dryRun: boolean, deps: InstallDeps = {}): void {
67
- const exitFn = deps.exit ?? defaultExit;
68
24
  const getHome = deps.homedir ?? homedir;
69
-
70
- const found = findZcodeConfig(getHome);
71
- if (!found) {
25
+ if (!findZcodeConfig(getHome)) {
72
26
  console.error(
73
27
  `ZCode config not found — open ZCode at least once to create ~/.zcode/cli/config.json`,
74
28
  );
75
- exitFn(1);
29
+ (deps.exit ?? defaultExit)(1);
76
30
  return;
77
31
  }
78
-
79
- let before: Record<string, unknown>;
80
- try {
81
- before = JSON.parse(readFileSync(found.path, "utf-8")) as Record<
82
- string,
83
- unknown
84
- >;
85
- } catch (e) {
86
- console.error(`failed to parse ${found.path}: ${(e as Error).message}`);
87
- exitFn(1);
88
- return;
89
- }
90
-
91
- const servers = getZcodeServers(before, found.key) ?? {};
92
- if (isZcodeConfigured(servers)) {
93
- console.error(`✓ mcp.${MCP_KEY} already configured — no change needed`);
94
- console.error(` (${found.path})`);
95
- const skillsDir = agentsSkillsDir(getHome);
96
- reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
97
- return;
98
- }
99
-
100
- const after = setZcodeServers(before, found.key, {
101
- ...servers,
102
- [MCP_KEY]: ZCODE_MCP_CONFIG,
103
- });
104
-
105
- if (dryRun) {
106
- console.error(`── dry-run: would write ${found.key}.${MCP_KEY} ──`);
107
- console.error(` (${found.path})`);
108
- console.log(computeDiff(before, after));
109
- return;
110
- }
111
-
112
- writeFileSync(found.path, `${JSON.stringify(after, null, 2)}\n`);
113
- console.error(`✓ added mcp.${MCP_KEY} to ${found.path}`);
114
- if (found.key === "mcpServers") {
115
- console.error(` (fallback path: ~/.agents/mcp.json)`);
116
- }
117
- console.error(` restart ZCode to load the MCP server`);
118
32
  const skillsDir = agentsSkillsDir(getHome);
119
33
  reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
120
34
  }
package/src/install.ts CHANGED
@@ -1,21 +1,13 @@
1
- // src/install.ts — `fapony install --platform antigravity|agy|opencode|claude|cursor|zcode|codex` command.
2
- // opencode: adds mcp.fapony config to ~/.config/opencode/opencode.json or opencode.jsonc.
3
- // claude: shells out to `claude mcp add` (never parses/writes ~/.claude.json directly).
4
- // cursor: writes mcpServers.fapony to ~/.cursor/mcp.json directly and merges the
5
- // fapony stop hook into the stop array in ~/.cursor/hooks.json.
6
- // zcode: reads/writes ~/.zcode/cli/config.json (fallback ~/.agents/mcp.json) directly,
7
- // and symlinks skills into ~/.agents/skills.
8
- // codex: reads/writes ~/.codex/config.toml directly.
1
+ // src/install.ts — `fapony install --platform antigravity|agy|opencode|claude|zcode|codex` command.
2
+ // Every platform gets skill/<name>/ symlinked (claude/opencode → ~/.claude/skills,
3
+ // zcode/codex/antigravity → ~/.agents/skills). claude also gets the plan-mv
4
+ // guard. Memory — MCP, Stop, SessionStart — moved to fael (`fael install`).
9
5
  // All platforms are idempotent + support --dry-run.
10
- // Claude/OpenCode also get skill/<name>/ symlinked into ~/.claude/skills so
11
- // `fapony update` reaches them without a second copy to keep in sync.
12
- // ZCode gets skill/<name>/ symlinked into ~/.agents/skills.
13
6
 
14
7
  import { createInterface } from "node:readline";
15
8
  import { cmdInstallAntigravity } from "./install/antigravity.js";
16
9
  import { cmdInstallClaude } from "./install/claude.js";
17
10
  import { cmdInstallCodex } from "./install/codex.js";
18
- import { cmdInstallCursor } from "./install/cursor.js";
19
11
  import { detectClients } from "./install/detect.js";
20
12
  import { cmdInstallOpencode } from "./install/opencode.js";
21
13
  import { defaultExit, type InstallDeps } from "./install/types.js";
@@ -24,24 +16,14 @@ import { ask } from "./setup.js";
24
16
  import { isAffirmative } from "./util.js";
25
17
 
26
18
  export { cmdInstallAntigravity } from "./install/antigravity.js";
27
- export {
28
- claudeAddArgs,
29
- claudeGetArgs,
30
- claudeGetPointsToFapony,
31
- cmdInstallClaude,
32
- } from "./install/claude.js";
33
- export { cmdInstallCodex, findCodexHooksJson } from "./install/codex.js";
34
- export { cmdInstallCursor } from "./install/cursor.js";
19
+ export { cmdInstallClaude } from "./install/claude.js";
20
+ export { cmdInstallCodex } from "./install/codex.js";
35
21
  export { detectClients } from "./install/detect.js";
36
22
  export {
37
23
  cmdInstallOpencode,
38
- commitHintPluginSource,
39
- editHintPluginSource,
40
24
  gitAutonomyPluginSource,
41
25
  type OpencodeInstallOpts,
42
26
  opencodePluginFiles,
43
- readHintPluginSource,
44
- sessionStartPluginSource,
45
27
  } from "./install/opencode.js";
46
28
  export {
47
29
  agentsSkillsDir,
@@ -49,7 +31,6 @@ export {
49
31
  linkSkills,
50
32
  } from "./install/skills.js";
51
33
  export {
52
- type ClaudeRunResult,
53
34
  INSTALL_ROOT,
54
35
  type InstallDeps,
55
36
  } from "./install/types.js";
@@ -80,10 +61,6 @@ export async function cmdInstall(
80
61
  cmdInstallClaude(dryRun, deps);
81
62
  return;
82
63
  }
83
- if (platform === "cursor") {
84
- cmdInstallCursor(dryRun, deps);
85
- return;
86
- }
87
64
  if (platform === "zcode") {
88
65
  cmdInstallZcode(dryRun, deps);
89
66
  return;
@@ -98,10 +75,10 @@ export async function cmdInstall(
98
75
  }
99
76
  if (platform !== undefined) {
100
77
  console.error(
101
- `usage: fapony install --platform antigravity|agy|opencode|claude|cursor|zcode|codex [--dry-run] [--git-autonomy] [--plugins-only]`,
78
+ `usage: fapony install --platform antigravity|agy|opencode|claude|zcode|codex [--dry-run] [--git-autonomy] [--plugins-only]`,
102
79
  );
103
80
  console.error(
104
- ` supported platforms: antigravity (agy), opencode, claude, cursor, zcode, codex`,
81
+ ` supported platforms: antigravity (agy), opencode, claude, zcode, codex`,
105
82
  );
106
83
  (deps.exit ?? defaultExit)(1);
107
84
  return;
@@ -125,7 +102,7 @@ export async function cmdInstall(
125
102
  if (found.length === 0) {
126
103
  console.error();
127
104
  console.error(
128
- ` no MCP client found (looked for claude on PATH; config files for antigravity, cursor, opencode, zcode, codex).`,
105
+ ` no agent client found (looked for claude on PATH; config files for antigravity, opencode, zcode, codex).`,
129
106
  );
130
107
  console.error(
131
108
  ` open the app once, then re-run — or force with: fapony install --platform <name>`,
@@ -193,9 +170,6 @@ function installPlatform(
193
170
  case "claude":
194
171
  cmdInstallClaude(dryRun, deps);
195
172
  break;
196
- case "cursor":
197
- cmdInstallCursor(dryRun, deps);
198
- break;
199
173
  case "opencode":
200
174
  cmdInstallOpencode(dryRun, deps, opts);
201
175
  break;
package/src/memory.ts CHANGED
@@ -1,53 +1,16 @@
1
- // src/memory.ts — memory domain: config.memory.* shell adapter helpers.
2
- // The shared mem-log reader now lives in src/core/mem-log.ts (PLAN-lib-layer chunk 2b).
1
+ // src/memory.ts — config.memory.* shell adapter helpers for the frozen ledger
2
+ // (gate.ts). Runs only commands the user configured; reading memory is
3
+ // src/fael.ts.
3
4
 
4
5
  import { execSync } from "node:child_process";
5
6
  import type { Config } from "./core/config.js";
6
7
  import { assertSafe } from "./safety.js";
7
8
  import { templateArgs } from "./util.js";
8
9
 
9
- // Re-export mem-log reader for existing callers (gate, hook, digest, mcp, seed, mem).
10
- export {
11
- type MemDirResult,
12
- type MemRow,
13
- readMemLog,
14
- readRecentMemDecisions,
15
- resolveMemDir,
16
- whereMemDir,
17
- } from "./core/mem-log.js";
18
-
19
- // Internal import used by resolveMemoryConfig below.
20
- import {
21
- resolveMemDir as _resolveMemDir,
22
- walkUpForMemDir,
23
- } from "./core/mem-log.js";
24
-
25
- /** Default memory commands — built into fapony via `fapony mem <sub>`. */
26
- export const DEFAULT_MEMORY: Config["memory"] = {
27
- claim: ["fapony", "mem", "claim", "{id}"],
28
- close: ["fapony", "mem", "close", "{id}", "{msg}"],
29
- add: ["fapony", "mem", "add", "{kind}", "{text}"],
30
- kickoff: ["fapony", "mem", "kickoff"],
31
- };
32
-
33
- /**
34
- * Returns the effective memory config:
35
- * - explicit config.memory wins if set
36
- * - fallback: a mem log dir exists (any `.fapony/.memory/`) → DEFAULT_MEMORY
37
- * - otherwise null (no memory)
38
- */
39
- export function resolveMemoryConfig(
40
- config: Config,
41
- worktree: string,
42
- ): Config["memory"] {
43
- if (config.memory) return config.memory;
44
- // A real log is not required for wiring: `fapony init` scaffolds an empty
45
- // `.fapony/.memory/` and the writer creates the first log there.
46
- if (_resolveMemDir(worktree) || walkUpForMemDir(worktree, true)) {
47
- return DEFAULT_MEMORY;
48
- }
49
- return null;
50
- }
10
+ /** config.memory when set — nothing is wired by default any more (memory
11
+ * moved to fael, which has its own commands). */
12
+ const resolveMemoryConfig = (config: Config): Config["memory"] =>
13
+ config.memory ?? null;
51
14
 
52
15
  export function closeMemory(
53
16
  config: Config,
@@ -55,7 +18,7 @@ export function closeMemory(
55
18
  memId: string,
56
19
  msg: string,
57
20
  ): void {
58
- const mem = resolveMemoryConfig(config, worktree);
21
+ const mem = resolveMemoryConfig(config);
59
22
  if (!mem) return;
60
23
  try {
61
24
  const cmd = templateArgs(mem.close, { id: memId, msg });
@@ -70,31 +33,9 @@ export function closeMemory(
70
33
  }
71
34
  }
72
35
 
73
- export function claimMemory(
74
- config: Config,
75
- worktree: string,
76
- memId: string,
77
- timeoutMs = 15_000,
78
- ): boolean {
79
- const mem = resolveMemoryConfig(config, worktree);
80
- if (!mem) return false;
81
- try {
82
- const cmd = templateArgs(mem.claim, { id: memId });
83
- assertSafe(cmd, config.safety?.deny ?? []);
84
- execSync(cmd.join(" "), {
85
- cwd: worktree,
86
- stdio: ["pipe", "pipe", "pipe"],
87
- timeout: timeoutMs,
88
- });
89
- return true;
90
- } catch {
91
- return false;
92
- }
93
- }
94
-
95
- /** Runs memory.kickoff (if configured or default-wired) and returns its stdout, or null if unset/failed. */
36
+ /** Runs memory.kickoff (if configured) and returns its stdout, or null if unset/failed. */
96
37
  export function kickoffMemory(config: Config, worktree: string): string | null {
97
- const mem = resolveMemoryConfig(config, worktree);
38
+ const mem = resolveMemoryConfig(config);
98
39
  if (!mem?.kickoff) return null;
99
40
  try {
100
41
  assertSafe(mem.kickoff, config.safety?.deny ?? []);
@@ -0,0 +1,34 @@
1
+ // src/plan/index.ts — `fapony plan [<PLAN.md>] | sweep | check`
2
+
3
+ import { cmdPlanNext } from "./next.js";
4
+ import { initPlanStore } from "./store.js";
5
+ import { cmdPlanCheck, cmdPlanSweep } from "./sweep.js";
6
+
7
+ const HELP = `usage: fapony plan [<PLAN.md>] | sweep [<PLAN.md>] [--apply] | check [--quiet]
8
+
9
+ fapony plan every active plan: progress + next unchecked chunk
10
+ fapony plan <PLAN.md> one plan: unchecked chunks, last-tick sha check,
11
+ open fael rows about it (the chunk handoff notes)
12
+ fapony plan sweep shipped plans not yet archived (dry run)
13
+ fapony plan sweep <PLAN.md> --apply
14
+ move a shipped or superseded plan into done/
15
+ (git mv; plain rename when .fapony/ is gitignored)
16
+ + rewrite the links to it
17
+ fapony plan check frontmatter deps, broken links, ticked-chunk shas
18
+ (exit 1 on issues)
19
+
20
+ close a chunk: tick it with its sha, commit, then
21
+ fael add note "<what chunk N+1 must know>" --files <f1>,<PLAN path>
22
+ example: fapony plan .fapony/plan/PLAN-x.md`;
23
+
24
+ export function cmdPlan(a: string[]): void {
25
+ const [sub, ...rest] = a;
26
+ if (sub === "-h" || sub === "--help" || rest.includes("--help")) {
27
+ console.log(HELP);
28
+ return;
29
+ }
30
+ initPlanStore();
31
+ if (sub === "sweep") cmdPlanSweep(rest);
32
+ else if (sub === "check") cmdPlanCheck(rest);
33
+ else cmdPlanNext(a);
34
+ }
@@ -0,0 +1,181 @@
1
+ // src/plan/next.ts — `fapony plan [<PLAN.md>]`: where a plan stands.
2
+ //
3
+ // Replaces the plan half of `fapony mem kickoff` (memory moved to fael):
4
+ // no arg → every active plan: progress, first unchecked chunk, priority
5
+ // high first, blocked marked; plus shipped-but-not-archived
6
+ // <PLAN.md> → its unchecked chunks, whether the last ticked chunk's sha
7
+ // verifies, and the open fael rows about it (the chunk handoff
8
+ // notes — `fael add note … --files <PLAN path>`)
9
+
10
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
11
+ import { basename, join } from "node:path";
12
+ import { doneDir, planDir, rel, root } from "./store.js";
13
+ import {
14
+ checkTickedLine,
15
+ openRowsFor,
16
+ parsePlanFrontmatter,
17
+ planSweepCmd,
18
+ shippedNotMoved,
19
+ } from "./sweep.js";
20
+
21
+ const HANDOFF_LIMIT = 5;
22
+ const TEXT_MAX = 200;
23
+
24
+ /** Checked + unchecked items of the first `##` section (the TL;DR). */
25
+ const readPlanSectionItems = (
26
+ planPath: string,
27
+ ): { checked: string[]; unchecked: string[] } => {
28
+ try {
29
+ const body = readFileSync(planPath, "utf8").replace(
30
+ /^---\r?\n[\s\S]*?\r?\n---/,
31
+ "",
32
+ );
33
+ const start = body.search(/^##\s+/m);
34
+ if (start < 0) return { checked: [], unchecked: [] };
35
+ const rest = body.slice(start);
36
+ const next = rest.slice(3).search(/^##\s+/m);
37
+ const block = next < 0 ? rest : rest.slice(0, next + 3);
38
+ const checked: string[] = [];
39
+ const unchecked: string[] = [];
40
+ for (const line of block.split("\n")) {
41
+ let m = /^\s*[-*]\s+\[\s\]\s+(.+)$/.exec(line);
42
+ if (m) {
43
+ unchecked.push(m[1].trim());
44
+ continue;
45
+ }
46
+ m = /^\s*[-*]\s+\[[xX]\]\s+(.+)$/.exec(line);
47
+ if (m) checked.push(m[1].trim());
48
+ }
49
+ return { checked, unchecked };
50
+ } catch {
51
+ return { checked: [], unchecked: [] };
52
+ }
53
+ };
54
+
55
+ // One line saying whether the last ticked chunk actually closed. Verified
56
+ // shas stay silent; only the newest ticked chunk is ever mentioned.
57
+ const closureHint = (checked: string[]): string | null => {
58
+ const last = checked[checked.length - 1];
59
+ if (!last) return null;
60
+ const { missing, diverged, cited } = checkTickedLine(last, root);
61
+ const label = /chunk\s+([^\s—–-]+)/i.exec(last)?.[1] ?? "latest";
62
+ if (missing.length)
63
+ return `⚠ chunk ${label} is ticked but ${missing[0]} is not in git — nothing proves it closed`;
64
+ if (diverged.length)
65
+ return `⚠ chunk ${label} is ticked but ${diverged[0]} is not on HEAD (rebased away?)`;
66
+ if (!cited)
67
+ return `⚠ chunk ${label} is ticked but cites no commit — nothing to verify it closed`;
68
+ return null;
69
+ };
70
+
71
+ const readPlanTitle = (planPath: string): string => {
72
+ try {
73
+ return /^#\s+(.+)$/m.exec(readFileSync(planPath, "utf8"))?.[1].trim() ?? "";
74
+ } catch {
75
+ return "";
76
+ }
77
+ };
78
+
79
+ const isHighPriority = (planPath: string): boolean => {
80
+ try {
81
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(
82
+ readFileSync(planPath, "utf8").slice(0, 1024),
83
+ );
84
+ return !!m && /^\s*priority\s*:\s*high\s*$/m.test(m[1]);
85
+ } catch {
86
+ return false;
87
+ }
88
+ };
89
+
90
+ const clip = (s: string): string => {
91
+ const flat = s.replace(/\s+/g, " ").trim();
92
+ return flat.length > TEXT_MAX ? `${flat.slice(0, TEXT_MAX - 1)}…` : flat;
93
+ };
94
+
95
+ /** A plan arg as typed (bare name, with/without .md, or a path) → the file. */
96
+ const findPlan = (arg: string): string | null => {
97
+ const name = arg.endsWith(".md") ? basename(arg) : `${basename(arg)}.md`;
98
+ return (
99
+ [arg, join(root, arg), join(planDir, name), join(doneDir, name)].find(
100
+ (p) => p.endsWith(".md") && existsSync(p),
101
+ ) ?? null
102
+ );
103
+ };
104
+
105
+ function showPlan(file: string): void {
106
+ const { checked, unchecked } = readPlanSectionItems(file);
107
+ console.log(`# ${readPlanTitle(file) || basename(file)} — ${rel(file)}`);
108
+ if (unchecked.length) {
109
+ console.log(`\n## unchecked`);
110
+ for (const c of unchecked) console.log(`- [ ] ${c}`);
111
+ } else {
112
+ console.log(`\n(all chunks checked — ready to ship or archive)`);
113
+ }
114
+ const hint = closureHint(checked);
115
+ if (hint) console.log(hint);
116
+
117
+ const rows = openRowsFor(file);
118
+ if (rows.length) {
119
+ console.log(`\n## open in fael (${rows.length})`);
120
+ for (const r of rows.slice(0, HANDOFF_LIMIT))
121
+ console.log(`- ${r.ts.slice(0, 10)} ${r.kind} [${r.id}] ${clip(r.text)}`);
122
+ if (rows.length > HANDOFF_LIMIT)
123
+ console.log(
124
+ `… +${rows.length - HANDOFF_LIMIT} more — fael find --files ${rel(file)}`,
125
+ );
126
+ }
127
+ }
128
+
129
+ function showAll(): void {
130
+ const files = existsSync(planDir)
131
+ ? readdirSync(planDir)
132
+ .filter((f) => f.endsWith(".md"))
133
+ .map((f) => join(planDir, f))
134
+ : [];
135
+ if (!files.length) {
136
+ console.log(`no plans in ${rel(planDir)}/`);
137
+ return;
138
+ }
139
+ const shipped = new Set(shippedNotMoved());
140
+ const active = files
141
+ .filter((f) => !shipped.has(basename(f)))
142
+ .sort(
143
+ (a, b) =>
144
+ Number(isHighPriority(b)) - Number(isHighPriority(a)) ||
145
+ a.localeCompare(b),
146
+ );
147
+ console.log(`# ${rel(planDir)}/ — ${active.length} active plan(s)`);
148
+ for (const f of active) {
149
+ const { checked, unchecked } = readPlanSectionItems(f);
150
+ const fm = parsePlanFrontmatter(f);
151
+ const tags = [
152
+ isHighPriority(f) ? "priority:high" : "",
153
+ fm.status === "blocked" ? `blocked_by: ${fm.blockedByRaw ?? "?"}` : "",
154
+ ].filter(Boolean);
155
+ console.log(
156
+ `\n- ${basename(f)} — ${checked.length}/${checked.length + unchecked.length} chunks${tags.length ? ` · ${tags.join(" · ")}` : ""}`,
157
+ );
158
+ if (unchecked[0]) console.log(` next: ${clip(unchecked[0])}`);
159
+ }
160
+ if (shipped.size) {
161
+ console.log(`\n## shipped but not archived into done/ (${shipped.size})`);
162
+ for (const n of shipped) console.log(`- ${n}`);
163
+ console.log(`(move: ${planSweepCmd} <file.md> --apply)`);
164
+ }
165
+ }
166
+
167
+ export function cmdPlanNext(a: string[]): void {
168
+ const arg = a.find((x) => !x.startsWith("--"));
169
+ if (!arg) {
170
+ showAll();
171
+ return;
172
+ }
173
+ const file = findPlan(arg);
174
+ if (!file) {
175
+ console.error(
176
+ `no plan "${arg}" (looked in ${rel(planDir)}/, ${rel(doneDir)}/ and the repo root)`,
177
+ );
178
+ process.exit(1);
179
+ }
180
+ showPlan(file);
181
+ }
@@ -0,0 +1,41 @@
1
+ // src/plan/store.ts — where plans live, resolved once per `fapony plan` run.
2
+ //
3
+ // plan/ and done/ sit side by side under the repo's `.fapony/` (nearest one
4
+ // walking up from cwd — app-scoped in a monorepo). done/ can be moved with
5
+ // paths.doneDir in fapony.config.json; a legacy plan/done/ is honoured.
6
+
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { dirname, join, relative } from "node:path";
9
+ import { CONFIG_FILENAME, FAPONY_DIR } from "../core/config.js";
10
+ import { faponyDirFrom, repoRootOf } from "../core/fapony-dir.js";
11
+
12
+ export let root = "";
13
+ export let planBase = "";
14
+ export let planDir = "";
15
+ export let doneDir = "";
16
+
17
+ export function initPlanStore(cwd: string = process.cwd()): void {
18
+ root = repoRootOf(cwd) ?? cwd;
19
+ planBase = faponyDirFrom(cwd);
20
+ planDir = join(planBase, "plan");
21
+
22
+ const configDir =
23
+ planBase === join(root, FAPONY_DIR) ? root : dirname(planBase);
24
+ let doneFromConfig: string | null = null;
25
+ try {
26
+ const paths = JSON.parse(
27
+ readFileSync(join(configDir, CONFIG_FILENAME), "utf8"),
28
+ )?.paths;
29
+ if (typeof paths?.doneDir === "string")
30
+ doneFromConfig = join(configDir, paths.doneDir);
31
+ } catch {
32
+ // no config — defaults below
33
+ }
34
+ doneDir =
35
+ doneFromConfig ??
36
+ (!existsSync(join(planBase, "done")) && existsSync(join(planDir, "done"))
37
+ ? join(planDir, "done")
38
+ : join(planBase, "done"));
39
+ }
40
+
41
+ export const rel = (p: string): string => relative(root, p) || ".";