@jaybeeuu/agent-cortex 2.7.0 → 2.8.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +239 -0
  2. package/README.md +61 -5
  3. package/agents/README.md +2 -2
  4. package/agents/ralph/agent.md +17 -16
  5. package/agents/ralph/claude/wake.md +4 -4
  6. package/agents/ralph/pi/wake.md +15 -20
  7. package/bin/agent-cortex.mjs +20 -1
  8. package/extensions/post-merge-watch/README.md +133 -0
  9. package/extensions/post-merge-watch/index.test.ts +247 -0
  10. package/extensions/post-merge-watch/index.ts +123 -0
  11. package/extensions/post-merge-watch/package.json +16 -0
  12. package/extensions/post-merge-watch/watch.test.ts +469 -0
  13. package/extensions/post-merge-watch/watch.ts +431 -0
  14. package/extensions/session-clear/README.md +74 -0
  15. package/extensions/session-clear/index.test.ts +524 -0
  16. package/extensions/session-clear/index.ts +159 -0
  17. package/extensions/session-clear/package.json +16 -0
  18. package/lib/cli.mjs +42 -1
  19. package/lib/self-update.mjs +553 -0
  20. package/package.json +1 -1
  21. package/skills/engineering/implement-task/SKILL.md +124 -0
  22. package/skills/engineering/tdd/SKILL.md +2 -0
  23. package/skills/planning/create-task/REFERENCE.md +15 -6
  24. package/skills/planning/create-task/SKILL.md +1 -1
  25. package/skills/planning/create-task/pipeline.json +8 -0
  26. package/skills/planning/create-task/scripts/create-chores.mjs +27 -18
  27. package/skills/planning/create-task/templates/code.md +8 -3
  28. package/skills/planning/create-task/templates/document.md +8 -3
  29. package/skills/planning/create-task/templates/fix.md +8 -3
  30. package/skills/planning/create-task/templates/housekeeping.md +8 -4
  31. package/skills/planning/create-task/templates/post-merge.md +67 -0
  32. package/skills/planning/create-task/templates/review.md +8 -3
  33. package/skills/planning/create-task/templates/verify.md +8 -3
  34. package/skills/productivity/bd-tool/SKILL.md +55 -0
  35. package/skills/review/review-defects/EXAMPLES.md +122 -0
  36. package/skills/review/review-defects/SKILL.md +91 -0
  37. package/skills/style/style-tests/EXAMPLES.md +30 -0
  38. package/skills/style/style-tests/SKILL.md +10 -6
  39. package/skills/workflow/plan/SKILL.md +14 -3
  40. package/skills/workflow/ralph/REFERENCE.md +124 -27
  41. package/skills/workflow/ralph/SKILL.md +24 -15
  42. package/skills/workflow/ralph/scripts/post-merge-check.mjs +651 -0
  43. package/skills/workflow/run-pipeline-stage/SKILL.md +37 -22
  44. package/skills/workflow/run-pipeline-stage/playbooks/code.md +14 -8
  45. package/skills/workflow/run-pipeline-stage/playbooks/document.md +2 -2
  46. package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -2
  47. package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +1 -1
  48. package/skills/workflow/run-pipeline-stage/playbooks/post-merge.md +54 -0
  49. package/skills/workflow/run-pipeline-stage/playbooks/review.md +34 -3
  50. package/skills/workflow/run-pipeline-stage/playbooks/verify.md +2 -2
  51. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +55 -11
  52. package/skills/workflow/run-pipeline-stage/scripts/recover-brief.mjs +805 -0
  53. package/skills/workflow/run-pipeline-stage/scripts/trail-entry.mjs +702 -0
  54. package/skills/workflow/using-agent-skills/SKILL.md +15 -7
  55. package/token-map.README.md +27 -0
  56. package/token-map.json +10 -4
