fapony 0.3.0 → 0.3.3
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/README.md +87 -65
- package/fapony.ts +2 -2
- package/package.json +5 -4
- package/skill/move-to-done/SKILL.md +19 -30
- package/skill/review-pony/SKILL.md +27 -58
- package/src/analyze.ts +1 -1
- package/src/gate.ts +3 -3
- package/src/hook.ts +210 -123
- package/src/init-mem.ts +57 -71
- package/src/init.ts +12 -16
- package/src/install/antigravity.ts +112 -0
- package/src/install/claude.ts +2 -123
- package/src/install/detect.ts +17 -7
- package/src/install/opencode.ts +53 -26
- package/src/install.ts +12 -3
- package/src/mcp/primitives.ts +1 -1
- package/src/mcp/tools/index.ts +0 -74
- package/src/mcp/transport.ts +4 -39
- package/src/mcp/worktree.ts +1 -1
- package/src/mem/commands/read.ts +219 -146
- package/src/mem/index.ts +4 -13
- package/src/{plan-seed.ts → seed/plan-seed.ts} +14 -32
- package/src/seed/primitives.ts +66 -0
- package/src/{review-seed.ts → seed/review-seed.ts} +7 -54
- package/src/stats/data.ts +1 -1
- package/src/telemetry.ts +1 -1
- package/src/util.ts +61 -0
- package/templates/SPEC.md +8 -1
- package/images/logo.png +0 -0
- package/images/logo.webp +0 -0
- package/images/logo@400.webp +0 -0
- package/images/sample.webp +0 -0
- package/images/summary.webp +0 -0
- package/src/math.ts +0 -13
- package/src/mcp/tools/verdict.ts +0 -161
package/src/install.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// ZCode gets skill/<name>/ symlinked into ~/.agents/skills.
|
|
13
13
|
|
|
14
14
|
import { createInterface } from "node:readline";
|
|
15
|
+
import { cmdInstallAntigravity } from "./install/antigravity.js";
|
|
15
16
|
import { cmdInstallClaude } from "./install/claude.js";
|
|
16
17
|
import { cmdInstallCodex } from "./install/codex.js";
|
|
17
18
|
import { cmdInstallCursor } from "./install/cursor.js";
|
|
@@ -22,6 +23,7 @@ import { cmdInstallZcode } from "./install/zcode.js";
|
|
|
22
23
|
import { ask } from "./setup.js";
|
|
23
24
|
import { isAffirmative } from "./util.js";
|
|
24
25
|
|
|
26
|
+
export { cmdInstallAntigravity } from "./install/antigravity.js";
|
|
25
27
|
export {
|
|
26
28
|
claudeAddArgs,
|
|
27
29
|
claudeGetArgs,
|
|
@@ -58,6 +60,10 @@ export async function cmdInstall(
|
|
|
58
60
|
const installAll = args.includes("--all");
|
|
59
61
|
|
|
60
62
|
// --- explicit platform: original behavior, unchanged ---
|
|
63
|
+
if (platform === "antigravity") {
|
|
64
|
+
cmdInstallAntigravity(dryRun, deps);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
61
67
|
if (platform === "claude") {
|
|
62
68
|
cmdInstallClaude(dryRun, deps);
|
|
63
69
|
return;
|
|
@@ -80,10 +86,10 @@ export async function cmdInstall(
|
|
|
80
86
|
}
|
|
81
87
|
if (platform !== undefined) {
|
|
82
88
|
console.error(
|
|
83
|
-
`usage: fapony install --platform opencode|claude|cursor|zcode|codex [--dry-run]`,
|
|
89
|
+
`usage: fapony install --platform antigravity|opencode|claude|cursor|zcode|codex [--dry-run]`,
|
|
84
90
|
);
|
|
85
91
|
console.error(
|
|
86
|
-
` supported platforms: opencode, claude, cursor, zcode, codex`,
|
|
92
|
+
` supported platforms: antigravity, opencode, claude, cursor, zcode, codex`,
|
|
87
93
|
);
|
|
88
94
|
(deps.exit ?? defaultExit)(1);
|
|
89
95
|
return;
|
|
@@ -107,7 +113,7 @@ export async function cmdInstall(
|
|
|
107
113
|
if (found.length === 0) {
|
|
108
114
|
console.error();
|
|
109
115
|
console.error(
|
|
110
|
-
` no MCP client found (looked for claude on PATH; config files for cursor, opencode, zcode, codex).`,
|
|
116
|
+
` no MCP client found (looked for claude on PATH; config files for antigravity, cursor, opencode, zcode, codex).`,
|
|
111
117
|
);
|
|
112
118
|
console.error(
|
|
113
119
|
` open the app once, then re-run — or force with: fapony install --platform <name>`,
|
|
@@ -162,6 +168,9 @@ function installPlatform(
|
|
|
162
168
|
deps: InstallDeps,
|
|
163
169
|
): void {
|
|
164
170
|
switch (platform) {
|
|
171
|
+
case "antigravity":
|
|
172
|
+
cmdInstallAntigravity(dryRun, deps);
|
|
173
|
+
break;
|
|
165
174
|
case "claude":
|
|
166
175
|
cmdInstallClaude(dryRun, deps);
|
|
167
176
|
break;
|
package/src/mcp/primitives.ts
CHANGED
|
@@ -157,7 +157,7 @@ export interface VerificationReport {
|
|
|
157
157
|
evidence: EvidenceItem[];
|
|
158
158
|
/** Evidence summary (computed from evidence array). */
|
|
159
159
|
evidence_summary: EvidenceSummary;
|
|
160
|
-
/** Verdict grade + note (from gate/
|
|
160
|
+
/** Verdict grade + note (from the frozen gate/verdict ledger, or null if never graded). */
|
|
161
161
|
verdict: {
|
|
162
162
|
grade: string;
|
|
163
163
|
note: string;
|
package/src/mcp/tools/index.ts
CHANGED
|
@@ -6,85 +6,11 @@
|
|
|
6
6
|
// saves more than it costs (see CLAUDE.md "จ่าย token อย่างฉลาด"). Keep
|
|
7
7
|
// descriptions imperative — say what to send, not why it matters.
|
|
8
8
|
|
|
9
|
-
import { VERDICT_GRADES } from "../../parse.js";
|
|
10
|
-
import { REASON_CODES, REGIME_CODES } from "../types.js";
|
|
11
|
-
|
|
12
9
|
export { toolMemAdd, toolMemClose, toolMemFind } from "./mem.js";
|
|
13
|
-
export { toolVerdictSubmit } from "./verdict.js";
|
|
14
10
|
|
|
15
11
|
// --- Tool definitions ---
|
|
16
12
|
|
|
17
13
|
export const TOOLS = [
|
|
18
|
-
{
|
|
19
|
-
name: "verdict_submit",
|
|
20
|
-
description:
|
|
21
|
-
"Grade a finished unit of work — every unit that ends, including work " +
|
|
22
|
-
"that went right the first time. Submit 'fail' the moment a first " +
|
|
23
|
-
"attempt turns out wrong, then a pass-family verdict once the fix is " +
|
|
24
|
-
"verified. Never leave a run open: one stuck at running/fixing absorbs " +
|
|
25
|
-
"later unrelated verdicts for that worktree. Without run_id, binds to " +
|
|
26
|
-
"the latest open run for the same worktree+plan, else creates one.",
|
|
27
|
-
inputSchema: {
|
|
28
|
-
type: "object" as const,
|
|
29
|
-
properties: {
|
|
30
|
-
run_id: {
|
|
31
|
-
type: "number",
|
|
32
|
-
description: "Existing run to attach to. Omitted = bind or create.",
|
|
33
|
-
},
|
|
34
|
-
verdict: {
|
|
35
|
-
type: "string",
|
|
36
|
-
enum: [...VERDICT_GRADES],
|
|
37
|
-
description: "Verdict grade",
|
|
38
|
-
},
|
|
39
|
-
reason_code: {
|
|
40
|
-
type: "string",
|
|
41
|
-
enum: [...REASON_CODES],
|
|
42
|
-
description:
|
|
43
|
-
"Failure reason. Clean passes take 'none', never 'other'.",
|
|
44
|
-
},
|
|
45
|
-
regime: {
|
|
46
|
-
type: "string",
|
|
47
|
-
enum: [...REGIME_CODES],
|
|
48
|
-
description:
|
|
49
|
-
"Task shape: code=feature/refactor, fix=debugging a defect, " +
|
|
50
|
-
"review=reviewing a diff, plan=producing a plan or spec, " +
|
|
51
|
-
"inquiry=questions without editing files, test=writing tests",
|
|
52
|
-
},
|
|
53
|
-
note: {
|
|
54
|
-
type: "string",
|
|
55
|
-
description:
|
|
56
|
-
"Standalone note — read months later with no access to this " +
|
|
57
|
-
"conversation. Required when reason_code = 'other'.",
|
|
58
|
-
},
|
|
59
|
-
worktree: {
|
|
60
|
-
type: "string",
|
|
61
|
-
description:
|
|
62
|
-
"Absolute repo path (git rev-parse --show-toplevel). Every query " +
|
|
63
|
-
"scopes by it, so a bare name or an omission files the verdict " +
|
|
64
|
-
"where nothing reads it. Neither case errors.",
|
|
65
|
-
},
|
|
66
|
-
plan: {
|
|
67
|
-
type: "string",
|
|
68
|
-
description: "Plan file path, for a new run",
|
|
69
|
-
},
|
|
70
|
-
session_id: {
|
|
71
|
-
type: "string",
|
|
72
|
-
description:
|
|
73
|
-
"Client session id (Claude Code/Codex: the transcript .jsonl " +
|
|
74
|
-
"path; OpenCode/ZCode: the session id) — this is what attributes " +
|
|
75
|
-
"the verdict to a model. Cannot find it → omit, never invent one.",
|
|
76
|
-
},
|
|
77
|
-
files: {
|
|
78
|
-
type: "array",
|
|
79
|
-
items: { type: "string" },
|
|
80
|
-
description:
|
|
81
|
-
"Repo-relative paths this unit touched. Optional, but always " +
|
|
82
|
-
"send them: nothing else records where the work landed.",
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
required: ["verdict", "reason_code", "regime"],
|
|
86
|
-
},
|
|
87
|
-
},
|
|
88
14
|
{
|
|
89
15
|
name: "mem_find",
|
|
90
16
|
description:
|
package/src/mcp/transport.ts
CHANGED
|
@@ -1,17 +1,8 @@
|
|
|
1
1
|
// src/mcp/transport.ts — JSON-RPC dispatch + stdio entry point
|
|
2
2
|
|
|
3
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
6
3
|
import { createInterface } from "node:readline";
|
|
7
4
|
import { getServerSha } from "./primitives.js";
|
|
8
|
-
import {
|
|
9
|
-
TOOLS,
|
|
10
|
-
toolMemAdd,
|
|
11
|
-
toolMemClose,
|
|
12
|
-
toolMemFind,
|
|
13
|
-
toolVerdictSubmit,
|
|
14
|
-
} from "./tools/index.js";
|
|
5
|
+
import { TOOLS, toolMemAdd, toolMemClose, toolMemFind } from "./tools/index.js";
|
|
15
6
|
import { errorResult, type ToolResult } from "./types.js";
|
|
16
7
|
|
|
17
8
|
// --- Server instructions ---
|
|
@@ -26,35 +17,14 @@ import { errorResult, type ToolResult } from "./types.js";
|
|
|
26
17
|
// Both habits degrade silently — an agent that ignores them still gets
|
|
27
18
|
// correct answers from every tool, just a thinner history.
|
|
28
19
|
|
|
29
|
-
const SERVER_INSTRUCTIONS = `fapony
|
|
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.
|
|
30
21
|
|
|
31
|
-
When a unit of work
|
|
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.
|
|
32
23
|
|
|
33
|
-
worktree must be the absolute path (git rev-parse --show-toplevel): every query scopes by it, so a bare name or none files the
|
|
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.
|
|
34
25
|
|
|
35
26
|
Skip it and every tool still answers correctly, on a thinner history.`;
|
|
36
27
|
|
|
37
|
-
// --- Statusline cache ---
|
|
38
|
-
//
|
|
39
|
-
// Written after every MCP tool call. The Claude Code statusline script reads
|
|
40
|
-
// this file (< 1ms, no spawn, no db). Format: single line of text.
|
|
41
|
-
// No remaining tool produces bytes_by_tool detail, so every call writes the
|
|
42
|
-
// minimal "fapony" marker — cross-client spend lives on the CLI now
|
|
43
|
-
// (`fapony usage-scan` + `fapony usage-web`), which the statusline cannot
|
|
44
|
-
// spawn (it must stay < 1ms).
|
|
45
|
-
|
|
46
|
-
const STATUSLINE_PATH = join(homedir(), ".config", "fapony", "statusline");
|
|
47
|
-
|
|
48
|
-
function writeStatuslineCache(): void {
|
|
49
|
-
try {
|
|
50
|
-
const dir = join(homedir(), ".config", "fapony");
|
|
51
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
52
|
-
writeFileSync(STATUSLINE_PATH, "fapony", "utf-8");
|
|
53
|
-
} catch {
|
|
54
|
-
// Cache write is best-effort — never block MCP on it.
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
28
|
// --- MCP protocol constants ---
|
|
59
29
|
|
|
60
30
|
const MCP_PROTOCOL_VERSION = "2025-03-26";
|
|
@@ -98,9 +68,6 @@ function dispatchToolCall(params: {
|
|
|
98
68
|
const args = params.arguments ?? {};
|
|
99
69
|
let result: ToolResult;
|
|
100
70
|
switch (params.name) {
|
|
101
|
-
case "verdict_submit":
|
|
102
|
-
result = toolVerdictSubmit(args);
|
|
103
|
-
break;
|
|
104
71
|
case "mem_find":
|
|
105
72
|
result = toolMemFind(args);
|
|
106
73
|
break;
|
|
@@ -113,8 +80,6 @@ function dispatchToolCall(params: {
|
|
|
113
80
|
default:
|
|
114
81
|
return errorResult(`unknown tool: ${params.name}`);
|
|
115
82
|
}
|
|
116
|
-
// Write statusline cache after every tool call — best-effort, never blocks.
|
|
117
|
-
writeStatuslineCache();
|
|
118
83
|
return result;
|
|
119
84
|
}
|
|
120
85
|
|
package/src/mcp/worktree.ts
CHANGED
|
@@ -8,7 +8,7 @@ const SENTINEL_MCP_EXTERNAL = "mcp-external";
|
|
|
8
8
|
* Resolve a worktree argument to an absolute path.
|
|
9
9
|
* - Absolute paths (contain "/") pass through unchanged
|
|
10
10
|
* - Keys are looked up in config.worktrees
|
|
11
|
-
* - "mcp-external" sentinel passes through (
|
|
11
|
+
* - "mcp-external" sentinel passes through (used when no worktree is available)
|
|
12
12
|
* - Throws if key not found in config
|
|
13
13
|
*/
|
|
14
14
|
export function resolveWorktreeArg(value: string): string {
|
package/src/mem/commands/read.ts
CHANGED
|
@@ -3,13 +3,28 @@
|
|
|
3
3
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
4
|
import { basename, join } from "node:path";
|
|
5
5
|
import { baselinePath, readEvidenceLintCmd } from "../../lint-baseline.js";
|
|
6
|
-
import { doneLines, fmtClose, fmtRow
|
|
6
|
+
import { doneLines, fmtClose, fmtRow } from "../render.js";
|
|
7
7
|
import { claimsOf, openRows, staleReport } from "../selectors.js";
|
|
8
8
|
import type { CloseRow, WorkRow } from "../store.js";
|
|
9
9
|
import { allRows, app, memCmd, planDir, root, rows } from "../store.js";
|
|
10
10
|
import { planSweepCmd, shippedNotMoved } from "./plan.js";
|
|
11
11
|
import { THRESHOLD } from "./rotate.js";
|
|
12
12
|
|
|
13
|
+
/** Files changed on this branch vs dev — empty set when dev is missing or diff fails. */
|
|
14
|
+
function getBranchDiffFiles(cwd: string): Set<string> {
|
|
15
|
+
try {
|
|
16
|
+
const p = Bun.spawnSync(["git", "diff", "--name-only", "dev...HEAD"], {
|
|
17
|
+
cwd,
|
|
18
|
+
stdout: "pipe",
|
|
19
|
+
stderr: "pipe",
|
|
20
|
+
});
|
|
21
|
+
if (p.exitCode !== 0) return new Set();
|
|
22
|
+
return new Set(p.stdout.toString().trim().split("\n").filter(Boolean));
|
|
23
|
+
} catch {
|
|
24
|
+
return new Set();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
13
28
|
const rotateLine = (n: number) =>
|
|
14
29
|
n >= THRESHOLD
|
|
15
30
|
? `\n## 🗜 log ${n} rows (≥ ${THRESHOLD}) — run: ${memCmd} rotate --apply`
|
|
@@ -24,26 +39,20 @@ const planSweepLine = () => {
|
|
|
24
39
|
: "";
|
|
25
40
|
};
|
|
26
41
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
stale.map((l) => `- ${l}`).join("\n"),
|
|
42
|
-
);
|
|
43
|
-
const sweep = planSweepLine();
|
|
44
|
-
if (sweep) console.log(sweep);
|
|
45
|
-
const rotate = rotateLine(all.length);
|
|
46
|
-
if (rotate) console.log(rotate);
|
|
42
|
+
// Row-list caps: no row-list section exceeds 10 (same budget as ## recent,
|
|
43
|
+
// which takes doneLines(all, 10)). Per-row text is cut at a word boundary —
|
|
44
|
+
// a kickoff line points at the row (mem find has the full text), it must not
|
|
45
|
+
// reprint it.
|
|
46
|
+
const BUGS_LIMIT = 10;
|
|
47
|
+
const BRANCH_LIMIT = 10;
|
|
48
|
+
const RECENT_OPEN_LIMIT = 10;
|
|
49
|
+
const RECENT_OPEN_TEXT = 160;
|
|
50
|
+
|
|
51
|
+
const shortText = (text: string, max = RECENT_OPEN_TEXT): string => {
|
|
52
|
+
if (text.length <= max) return text;
|
|
53
|
+
const cut = text.slice(0, max);
|
|
54
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
55
|
+
return `${lastSpace > max / 2 ? cut.slice(0, lastSpace) : cut}…`;
|
|
47
56
|
};
|
|
48
57
|
|
|
49
58
|
export const cmdDone = () => {
|
|
@@ -235,118 +244,12 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
235
244
|
}
|
|
236
245
|
}
|
|
237
246
|
|
|
238
|
-
// ---
|
|
239
|
-
|
|
240
|
-
if (!arg && !planFile) {
|
|
241
|
-
// no args = now + a "recent" section = the last 10 closes
|
|
242
|
-
console.log(`# ${app} — ${all.length} entries`);
|
|
243
|
-
printOpenRows(all, { showHold: true });
|
|
244
|
-
console.log(`\n## recent\n${doneLines(all, 10).join("\n")}`);
|
|
245
|
-
const stale = staleReport(all);
|
|
246
|
-
if (stale.length)
|
|
247
|
-
console.log(
|
|
248
|
-
`\n## ⚠ stale (${stale.length})\n` +
|
|
249
|
-
stale.map((l) => `- ${l}`).join("\n"),
|
|
250
|
-
);
|
|
251
|
-
const sweep = planSweepLine();
|
|
252
|
-
if (sweep) console.log(sweep);
|
|
253
|
-
const rotate = rotateLine(all.length);
|
|
254
|
-
if (rotate) console.log(rotate);
|
|
255
|
-
} else if (resolvedSpec) {
|
|
256
|
-
// spec.md = a brief for that spec
|
|
257
|
-
const open = openRows(all);
|
|
258
|
-
const specRows = open.filter((r) => r.spec === resolvedSpec);
|
|
259
|
-
const specDecisions = all
|
|
260
|
-
.filter(
|
|
261
|
-
(r): r is WorkRow => r.kind === "decision" && r.spec === resolvedSpec,
|
|
262
|
-
)
|
|
263
|
-
.slice(-5);
|
|
264
|
-
const specCloses = all
|
|
265
|
-
.filter((r): r is CloseRow => r.kind === "close")
|
|
266
|
-
.filter((r) => byId.get(r.ref)?.spec === resolvedSpec)
|
|
267
|
-
.slice(-3);
|
|
268
|
-
|
|
269
|
-
console.log(`# ${resolvedSpec} — ${specRows.length} open rows`);
|
|
270
|
-
if (specRows.length) {
|
|
271
|
-
console.log(`\n## open\n${specRows.map((r) => fmtRow(r)).join("\n")}`);
|
|
272
|
-
}
|
|
273
|
-
if (specDecisions.length) {
|
|
274
|
-
console.log(
|
|
275
|
-
`\n## decisions\n` +
|
|
276
|
-
specDecisions
|
|
277
|
-
.map((r) => `- ${r.ts.slice(0, 10)} ${r.text}`)
|
|
278
|
-
.join("\n"),
|
|
279
|
-
);
|
|
280
|
-
}
|
|
281
|
-
if (specCloses.length) {
|
|
282
|
-
console.log(
|
|
283
|
-
`\n## closes\n${specCloses.map((c) => fmtClose(c, byId)).join("\n")}`,
|
|
284
|
-
);
|
|
285
|
-
}
|
|
286
|
-
if (!specRows.length && !specDecisions.length && !specCloses.length) {
|
|
287
|
-
console.log("(no entries for this spec)");
|
|
288
|
-
}
|
|
289
|
-
} else if (target) {
|
|
290
|
-
// id = a brief for that task
|
|
291
|
-
const claims = claimsOf(all);
|
|
292
|
-
const claimInfo = claims.has(arg)
|
|
293
|
-
? `\nClaimed by: ${claims.get(arg)?.agent} (${claims.get(arg)?.ts.slice(0, 10)})`
|
|
294
|
-
: "";
|
|
295
|
-
|
|
296
|
-
const spec = target.spec;
|
|
297
|
-
const open = spec
|
|
298
|
-
? openRows(all).filter((r) => r.spec === spec && r.id !== arg)
|
|
299
|
-
: [];
|
|
300
|
-
const specDecisions = spec
|
|
301
|
-
? all
|
|
302
|
-
.filter((r): r is WorkRow => r.kind === "decision" && r.spec === spec)
|
|
303
|
-
.slice(-5)
|
|
304
|
-
: [];
|
|
305
|
-
const specNotes = spec
|
|
306
|
-
? all
|
|
307
|
-
.filter((r): r is WorkRow => r.kind === "note" && r.spec === spec)
|
|
308
|
-
.slice(-5)
|
|
309
|
-
: [];
|
|
310
|
-
const specCloses = spec
|
|
311
|
-
? all
|
|
312
|
-
.filter((r): r is CloseRow => r.kind === "close")
|
|
313
|
-
.filter((r) => byId.get(r.ref)?.spec === spec)
|
|
314
|
-
.slice(-3)
|
|
315
|
-
: [];
|
|
316
|
-
|
|
317
|
-
console.log(
|
|
318
|
-
`# [${target.id}] ${target.kind} — ${target.text}${target.spec ? ` → ${target.spec}` : ""}${claimInfo}`,
|
|
319
|
-
);
|
|
320
|
-
if (open.length) {
|
|
321
|
-
console.log(
|
|
322
|
-
`\n## open (same spec)\n` +
|
|
323
|
-
open.map((r) => fmtRow(r, claims)).join("\n"),
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
if (specDecisions.length || specNotes.length) {
|
|
327
|
-
console.log(`\n## decisions & notes (spec)\n`);
|
|
328
|
-
for (const r of [...specDecisions, ...specNotes]) {
|
|
329
|
-
console.log(`- ${r.ts.slice(0, 10)} ${r.kind} ${r.text}`);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
if (specCloses.length) {
|
|
333
|
-
console.log(
|
|
334
|
-
`\n## closes (spec)\n` +
|
|
335
|
-
specCloses.map((c) => fmtClose(c, byId)).join("\n"),
|
|
336
|
-
);
|
|
337
|
-
}
|
|
338
|
-
} else if (arg) {
|
|
339
|
-
console.error(`no id "${arg}" in the log`);
|
|
340
|
-
process.exit(1);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// --- next up section (chunk 3) ---
|
|
247
|
+
// --- suggestions (generated early so the no-args path can print next up
|
|
248
|
+
// before the recency tier, keeping it inside the 4KB cap) ---
|
|
344
249
|
|
|
345
|
-
// A suggestion may carry a fully-runnable command. `manual` marks a command
|
|
346
|
-
// that is a template with a placeholder (bug close) — show it, never auto-run it.
|
|
347
250
|
type Suggestion = { text: string; run?: string; manual?: boolean };
|
|
348
251
|
const suggestions: Suggestion[] = [];
|
|
349
|
-
const GROUP_CAP = 3;
|
|
252
|
+
const GROUP_CAP = 3;
|
|
350
253
|
|
|
351
254
|
const truncate = (s: string, n = 80) =>
|
|
352
255
|
s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s;
|
|
@@ -358,7 +261,6 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
358
261
|
for (const file of files) {
|
|
359
262
|
if (planLines.length >= GROUP_CAP) break;
|
|
360
263
|
const fullPath = join(planDir, file);
|
|
361
|
-
// Skip the plan we already showed
|
|
362
264
|
if (planFile && fullPath === planFile) continue;
|
|
363
265
|
if (hasHighPriority(fullPath)) {
|
|
364
266
|
const title = readPlanTitle(fullPath);
|
|
@@ -369,19 +271,16 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
369
271
|
}
|
|
370
272
|
}
|
|
371
273
|
}
|
|
372
|
-
// Only the first unchecked chunk (SPEC §3) — the body of the plan is the plan's job.
|
|
373
|
-
// There is no per-chunk command, so this line is context, not a runnable item.
|
|
374
274
|
if (planLines.length < GROUP_CAP && planCheckboxes.length) {
|
|
375
275
|
const planName = planFile ? basename(planFile) : "plan";
|
|
376
276
|
planLines.push({ text: `${planCheckboxes[0]} (${planName})` });
|
|
377
277
|
}
|
|
378
278
|
suggestions.push(...planLines);
|
|
379
279
|
|
|
380
|
-
// group 2 — open bugs, oldest first
|
|
381
|
-
|
|
382
|
-
const claims = claimsOf(all);
|
|
280
|
+
// group 2 — open bugs, oldest first
|
|
281
|
+
const suggClaims = claimsOf(all);
|
|
383
282
|
const openBugs = openRows(all)
|
|
384
|
-
.filter((r) => r.kind === "bug" && !
|
|
283
|
+
.filter((r) => r.kind === "bug" && !suggClaims.has(r.id))
|
|
385
284
|
.slice(0, GROUP_CAP);
|
|
386
285
|
for (const b of openBugs) {
|
|
387
286
|
suggestions.push({
|
|
@@ -407,11 +306,7 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
407
306
|
});
|
|
408
307
|
}
|
|
409
308
|
|
|
410
|
-
// group 4 — the lint baseline
|
|
411
|
-
// already answered by `lint-baseline`, but only if it was captured before
|
|
412
|
-
// the work started — and nobody remembers at session open, which is what
|
|
413
|
-
// kickoff is. Only offered when the repo declared a lint command; without
|
|
414
|
-
// one the capture would just fail.
|
|
309
|
+
// group 4 — the lint baseline
|
|
415
310
|
try {
|
|
416
311
|
if (readEvidenceLintCmd(root) && !existsSync(baselinePath(root))) {
|
|
417
312
|
suggestions.push({
|
|
@@ -423,15 +318,14 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
423
318
|
// state dir unreadable — the baseline line is a convenience, never a gate
|
|
424
319
|
}
|
|
425
320
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
321
|
+
/** Print ## next up and handle --pick. */
|
|
322
|
+
const printNextUp = () => {
|
|
323
|
+
if (!suggestions.length) return;
|
|
429
324
|
console.log(`\n## next up`);
|
|
430
325
|
for (let i = 0; i < suggestions.length; i++) {
|
|
431
326
|
console.log(` [${i + 1}] ${suggestions[i].text}`);
|
|
432
327
|
if (suggestions[i].run) console.log(` → ${suggestions[i].run}`);
|
|
433
328
|
}
|
|
434
|
-
|
|
435
329
|
if (pickIdx !== null) {
|
|
436
330
|
if (pickIdx > suggestions.length) {
|
|
437
331
|
console.error(
|
|
@@ -461,5 +355,184 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
461
355
|
process.exit(1);
|
|
462
356
|
}
|
|
463
357
|
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// --- main output ---
|
|
361
|
+
|
|
362
|
+
// Deferred output for the no-args path — recent closes, plan sweep, rotate
|
|
363
|
+
let kickoffExtras: (() => void) | null = null;
|
|
364
|
+
|
|
365
|
+
if (!arg && !planFile) {
|
|
366
|
+
// no args = ranked open rows + next up + recent closes
|
|
367
|
+
console.log(`# ${app} — ${all.length} entries`);
|
|
368
|
+
|
|
369
|
+
const open = openRows(all);
|
|
370
|
+
const claims = claimsOf(all);
|
|
371
|
+
const diffFiles = getBranchDiffFiles(root);
|
|
372
|
+
|
|
373
|
+
// Tier 1: unclaimed bugs (always first, no matter how old) — capped: a
|
|
374
|
+
// backlog of unclosable bugs must not push the rest of the ranking out.
|
|
375
|
+
const bugs = open.filter((r) => r.kind === "bug" && !claims.has(r.id));
|
|
376
|
+
if (bugs.length) {
|
|
377
|
+
console.log(`\n## bugs`);
|
|
378
|
+
for (const r of bugs.slice(0, BUGS_LIMIT)) console.log(fmtRow(r));
|
|
379
|
+
if (bugs.length > BUGS_LIMIT)
|
|
380
|
+
console.log(
|
|
381
|
+
`… +${bugs.length - BUGS_LIMIT} more unclosed bugs — \`fapony mem find <word>\` for the rest`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// next up BEFORE the branch/recency tiers so no row-list section can
|
|
386
|
+
// push it out — order is by actionability, never by "this tier is short".
|
|
387
|
+
printNextUp();
|
|
388
|
+
|
|
389
|
+
// Tier 2: rows whose files[] overlap with git diff --name-only dev...HEAD
|
|
390
|
+
// — capped like every other row-list section (≤10 + overflow, pointer
|
|
391
|
+
// lines via shortText): the branch diff is unbounded and mem clusters on
|
|
392
|
+
// files every real branch touches, so uncapped this reprints the log.
|
|
393
|
+
const bugIds = new Set(bugs.map((r) => r.id));
|
|
394
|
+
const diffMatched = open.filter((r) => {
|
|
395
|
+
if (bugIds.has(r.id)) return false;
|
|
396
|
+
if (diffFiles.size === 0) return false;
|
|
397
|
+
return (r.files ?? []).some((f) => diffFiles.has(f));
|
|
398
|
+
});
|
|
399
|
+
if (diffMatched.length) {
|
|
400
|
+
console.log(`\n## on this branch`);
|
|
401
|
+
for (const r of diffMatched.slice(0, BRANCH_LIMIT))
|
|
402
|
+
console.log(fmtRow({ ...r, text: shortText(r.text) }, claims));
|
|
403
|
+
if (diffMatched.length > BRANCH_LIMIT)
|
|
404
|
+
console.log(
|
|
405
|
+
`… +${diffMatched.length - BRANCH_LIMIT} more — \`fapony mem find <word>\` for the rest`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Tier 3: rest by recency (newest first) — tie-breaker only, capped like
|
|
410
|
+
// ## recent (doneLines takes 10): decision/note can never close, so an
|
|
411
|
+
// uncapped tier reprints the whole log. Lines are pointers, not prose —
|
|
412
|
+
// cut at a word boundary and point at mem find for the rest.
|
|
413
|
+
const shown = new Set([...bugIds, ...diffMatched.map((r) => r.id)]);
|
|
414
|
+
const rest = open
|
|
415
|
+
.filter((r) => !shown.has(r.id))
|
|
416
|
+
.sort((a, b) => b.ts.localeCompare(a.ts));
|
|
417
|
+
if (rest.length) {
|
|
418
|
+
console.log(`\n## recent open`);
|
|
419
|
+
for (const r of rest.slice(0, RECENT_OPEN_LIMIT))
|
|
420
|
+
console.log(fmtRow({ ...r, text: shortText(r.text) }, claims));
|
|
421
|
+
if (rest.length > RECENT_OPEN_LIMIT)
|
|
422
|
+
console.log(
|
|
423
|
+
`… +${rest.length - RECENT_OPEN_LIMIT} more — \`fapony mem find <word>\` for the rest`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// recent closes, plan sweep, rotate — after next up
|
|
428
|
+
kickoffExtras = () => {
|
|
429
|
+
console.log(`\n## recent\n${doneLines(all, 10).join("\n")}`);
|
|
430
|
+
const sweep = planSweepLine();
|
|
431
|
+
if (sweep) console.log(sweep);
|
|
432
|
+
const rotate = rotateLine(all.length);
|
|
433
|
+
if (rotate) console.log(rotate);
|
|
434
|
+
};
|
|
435
|
+
} else if (resolvedSpec) {
|
|
436
|
+
// spec.md = a brief for that spec
|
|
437
|
+
const open = openRows(all);
|
|
438
|
+
const specRows = open.filter((r) => r.spec === resolvedSpec);
|
|
439
|
+
const specDecisions = all
|
|
440
|
+
.filter(
|
|
441
|
+
(r): r is WorkRow => r.kind === "decision" && r.spec === resolvedSpec,
|
|
442
|
+
)
|
|
443
|
+
.slice(-5);
|
|
444
|
+
const specCloses = all
|
|
445
|
+
.filter((r): r is CloseRow => r.kind === "close")
|
|
446
|
+
.filter((r) => byId.get(r.ref)?.spec === resolvedSpec)
|
|
447
|
+
.slice(-3);
|
|
448
|
+
|
|
449
|
+
console.log(`# ${resolvedSpec} — ${specRows.length} open rows`);
|
|
450
|
+
if (specRows.length) {
|
|
451
|
+
console.log(`\n## open\n${specRows.map((r) => fmtRow(r)).join("\n")}`);
|
|
452
|
+
}
|
|
453
|
+
if (specDecisions.length) {
|
|
454
|
+
console.log(
|
|
455
|
+
`\n## decisions\n` +
|
|
456
|
+
specDecisions
|
|
457
|
+
.map((r) => `- ${r.ts.slice(0, 10)} ${r.text}`)
|
|
458
|
+
.join("\n"),
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
if (specCloses.length) {
|
|
462
|
+
console.log(
|
|
463
|
+
`\n## closes\n${specCloses.map((c) => fmtClose(c, byId)).join("\n")}`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
if (!specRows.length && !specDecisions.length && !specCloses.length) {
|
|
467
|
+
console.log("(no entries for this spec)");
|
|
468
|
+
}
|
|
469
|
+
} else if (target) {
|
|
470
|
+
// id = a brief for that task
|
|
471
|
+
const claims = claimsOf(all);
|
|
472
|
+
const claimInfo = claims.has(arg)
|
|
473
|
+
? `\nClaimed by: ${claims.get(arg)?.agent} (${claims.get(arg)?.ts.slice(0, 10)})`
|
|
474
|
+
: "";
|
|
475
|
+
|
|
476
|
+
const spec = target.spec;
|
|
477
|
+
const open = spec
|
|
478
|
+
? openRows(all).filter((r) => r.spec === spec && r.id !== arg)
|
|
479
|
+
: [];
|
|
480
|
+
const specDecisions = spec
|
|
481
|
+
? all
|
|
482
|
+
.filter((r): r is WorkRow => r.kind === "decision" && r.spec === spec)
|
|
483
|
+
.slice(-5)
|
|
484
|
+
: [];
|
|
485
|
+
const specNotes = spec
|
|
486
|
+
? all
|
|
487
|
+
.filter((r): r is WorkRow => r.kind === "note" && r.spec === spec)
|
|
488
|
+
.slice(-5)
|
|
489
|
+
: [];
|
|
490
|
+
const specCloses = spec
|
|
491
|
+
? all
|
|
492
|
+
.filter((r): r is CloseRow => r.kind === "close")
|
|
493
|
+
.filter((r) => byId.get(r.ref)?.spec === spec)
|
|
494
|
+
.slice(-3)
|
|
495
|
+
: [];
|
|
496
|
+
|
|
497
|
+
console.log(
|
|
498
|
+
`# [${target.id}] ${target.kind} — ${target.text}${target.spec ? ` → ${target.spec}` : ""}${claimInfo}`,
|
|
499
|
+
);
|
|
500
|
+
if (open.length) {
|
|
501
|
+
console.log(
|
|
502
|
+
`\n## open (same spec)\n` +
|
|
503
|
+
open.map((r) => fmtRow(r, claims)).join("\n"),
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
if (specDecisions.length || specNotes.length) {
|
|
507
|
+
console.log(`\n## decisions & notes (spec)\n`);
|
|
508
|
+
for (const r of [...specDecisions, ...specNotes]) {
|
|
509
|
+
console.log(`- ${r.ts.slice(0, 10)} ${r.kind} ${r.text}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (specCloses.length) {
|
|
513
|
+
console.log(
|
|
514
|
+
`\n## closes (spec)\n` +
|
|
515
|
+
specCloses.map((c) => fmtClose(c, byId)).join("\n"),
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
} else if (planFile) {
|
|
519
|
+
const title = readPlanTitle(planFile);
|
|
520
|
+
console.log(`# ${title || basename(planFile)} — plan`);
|
|
521
|
+
if (planCheckboxes.length) {
|
|
522
|
+
console.log(
|
|
523
|
+
`\n## unchecked\n${planCheckboxes.map((c) => `- [ ] ${c}`).join("\n")}`,
|
|
524
|
+
);
|
|
525
|
+
} else {
|
|
526
|
+
console.log(`\n(all chunks checked — ready to ship or archive)`);
|
|
527
|
+
}
|
|
528
|
+
} else if (arg) {
|
|
529
|
+
console.error(`no id "${arg}" in the log`);
|
|
530
|
+
process.exit(1);
|
|
464
531
|
}
|
|
532
|
+
|
|
533
|
+
// For non-no-args paths, print next up at the end
|
|
534
|
+
if (arg || planFile) printNextUp();
|
|
535
|
+
|
|
536
|
+
// Deferred output from no-args path (recent closes, plan sweep, rotate)
|
|
537
|
+
kickoffExtras?.();
|
|
465
538
|
};
|