@juicesharp/rpiv-pi 1.9.2 → 1.10.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.
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juicesharp/rpiv-pi",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.1",
|
|
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);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, rmSync, utimesSync, 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 LIST_RECENT_MJS = fileURLToPath(new URL("./list-recent.mjs", import.meta.url));
|
|
9
|
+
|
|
10
|
+
const run = (cwd: string, ...argv: string[]) =>
|
|
11
|
+
execFileSync("node", [LIST_RECENT_MJS, ...argv], {
|
|
12
|
+
cwd,
|
|
13
|
+
encoding: "utf-8",
|
|
14
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const touch = (path: string, mtimeSec: number) => {
|
|
18
|
+
writeFileSync(path, "");
|
|
19
|
+
utimesSync(path, mtimeSec, mtimeSec);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
let dir: string;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
dir = mkdtempSync(join(tmpdir(), "rpiv-list-recent-"));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
rmSync(dir, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("list-recent.mjs", () => {
|
|
33
|
+
it("returns files sorted by mtime descending (newest first)", () => {
|
|
34
|
+
touch(join(dir, "old.md"), 1_000_000_000);
|
|
35
|
+
touch(join(dir, "mid.md"), 1_000_000_500);
|
|
36
|
+
touch(join(dir, "new.md"), 1_000_001_000);
|
|
37
|
+
const out = run(dir, dir, "10");
|
|
38
|
+
expect(out.trim().split("\n")).toEqual(["new.md", "mid.md", "old.md"]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("caps output at N entries", () => {
|
|
42
|
+
for (let i = 0; i < 5; i++) touch(join(dir, `f${i}.md`), 1_000_000_000 + i);
|
|
43
|
+
const out = run(dir, dir, "3");
|
|
44
|
+
expect(out.trim().split("\n")).toHaveLength(3);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("emits nothing for a missing directory (exit 0)", () => {
|
|
48
|
+
const missing = join(dir, "does-not-exist");
|
|
49
|
+
const out = run(dir, missing, "10");
|
|
50
|
+
expect(out).toBe("");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("emits nothing for an empty directory (exit 0)", () => {
|
|
54
|
+
const out = run(dir, dir, "10");
|
|
55
|
+
expect(out).toBe("");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("skips subdirectories (files only)", () => {
|
|
59
|
+
touch(join(dir, "real.md"), 1_000_000_000);
|
|
60
|
+
execFileSync("mkdir", [join(dir, "sub")]);
|
|
61
|
+
const out = run(dir, dir, "10");
|
|
62
|
+
expect(out.trim().split("\n")).toEqual(["real.md"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("defaults count to 10 when not provided", () => {
|
|
66
|
+
for (let i = 0; i < 15; i++) touch(join(dir, `f${i}.md`), 1_000_000_000 + i);
|
|
67
|
+
const out = run(dir, dir);
|
|
68
|
+
expect(out.trim().split("\n")).toHaveLength(10);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("clamps count to ≥1 when given 0 or negative", () => {
|
|
72
|
+
touch(join(dir, "only.md"), 1_000_000_000);
|
|
73
|
+
const out = run(dir, dir, "0");
|
|
74
|
+
// Math.max(1, ...) — 0 becomes 1, so we get the single newest file.
|
|
75
|
+
expect(out.trim().split("\n")).toEqual(["only.md"]);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Print one tab-separated line: <iso>\t<slug>
|
|
2
|
+
// <iso> — ISO 8601 with local timezone offset, e.g. 2026-05-19T11:23:04-0400
|
|
3
|
+
// <slug> — first 19 chars of <iso> with `T`→`_` and `:`→`-`, e.g. 2026-05-19_11-23-04
|
|
4
|
+
// No trailing newline so the value can be inlined cleanly.
|
|
5
|
+
// No locale dependence: timestamp built from Date components, not toLocaleString().
|
|
6
|
+
const d = new Date();
|
|
7
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
8
|
+
const tzMin = -d.getTimezoneOffset();
|
|
9
|
+
const tzSign = tzMin >= 0 ? "+" : "-";
|
|
10
|
+
const tzAbs = Math.abs(tzMin);
|
|
11
|
+
const offset = `${tzSign}${pad(Math.floor(tzAbs / 60))}${pad(tzAbs % 60)}`;
|
|
12
|
+
const iso = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${offset}`;
|
|
13
|
+
const slug = iso.slice(0, 19).replaceAll(":", "-").replace("T", "_");
|
|
14
|
+
process.stdout.write(`${iso}\t${slug}`);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
const NOW_MJS = fileURLToPath(new URL("./now.mjs", import.meta.url));
|
|
6
|
+
|
|
7
|
+
const runNow = () => execFileSync("node", [NOW_MJS], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
8
|
+
|
|
9
|
+
describe("now.mjs", () => {
|
|
10
|
+
it("emits exactly one tab-separated line: <iso>\\t<slug>", () => {
|
|
11
|
+
const out = runNow();
|
|
12
|
+
// Single line, no leading/trailing newline — every consumer in the
|
|
13
|
+
// skill set concatenates `echo` or `git-context.mjs` output directly
|
|
14
|
+
// after this. A trailing \n would still parse, but a trailing space or
|
|
15
|
+
// extra tab would silently corrupt the slug-based filename.
|
|
16
|
+
expect(out).not.toContain("\n");
|
|
17
|
+
const [iso, slug, ...rest] = out.split("\t");
|
|
18
|
+
expect(rest).toHaveLength(0);
|
|
19
|
+
expect(iso).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{4}$/);
|
|
20
|
+
expect(slug).toMatch(/^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("slug is derived from iso: T→_, :→-, first 19 chars", () => {
|
|
24
|
+
const out = runNow();
|
|
25
|
+
const [iso, slug] = out.split("\t");
|
|
26
|
+
expect(slug).toBe(iso.slice(0, 19).replaceAll(":", "-").replace("T", "_"));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("no trailing newline (contract that revise/SKILL.md's `echo` separator depends on)", () => {
|
|
30
|
+
const out = runNow();
|
|
31
|
+
// If this assertion ever flips, every Metadata block that combines
|
|
32
|
+
// now.mjs with a second helper via `echo` will have a blank line
|
|
33
|
+
// inserted between them — harmless — but the explicit no-`echo` peer
|
|
34
|
+
// (none currently exist after the I1 fix) would now parse cleanly.
|
|
35
|
+
// Keep this contract pinned so revise/SKILL.md's `echo` line is
|
|
36
|
+
// load-bearing as documented.
|
|
37
|
+
expect(out.endsWith("\n")).toBe(false);
|
|
38
|
+
expect(out.endsWith("\t")).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("iso timezone offset is exactly +HHMM or -HHMM (no colon, no Z)", () => {
|
|
42
|
+
const out = runNow();
|
|
43
|
+
const [iso] = out.split("\t");
|
|
44
|
+
expect(iso).toMatch(/[+-]\d{4}$/);
|
|
45
|
+
expect(iso.endsWith("Z")).toBe(false);
|
|
46
|
+
expect(iso).not.toMatch(/[+-]\d{2}:\d{2}$/);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
name: blueprint
|
|
3
3
|
description: Plan complex features by decomposing them into vertical slices (one slice equals one phase) with developer micro-checkpoints between phases, producing an implement-ready phased plan in .rpiv/artifacts/plans/. Use for complex multi-component features touching 6+ files across multiple layers when iterative review between slices is valuable. Requires a research artifact or a solutions artifact (from explore). Prefer blueprint over plan when mid-flight micro-checkpoints matter, and prefer plan when a straightforward phased breakdown is enough.
|
|
4
4
|
argument-hint: "[research artifact path]"
|
|
5
|
+
shell-timeout: 10
|
|
5
6
|
---
|
|
6
7
|
|
|
7
8
|
# Blueprint
|
|
@@ -12,6 +13,19 @@ You are tasked with planning how code will be shaped for a feature or change AND
|
|
|
12
13
|
|
|
13
14
|
`$ARGUMENTS` — path to a research artifact (`.rpiv/artifacts/research/*.md`) or a solutions artifact (`.rpiv/artifacts/solutions/*.md`).
|
|
14
15
|
|
|
16
|
+
## Metadata
|
|
17
|
+
|
|
18
|
+
```!
|
|
19
|
+
node "${SKILL_DIR}/../_shared/now.mjs"
|
|
20
|
+
echo
|
|
21
|
+
node "${SKILL_DIR}/../_shared/git-context.mjs"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- `now.mjs` (line 1) — `<iso>\t<slug>` tab-separated.
|
|
25
|
+
- `git-context.mjs` (lines below) — `branch:` / `commit:` / `repo:` / `root:` / `in_repo:` / `author:`.
|
|
26
|
+
|
|
27
|
+
Copy values verbatim — do not reformat the timezone offset.
|
|
28
|
+
|
|
15
29
|
## Flow
|
|
16
30
|
|
|
17
31
|
1. Input → 2. Research → 3. Dimension sweep → 4. Self-critique → 5. Checkpoint → 6. Decompose → 7. Generate slices → 8. Integration verify → 9. Finalize → 10. Independent review → 11. Triage & iterate → 12. Follow-ups
|
|
@@ -200,8 +214,8 @@ After the design summary is confirmed, decompose the feature into vertical slice
|
|
|
200
214
|
3. **Confirm decomposition** using the `ask_user_question` tool. Question: "{N} slices for {feature}. Slice 1: {name} (foundation). Slices 2-N: {brief}. Approve decomposition?". Header: "Slices". Options: "Approve (Recommended)" (Proceed to slice-by-slice code generation); "Adjust slices" (Reorder, merge, or split slices before generating); "Change scope" (Add or remove files from the decomposition).
|
|
201
215
|
|
|
202
216
|
4. **Create skeleton artifact** — immediately after decomposition is approved:
|
|
203
|
-
- Determine metadata: filename `.rpiv/artifacts/plans
|
|
204
|
-
- Timestamp:
|
|
217
|
+
- Determine metadata from the Metadata block above: filename `.rpiv/artifacts/plans/<slug>_<topic>.md` (use `<slug>` from `now.mjs` line 1); `repository:` from `repo:`; `branch:` / `commit:` from matching labels; `author:` ← matching label (fallback: `unknown`).
|
|
218
|
+
- Timestamp: use `<iso>` from `now.mjs` line 1 for `date:` and `last_updated:` (copy the offset verbatim).
|
|
205
219
|
- Write skeleton using the Write tool with `status: in-progress` in frontmatter
|
|
206
220
|
- **Include all prose sections filled** from Steps 1-5: Overview, Requirements, Current State Analysis, Desired End State, What We're NOT Doing, Decisions, Ordering Constraints, Verification Notes, Performance Considerations, Migration Notes, Pattern References, Developer Context, References
|
|
207
221
|
- **Phase sections**: one `## Phase N: {slice name}` heading per slice from the decomposition (in slice order), each with `### Overview`, `### Changes Required:` (one `#### N. path/to/file.ext` subsection per file with empty code fence + NEW/MODIFY label), and `### Success Criteria:` (empty Automated + Manual subsection headers — filled in Step 7.4 on approval)
|
|
@@ -3,6 +3,7 @@ name: changelog
|
|
|
3
3
|
description: Regenerate the [Unreleased] section of every affected CHANGELOG.md in Keep a Changelog style. Reads commits since the last release tag plus any uncommitted or staged changes, classifies them by Conventional Commit prefix, and rewrites each [Unreleased] block. Works in single-package repos and monorepos (one CHANGELOG.md per package). Use when preparing a release or drafting changelog entries. Idempotent — safe to re-run as work lands.
|
|
4
4
|
argument-hint: [--since <ref>]
|
|
5
5
|
allowed-tools: Bash(git *), Read, Edit
|
|
6
|
+
shell-timeout: 10
|
|
6
7
|
---
|
|
7
8
|
|
|
8
9
|
# Generate CHANGELOG entries
|
|
@@ -11,7 +12,17 @@ You are tasked with regenerating the `## [Unreleased]` section of every affected
|
|
|
11
12
|
|
|
12
13
|
## Input
|
|
13
14
|
|
|
14
|
-
`$ARGUMENTS` — optional `--since <ref>` flag. Empty/literal → range starts at
|
|
15
|
+
`$ARGUMENTS` — optional `--since <ref>` flag. Empty/literal → range starts at `last_tag:` from the Metadata block.
|
|
16
|
+
|
|
17
|
+
## Metadata
|
|
18
|
+
|
|
19
|
+
```!
|
|
20
|
+
node "${SKILL_DIR}/../_shared/changelog-bootstrap.mjs"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- `in_repo:` — `yes` or `no`. Used by Step 1.1.
|
|
24
|
+
- `last_tag:` — last release tag, or `(no tags)`. Used by Step 1.3 and Step 2.1 when no `--since` is supplied.
|
|
25
|
+
- `---changelogs---` block — paths of every tracked `CHANGELOG.md` (one per line, empty if none). Used by Step 1.2.
|
|
15
26
|
|
|
16
27
|
## Workflow
|
|
17
28
|
|
|
@@ -24,13 +35,13 @@ You are tasked with regenerating the `## [Unreleased]` section of every affected
|
|
|
24
35
|
|
|
25
36
|
## Step 1: Bail-out checks
|
|
26
37
|
|
|
27
|
-
1.
|
|
28
|
-
2.
|
|
29
|
-
3.
|
|
38
|
+
1. If `in_repo:` is `no`, tell the user "This directory is not a git repository." and stop.
|
|
39
|
+
2. If the `---changelogs---` block is empty, tell the user "No `CHANGELOG.md` found in the repository — create one (root or per-package) before running this skill." and stop.
|
|
40
|
+
3. If `last_tag:` is `(no tags)` AND `$ARGUMENTS` lacks `--since <ref>`, ask the user to supply `--since <ref>` and stop until they do.
|
|
30
41
|
|
|
31
42
|
## Step 2: Determine the change range
|
|
32
43
|
|
|
33
|
-
1. Parse the input for a `--since <ref>` flag. If absent,
|
|
44
|
+
1. Parse the input for a `--since <ref>` flag. If absent, use `last_tag:` from the Metadata block as `SINCE`.
|
|
34
45
|
2. The range is `$SINCE..HEAD` for committed changes, plus the current uncommitted+staged working tree.
|
|
35
46
|
|
|
36
47
|
## Step 3: Determine each CHANGELOG's scope, then collect commits + uncommitted hunks
|
|
@@ -75,7 +86,7 @@ Skip any commit whose subject matches one of these — they are release pipeline
|
|
|
75
86
|
|
|
76
87
|
Flag a commit as breaking if any of these are true:
|
|
77
88
|
|
|
78
|
-
- The type
|
|
89
|
+
- The type carries an exclamation suffix (`feat!:`, `refactor!:`, etc.)
|
|
79
90
|
- The commit body contains a `BREAKING CHANGE:` footer
|
|
80
91
|
- The diff removes or renames an exported symbol, removes a CLI flag, or removes a public file
|
|
81
92
|
|