@@ -0,0 +1,431 @@
1
+ /**
2
+ * The watch: poll the post-merge check script until it reports a verdict, then
3
+ * forward that verdict to the session.
4
+ *
5
+ * The check script (`skills/workflow/ralph/scripts/post-merge-check.mjs`) owns
6
+ * the verdict: it exits `0` passed, `1` failed, `2` inconclusive. This module
7
+ * owns only three things — when to call the script again, when to stop, and what
8
+ * to say when it stops. It reads the run URL out of the script's JSON report; it
9
+ * never decides whether a run passed.
10
+ *
11
+ * Every collaborator is injected, so a test drives the whole lifecycle with a
12
+ * scheduler it steps by hand, a check that returns scripted exit codes, and a
13
+ * temp state directory. Nothing here imports pi, so the module is exercised
14
+ * without a session.
15
+ */
16
+
17
+ import { createHash } from "node:crypto";
18
+ import { mkdir, rm, writeFile } from "node:fs/promises";
19
+ import { basename, join } from "node:path";
20
+
21
+ /** Provenance of the wake message; also the custom-message type. */
22
+ export const WAKE_CUSTOM_TYPE = "post-merge-watch";
23
+
24
+ /** Milliseconds between two checks while waiting for the run to settle. */
25
+ export const DEFAULT_INTERVAL_MS = 15_000;
26
+
27
+ /** Checks before the watch gives up and reports inconclusive: 40 × 15s ≈ 10 min. */
28
+ export const DEFAULT_MAX_ATTEMPTS = 40;
29
+
30
+ /**
31
+ * Arguments every invocation passes to the check. `--wait 0` makes one
32
+ * script-level check return instead of waiting inside the script: the poll
33
+ * interval belongs to this module, so a stopped watch never blocks on a child.
34
+ */
35
+ export const CHECK_ARGS = ["--wait", "0"] as const;
36
+
37
+ /** What the check script reports, by exit code. */
38
+ export type Verdict = "passed" | "failed" | "inconclusive";
39
+
40
+ /** The state file layout. Versioned so a later change can migrate instead of guess. */
41
+ export const STATE_VERSION = 1;
42
+
43
+ /** One run of the check script. */
44
+ export interface CheckRun {
45
+ /** Exit code: 0 passed, 1 failed, 2 inconclusive. */
46
+ code: number;
47
+ stdout: string;
48
+ stderr: string;
49
+ /** The run was terminated by a timeout, so its report may be incomplete. */
50
+ killed: boolean;
51
+ }
52
+
53
+ /** One call of the check script. */
54
+ export interface CheckInvocation {
55
+ script: string;
56
+ cwd: string;
57
+ args: readonly string[];
58
+ }
59
+
60
+ export type CheckRunner = (invocation: CheckInvocation) => Promise<CheckRun>;
61
+
62
+ /** The timer this module needs, injectable so a test owns the clock. */
63
+ export interface Scheduler {
64
+ now(): Date;
65
+ /**
66
+ * Run `callback` once after `delayMs`, and return a function that cancels it.
67
+ * The callback may return a promise: the real scheduler ignores it, and a test
68
+ * scheduler awaits it to step one poll deterministically.
69
+ */
70
+ schedule(callback: () => void | Promise<void>, delayMs: number): () => void;
71
+ }
72
+
73
+ /** The verdict as the script reported it, plus what the report says about it. */
74
+ export interface VerdictReport {
75
+ verdict: Verdict;
76
+ exitCode: number;
77
+ /** The script's `reason` field, or why the report could not be read. */
78
+ reason: string;
79
+ runUrl: string | undefined;
80
+ }
81
+
82
+ /** The data the follow-up message carries beyond its text. */
83
+ export interface WakeDetails {
84
+ bead: string;
85
+ verdict: Verdict;
86
+ runUrl: string | null;
87
+ exitCode: number;
88
+ reason: string;
89
+ attempts: number;
90
+ project: string;
91
+ }
92
+
93
+ /** The message `pi.sendMessage` receives. */
94
+ export interface WakeMessage {
95
+ customType: string;
96
+ content: string;
97
+ display: boolean;
98
+ details: WakeDetails;
99
+ }
100
+
101
+ /** One watch, as it is recorded on disk. */
102
+ export interface WatchState {
103
+ version: typeof STATE_VERSION;
104
+ bead: string;
105
+ project: string;
106
+ script: string;
107
+ pid: number;
108
+ startedAt: string;
109
+ updatedAt: string;
110
+ attempts: number;
111
+ maxAttempts: number;
112
+ lastExitCode: number | null;
113
+ lastReason: string | null;
114
+ }
115
+
116
+ /** Everything a watch needs, and the collaborators a test replaces. */
117
+ export interface PostMergeWatchOptions {
118
+ /** The bead the wake names. */
119
+ bead: string;
120
+ /** The project the watch belongs to; also the directory the check runs in. */
121
+ project: string;
122
+ /** Absolute path of the post-merge check script. */
123
+ script: string;
124
+ /** Directory the state file is written to. */
125
+ stateDir: string;
126
+ runCheck: CheckRunner;
127
+ deliver: (message: WakeMessage) => void;
128
+ scheduler?: Scheduler;
129
+ intervalMs?: number;
130
+ maxAttempts?: number;
131
+ /** Report a problem that must not stop the watch, such as an unreadable report. */
132
+ warn?: (message: string) => void;
133
+ }
134
+
135
+ /** The real timer: `setTimeout`, cancelled by `clearTimeout`. */
136
+ export const systemScheduler: Scheduler = {
137
+ now: () => new Date(),
138
+ schedule: (callback, delayMs) => {
139
+ const timer = setTimeout(() => void callback(), delayMs);
140
+ // Never hold the host process open for a watch: pi's own REPL keeps it alive.
141
+ timer.unref?.();
142
+ return () => clearTimeout(timer);
143
+ },
144
+ };
145
+
146
+ /**
147
+ * The verdict is the exit code, and nothing else. Any code other than `0` or `1`
148
+ * — including a timeout kill — is inconclusive, matching the check's contract.
149
+ */
150
+ export function verdictOf(exitCode: number): Verdict {
151
+ if (exitCode === 0) return "passed";
152
+ if (exitCode === 1) return "failed";
153
+ return "inconclusive";
154
+ }
155
+
156
+ /** The JSON report the check prints, read as untrusted data. */
157
+ interface ParsedReport {
158
+ reason: string | undefined;
159
+ runUrl: string | undefined;
160
+ }
161
+
162
+ function parseReport(stdout: string): ParsedReport {
163
+ const text = stdout.trim();
164
+ if (text === "") return { reason: undefined, runUrl: undefined };
165
+
166
+ let parsed: unknown;
167
+ try {
168
+ parsed = JSON.parse(text);
169
+ } catch {
170
+ return { reason: undefined, runUrl: undefined };
171
+ }
172
+
173
+ if (typeof parsed !== "object" || parsed === null) return { reason: undefined, runUrl: undefined };
174
+ const record = parsed as { reason?: unknown; runs?: unknown };
175
+
176
+ const reason = typeof record.reason === "string" ? record.reason : undefined;
177
+ const runs = Array.isArray(record.runs) ? record.runs : [];
178
+ const first = runs.find(
179
+ (run): run is { url: string } =>
180
+ typeof run === "object" && run !== null && typeof (run as { url?: unknown }).url === "string",
181
+ );
182
+
183
+ return { reason, runUrl: first?.url };
184
+ }
185
+
186
+ /**
187
+ * Read one check run as a verdict. The exit code decides; the JSON report only
188
+ * contributes the run URL and a human-readable reason.
189
+ */
190
+ export function readVerdict(run: CheckRun): VerdictReport {
191
+ const report = parseReport(run.stdout);
192
+ const verdict = verdictOf(run.code);
193
+
194
+ const reason =
195
+ report.reason ??
196
+ (run.killed ? "check-timed-out" : run.stdout.trim() === "" ? "no-report" : "unreadable-report");
197
+
198
+ return { verdict, exitCode: run.code, reason, runUrl: report.runUrl };
199
+ }
200
+
201
+ function safeName(value: string): string {
202
+ return value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
203
+ }
204
+
205
+ /**
206
+ * Where one project's watch of one bead is recorded. The name carries the bead
207
+ * and the project directory for a human reading the directory, and a digest of
208
+ * the full project path so two checkouts with the same directory name — or two
209
+ * projects watching the same bead — never share a file.
210
+ */
211
+ export function stateFileFor(stateDir: string, project: string, bead: string): string {
212
+ const digest = createHash("sha256").update(`${project}\u0000${bead}`).digest("hex").slice(0, 12);
213
+ const label = [safeName(basename(project)) || "project", safeName(bead) || "bead"].join("-");
214
+ return join(stateDir, `${label}-${digest}.json`);
215
+ }
216
+
217
+ /**
218
+ * Build the wake message: the bead, the verdict, and the run URL, in that order.
219
+ * The text is written for the agent that receives it, so it names the run and
220
+ * says who made the judgement — the extension did not.
221
+ */
222
+ export function buildWake(details: WakeDetails): WakeMessage {
223
+ const run = details.runUrl ?? "none reported";
224
+ const content = [
225
+ `The post-merge check settled for ${details.bead}: ${details.verdict}.`,
226
+ "",
227
+ `- Bead: ${details.bead}`,
228
+ `- Verdict: ${details.verdict} (check exit code ${details.exitCode}, reason ${details.reason})`,
229
+ `- Run: ${run}`,
230
+ `- Project: ${details.project}`,
231
+ `- Checks run: ${details.attempts}`,
232
+ "",
233
+ "The check's exit code is the verdict; this message only forwards it. Read the run before deciding the next step.",
234
+ ].join("\n");
235
+
236
+ return { customType: WAKE_CUSTOM_TYPE, content, display: true, details };
237
+ }
238
+
239
+ /**
240
+ * One watch of one bead in one project.
241
+ *
242
+ * A watch is active from `start()` until it settles — a verdict, the attempt cap,
243
+ * or `stop()` — and it deletes its state file as it settles, so the state
244
+ * directory holds only watches that are still in flight.
245
+ */
246
+ export class PostMergeWatch {
247
+ readonly #bead: string;
248
+ readonly #project: string;
249
+ readonly #script: string;
250
+ readonly #stateDir: string;
251
+ readonly #runCheck: CheckRunner;
252
+ readonly #deliver: (message: WakeMessage) => void;
253
+ readonly #scheduler: Scheduler;
254
+ readonly #intervalMs: number;
255
+ readonly #maxAttempts: number;
256
+ readonly #warn: (message: string) => void;
257
+
258
+ #attempts = 0;
259
+ #lastExitCode: number | null = null;
260
+ #lastReason: string | null = null;
261
+ #cancel: (() => void) | undefined;
262
+ #startedAt: Date | undefined;
263
+ #stopped = false;
264
+
265
+ constructor(options: PostMergeWatchOptions) {
266
+ this.#bead = options.bead;
267
+ this.#project = options.project;
268
+ this.#script = options.script;
269
+ this.#stateDir = options.stateDir;
270
+ this.#runCheck = options.runCheck;
271
+ this.#deliver = options.deliver;
272
+ this.#scheduler = options.scheduler ?? systemScheduler;
273
+ this.#intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
274
+ this.#maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
275
+ this.#warn = options.warn ?? (() => {});
276
+ }
277
+
278
+ /** Whether the watch is still polling. */
279
+ get active(): boolean {
280
+ return !this.#stopped;
281
+ }
282
+
283
+ /** Checks run so far. */
284
+ get attempts(): number {
285
+ return this.#attempts;
286
+ }
287
+
288
+ /** The state file this watch writes while it is active. */
289
+ get stateFile(): string {
290
+ return stateFileFor(this.#stateDir, this.#project, this.#bead);
291
+ }
292
+
293
+ /** Record the watch, then run the first check. */
294
+ async start(): Promise<void> {
295
+ if (this.#stopped || this.#startedAt !== undefined) return;
296
+ this.#startedAt = this.#scheduler.now();
297
+ await this.#writeState();
298
+ await this.#check();
299
+ }
300
+
301
+ /**
302
+ * End the watch without a wake: cancel the pending timer, ignore a check that
303
+ * is still in flight, and remove the state file. Called when the session shuts
304
+ * down, so an abandoned watch leaves nothing behind.
305
+ */
306
+ async stop(): Promise<void> {
307
+ if (this.#stopped) return;
308
+ this.#stopped = true;
309
+ this.#cancelTimer();
310
+ await this.#removeState();
311
+ }
312
+
313
+ #cancelTimer(): void {
314
+ this.#cancel?.();
315
+ this.#cancel = undefined;
316
+ }
317
+
318
+ /** One poll. Never rejects: a throw would leave an unhandled rejection in the host. */
319
+ async #check(): Promise<void> {
320
+ try {
321
+ await this.#poll();
322
+ } catch (error) {
323
+ const detail = error instanceof Error ? error.message : String(error);
324
+ this.#warn(`post-merge-watch: the poll failed: ${detail}`);
325
+ }
326
+ }
327
+
328
+ async #poll(): Promise<void> {
329
+ if (this.#stopped) return;
330
+ this.#attempts += 1;
331
+
332
+ const run = await this.#runCheckSafely();
333
+ // `stop()` may have landed while the check ran: the session is gone, so the
334
+ // verdict has nobody to wake.
335
+ if (this.#stopped) return;
336
+
337
+ const verdict = readVerdict(run);
338
+ this.#lastExitCode = verdict.exitCode;
339
+ this.#lastReason = verdict.reason;
340
+
341
+ // A verdict, or the last check the watch is allowed: either way, one wake.
342
+ if (verdict.verdict !== "inconclusive" || this.#attempts >= this.#maxAttempts) {
343
+ await this.#settle(verdict);
344
+ return;
345
+ }
346
+
347
+ await this.#writeState();
348
+ // `stop()` may have landed while the state was written: the session is gone,
349
+ // so schedule nothing more.
350
+ if (this.#stopped) return;
351
+ this.#cancel = this.#scheduler.schedule(() => {
352
+ this.#cancel = undefined;
353
+ return this.#check();
354
+ }, this.#intervalMs);
355
+ }
356
+
357
+ /** A check that throws is inconclusive, never a pass and never a crash. */
358
+ async #runCheckSafely(): Promise<CheckRun> {
359
+ try {
360
+ return await this.#runCheck({ script: this.#script, cwd: this.#project, args: CHECK_ARGS });
361
+ } catch (error) {
362
+ const message = error instanceof Error ? error.message : String(error);
363
+ this.#warn(`post-merge-watch: the check script could not run: ${message}`);
364
+ return { code: 2, stdout: "", stderr: message, killed: false };
365
+ }
366
+ }
367
+
368
+ async #settle(verdict: VerdictReport): Promise<void> {
369
+ this.#stopped = true;
370
+ this.#cancelTimer();
371
+ await this.#removeState();
372
+
373
+ const message = buildWake({
374
+ bead: this.#bead,
375
+ verdict: verdict.verdict,
376
+ runUrl: verdict.runUrl ?? null,
377
+ exitCode: verdict.exitCode,
378
+ reason: verdict.reason,
379
+ attempts: this.#attempts,
380
+ project: this.#project,
381
+ });
382
+
383
+ try {
384
+ this.#deliver(message);
385
+ } catch (error) {
386
+ const detail = error instanceof Error ? error.message : String(error);
387
+ this.#warn(`post-merge-watch: the verdict could not be delivered: ${detail}`);
388
+ }
389
+ }
390
+
391
+ async #writeState(): Promise<void> {
392
+ const now = this.#scheduler.now().toISOString();
393
+ const state: WatchState = {
394
+ version: STATE_VERSION,
395
+ bead: this.#bead,
396
+ project: this.#project,
397
+ script: this.#script,
398
+ pid: process.pid,
399
+ startedAt: (this.#startedAt ?? this.#scheduler.now()).toISOString(),
400
+ updatedAt: now,
401
+ attempts: this.#attempts,
402
+ maxAttempts: this.#maxAttempts,
403
+ lastExitCode: this.#lastExitCode,
404
+ lastReason: this.#lastReason,
405
+ };
406
+
407
+ try {
408
+ await mkdir(this.#stateDir, { recursive: true });
409
+ await writeFile(this.stateFile, `${JSON.stringify(state, null, 2)}\n`, "utf-8");
410
+ } catch (error) {
411
+ const detail = error instanceof Error ? error.message : String(error);
412
+ this.#warn(`post-merge-watch: the watch state could not be written: ${detail}`);
413
+ return;
414
+ }
415
+
416
+ // `stop()` and `#settle()` remove the state file, and either can land while
417
+ // this write is in flight: the remove then runs first and this write
418
+ // recreates the file. Nothing sweeps the directory, so remove it again — a
419
+ // watch that is no longer active leaves no state file behind.
420
+ if (this.#stopped) await this.#removeState();
421
+ }
422
+
423
+ async #removeState(): Promise<void> {
424
+ try {
425
+ await rm(this.stateFile, { force: true });
426
+ } catch (error) {
427
+ const detail = error instanceof Error ? error.message : String(error);
428
+ this.#warn(`post-merge-watch: the watch state could not be removed: ${detail}`);
429
+ }
430
+ }
431
+ }
@@ -0,0 +1,74 @@
1
+ # Session Clear Extension
2
+
3
+ Adds `/clear` to PI: keep the session identity, drop the transcript context.
4
+
5
+ PI has `/new`, `/compact`, `/fork`, and `/clone`, but none of them is "same
6
+ session identity, empty context". `/new` starts a fresh session and the
7
+ replacement falls back to the default display name, model, and thinking level, so
8
+ clearing the context costs three follow-up commands (`/name`, `/model`, the
9
+ thinking level). `/clear` carries the identity across in one command.
10
+
11
+ ## Usage
12
+
13
+ | Command | Description |
14
+ | --- | --- |
15
+ | `/clear` | Replace the session with an empty one, keeping its identity |
16
+
17
+ What it keeps: the session display name, the model, the thinking level, and the
18
+ agent selected at launch. What it drops: every message, tool call, and file
19
+ content.
20
+
21
+ An agent switched mid-session with `/agent` is session state, not launch state,
22
+ so it does not cross into the replacement.
23
+
24
+ It never asks for confirmation, never summarises, never carries a handoff
25
+ message, and never deletes the cleared session file. The cleared session stays
26
+ resumable through `/resume` and appears as the parent in `/tree`.
27
+
28
+ ## What happens
29
+
30
+ 1. `/clear` captures the identity as plain data: display name, model, thinking
31
+ level, and the current session file.
32
+ 2. It replaces the session with `ctx.newSession({ parentSession })`, so the old
33
+ session file is recorded as the parent.
34
+ 3. The replacement instance re-applies the name, and restores the model and the
35
+ thinking level only when they differ from the replacement's own values — a
36
+ default is never needlessly re-pinned.
37
+ 4. It appends a `session-clear` provenance entry naming the cleared session, and
38
+ reports the clear in one line.
39
+
40
+ All of this happens without a model call: the extension never prompts the model.
41
+
42
+ ## Why the identity is re-applied by the replacement instance
43
+
44
+ `newSession()` tears the old session down before it rebinds the replacement, so
45
+ the `pi` and command `ctx` captured by the command handler are stale by the time
46
+ the replacement exists, and `withSession`'s context exposes no way to set the
47
+ name, model, or thinking level. The replacement extension instance is the only
48
+ live handle, so the captured identity crosses to it in one process-global slot
49
+ and it is applied at that instance's own `session_start`. PI re-evaluates
50
+ extension modules for the replacement runtime, so a module-level variable would
51
+ not survive the crossing.
52
+
53
+ The active agent needs no handling: the replacement runtime is built from the
54
+ same launch configuration, so an agent selected with `--agent <name>` re-applies
55
+ its own system prompt and tool set at `session_start`, exactly as it does at
56
+ startup.
57
+
58
+ ## Claude Code parity
59
+
60
+ This extension is PI-only. Claude Code already ships `/clear`, and it keeps the
61
+ model and the permission mode, so there is nothing to port: no hook, no command,
62
+ and no settings entry.
63
+
64
+ ## Development
65
+
66
+ ```bash
67
+ pnpm test # behaviour: capture, replace, re-apply, provenance, no prompts
68
+ pnpm typecheck # tsc --noEmit, which also checks the complete ExtensionAPI double
69
+ ```
70
+
71
+ `index.test.ts` drives the command through a fake `ExtensionAPI` whose action
72
+ methods throw once the session is replaced — the same failure a stale `pi` gives
73
+ at runtime — plus a fake command context that performs the replacement, loads a
74
+ fresh extension instance, and starts the new session before returning.