@juicesharp/rpiv-pi 1.9.1 → 1.10.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.
- package/extensions/rpiv-core/agents.test.ts +1 -1
- package/extensions/rpiv-core/agents.ts +2 -18
- package/extensions/rpiv-core/paths.ts +24 -0
- package/extensions/rpiv-core/session-hooks.test.ts +63 -5
- package/extensions/rpiv-core/session-hooks.ts +34 -1
- package/package.json +1 -1
- package/skills/_shared/changelog-bootstrap.mjs +42 -0
- package/skills/_shared/git-changes.mjs +70 -0
- package/skills/_shared/git-changes.test.ts +107 -0
- package/skills/_shared/git-context.mjs +37 -0
- package/skills/_shared/git-context.test.ts +90 -0
- package/skills/_shared/list-recent.mjs +35 -0
- package/skills/_shared/list-recent.test.ts +77 -0
- package/skills/_shared/now.mjs +14 -0
- package/skills/_shared/now.test.ts +48 -0
- package/skills/blueprint/SKILL.md +16 -2
- package/skills/changelog/SKILL.md +17 -6
- package/skills/code-review/SKILL.md +54 -28
- package/skills/code-review/_helpers/review-range.mjs +269 -0
- package/skills/commit/SKILL.md +19 -7
- package/skills/create-handoff/SKILL.md +22 -12
- package/skills/design/SKILL.md +16 -2
- package/skills/discover/SKILL.md +19 -8
- package/skills/discover/templates/frd.md +2 -2
- package/skills/explore/SKILL.md +20 -8
- package/skills/outline-test-cases/SKILL.md +11 -2
- package/skills/plan/SKILL.md +19 -6
- package/skills/research/SKILL.md +21 -11
- package/skills/resume-handoff/SKILL.md +14 -9
- package/skills/revise/SKILL.md +18 -11
- package/skills/validate/SKILL.md +26 -16
- package/skills/write-test-cases/SKILL.md +14 -2
|
@@ -14,7 +14,6 @@ import { homedir, tmpdir } from "node:os";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
16
16
|
import {
|
|
17
|
-
BUNDLED_AGENTS_DIR,
|
|
18
17
|
CLEANUP_SKIP_REASON,
|
|
19
18
|
cleanupPerCwdAgents,
|
|
20
19
|
isSafeDestructiveOp,
|
|
@@ -22,6 +21,7 @@ import {
|
|
|
22
21
|
summarizeCleanupSkips,
|
|
23
22
|
syncBundledAgents,
|
|
24
23
|
} from "./agents.js";
|
|
24
|
+
import { BUNDLED_AGENTS_DIR } from "./paths.js";
|
|
25
25
|
|
|
26
26
|
const sha256 = (s: string | Buffer) => createHash("sha256").update(s).digest("hex");
|
|
27
27
|
|
|
@@ -26,27 +26,11 @@ import {
|
|
|
26
26
|
unlinkSync,
|
|
27
27
|
writeFileSync,
|
|
28
28
|
} from "node:fs";
|
|
29
|
-
import {
|
|
30
|
-
import { fileURLToPath } from "node:url";
|
|
29
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
31
30
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { BUNDLED_AGENTS_DIR } from "./paths.js";
|
|
32
32
|
import { isPlainObject, toErrorMessage } from "./utils.js";
|
|
33
33
|
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
// Package-root resolution
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Resolves the rpiv-pi package root from this module's file URL.
|
|
40
|
-
* Walks up from `extensions/rpiv-core/agents.ts` to the repo root.
|
|
41
|
-
*/
|
|
42
|
-
export const PACKAGE_ROOT = (() => {
|
|
43
|
-
const thisFile = fileURLToPath(import.meta.url);
|
|
44
|
-
// extensions/rpiv-core/agents.ts -> rpiv-pi/
|
|
45
|
-
return dirname(dirname(dirname(thisFile)));
|
|
46
|
-
})();
|
|
47
|
-
|
|
48
|
-
export const BUNDLED_AGENTS_DIR = join(PACKAGE_ROOT, "agents");
|
|
49
|
-
|
|
50
34
|
// ---------------------------------------------------------------------------
|
|
51
35
|
// Types
|
|
52
36
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolved filesystem paths for rpiv-pi's own bundled resources.
|
|
3
|
+
*
|
|
4
|
+
* `PACKAGE_ROOT` is computed at module load from this file's URL. The walk-up
|
|
5
|
+
* is anchored to this file's location (`extensions/rpiv-core/paths.ts`) — three
|
|
6
|
+
* `dirname` levels reach the rpiv-pi package root. Other resource directories
|
|
7
|
+
* mirror the `pi.skills` / `pi.extensions` declarations in package.json.
|
|
8
|
+
*
|
|
9
|
+
* Pi's SDK does not expose a "give me my own extension root" API, so this is
|
|
10
|
+
* the idiomatic resolution path (see also docs/packages.md on `pi.*` manifest
|
|
11
|
+
* paths being relative to the package root).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
export const PACKAGE_ROOT = (() => {
|
|
18
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
19
|
+
// extensions/rpiv-core/paths.ts -> rpiv-pi/
|
|
20
|
+
return dirname(dirname(dirname(thisFile)));
|
|
21
|
+
})();
|
|
22
|
+
|
|
23
|
+
export const BUNDLED_AGENTS_DIR = join(PACKAGE_ROOT, "agents");
|
|
24
|
+
export const BUNDLED_SKILLS_DIR = join(PACKAGE_ROOT, "skills");
|
|
@@ -56,10 +56,17 @@ afterEach(() => {
|
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
describe("registerSessionHooks — event wiring", () => {
|
|
59
|
-
it("registers
|
|
59
|
+
it("registers 6 events", () => {
|
|
60
60
|
const { pi, captured } = createMockPi();
|
|
61
61
|
registerSessionHooks(pi);
|
|
62
|
-
for (const ev of [
|
|
62
|
+
for (const ev of [
|
|
63
|
+
"session_start",
|
|
64
|
+
"session_compact",
|
|
65
|
+
"session_shutdown",
|
|
66
|
+
"tool_call",
|
|
67
|
+
"before_agent_start",
|
|
68
|
+
"agent_end",
|
|
69
|
+
]) {
|
|
63
70
|
expect(captured.events.has(ev)).toBe(true);
|
|
64
71
|
}
|
|
65
72
|
});
|
|
@@ -458,7 +465,7 @@ describe("before_agent_start hook", () => {
|
|
|
458
465
|
registerSessionHooks(pi);
|
|
459
466
|
const handler = captured.events.get("before_agent_start")?.[0];
|
|
460
467
|
const ctx = createMockCtx({ cwd: projectDir });
|
|
461
|
-
const r = await handler?.({} as never, ctx as never);
|
|
468
|
+
const r = await handler?.({ prompt: "" } as never, ctx as never);
|
|
462
469
|
expect(r).toHaveProperty("message");
|
|
463
470
|
});
|
|
464
471
|
|
|
@@ -469,8 +476,59 @@ describe("before_agent_start hook", () => {
|
|
|
469
476
|
registerSessionHooks(pi);
|
|
470
477
|
const handler = captured.events.get("before_agent_start")?.[0];
|
|
471
478
|
const ctx = createMockCtx({ cwd: projectDir });
|
|
472
|
-
await handler?.({} as never, ctx as never);
|
|
473
|
-
const second = await handler?.({} as never, ctx as never);
|
|
479
|
+
await handler?.({ prompt: "" } as never, ctx as never);
|
|
480
|
+
const second = await handler?.({ prompt: "" } as never, ctx as never);
|
|
474
481
|
expect(second).toBeUndefined();
|
|
475
482
|
});
|
|
483
|
+
|
|
484
|
+
it("sets status to 'rpiv: <name>' when prompt contains an owned rpiv-pi skill block", async () => {
|
|
485
|
+
const { pi, captured } = createMockPi({
|
|
486
|
+
exec: stubGitExec({ branch: "main", commit: "abc", user: "alice" }) as never,
|
|
487
|
+
});
|
|
488
|
+
registerSessionHooks(pi);
|
|
489
|
+
const handler = captured.events.get("before_agent_start")?.[0];
|
|
490
|
+
const ctx = createMockCtx({ cwd: projectDir });
|
|
491
|
+
const skillPrompt = `<skill name="discover" location="/some/path">\nbody\n</skill>`;
|
|
492
|
+
await handler?.({ prompt: skillPrompt } as never, ctx as never);
|
|
493
|
+
expect(ctx.ui.setStatus).toHaveBeenCalledWith("rpiv-skill", "rpiv: discover");
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("does not set status for a skill block whose name is not bundled with rpiv-pi", async () => {
|
|
497
|
+
// Foreign / user-supplied skills must not be branded as rpiv: — only names that
|
|
498
|
+
// match a directory under packages/rpiv-pi/skills/ get the rpiv-skill status.
|
|
499
|
+
const { pi, captured } = createMockPi({
|
|
500
|
+
exec: stubGitExec({ branch: "main", commit: "abc", user: "alice" }) as never,
|
|
501
|
+
});
|
|
502
|
+
registerSessionHooks(pi);
|
|
503
|
+
const handler = captured.events.get("before_agent_start")?.[0];
|
|
504
|
+
const ctx = createMockCtx({ cwd: projectDir });
|
|
505
|
+
const skillPrompt = `<skill name="not-an-rpiv-skill" location="/home/u/.pi/skills/not-an-rpiv-skill">\nbody\n</skill>`;
|
|
506
|
+
await handler?.({ prompt: skillPrompt } as never, ctx as never);
|
|
507
|
+
const setStatusCalls = (ctx.ui.setStatus as ReturnType<typeof vi.fn>).mock.calls.filter(
|
|
508
|
+
(c) => c[0] === "rpiv-skill",
|
|
509
|
+
);
|
|
510
|
+
expect(setStatusCalls).toHaveLength(0);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("does not set status when prompt has no skill block", async () => {
|
|
514
|
+
const { pi, captured } = createMockPi({
|
|
515
|
+
exec: stubGitExec({ branch: "main", commit: "abc", user: "alice" }) as never,
|
|
516
|
+
});
|
|
517
|
+
registerSessionHooks(pi);
|
|
518
|
+
const handler = captured.events.get("before_agent_start")?.[0];
|
|
519
|
+
const ctx = createMockCtx({ cwd: projectDir });
|
|
520
|
+
await handler?.({ prompt: "just a normal chat message" } as never, ctx as never);
|
|
521
|
+
expect(ctx.ui.setStatus).not.toHaveBeenCalled();
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
describe("agent_end hook", () => {
|
|
526
|
+
it("clears the rpiv-skill status", async () => {
|
|
527
|
+
const { pi, captured } = createMockPi();
|
|
528
|
+
registerSessionHooks(pi);
|
|
529
|
+
const handler = captured.events.get("agent_end")?.[0];
|
|
530
|
+
const ctx = createMockCtx();
|
|
531
|
+
await handler?.({ messages: [] } as never, ctx as never);
|
|
532
|
+
expect(ctx.ui.setStatus).toHaveBeenCalledWith("rpiv-skill", undefined);
|
|
533
|
+
});
|
|
476
534
|
});
|
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import {
|
|
11
|
+
type AgentEndEvent,
|
|
12
|
+
type BeforeAgentStartEvent,
|
|
11
13
|
type ExtensionAPI,
|
|
12
14
|
type ExtensionContext,
|
|
13
15
|
isToolCallEventType,
|
|
16
|
+
parseSkillBlock,
|
|
14
17
|
type ToolCallEvent,
|
|
15
18
|
} from "@earendil-works/pi-coding-agent";
|
|
16
19
|
import {
|
|
@@ -29,6 +32,7 @@ import {
|
|
|
29
32
|
} from "./git-context.js";
|
|
30
33
|
import { ARTIFACTS_SUBDIR, clearInjectionState, handleToolCallGuidance, injectRootGuidance } from "./guidance.js";
|
|
31
34
|
import { findMissingSiblings } from "./package-checks.js";
|
|
35
|
+
import { BUNDLED_SKILLS_DIR } from "./paths.js";
|
|
32
36
|
|
|
33
37
|
const msgAgentsAdded = (n: number) => `Copied ${n} rpiv-pi agent(s) to ~/.pi/agent/agents/`;
|
|
34
38
|
const msgAgentsHealed = (parts: string[]) => `Synced bundled agent(s): ${parts.join(", ")}.`;
|
|
@@ -60,7 +64,8 @@ export function registerSessionHooks(pi: ExtensionAPI): void {
|
|
|
60
64
|
pi.on("session_compact", async (_event, ctx) => onSessionCompact(_event, ctx, pi));
|
|
61
65
|
pi.on("session_shutdown", async () => onSessionShutdown());
|
|
62
66
|
pi.on("tool_call", async (event, ctx) => onToolCall(event, ctx, pi));
|
|
63
|
-
pi.on("before_agent_start", async () => onBeforeAgentStart(pi));
|
|
67
|
+
pi.on("before_agent_start", async (event, ctx) => onBeforeAgentStart(event, ctx, pi));
|
|
68
|
+
pi.on("agent_end", async (_event, ctx) => onAgentEnd(_event, ctx));
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
// ---------------------------------------------------------------------------
|
|
@@ -107,17 +112,45 @@ async function onToolCall(event: ToolCallEvent, ctx: ExtensionContext, pi: Exten
|
|
|
107
112
|
}
|
|
108
113
|
|
|
109
114
|
async function onBeforeAgentStart(
|
|
115
|
+
event: BeforeAgentStartEvent,
|
|
116
|
+
ctx: ExtensionContext,
|
|
110
117
|
pi: ExtensionAPI,
|
|
111
118
|
): Promise<{ message: ReturnType<typeof buildGitContextMessage> } | undefined> {
|
|
119
|
+
const parsed = parseSkillBlock(event.prompt);
|
|
120
|
+
if (parsed && isOwnedSkill(parsed.name)) ctx.ui.setStatus("rpiv-skill", `rpiv: ${parsed.name}`);
|
|
112
121
|
const content = await takeGitContextIfChanged(pi);
|
|
113
122
|
if (!content) return undefined;
|
|
114
123
|
return { message: buildGitContextMessage(pi, content) };
|
|
115
124
|
}
|
|
116
125
|
|
|
126
|
+
async function onAgentEnd(_event: AgentEndEvent, ctx: ExtensionContext): Promise<void> {
|
|
127
|
+
ctx.ui.setStatus("rpiv-skill", undefined);
|
|
128
|
+
}
|
|
129
|
+
|
|
117
130
|
// ---------------------------------------------------------------------------
|
|
118
131
|
// Helpers
|
|
119
132
|
// ---------------------------------------------------------------------------
|
|
120
133
|
|
|
134
|
+
// Allowlist of rpiv-pi's own skill names, generated at module load by reading
|
|
135
|
+
// the package's bundled skills/ directory (see paths.ts — matches the
|
|
136
|
+
// `pi.skills` manifest in package.json). Prevents the status bar from
|
|
137
|
+
// claiming `rpiv:` ownership of user-supplied or third-party skills.
|
|
138
|
+
const OWNED_SKILL_NAMES: ReadonlySet<string> = (() => {
|
|
139
|
+
try {
|
|
140
|
+
return new Set(
|
|
141
|
+
readdirSync(BUNDLED_SKILLS_DIR, { withFileTypes: true })
|
|
142
|
+
.filter((e) => e.isDirectory())
|
|
143
|
+
.map((e) => e.name),
|
|
144
|
+
);
|
|
145
|
+
} catch {
|
|
146
|
+
return new Set<string>();
|
|
147
|
+
}
|
|
148
|
+
})();
|
|
149
|
+
|
|
150
|
+
function isOwnedSkill(name: string): boolean {
|
|
151
|
+
return OWNED_SKILL_NAMES.has(name);
|
|
152
|
+
}
|
|
153
|
+
|
|
121
154
|
function resetInjectionState(): void {
|
|
122
155
|
clearInjectionState();
|
|
123
156
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juicesharp/rpiv-pi",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "A skill-based development workflow for Pi Agent. Five skills (research, design, plan, implement, validate) and the shared subagents that compose its ship-loop.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Pre-bake the bail-out checks and reference values for the changelog skill.
|
|
2
|
+
//
|
|
3
|
+
// Prints:
|
|
4
|
+
// in_repo: yes|no
|
|
5
|
+
// last_tag: <tag>|(no tags)
|
|
6
|
+
// ---changelogs---
|
|
7
|
+
// <one CHANGELOG.md path per line> (empty if none tracked)
|
|
8
|
+
//
|
|
9
|
+
// All values are bounded. The unbounded `git log` and `git diff` calls
|
|
10
|
+
// remain LLM-issued via Bash (their output can exceed the 50KB tail budget).
|
|
11
|
+
//
|
|
12
|
+
// Always exits 0 — non-repo cwd collapses to `in_repo: no` and empty sections.
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
|
|
15
|
+
const safe = (args, fb) => {
|
|
16
|
+
try {
|
|
17
|
+
return execFileSync("git", args, {
|
|
18
|
+
encoding: "utf-8",
|
|
19
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
20
|
+
});
|
|
21
|
+
} catch {
|
|
22
|
+
return fb;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const root = safe(["rev-parse", "--show-toplevel"], "").trim();
|
|
27
|
+
const inRepo = root ? "yes" : "no";
|
|
28
|
+
|
|
29
|
+
process.stdout.write(`in_repo: ${inRepo}\n`);
|
|
30
|
+
|
|
31
|
+
if (!root) {
|
|
32
|
+
process.stdout.write("last_tag: (not in a git repo)\n");
|
|
33
|
+
process.stdout.write("---changelogs---\n");
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const lastTag = safe(["describe", "--tags", "--abbrev=0"], "").trim();
|
|
38
|
+
process.stdout.write(`last_tag: ${lastTag || "(no tags)"}\n`);
|
|
39
|
+
|
|
40
|
+
const changelogs = safe(["ls-files", "CHANGELOG.md", "**/CHANGELOG.md"], "");
|
|
41
|
+
process.stdout.write("---changelogs---\n");
|
|
42
|
+
process.stdout.write(changelogs);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Pre-bake the "what's changed" snapshot for the commit skill.
|
|
2
|
+
//
|
|
3
|
+
// Prints:
|
|
4
|
+
// in_repo: yes|no
|
|
5
|
+
// ---status---
|
|
6
|
+
// <git status --short> (capped at 200 lines + footer)
|
|
7
|
+
// ---diffstat---
|
|
8
|
+
// <git diff HEAD --stat --ignore-submodules=all> | fallback for no-HEAD
|
|
9
|
+
//
|
|
10
|
+
// Full `git diff` is deliberately NOT included — large diffs would push the
|
|
11
|
+
// 50KB / 2000-line tail-truncation budget. The commit skill issues
|
|
12
|
+
// `git diff <file>` via the Bash tool when it needs per-file detail.
|
|
13
|
+
//
|
|
14
|
+
// Always exits 0 — non-repo cwd or no-HEAD initial repo collapses to safe
|
|
15
|
+
// fallback strings so the skill body never receives a `[Shell error: ...]`.
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
|
|
18
|
+
const LINE_CAP = 200;
|
|
19
|
+
|
|
20
|
+
const safe = (args, fb) => {
|
|
21
|
+
try {
|
|
22
|
+
return execFileSync("git", args, {
|
|
23
|
+
encoding: "utf-8",
|
|
24
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
25
|
+
}).trim();
|
|
26
|
+
} catch {
|
|
27
|
+
return fb;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Emit `raw` line-capped at LINE_CAP, with a truncation footer when over-limit.
|
|
32
|
+
// Empty input → `emptyLabel`. Both the status and diffstat sections are
|
|
33
|
+
// line-per-file in shape, so the "more files truncated" footer matches the
|
|
34
|
+
// convention in code-review/_helpers/review-range.mjs.
|
|
35
|
+
const emitCapped = (raw, emptyLabel) => {
|
|
36
|
+
const lines = raw.split("\n");
|
|
37
|
+
const trailingEmpty = lines.length > 0 && lines.at(-1) === "";
|
|
38
|
+
const real = trailingEmpty ? lines.slice(0, -1) : lines;
|
|
39
|
+
if (real.length === 0 || (real.length === 1 && real[0] === "")) {
|
|
40
|
+
process.stdout.write(`${emptyLabel}\n`);
|
|
41
|
+
} else if (real.length > LINE_CAP) {
|
|
42
|
+
process.stdout.write(real.slice(0, LINE_CAP).join("\n"));
|
|
43
|
+
process.stdout.write(`\n(... ${real.length - LINE_CAP} more files truncated ...)\n`);
|
|
44
|
+
} else {
|
|
45
|
+
process.stdout.write(`${real.join("\n")}\n`);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const root = safe(["rev-parse", "--show-toplevel"], "");
|
|
50
|
+
const inRepo = root ? "yes" : "no";
|
|
51
|
+
|
|
52
|
+
process.stdout.write(`in_repo: ${inRepo}\n`);
|
|
53
|
+
|
|
54
|
+
if (!root) {
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
process.stdout.write("---status---\n");
|
|
59
|
+
emitCapped(safe(["status", "--short"], ""), "(working tree clean)");
|
|
60
|
+
|
|
61
|
+
// `git diff HEAD --stat` errors on a fresh repo with no commits — substitute
|
|
62
|
+
// a fallback marker so the LLM knows the status block above already covers
|
|
63
|
+
// what would land in the initial commit.
|
|
64
|
+
const hasHead = safe(["rev-parse", "--verify", "--quiet", "HEAD"], "") !== "";
|
|
65
|
+
process.stdout.write("---diffstat---\n");
|
|
66
|
+
if (!hasHead) {
|
|
67
|
+
process.stdout.write("(no HEAD yet — initial commit; status above lists all files to be added)\n");
|
|
68
|
+
} else {
|
|
69
|
+
emitCapped(safe(["diff", "HEAD", "--stat", "--ignore-submodules=all"], ""), "(no changes against HEAD)");
|
|
70
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
const GIT_CHANGES_MJS = fileURLToPath(new URL("./git-changes.mjs", import.meta.url));
|
|
9
|
+
|
|
10
|
+
const run = (cwd: string) =>
|
|
11
|
+
execFileSync("node", [GIT_CHANGES_MJS], {
|
|
12
|
+
cwd,
|
|
13
|
+
encoding: "utf-8",
|
|
14
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const gitIn = (cwd: string, ...args: string[]) =>
|
|
18
|
+
execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] });
|
|
19
|
+
|
|
20
|
+
const initRepo = (cwd: string) => {
|
|
21
|
+
gitIn(cwd, "init", "--initial-branch=main", "-q");
|
|
22
|
+
gitIn(cwd, "config", "user.email", "test@example.com");
|
|
23
|
+
gitIn(cwd, "config", "user.name", "Test User");
|
|
24
|
+
gitIn(cwd, "config", "commit.gpgsign", "false");
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let dir: string;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
dir = mkdtempSync(join(tmpdir(), "rpiv-git-changes-"));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
rmSync(dir, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("git-changes.mjs", () => {
|
|
38
|
+
it("emits `in_repo: no` and exits when cwd is not a git repo", () => {
|
|
39
|
+
const out = run(dir);
|
|
40
|
+
expect(out).toContain("in_repo: no");
|
|
41
|
+
expect(out).not.toContain("---status---");
|
|
42
|
+
expect(out).not.toContain("---diffstat---");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("emits no-HEAD fallback for an initialized repo with zero commits", () => {
|
|
46
|
+
initRepo(dir);
|
|
47
|
+
writeFileSync(join(dir, "f.txt"), "hi");
|
|
48
|
+
gitIn(dir, "add", "f.txt");
|
|
49
|
+
const out = run(dir);
|
|
50
|
+
expect(out).toContain("in_repo: yes");
|
|
51
|
+
expect(out).toContain("---status---");
|
|
52
|
+
expect(out).toMatch(/^A\s+f\.txt$/m);
|
|
53
|
+
expect(out).toContain("---diffstat---");
|
|
54
|
+
expect(out).toContain("(no HEAD yet");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("emits `(working tree clean)` when status is empty against HEAD", () => {
|
|
58
|
+
initRepo(dir);
|
|
59
|
+
writeFileSync(join(dir, "f.txt"), "hi");
|
|
60
|
+
gitIn(dir, "add", "f.txt");
|
|
61
|
+
gitIn(dir, "commit", "-m", "init", "-q");
|
|
62
|
+
const out = run(dir);
|
|
63
|
+
expect(out).toContain("---status---\n(working tree clean)");
|
|
64
|
+
expect(out).toContain("---diffstat---");
|
|
65
|
+
expect(out).toContain("(no changes against HEAD)");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("emits diffstat lines when there are committed-tree differences", () => {
|
|
69
|
+
initRepo(dir);
|
|
70
|
+
writeFileSync(join(dir, "f.txt"), "hi");
|
|
71
|
+
gitIn(dir, "add", "f.txt");
|
|
72
|
+
gitIn(dir, "commit", "-m", "init", "-q");
|
|
73
|
+
writeFileSync(join(dir, "f.txt"), "hi\nworld\n");
|
|
74
|
+
const out = run(dir);
|
|
75
|
+
const diffstatBlock = out.slice(out.indexOf("---diffstat---"));
|
|
76
|
+
expect(diffstatBlock).toMatch(/f\.txt\s*\|\s*\d+/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("caps status at 200 files with `(... N more files truncated ...)` footer", () => {
|
|
80
|
+
initRepo(dir);
|
|
81
|
+
writeFileSync(join(dir, ".gitignore"), "");
|
|
82
|
+
gitIn(dir, "add", ".gitignore");
|
|
83
|
+
gitIn(dir, "commit", "-m", "init", "-q");
|
|
84
|
+
// Create 250 untracked files — `git status --short` lists each as `?? path`.
|
|
85
|
+
for (let i = 0; i < 250; i++) writeFileSync(join(dir, `f${i}.txt`), "");
|
|
86
|
+
const out = run(dir);
|
|
87
|
+
const statusBlock = out.slice(out.indexOf("---status---"), out.indexOf("---diffstat---"));
|
|
88
|
+
const statusLines = statusBlock.split("\n").filter((l) => l.startsWith("??"));
|
|
89
|
+
expect(statusLines).toHaveLength(200);
|
|
90
|
+
expect(statusBlock).toContain("(... 50 more files truncated ...)");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("caps diffstat at 200 lines with truncation footer (I4 — symmetry with status cap)", () => {
|
|
94
|
+
initRepo(dir);
|
|
95
|
+
// Seed 250 files committed at HEAD, then modify all of them so
|
|
96
|
+
// `git diff HEAD --stat` produces 250+ lines (one per file + summary).
|
|
97
|
+
writeFileSync(join(dir, ".gitignore"), "");
|
|
98
|
+
gitIn(dir, "add", ".gitignore");
|
|
99
|
+
for (let i = 0; i < 250; i++) writeFileSync(join(dir, `f${i}.txt`), "a\n");
|
|
100
|
+
gitIn(dir, "add", "-A");
|
|
101
|
+
gitIn(dir, "commit", "-m", "seed", "-q");
|
|
102
|
+
for (let i = 0; i < 250; i++) writeFileSync(join(dir, `f${i}.txt`), "b\nc\n");
|
|
103
|
+
const out = run(dir);
|
|
104
|
+
const diffstatBlock = out.slice(out.indexOf("---diffstat---"));
|
|
105
|
+
expect(diffstatBlock).toContain("more files truncated ...)");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Print six labeled lines summarising the current cwd's git state.
|
|
2
|
+
// Always exits 0 — every failure path collapses to a stable fallback so the
|
|
3
|
+
// skill body never receives a `[Shell error: ...]` substitution.
|
|
4
|
+
//
|
|
5
|
+
// branch: <name>|no-branch
|
|
6
|
+
// commit: <short-sha>|no-commit
|
|
7
|
+
// repo: <basename of toplevel>|unknown
|
|
8
|
+
// root: <absolute toplevel path>|(empty)
|
|
9
|
+
// in_repo: yes|no
|
|
10
|
+
// author: <git config user.name>|unknown
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { basename } from "node:path";
|
|
13
|
+
|
|
14
|
+
const safe = (args, fb) => {
|
|
15
|
+
try {
|
|
16
|
+
const out = execFileSync("git", args, {
|
|
17
|
+
encoding: "utf-8",
|
|
18
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
19
|
+
}).trim();
|
|
20
|
+
return out || fb;
|
|
21
|
+
} catch {
|
|
22
|
+
return fb;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const root = safe(["rev-parse", "--show-toplevel"], "");
|
|
27
|
+
process.stdout.write(
|
|
28
|
+
[
|
|
29
|
+
`branch: ${safe(["branch", "--show-current"], "no-branch")}`,
|
|
30
|
+
`commit: ${safe(["rev-parse", "--short", "HEAD"], "no-commit")}`,
|
|
31
|
+
`repo: ${root ? basename(root) : "unknown"}`,
|
|
32
|
+
`root: ${root}`,
|
|
33
|
+
`in_repo: ${root ? "yes" : "no"}`,
|
|
34
|
+
`author: ${safe(["config", "user.name"], "unknown")}`,
|
|
35
|
+
"",
|
|
36
|
+
].join("\n"),
|
|
37
|
+
);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
const GIT_CONTEXT_MJS = fileURLToPath(new URL("./git-context.mjs", import.meta.url));
|
|
9
|
+
|
|
10
|
+
// Spawn git-context.mjs with `cwd` overridden so the helper resolves the
|
|
11
|
+
// passed tmpdir (not the test runner's repo root).
|
|
12
|
+
const runIn = (cwd: string) =>
|
|
13
|
+
execFileSync("node", [GIT_CONTEXT_MJS], {
|
|
14
|
+
cwd,
|
|
15
|
+
encoding: "utf-8",
|
|
16
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const gitIn = (cwd: string, ...args: string[]) =>
|
|
20
|
+
execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] });
|
|
21
|
+
|
|
22
|
+
const initRepo = (cwd: string) => {
|
|
23
|
+
gitIn(cwd, "init", "--initial-branch=main", "-q");
|
|
24
|
+
gitIn(cwd, "config", "user.email", "test@example.com");
|
|
25
|
+
gitIn(cwd, "config", "user.name", "Test User");
|
|
26
|
+
gitIn(cwd, "config", "commit.gpgsign", "false");
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
let dir: string;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
dir = mkdtempSync(join(tmpdir(), "rpiv-git-context-"));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
rmSync(dir, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("git-context.mjs", () => {
|
|
40
|
+
it("emits six labeled lines for a fresh repo with one commit", () => {
|
|
41
|
+
initRepo(dir);
|
|
42
|
+
writeFileSync(join(dir, "f.txt"), "hello");
|
|
43
|
+
gitIn(dir, "add", "f.txt");
|
|
44
|
+
gitIn(dir, "commit", "-m", "init", "-q");
|
|
45
|
+
|
|
46
|
+
const out = runIn(dir);
|
|
47
|
+
const lines = out.split("\n");
|
|
48
|
+
// macOS `mkdtempSync` returns `/var/folders/...` but `git rev-parse
|
|
49
|
+
// --show-toplevel` normalises symlinks to `/private/var/folders/...`.
|
|
50
|
+
// Compare against realpath so the test is platform-stable.
|
|
51
|
+
const realDir = realpathSync(dir);
|
|
52
|
+
expect(lines).toContain("branch: main");
|
|
53
|
+
expect(lines.some((l) => /^commit: [0-9a-f]{7,}$/.test(l))).toBe(true);
|
|
54
|
+
expect(lines).toContain(`repo: ${basename(realDir)}`);
|
|
55
|
+
expect(lines).toContain(`root: ${realDir}`);
|
|
56
|
+
expect(lines).toContain("in_repo: yes");
|
|
57
|
+
expect(lines).toContain("author: Test User");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("falls back gracefully when cwd is not a git repo", () => {
|
|
61
|
+
// No `git init` — `git rev-parse --show-toplevel` fails.
|
|
62
|
+
const out = runIn(dir);
|
|
63
|
+
const lines = out.split("\n");
|
|
64
|
+
expect(lines).toContain("branch: no-branch");
|
|
65
|
+
expect(lines).toContain("commit: no-commit");
|
|
66
|
+
expect(lines).toContain("repo: unknown");
|
|
67
|
+
expect(lines).toContain("root: ");
|
|
68
|
+
expect(lines).toContain("in_repo: no");
|
|
69
|
+
// `author:` may be "unknown" or pulled from global git config — the
|
|
70
|
+
// helper falls back to `unknown` when `git config user.name` errors
|
|
71
|
+
// (which it doesn't when global config is present). Either is OK; the
|
|
72
|
+
// contract is just that the line is emitted.
|
|
73
|
+
expect(lines.some((l) => l.startsWith("author: "))).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("exits 0 in both repo and non-repo cwds (never surfaces [Shell error])", () => {
|
|
77
|
+
expect(() => runIn(dir)).not.toThrow();
|
|
78
|
+
initRepo(dir);
|
|
79
|
+
expect(() => runIn(dir)).not.toThrow();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("emits a trailing newline so chained helpers parse on their own line", () => {
|
|
83
|
+
initRepo(dir);
|
|
84
|
+
writeFileSync(join(dir, "f.txt"), "hello");
|
|
85
|
+
gitIn(dir, "add", "f.txt");
|
|
86
|
+
gitIn(dir, "commit", "-m", "init", "-q");
|
|
87
|
+
const out = runIn(dir);
|
|
88
|
+
expect(out.endsWith("\n")).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// List N most recently modified files in a directory, newest first, one per line.
|
|
2
|
+
// Usage: node list-recent.mjs <dir> [count=10]
|
|
3
|
+
// <dir> — relative paths are resolved against the git root (or cwd if not in a repo).
|
|
4
|
+
// <count> — max entries to print (default 10).
|
|
5
|
+
// Empty stdout when the directory does not exist or contains no files.
|
|
6
|
+
// Always exits 0 — directory-missing is a recoverable state, not an error.
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
9
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
10
|
+
|
|
11
|
+
const [rawDir = ".", nStr = "10"] = process.argv.slice(2);
|
|
12
|
+
const n = Math.max(1, Number.parseInt(nStr, 10) || 10);
|
|
13
|
+
|
|
14
|
+
const gitRoot = (() => {
|
|
15
|
+
try {
|
|
16
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
17
|
+
encoding: "utf-8",
|
|
18
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
19
|
+
}).trim();
|
|
20
|
+
} catch {
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
})();
|
|
24
|
+
|
|
25
|
+
const dir = isAbsolute(rawDir) ? rawDir : resolve(gitRoot || process.cwd(), rawDir);
|
|
26
|
+
|
|
27
|
+
if (!existsSync(dir)) process.exit(0);
|
|
28
|
+
|
|
29
|
+
const items = readdirSync(dir, { withFileTypes: true })
|
|
30
|
+
.filter((d) => d.isFile())
|
|
31
|
+
.map((d) => ({ name: d.name, mtime: statSync(join(dir, d.name)).mtimeMs }))
|
|
32
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
33
|
+
.slice(0, n);
|
|
34
|
+
|
|
35
|
+
for (const it of items) console.log(it.name);
|