@jaybeeuu/agent-cortex 2.3.0 → 2.4.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.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Transcript components for the subagent tool's renderers.
3
+ *
4
+ * pi-tui's `Text`, `Container` and `Spacer` are not reachable from an installed
5
+ * extension: a value import of `@earendil-works/pi-tui` makes the extension fail
6
+ * to load outside the agent-cortex checkout (test/pi-extensions-load.test.mjs).
7
+ * These are the local equivalents, implementing the `Component` contract pi's
8
+ * transcript renderer calls: `render(width)` returns lines that fit `width`, and
9
+ * `invalidate()` drops cached layout.
10
+ *
11
+ * Two deliberate limits, because reimplementing pi-tui is not this change:
12
+ * markdown is drawn as plain text, and only SGR colour sequences (`ESC[…m`) are
13
+ * measured and carried across a wrapped line. Other escapes — OSC-8 hyperlinks,
14
+ * images — count as visible characters, the same way a naive strip does.
15
+ * Width is measured in terminal cells: a wide (East Asian) character, and any
16
+ * astral character such as an emoji, counts as two.
17
+ */
18
+
19
+ /** Lines to draw at a given width — what pi's transcript renderer calls. */
20
+ export interface Component {
21
+ render(width: number): string[];
22
+ invalidate(): void;
23
+ }
24
+
25
+ const SGR = /\x1b\[[0-9;]*m/g;
26
+ const SGR_AT = /\x1b\[[0-9;]*m/y;
27
+ const RESET = "\x1b[0m";
28
+
29
+ /** A block of text, word-wrapped to the width it is drawn at. */
30
+ export class Text implements Component {
31
+ readonly #text: string;
32
+ readonly #paddingX: number;
33
+ readonly #paddingY: number;
34
+
35
+ constructor(text = "", paddingX = 0, paddingY = 0) {
36
+ this.#text = text;
37
+ this.#paddingX = paddingX;
38
+ this.#paddingY = paddingY;
39
+ }
40
+
41
+ render(width: number): string[] {
42
+ const available = Math.max(1, width - this.#paddingX * 2);
43
+ const indent = " ".repeat(this.#paddingX);
44
+ const blank = indent.repeat(2);
45
+ const body = this.#text.split("\n").flatMap((line) => wrapLine(line, available));
46
+
47
+ return [
48
+ ...emptyLines(this.#paddingY, blank),
49
+ ...body.map((line) => `${indent}${line}${indent}`),
50
+ ...emptyLines(this.#paddingY, blank),
51
+ ];
52
+ }
53
+
54
+ invalidate(): void {}
55
+ }
56
+
57
+ /** Blank lines. */
58
+ export class Spacer implements Component {
59
+ readonly #lines: number;
60
+
61
+ constructor(lines = 1) {
62
+ this.#lines = lines;
63
+ }
64
+
65
+ render(_width: number): string[] {
66
+ return emptyLines(this.#lines, "");
67
+ }
68
+
69
+ invalidate(): void {}
70
+ }
71
+
72
+ /** A vertical stack of components. */
73
+ export class Container implements Component {
74
+ readonly #children: Component[] = [];
75
+
76
+ addChild(child: Component): void {
77
+ this.#children.push(child);
78
+ }
79
+
80
+ render(width: number): string[] {
81
+ return this.#children.flatMap((child) => child.render(width));
82
+ }
83
+
84
+ invalidate(): void {
85
+ for (const child of this.#children) child.invalidate();
86
+ }
87
+ }
88
+
89
+ /** `count` blank lines, each filled with `filler` (padding keeps the width). */
90
+ function emptyLines(count: number, filler: string): string[] {
91
+ return Array.from({ length: Math.max(0, count) }, () => filler);
92
+ }
93
+
94
+ /** Visible cell count of a line: everything except SGR sequences, wide characters as two. */
95
+ function visibleWidth(line: string): number {
96
+ let cells = 0;
97
+ for (const character of line.replace(SGR, "")) cells += cellWidth(character.codePointAt(0) ?? 0);
98
+ return cells;
99
+ }
100
+
101
+ /**
102
+ * Terminal cells one character occupies. Wide East Asian characters and astral
103
+ * characters (emoji included) occupy two; every other character occupies one.
104
+ * Counting in `String.length` units would let a wide character overflow: it is
105
+ * one unit but two cells.
106
+ */
107
+ function cellWidth(codePoint: number): number {
108
+ if (codePoint > 0xffff) return 2;
109
+ return codePoint >= 0x1100 &&
110
+ (codePoint <= 0x115f ||
111
+ (codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
112
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
113
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
114
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
115
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
116
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6))
117
+ ? 2
118
+ : 1;
119
+ }
120
+
121
+ /**
122
+ * Wrap one line to `width` visible columns without cutting an SGR sequence. A
123
+ * cut inside a coloured span closes the line and reopens the same colour on the
124
+ * continuation, so broken escapes never reach the terminal.
125
+ */
126
+ function wrapLine(line: string, width: number): string[] {
127
+ const lines: string[] = [];
128
+ let rest = line;
129
+
130
+ while (visibleWidth(rest) > width) {
131
+ const cut = breakIndex(rest, width);
132
+ if (cut <= 0) break;
133
+
134
+ const head = rest.slice(0, cut);
135
+ const colour = lastColour(head);
136
+ lines.push(colour === "" ? head : `${head}${RESET}`);
137
+ // A break taken at a space consumes it, so the continuation starts with its word.
138
+ rest = `${colour}${rest.slice(rest[cut] === " " ? cut + 1 : cut)}`;
139
+ }
140
+
141
+ lines.push(rest);
142
+ return lines;
143
+ }
144
+
145
+ /**
146
+ * Index at which to cut `text` so its visible width stays within `width`:
147
+ * the last space at or before the limit, or the limit itself when the line has
148
+ * no usable space.
149
+ */
150
+ function breakIndex(text: string, width: number): number {
151
+ let visible = 0;
152
+ let lastSpace = -1;
153
+ let index = 0;
154
+
155
+ while (index < text.length) {
156
+ SGR_AT.lastIndex = index;
157
+ const escape = SGR_AT.exec(text);
158
+ if (escape !== null) {
159
+ index += escape[0].length;
160
+ continue;
161
+ }
162
+
163
+ const codePoint = text.codePointAt(index) ?? 0;
164
+ const character = String.fromCodePoint(codePoint);
165
+ if (character === " ") lastSpace = index;
166
+ if (visible + cellWidth(codePoint) > width) return lastSpace > 0 ? lastSpace : index;
167
+
168
+ visible += cellWidth(codePoint);
169
+ index += character.length;
170
+ }
171
+
172
+ return lastSpace > 0 ? lastSpace : index;
173
+ }
174
+
175
+ /** The colour sequence left open at the end of `text`, or "" when none is. */
176
+ function lastColour(text: string): string {
177
+ let colour = "";
178
+ for (const [sequence] of text.matchAll(SGR)) {
179
+ colour = sequence === RESET || sequence === "\x1b[m" ? "" : sequence;
180
+ }
181
+ return colour;
182
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The local frontmatter reader for agent files.
3
+ *
4
+ * pi's `parseFrontmatter` is unreachable from an installed extension, so this
5
+ * parser reads the subset agent files use. Agents are user-authored files at a
6
+ * system edge: a field this parser mishandles silently changes an agent's name,
7
+ * description or tool list, and an exception here would break discovery of every
8
+ * other agent, so both the parsing and the tolerance are covered.
9
+ */
10
+
11
+ import { describe, it } from "node:test";
12
+ import assert from "node:assert/strict";
13
+
14
+ import { parseFrontmatter } from "./frontmatter.ts";
15
+
16
+ describe("parseFrontmatter", () => {
17
+ it("splits fields from the body and trims the body", () => {
18
+ const { frontmatter, body } = parseFrontmatter(`---\nname: ralph\ndescription: d\n---\n\n# ralph\n`);
19
+
20
+ assert.deepEqual(frontmatter, { name: "ralph", description: "d" });
21
+ assert.equal(body, "# ralph");
22
+ });
23
+
24
+ it("reads a quoted scalar, unescaping it", () => {
25
+ const { frontmatter } = parseFrontmatter(`---\nname: "a: b\\"c\\n"\n---\n`);
26
+
27
+ assert.equal(frontmatter.name, 'a: b"c\n');
28
+ });
29
+
30
+ it("reads a flow sequence of quoted items", () => {
31
+ const { frontmatter } = parseFrontmatter(`---\ntools: ["bash", "read"]\n---\n`);
32
+
33
+ assert.deepEqual(frontmatter.tools, ["bash", "read"]);
34
+ });
35
+
36
+ it("reads a block sequence", () => {
37
+ const { frontmatter } = parseFrontmatter(`---\ntools:\n - bash\n - read\n---\n`);
38
+
39
+ assert.deepEqual(frontmatter.tools, ["bash", "read"]);
40
+ });
41
+
42
+ it("keeps a comma-separated tool list as one scalar", () => {
43
+ const { frontmatter } = parseFrontmatter(`---\ntools: "bash, read"\n---\n`);
44
+
45
+ assert.equal(frontmatter.tools, "bash, read");
46
+ });
47
+
48
+ it("separates fields from a following block sequence", () => {
49
+ const { frontmatter } = parseFrontmatter(`---\nname: ralph\ntools:\n - bash\nmodel: fast\n---\n`);
50
+
51
+ assert.deepEqual(frontmatter, { name: "ralph", tools: ["bash"], model: "fast" });
52
+ });
53
+
54
+ it("normalises CRLF line endings", () => {
55
+ const { frontmatter, body } = parseFrontmatter(`---\r\nname: ralph\r\n---\r\n\r\nbody\r\n`);
56
+
57
+ assert.deepEqual(frontmatter, { name: "ralph" });
58
+ assert.equal(body, "body");
59
+ });
60
+
61
+ it("returns the whole document as body when there is no frontmatter", () => {
62
+ const { frontmatter, body } = parseFrontmatter("# just markdown\n");
63
+
64
+ assert.deepEqual(frontmatter, {});
65
+ assert.equal(body, "# just markdown\n");
66
+ });
67
+
68
+ it("treats an unclosed block as body rather than throwing", () => {
69
+ const { frontmatter, body } = parseFrontmatter(`---\nname: ralph\n`);
70
+
71
+ assert.deepEqual(frontmatter, {});
72
+ assert.equal(body, "---\nname: ralph\n");
73
+ });
74
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Read the YAML frontmatter block of a markdown document.
3
+ *
4
+ * pi's own `parseFrontmatter` is not reachable from an installed extension: a
5
+ * value import of `@earendil-works/pi-coding-agent` makes the extension fail to
6
+ * load outside the agent-cortex checkout (test/pi-extensions-load.test.mjs).
7
+ * This module is the subset agent files use — top-level `key: value` pairs,
8
+ * plain and quoted scalars, flow sequences (`[a, b]`) and block sequences
9
+ * (`- a`).
10
+ *
11
+ * Deliberately outside the subset: nested maps and block scalars (`|`, `>`) keep
12
+ * their literal text as the value. No construct throws — an unreadable block
13
+ * yields empty frontmatter — so one malformed agent file cannot break discovery
14
+ * of the others.
15
+ */
16
+
17
+ /** A parsed document: its frontmatter fields and the markdown body. */
18
+ export interface ParsedFrontmatter<T extends Record<string, unknown>> {
19
+ frontmatter: T;
20
+ body: string;
21
+ }
22
+
23
+ const FENCE = "---";
24
+
25
+ /**
26
+ * Split a markdown document into frontmatter fields and body, matching pi's
27
+ * contract: the document must open with `---` and close the block with a line
28
+ * that starts with `---`; the body is trimmed.
29
+ */
30
+ export function parseFrontmatter<T extends Record<string, unknown>>(content: string): ParsedFrontmatter<T> {
31
+ const normalized = content.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
32
+ const end = normalized.startsWith(FENCE) ? normalized.indexOf(`\n${FENCE}`, FENCE.length) : -1;
33
+ if (end === -1) return { frontmatter: {} as T, body: normalized };
34
+
35
+ return {
36
+ frontmatter: parseBlock(normalized.slice(FENCE.length + 1, end)) as T,
37
+ body: normalized.slice(end + FENCE.length + 1).trim(),
38
+ };
39
+ }
40
+
41
+ /** Parse the `key: value` lines of a frontmatter block. */
42
+ function parseBlock(block: string): Record<string, unknown> {
43
+ const fields: Record<string, unknown> = {};
44
+ const lines = block.split("\n");
45
+
46
+ for (let index = 0; index < lines.length; index++) {
47
+ const match = /^([\w.-]+):(?:[ \t]+(.*))?$/.exec(lines[index]);
48
+ if (match === null) continue;
49
+
50
+ const [, key, inlineValue] = match;
51
+ if (inlineValue !== undefined && inlineValue.trim() !== "") {
52
+ fields[key] = parseValue(inlineValue.trim());
53
+ continue;
54
+ }
55
+
56
+ const { items, consumed } = readBlockSequence(lines, index + 1);
57
+ index += consumed;
58
+ fields[key] = items.length > 0 ? items : null;
59
+ }
60
+
61
+ return fields;
62
+ }
63
+
64
+ /**
65
+ * Read the indented `- item` lines that follow a `key:` line with no inline
66
+ * value. Returns the items and how many lines were consumed.
67
+ */
68
+ function readBlockSequence(lines: string[], start: number): { items: string[]; consumed: number } {
69
+ const items: string[] = [];
70
+ let consumed = 0;
71
+
72
+ for (let index = start; index < lines.length; index++) {
73
+ const match = /^[ \t]+-[ \t]+(.*)$/.exec(lines[index]);
74
+ if (match === null) break;
75
+
76
+ const value = parseValue(match[1].trim());
77
+ items.push(typeof value === "string" ? value : String(value));
78
+ consumed++;
79
+ }
80
+
81
+ return { items, consumed };
82
+ }
83
+
84
+ /** Parse one scalar or flow sequence. */
85
+ function parseValue(raw: string): unknown {
86
+ if (raw.startsWith("[") && raw.endsWith("]")) {
87
+ return raw
88
+ .slice(1, -1)
89
+ .split(",")
90
+ .map((item) => item.trim())
91
+ .filter(Boolean)
92
+ .map(parseValue);
93
+ }
94
+ if (raw.startsWith('"')) return unquote(raw, '"', (text) => text.replace(/\\(["\\/nrt])/g, (_, escape: string) => {
95
+ return escape === "n" ? "\n" : escape === "r" ? "\r" : escape === "t" ? "\t" : escape;
96
+ }));
97
+ if (raw.startsWith("'")) return unquote(raw, "'", (text) => text.replace(/''/g, "'"));
98
+ return raw;
99
+ }
100
+
101
+ /** Strip a matching quote pair and unescape the content; an unterminated quote keeps the raw text. */
102
+ function unquote(raw: string, quote: string, unescape: (text: string) => string): string {
103
+ if (raw.length < 2 || !raw.endsWith(quote)) return raw;
104
+ return unescape(raw.slice(1, -1));
105
+ }
@@ -21,8 +21,13 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
21
21
  import type { Message } from "@earendil-works/pi-ai";
22
22
  import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.ts";
23
23
  import { waitForAgents } from "./wait.ts";
24
- import { type ExtensionAPI, getMarkdownTheme, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
25
- import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
24
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
25
+ import { Container, Spacer, Text } from "./components.ts";
26
+ // `typebox` is the one bare runtime import an extension may make: pi documents
27
+ // it as host-provided and its loader aliases it (pi's docs/packages.md), so it
28
+ // resolves in the staged package too. Every `@earendil-works/*` import here is
29
+ // `import type`, which is erased; a value import of pi's runtime breaks the
30
+ // package outside this checkout (test/pi-extensions-load.test.mjs).
26
31
  import { Type } from "typebox";
27
32
 
28
33
 
@@ -236,9 +241,8 @@ async function writePromptToTempFile(agentName: string, prompt: string): Promise
236
241
  const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
237
242
  const safeName = agentName.replace(/[^\w.-]+/g, "_");
238
243
  const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
239
- await withFileMutationQueue(filePath, async () => {
240
- await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
241
- });
244
+ // No mutation queue: the directory is fresh per call, so nothing else writes this file.
245
+ await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
242
246
  return { dir: tmpDir, filePath };
243
247
  }
244
248
 
@@ -746,8 +750,6 @@ export default function (pi: ExtensionAPI) {
746
750
  return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
747
751
  }
748
752
 
749
- const mdTheme = getMarkdownTheme();
750
-
751
753
  const renderDisplayItems = (items: DisplayItem[], limit?: number) => {
752
754
  const toShow = limit ? items.slice(-limit) : items;
753
755
  const skipped = limit && items.length > limit ? items.length - limit : 0;
@@ -798,7 +800,7 @@ export default function (pi: ExtensionAPI) {
798
800
  }
799
801
  if (finalOutput) {
800
802
  container.addChild(new Spacer(1));
801
- container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
803
+ container.addChild(new Text(finalOutput.trim(), 0, 0));
802
804
  }
803
805
  }
804
806
  const usageStr = formatUsageStats(r.usage, r.model);
@@ -880,10 +882,10 @@ export default function (pi: ExtensionAPI) {
880
882
  }
881
883
  }
882
884
 
883
- // Show final output as markdown
885
+ // Show final output as plain text
884
886
  if (finalOutput) {
885
887
  container.addChild(new Spacer(1));
886
- container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
888
+ container.addChild(new Text(finalOutput.trim(), 0, 0));
887
889
  }
888
890
 
889
891
  const stepUsage = formatUsageStats(r.usage, r.model);
@@ -965,10 +967,10 @@ export default function (pi: ExtensionAPI) {
965
967
  }
966
968
  }
967
969
 
968
- // Show final output as markdown
970
+ // Show final output as plain text
969
971
  if (finalOutput) {
970
972
  container.addChild(new Spacer(1));
971
- container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
973
+ container.addChild(new Text(finalOutput.trim(), 0, 0));
972
974
  }
973
975
 
974
976
  const taskUsage = formatUsageStats(r.usage, r.model);
@@ -4,20 +4,19 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "test": "node --test --import tsx/esm wait.test.ts agents.test.ts",
7
+ "test": "node --test --import tsx/esm wait.test.ts agents.test.ts frontmatter.test.ts components.test.ts",
8
8
  "typecheck": "tsc --noEmit"
9
9
  },
10
10
  "devDependencies": {
11
11
  "@earendil-works/pi-agent-core": "^0.78.1",
12
12
  "@earendil-works/pi-ai": "^0.78.1",
13
13
  "@earendil-works/pi-coding-agent": "^0.78.1",
14
- "@earendil-works/pi-tui": "^0.78.1",
15
14
  "@types/node": "^22.0.0",
16
15
  "tsx": "^4.0.0",
17
16
  "typebox": "^1.1.38",
18
17
  "typescript": "^5.0.0"
19
18
  },
20
19
  "peerDependencies": {
21
- "@earendil-works/pi-coding-agent": "^0.78.1"
20
+ "typebox": "*"
22
21
  }
23
- }
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaybeeuu/agent-cortex",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
4
4
  "description": "Personal PI package with custom agents, skills, and extensions",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -16,21 +16,26 @@ Log stage start, security scan result, review outcome, and stage complete (see P
16
16
 
17
17
  ## Instructions
18
18
  **First**, invoke the `review-security` skill and run the scan against the changes. If the
19
- verdict is FAIL, set `REVIEW_OUTCOME: CHANGES_REQUESTED` immediately and list each finding
20
- as a required change — do not proceed with the quality review.
19
+ verdict is FAIL, set `OUTCOME: BLOCKED` immediately and list each finding under
20
+ `BLOCKING_ISSUES` — do not proceed with the quality review.
21
21
 
22
22
  **Then**, review the implementation for correctness, quality, and alignment with the task
23
23
  description and project conventions. Only flag genuine correctness issues or clear deviations
24
24
  from stated requirements — not stylistic preferences.
25
25
 
26
- End your response with a ---REPORT--- block:
26
+ End your response with a ---REPORT--- block. The `run-pipeline-stage` skill's
27
+ `prompts/stage-runner.md` holds the authoritative REPORT contract — if this block and that
28
+ prompt differ, follow the prompt and correct this template.
27
29
  ---REPORT---
28
30
  BEAD_ID: <id>
29
- STAGE_COMPLETED: reviewing
31
+ STAGE_COMPLETED: review
30
32
  SUMMARY: <2–3 sentence summary of what you reviewed>
31
33
  FILES_CHANGED: none
32
- REVIEW_OUTCOME: <APPROVED|CHANGES_REQUESTED>
33
- CHANGES_REQUESTED: ← include only if REVIEW_OUTCOME is CHANGES_REQUESTED
34
- 1. <required change>
35
- 2. <required change>
34
+ COMMIT: <sha, or "none" if this stage added no commit>
35
+ PUSHED: <yes|no>
36
+ PR: none
37
+ OUTCOME: <SUCCESS|BLOCKED>
38
+ BLOCKING_ISSUES: ← include only if OUTCOME is BLOCKED; one numbered line per finding
39
+ 1. <path:line — what is wrong and what to fix>
40
+ 2. <path:line — what is wrong and what to fix>
36
41
  ---
@@ -54,7 +54,10 @@ bash "$(find ~ -path '*/agent-cortex/skills/review-security/scripts/install-gitl
54
54
 
55
55
  If the verdict is FAIL, report each finding as a required change. Do not proceed until secrets are removed.
56
56
 
57
- When running within a ralph review pipeline, the outcome is communicated through the `---REPORT---` block that ralph parses — set `REVIEW_OUTCOME: CHANGES_REQUESTED` in that block and list each finding under `CHANGES_REQUESTED` (see the review template at `skills/create-task/templates/review.md`).
57
+ When running within a ralph review pipeline, the outcome is communicated through the
58
+ `---REPORT---` block that ralph parses — set `OUTCOME: BLOCKED` in that block and list each
59
+ finding under `BLOCKING_ISSUES` with its file and line (see the review template in the
60
+ `create-task` skill's `templates/review.md`).
58
61
 
59
62
  ## Red Flags
60
63