@jaybeeuu/agent-cortex 1.41.1 → 1.43.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 (115) hide show
  1. package/.agents/skills/style-versioning/SKILL.md +73 -2
  2. package/.github/workflows/ci.yml +52 -32
  3. package/AGENTS.md +23 -2
  4. package/CHANGELOG.md +189 -0
  5. package/README.md +125 -73
  6. package/agents/README.md +17 -4
  7. package/agents/ralph-plan/agent.md +7 -3
  8. package/agents/ralph-plan.agent.md +7 -3
  9. package/bin/agent-cortex.mjs +48 -30
  10. package/bin/installers/pi.mjs +885 -237
  11. package/claude-extras/scripts/statusline-command.sh +0 -0
  12. package/docs/claude-hooks.md +5 -4
  13. package/docs/commit-signing.md +231 -0
  14. package/docs/ideas/ban-content-assertion-tests.md +3 -1
  15. package/docs/ideas/dedicated-review-tests-skill.md +71 -0
  16. package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +10 -1
  17. package/docs/ideas/extension-manifests.md +5 -0
  18. package/docs/ideas/unify-the-ticket-and-task-decomposition-workflows.md +71 -0
  19. package/docs/research/disable-model-invocation.md +103 -0
  20. package/docs/research/skill-design-research.md +2 -2
  21. package/docs/skills/skill-anatomy.md +5 -1
  22. package/docs/technical-direction/harness-config.md +25 -1
  23. package/docs/technical-direction/migration-execution-labels.md +17 -9
  24. package/extensions/agent-modes/discover.test.ts +19 -0
  25. package/extensions/agent-modes/discover.ts +25 -3
  26. package/extensions/session-start/README.md +38 -0
  27. package/extensions/session-start/index.test.ts +122 -0
  28. package/extensions/session-start/index.ts +27 -0
  29. package/extensions/session-start/package.json +19 -0
  30. package/extensions/session-start/tsconfig.json +12 -0
  31. package/extensions/subagent/README.md +1 -1
  32. package/extensions/subagent/agents.test.ts +176 -0
  33. package/extensions/subagent/agents.ts +102 -11
  34. package/extensions/subagent/index.ts +2 -1
  35. package/extensions/subagent/package.json +1 -1
  36. package/lib/cli.mjs +35 -9
  37. package/package.json +10 -6
  38. package/pi/settings.json +1 -7
  39. package/plugin.json +1 -1
  40. package/pnpm-workspace.yaml +6 -0
  41. package/scripts/check-changeset.mjs +42 -0
  42. package/scripts/install-instructions.sh +0 -0
  43. package/scripts/lib/release-gates.mjs +61 -0
  44. package/scripts/lib/verify-worker-tree.mjs +107 -0
  45. package/scripts/resolve-publish-command.mjs +17 -0
  46. package/scripts/sync-plugin-version.sh +0 -0
  47. package/skills/engineering/grill-with-docs/SKILL.md +3 -2
  48. package/skills/engineering/improve-codebase-architecture/REFERENCE.md +5 -0
  49. package/skills/engineering/improve-codebase-architecture/SKILL.md +2 -1
  50. package/skills/planning/create-task/SKILL.md +8 -3
  51. package/skills/planning/create-task/scripts/create-chores.ts +4 -1
  52. package/skills/planning/prd-to-tasks/REFERENCE.md +2 -2
  53. package/skills/planning/prd-to-tasks/SKILL.md +9 -4
  54. package/skills/planning/request-refactor-plan/FORMAT.md +5 -0
  55. package/skills/planning/request-refactor-plan/SKILL.md +3 -2
  56. package/skills/planning/spec-to-tickets/REFERENCE.md +82 -0
  57. package/skills/planning/spec-to-tickets/SKILL.md +143 -0
  58. package/skills/planning/write-a-prd/FORMAT.md +6 -0
  59. package/skills/planning/write-a-prd/SKILL.md +9 -6
  60. package/skills/planning/write-a-ticket/EXAMPLES.md +133 -0
  61. package/skills/planning/write-a-ticket/SKILL.md +77 -59
  62. package/skills/productivity/bd-tool/SKILL.md +27 -2
  63. package/skills/productivity/grill-me/SKILL.md +11 -0
  64. package/skills/review/review-security/scripts/install-gitleaks.sh +0 -0
  65. package/skills/workflow/git-workflow/SKILL.md +35 -1
  66. package/skills/workflow/plan/SKILL.md +20 -8
  67. package/skills/workflow/ralph/REFERENCE.md +20 -2
  68. package/skills/workflow/ralph/SKILL.md +4 -1
  69. package/skills/workflow/run-pipeline-stage/SKILL.md +17 -5
  70. package/skills/workflow/run-pipeline-stage/playbooks/code.md +2 -0
  71. package/skills/workflow/run-pipeline-stage/playbooks/document.md +3 -0
  72. package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -0
  73. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +11 -0
  74. package/skills/workflow/using-agent-skills/SKILL.md +1 -0
  75. package/test/cli.test.mjs +26 -29
  76. package/test/create-chores.test.mjs +12 -0
  77. package/test/fake-bd.test.mjs +72 -0
  78. package/test/helpers/fake-bd.mjs +26 -6
  79. package/test/helpers/fake-pi.mjs +132 -0
  80. package/test/pi-installer.test.mjs +345 -286
  81. package/test/pi-register.test.mjs +342 -0
  82. package/test/pi-sweep.test.mjs +619 -0
  83. package/test/release-gates.test.mjs +190 -0
  84. package/test/verify-worker-tree.test.mjs +120 -0
  85. package/token-map.README.md +7 -6
  86. package/token-map.json +6 -5
  87. package/extensions/agent-guardrails/node_modules/.bin/pi +0 -21
  88. package/extensions/agent-guardrails/node_modules/.bin/tsc +0 -21
  89. package/extensions/agent-guardrails/node_modules/.bin/tsserver +0 -21
  90. package/extensions/agent-guardrails/node_modules/.bin/tsx +0 -21
  91. package/extensions/agent-modes/node_modules/.bin/pi +0 -21
  92. package/extensions/agent-modes/node_modules/.bin/tsc +0 -21
  93. package/extensions/agent-modes/node_modules/.bin/tsserver +0 -21
  94. package/extensions/agent-modes/node_modules/.bin/tsx +0 -21
  95. package/extensions/session-start.ts +0 -44
  96. package/extensions/skill-stats/node_modules/.bin/pi +0 -21
  97. package/extensions/skill-stats/node_modules/.bin/tsc +0 -21
  98. package/extensions/skill-stats/node_modules/.bin/tsserver +0 -21
  99. package/extensions/skill-stats/node_modules/.bin/tsx +0 -21
  100. package/extensions/subagent/node_modules/.bin/pi +0 -21
  101. package/extensions/subagent/node_modules/.bin/pi-ai +0 -21
  102. package/extensions/subagent/node_modules/.bin/tsc +0 -21
  103. package/extensions/subagent/node_modules/.bin/tsserver +0 -21
  104. package/extensions/subagent/node_modules/.bin/tsx +0 -21
  105. package/lib/pi-packages.mjs +0 -169
  106. package/skills/planning/create-task/scripts/node_modules/.bin/tsc +0 -21
  107. package/skills/planning/create-task/scripts/node_modules/.bin/tsserver +0 -21
  108. package/skills/planning/create-task/scripts/node_modules/.bin/tsx +0 -21
  109. package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsc +0 -21
  110. package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsserver +0 -21
  111. package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsx +0 -21
  112. package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsc +0 -21
  113. package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsserver +0 -21
  114. package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsx +0 -21
  115. package/test/pi-packages.test.mjs +0 -295
@@ -4,6 +4,7 @@
4
4
 
5
5
  import * as fs from "node:fs";
6
6
  import * as path from "node:path";
7
+ import { fileURLToPath } from "node:url";
7
8
  import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
9
 
9
10
  export type AgentScope = "user" | "project" | "both";
@@ -23,6 +24,25 @@ export interface AgentDiscoveryResult {
23
24
  projectAgentsDir: string | null;
24
25
  }
25
26
 
27
+ /**
28
+ * Parse a frontmatter `tools` value into PI tool names.
29
+ *
30
+ * `parseFrontmatter` returns the scalar as a single string, and agent files are
31
+ * written with either a comma-separated list (`tools: read, grep`) or a
32
+ * whitespace-separated one. Splitting on commas alone collapses the whitespace
33
+ * form into one bogus name, which becomes a `--tools` allowlist matching no
34
+ * tool — the subagent then runs with an empty tool block. Accept both
35
+ * separators so neither form can zero out the tool list.
36
+ *
37
+ * @param {string | undefined} tools Raw frontmatter `tools` scalar
38
+ * @returns {string[] | undefined} Tool names, or undefined when there are none
39
+ */
40
+ export function parseToolList(tools: string | undefined): string[] | undefined {
41
+ const parsed = tools?.split(/[,\s]+/).filter(Boolean);
42
+
43
+ return parsed && parsed.length > 0 ? parsed : undefined;
44
+ }
45
+
26
46
  function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
27
47
  const agents: AgentConfig[] = [];
28
48
 
@@ -49,22 +69,17 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
49
69
  continue;
50
70
  }
51
71
 
52
- const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
72
+ const { frontmatter, body } = parseFrontmatter<Record<string, unknown>>(content);
53
73
 
54
- if (!frontmatter.name || !frontmatter.description) {
74
+ if (typeof frontmatter.name !== "string" || typeof frontmatter.description !== "string") {
55
75
  continue;
56
76
  }
57
77
 
58
- const tools = frontmatter.tools
59
- ?.split(",")
60
- .map((t: string) => t.trim())
61
- .filter(Boolean);
62
-
63
78
  agents.push({
64
79
  name: frontmatter.name,
65
80
  description: frontmatter.description,
66
- tools: tools && tools.length > 0 ? tools : undefined,
67
- model: frontmatter.model,
81
+ tools: readTools(frontmatter.tools),
82
+ model: typeof frontmatter.model === "string" ? frontmatter.model : undefined,
68
83
  systemPrompt: body,
69
84
  source,
70
85
  filePath,
@@ -74,6 +89,21 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
74
89
  return agents;
75
90
  }
76
91
 
92
+ /**
93
+ * The tools of a frontmatter `tools` value, which is written either as a YAML
94
+ * scalar (one string) or as a YAML sequence (an array).
95
+ */
96
+ function readTools(value: unknown): string[] | undefined {
97
+ if (Array.isArray(value)) {
98
+ const tools = value
99
+ .filter((tool): tool is string => typeof tool === "string")
100
+ .map((tool) => tool.trim())
101
+ .filter(Boolean);
102
+ return tools.length > 0 ? tools : undefined;
103
+ }
104
+ return parseToolList(typeof value === "string" ? value : undefined);
105
+ }
106
+
77
107
  function isDirectory(p: string): boolean {
78
108
  try {
79
109
  return fs.statSync(p).isDirectory();
@@ -94,11 +124,72 @@ function findNearestProjectAgentsDir(cwd: string): string | null {
94
124
  }
95
125
  }
96
126
 
127
+ /**
128
+ * The package this extension ships inside: `<extension>/../..`, i.e. the repo
129
+ * checkout or the staged pi package.
130
+ */
131
+ const PACKAGE_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
132
+
133
+ /**
134
+ * True when `agentsDir` is a rendered tree — flat `*.agent.md` files and no
135
+ * composable `<name>/agent.md` source directories.
136
+ */
137
+ function isRenderedAgentsTree(agentsDir: string): boolean {
138
+ if (!isDirectory(agentsDir)) return false;
139
+ let entries: fs.Dirent[];
140
+ try {
141
+ entries = fs.readdirSync(agentsDir, { withFileTypes: true });
142
+ } catch {
143
+ return false;
144
+ }
145
+ return !entries.some((entry) => entry.isDirectory() && fs.existsSync(path.join(agentsDir, entry.name, "agent.md")));
146
+ }
147
+
148
+ /**
149
+ * Directories the "user" scope discovers agents from, in precedence order.
150
+ *
151
+ * pi's own user agent dir comes first: it holds hand-written personal agents
152
+ * and the copies older installers made, so existing setups keep resolving what
153
+ * they did before. Second is the package this extension ships inside, which is
154
+ * what makes a staged pi package self-contained — `agent-cortex install pi`
155
+ * renders the flat pi agents into `<package>/agents`.
156
+ *
157
+ * A source checkout contributes nothing: its `agents/` tree is the composable
158
+ * source (composed at runtime by the agent-modes extension) alongside flat
159
+ * renders for other harnesses — Copilot's, whose tool names pi does not have.
160
+ */
161
+ export function resolveUserAgentDirs(packageRoot: string = PACKAGE_ROOT): string[] {
162
+ const dirs: string[] = [];
163
+ const add = (dir: string) => {
164
+ const resolved = path.resolve(dir);
165
+ if (!dirs.includes(resolved) && isDirectory(resolved)) dirs.push(resolved);
166
+ };
167
+
168
+ add(path.join(getAgentDir(), "agents"));
169
+ const packageAgentsDir = path.join(packageRoot, "agents");
170
+ if (isRenderedAgentsTree(packageAgentsDir)) add(packageAgentsDir);
171
+
172
+ return dirs;
173
+ }
174
+
175
+ /**
176
+ * Load the user-scope agents from `dirs`, first directory wins on a name
177
+ * collision. Missing directories contribute nothing.
178
+ */
179
+ export function loadUserAgents(dirs: string[]): AgentConfig[] {
180
+ const byName = new Map<string, AgentConfig>();
181
+ for (const dir of dirs) {
182
+ for (const agent of loadAgentsFromDir(dir, "user")) {
183
+ if (!byName.has(agent.name)) byName.set(agent.name, agent);
184
+ }
185
+ }
186
+ return Array.from(byName.values());
187
+ }
188
+
97
189
  export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
98
- const userDir = path.join(getAgentDir(), "agents");
99
190
  const projectAgentsDir = findNearestProjectAgentsDir(cwd);
100
191
 
101
- const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
192
+ const userAgents = scope === "project" ? [] : loadUserAgents(resolveUserAgentDirs());
102
193
  const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
103
194
 
104
195
  const agentMap = new Map<string, AgentConfig>();
@@ -461,7 +461,8 @@ export default function (pi: ExtensionAPI) {
461
461
  description: [
462
462
  "Delegate tasks to specialized subagents with isolated context.",
463
463
  "Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).",
464
- 'Default agent scope is "user" (from ~/.pi/agent/agents).',
464
+ 'Default agent scope is "user": ~/.pi/agent/agents plus the agents/ tree of the package',
465
+ "this extension ships inside.",
465
466
  'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").',
466
467
  ].join(" "),
467
468
  parameters: SubagentParams,
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "test": "node --test --import tsx/esm wait.test.ts",
7
+ "test": "node --test --import tsx/esm wait.test.ts agents.test.ts",
8
8
  "typecheck": "tsc --noEmit"
9
9
  },
10
10
  "devDependencies": {
package/lib/cli.mjs CHANGED
@@ -9,7 +9,6 @@ const INSTALL_OPTIONS = {
9
9
  "--output": { key: "output" },
10
10
  "--plugin-root": { key: "pluginRoot" },
11
11
  "--require-register": { key: "requireRegister", boolean: true },
12
- "--no-provision": { key: "noProvision", boolean: true },
13
12
  };
14
13
 
15
14
  export function parseArgs(argv) {
@@ -94,16 +93,43 @@ Commands:
94
93
  plugin.json "agents" scans)
95
94
  --dry-run Print what would be generated without writing
96
95
  Options (pi):
97
- --dry-run Show what would be installed without writing
98
- --output <dir> Install into <dir> — agents/, skills/,
99
- settings.json (merged, packages managed)
100
- and keybindings.json (default: ~/.pi/agent)
96
+ (no --output) Materialise the pi package into
97
+ ~/.agent-cortex/pi: package.json,
98
+ agents/, skills/, extensions/ and
99
+ the bundled third-party pi packages
100
+ (pi-questions, pi-web-access), then
101
+ REGISTER that directory with pi via
102
+ "pi install" (user scope, idempotent
103
+ by state: a re-run is a no-op update,
104
+ and stale raw-repo sources are
105
+ removed so one source remains).
106
+ Once registered, the artifacts the
107
+ pre-staged-model installer rendered
108
+ into ~/.pi/agent are removed in a
109
+ one-time migration: agents and
110
+ skills whose names this package
111
+ ships or once shipped. Anything
112
+ else there is left alone, including
113
+ the whole extensions/ directory.
114
+ A migration that removed every
115
+ copy it attributed is recorded in
116
+ ~/.agent-cortex/legacy-sweep.json
117
+ (keyed by the pi user directory)
118
+ and never runs there twice; one
119
+ that could not be completed warns
120
+ and is retried on the next install.
121
+ --output <dir> Stage the package into <dir> only —
122
+ no registration (default:
123
+ ~/.agent-cortex/pi)
124
+ --dry-run Show what would be staged and
125
+ registered without writing or spawning
101
126
  --plugin-root <dir> Override the plugin root used for {{PATH:...}}
102
127
  tokens (default: token-map.json's pi value)
103
- --no-provision Skip installing the third-party pi packages
104
- declared in the package manifest (e.g.
105
- pi-questions, pi-web-access). They are
106
- installed by default on a real install.
128
+ --require-register
129
+ Fail the install when the pi CLI
130
+ cannot register the staged package
131
+ (missing from PATH); default warns and
132
+ prints the manual commands
107
133
  --help, -h Show this help text`;
108
134
  }
109
135
 
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@jaybeeuu/agent-cortex",
3
- "version": "1.41.1",
3
+ "version": "1.43.0",
4
4
  "description": "Personal PI package with custom agents, skills, and extensions",
5
5
  "keywords": [
6
6
  "pi-package"
7
7
  ],
8
8
  "license": "MIT",
9
- "repository": "https://github.com/jaybeeuu/agent-cortex",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/jaybeeuu/agent-cortex"
12
+ },
10
13
  "bin": {
11
14
  "agent-cortex": "./bin/agent-cortex.mjs"
12
15
  },
@@ -24,12 +27,12 @@
24
27
  ],
25
28
  "skills": [
26
29
  "./skills"
27
- ],
28
- "packages": [
29
- "npm:pi-questions",
30
- "npm:pi-web-access@0.10.7"
31
30
  ]
32
31
  },
32
+ "dependencies": {
33
+ "pi-questions": "^0.3.4",
34
+ "pi-web-access": "0.10.7"
35
+ },
33
36
  "scripts": {
34
37
  "build": "echo 'Build complete — no compilation needed for PI package'",
35
38
  "lint": "pnpm typecheck && pnpm check:sync",
@@ -40,6 +43,7 @@
40
43
  "changeset": "changeset",
41
44
  "version-packages": "changeset version && bash scripts/sync-plugin-version.sh && pnpm build:copilot",
42
45
  "publish-package": "TARBALL=$(pnpm pack | tail -n 1) && npm publish \"$TARBALL\" --provenance --access public",
46
+ "publish-noop": "echo 'No publish: the merged PR carries the no-publish label.'",
43
47
  "release": "changeset publish",
44
48
  "postinstall": "bash scripts/install-instructions.sh"
45
49
  }
package/pi/settings.json CHANGED
@@ -6,15 +6,9 @@
6
6
  },
7
7
  "lastChangelogVersion": "0.87.1",
8
8
  "packages": [
9
- {
10
- "source": "../../src/agent-cortex",
11
- "skills": []
12
- },
13
- "npm:pi-web-access@0.10.7",
14
9
  "npm:@getpipher/vision",
15
10
  "npm:@hypabolic/pi-hypa",
16
- "npm:context-mode",
17
- "npm:pi-questions"
11
+ "npm:context-mode"
18
12
  ],
19
13
  "defaultProvider": "opencode-go",
20
14
  "defaultModel": "deepseek-v4.1-flash",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-cortex",
3
3
  "description": "Personal Copilot CLI plugin with custom agents and skills",
4
- "version": "1.41.1",
4
+ "version": "1.43.0",
5
5
  "author": {
6
6
  "name": "jaybeeuu"
7
7
  },
@@ -5,4 +5,10 @@ packages:
5
5
  allowBuilds:
6
6
  '@google/genai': false
7
7
  esbuild: false
8
+ # koffi (an optionalDependency of @mariozechner/pi-tui, which pi-questions
9
+ # peers on) ships prebuilt build/koffi/<platform>_<arch>/*.node binaries that
10
+ # index.js loads directly. Its install script (cnoke --prebuild) only
11
+ # matters for toolchains with no shipped binary, so deny it: acknowledged
12
+ # explicitly to satisfy the strict allowlist, no compilation, no download.
13
+ koffi: false
8
14
  protobufjs: false
@@ -0,0 +1,42 @@
1
+ // PR gate: a pull request must add a changeset (.changeset/*.md) unless it is
2
+ // the changesets bot's Version Packages PR or carries the `no-publish` label.
3
+ // Reads `git diff --name-status <base>...HEAD` on stdin and the PR metadata
4
+ // from the environment (CI supplies all three).
5
+ import { evaluateChangesetGate, parseNameStatus } from "./lib/release-gates.mjs";
6
+
7
+ async function readStdin() {
8
+ const chunks = [];
9
+ for await (const chunk of process.stdin) chunks.push(chunk);
10
+ return Buffer.concat(chunks).toString("utf8");
11
+ }
12
+
13
+ function readLabels(raw) {
14
+ try {
15
+ return JSON.parse(raw);
16
+ } catch {
17
+ console.error("::error::PR_LABELS must be a JSON array of label names.");
18
+ process.exit(1);
19
+ }
20
+ }
21
+
22
+ const labels = readLabels(process.env.PR_LABELS ?? "[]");
23
+ const changes = parseNameStatus(await readStdin());
24
+ const result = evaluateChangesetGate({
25
+ changes,
26
+ labels,
27
+ headRef: process.env.PR_HEAD_REF ?? "",
28
+ actor: process.env.PR_ACTOR ?? "",
29
+ });
30
+
31
+ if (result.ok) {
32
+ console.log(`✓ Changeset gate passed (${result.reason}).`);
33
+ } else {
34
+ console.error(
35
+ [
36
+ "::error::This PR adds no changeset (.changeset/*.md).",
37
+ "Publishing is the default: write a changeset file (see the style-versioning skill)",
38
+ "and commit it, or label the PR 'no-publish' when it genuinely ships nothing.",
39
+ ].join("\n"),
40
+ );
41
+ process.exitCode = 1;
42
+ }
File without changes
@@ -0,0 +1,61 @@
1
+ // Decision logic for the two release gates that stop `main` re-publishing an
2
+ // already-published version. Both gates are pure: the CLI adapters read git
3
+ // diffs, env vars, and the GitHub API, then delegate the decision here.
4
+ //
5
+ // Model: publishing is the default. The only path to "no publish" is an
6
+ // explicit `no-publish` label on the merged PR — skipping is never implicit.
7
+
8
+ export const NO_PUBLISH_LABEL = "no-publish";
9
+ export const PUBLISH_COMMAND = "pnpm publish-package";
10
+ export const NO_PUBLISH_COMMAND = "pnpm publish-noop";
11
+
12
+ // The changesets bot's Version Packages PR consumes changesets and adds none.
13
+ const VERSION_PACKAGES_HEAD_REF = "changeset-release/";
14
+ const GITHUB_ACTIONS_BOT = "github-actions[bot]";
15
+
16
+ const CHANGESET_PATH = /^\.changeset\/[^/]+\.md$/;
17
+ const CHANGESET_EXEMPT_PATHS = new Set([".changeset/README.md"]);
18
+
19
+ /**
20
+ * Parse `git diff --name-status` output into `{ status, path }` entries, where
21
+ * `status` is the single-letter change type and `path` is the new path.
22
+ */
23
+ export function parseNameStatus(output) {
24
+ return output
25
+ .split("\n")
26
+ .filter((line) => line.trim() !== "")
27
+ .map((line) => {
28
+ const [status, ...paths] = line.split("\t");
29
+ return { status: status.charAt(0), path: paths.at(-1) };
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Decide whether a pull request may skip adding a changeset. Exempt only the
35
+ * Version Packages PR and PRs explicitly labelled `no-publish`; every other PR
36
+ * must add its own changeset.
37
+ */
38
+ export function evaluateChangesetGate({ changes, labels, headRef, actor }) {
39
+ if (headRef.startsWith(VERSION_PACKAGES_HEAD_REF) || actor === GITHUB_ACTIONS_BOT) {
40
+ return { ok: true, reason: "version-packages-pr" };
41
+ }
42
+ if (labels.includes(NO_PUBLISH_LABEL)) {
43
+ return { ok: true, reason: "no-publish-label" };
44
+ }
45
+ if (changes.some((change) => change.status === "A" && isChangeset(change.path))) {
46
+ return { ok: true, reason: "changeset-added" };
47
+ }
48
+ return { ok: false, reason: "missing-changeset" };
49
+ }
50
+
51
+ /**
52
+ * Resolve the command changesets/action should exec for publishing, based on
53
+ * the labels of the PR merged for the pushed commit.
54
+ */
55
+ export function resolvePublishCommand(labels) {
56
+ return labels.includes(NO_PUBLISH_LABEL) ? NO_PUBLISH_COMMAND : PUBLISH_COMMAND;
57
+ }
58
+
59
+ function isChangeset(path) {
60
+ return CHANGESET_PATH.test(path) && !CHANGESET_EXEMPT_PATHS.has(path);
61
+ }
@@ -0,0 +1,107 @@
1
+ // Verify that a dispatched pipeline-stage worker actually delivered.
2
+ //
3
+ // Why this exists: a worker's `---REPORT---` is a claim, not evidence. A stage
4
+ // that reports `OUTCOME: SUCCESS` and leaves its work uncommitted looks
5
+ // identical, from the report alone, to one that committed and pushed — and the
6
+ // orchestrator opens the feature PR from the branch, so uncommitted work is
7
+ // silently dropped. The observed failure was exactly that: workers reporting
8
+ // success with a dirty worktree, every one of them needing a human to finish
9
+ // the tree by hand.
10
+ //
11
+ // This module answers one question against the worktree itself: did this worker
12
+ // leave a clean tree with at least one commit ahead of where it started? It
13
+ // reads nothing the worker wrote, so it cannot be fooled by the worker's prose.
14
+ //
15
+ // Usage (CLI):
16
+ // node scripts/lib/verify-worker-tree.mjs --worktree <dir> --base <sha>
17
+ // node scripts/lib/verify-worker-tree.mjs --worktree <dir> --base <ref> --json
18
+ //
19
+ // Exit code is 0 only when the worker delivered, so a caller can gate on it.
20
+
21
+ import { execFile } from "node:child_process";
22
+ import { promisify } from "node:util";
23
+ import { resolve } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ const execFileAsync = promisify(execFile);
27
+ const SELF = fileURLToPath(import.meta.url);
28
+
29
+ async function git(worktree, args) {
30
+ const { stdout } = await execFileAsync("git", args, { cwd: worktree });
31
+ return stdout.trim();
32
+ }
33
+
34
+ function countPaths(porcelain) {
35
+ return porcelain.split("\n").filter((line) => line.trim() !== "").length;
36
+ }
37
+
38
+ /**
39
+ * Check that a worker delivered: clean worktree and at least one commit ahead
40
+ * of `base`. Returns the reasons it failed, so the caller can surface them
41
+ * instead of guessing why a stage did not advance.
42
+ */
43
+ export async function verifyWorkerTree({ worktree, base }) {
44
+ const reasons = [];
45
+
46
+ const dirty = await git(worktree, ["status", "--porcelain"]);
47
+ if (dirty !== "") {
48
+ reasons.push(`worktree is dirty: ${countPaths(dirty)} uncommitted path(s)`);
49
+ }
50
+
51
+ const ahead = Number(await git(worktree, ["rev-list", "--count", `${base}..HEAD`]));
52
+ if (!Number.isFinite(ahead) || ahead < 1) {
53
+ reasons.push(`no commit ahead of base ${base.slice(0, 7)}`);
54
+ }
55
+
56
+ return { ok: reasons.length === 0, reasons, commitsAhead: ahead };
57
+ }
58
+
59
+ // ─── CLI ─────────────────────────────────────────────────────────────────────
60
+
61
+ function parseFlags(argv) {
62
+ const flags = {};
63
+ for (let i = 0; i < argv.length; i++) {
64
+ const arg = argv[i];
65
+ if (!arg.startsWith("--")) continue;
66
+ const key = arg.slice(2);
67
+ const next = argv[i + 1];
68
+ if (next === undefined || next.startsWith("--")) {
69
+ flags[key] = true;
70
+ } else {
71
+ flags[key] = next;
72
+ i++;
73
+ }
74
+ }
75
+ return flags;
76
+ }
77
+
78
+ function required(flags, name) {
79
+ const value = flags[name];
80
+ if (typeof value !== "string" || value === "") {
81
+ console.error(`verify-worker-tree: --${name} <value> is required`);
82
+ process.exit(2);
83
+ }
84
+ return value;
85
+ }
86
+
87
+ async function main(argv) {
88
+ const flags = parseFlags(argv);
89
+ const verdict = await verifyWorkerTree({
90
+ worktree: required(flags, "worktree"),
91
+ base: required(flags, "base"),
92
+ });
93
+
94
+ if (flags.json) {
95
+ console.log(JSON.stringify(verdict, null, 2));
96
+ } else if (verdict.ok) {
97
+ console.log(`ok: ${verdict.commitsAhead} commit(s) ahead of base, tree clean`);
98
+ } else {
99
+ for (const reason of verdict.reasons) console.error(`failed: ${reason}`);
100
+ }
101
+
102
+ process.exit(verdict.ok ? 0 : 1);
103
+ }
104
+
105
+ if (process.argv[1] && resolve(process.argv[1]) === SELF) {
106
+ await main(process.argv.slice(2));
107
+ }
@@ -0,0 +1,17 @@
1
+ // Release gate: print the single-token publish command changesets/action
2
+ // should exec. The merged PR's labels arrive as a JSON array in argv[2]; the
3
+ // action splits the printed command on whitespace with no shell, so it must
4
+ // stay a plain `pnpm <script>` token pair.
5
+ import { resolvePublishCommand } from "./lib/release-gates.mjs";
6
+
7
+ function readLabels(raw) {
8
+ try {
9
+ return JSON.parse(raw);
10
+ } catch {
11
+ console.error("::error::labels argument must be a JSON array of label names.");
12
+ process.exit(1);
13
+ }
14
+ }
15
+
16
+ const labels = readLabels(process.argv[2] ?? "[]");
17
+ process.stdout.write(`${resolvePublishCommand(labels)}\n`);
File without changes
@@ -27,7 +27,7 @@ description: "Grilling session that challenges your plan against the existing do
27
27
  5. **Cross-reference with code.** When the user states how something works, check whether the code agrees. Surface contradictions the moment you find them.
28
28
  6. **Update `CONTEXT.md` inline.** When a term resolves, write the entry right there — do not batch. Keep it a pure glossary: no implementation details, no specs, no scratch notes. Entry format and file-discovery rules live in `REFERENCE.md`.
29
29
  7. **Offer ADRs sparingly.** Propose an ADR only when all three hold: hard to reverse, surprising without context, and the result of a real trade-off. If any is missing, skip it. ADR format lives in `REFERENCE.md`.
30
- 8. **Close when shared understanding is reached.** Confirm every design-tree branch is resolved or explicitly deferred, and that the glossary diff captures the session's new language.
30
+ 8. **Close when shared understanding is reached.** Confirm every design-tree branch is resolved or explicitly deferred, that the glossary diff captures the session's new language, and — when the session feeds a bead — that there is enough agreed detail to fill every required section of the target bead type (see `bd-tool` → Required sections). Never invent section content; surface assumptions and confirm them.
31
31
 
32
32
  ## Red Flags
33
33
 
@@ -88,4 +88,5 @@ which voids an order before fulfilment. See cancellation.
88
88
  - [ ] Terminology conflicts and vague terms called out the moment they appeared
89
89
  - [ ] Claims cross-referenced against code; contradictions surfaced
90
90
  - [ ] ADRs offered only when all three criteria held (offering none is a valid outcome)
91
- - [ ] All design-tree branches resolved or explicitly deferred before closing
91
+ - [ ] All design-tree branches resolved or explicitly deferred before closing
92
+ - [ ] When feeding a bead, enough agreed detail to fill every required section; no fabricated answers
@@ -43,6 +43,11 @@ Describe the architectural friction:
43
43
  - What integration risk exists in the seams between them
44
44
  - Why this makes the codebase harder to navigate and maintain
45
45
 
46
+ ## Acceptance Criteria
47
+
48
+ - [ ] <verifiable outcome 1>
49
+ - [ ] <verifiable outcome 2>
50
+
46
51
  ## Proposed Interface
47
52
 
48
53
  The chosen interface design:
@@ -43,7 +43,7 @@ A **deep module** (John Ousterhout, "A Philosophy of Software Design") has a sma
43
43
 
44
44
  6. **User picks an interface** (or accepts the recommendation).
45
45
 
46
- 7. **Create the refactor bead.** Run the `create-task` skill with a concise title, the description from the template in [REFERENCE.md](REFERENCE.md), and priority P2. Always use `create-task` — never `bd create` directly — because it classifies the bead and expands AFK work into pipeline stages ralph can execute. Do not ask the user to review before creating; share the bead ID.
46
+ 7. **Create the refactor bead.** Run the `create-task` skill with a concise title, the description from the template in [REFERENCE.md](REFERENCE.md), and priority P2. The template's `## Acceptance Criteria` is required (enforced by `create-task`'s `--validate`) and must reflect outcomes the user agreed, not assumptions. Always use `create-task` — never `bd create` directly — because it classifies the bead and expands AFK work into pipeline stages ralph can execute. Do not ask the user to review before creating; share the bead ID.
47
47
 
48
48
  ## Red Flags
49
49
 
@@ -95,5 +95,6 @@ Output: numbered deepening candidates (cluster, coupling, dependency category, t
95
95
  - [ ] At least two radically different designs, each with signature, usage example, hidden complexity, dependency strategy, and trade-offs
96
96
  - [ ] Recommendation given in prose, with a hybrid proposed where elements combine well
97
97
  - [ ] Refactor bead created via `create-task` at P2 using the REFERENCE.md description template
98
+ - [ ] Description carries agreed `## Acceptance Criteria`; the bead lints clean
98
99
  - [ ] Bead ID reported and no review gate imposed before creation
99
100
  - [ ] No unrelated areas explored or refactors proposed outside the chosen candidate
@@ -5,7 +5,7 @@ description: Create a task bead, classify it as AFK or HITL, and if AFK expand i
5
5
 
6
6
  # Create Task
7
7
 
8
- Turn a piece of work into a tracked bead, classify it for autonomous (AFK) or human-in-the-loop (HITL) execution, and — when AFK — expand it into the standard pipeline: one chore bead per stage plus a HITL PR gate task so a human always reviews the finished branch. Parameters: `title` (required), `description` (required), `priority` (0–3, default 2), `parent` (epic bead ID, optional — sets a `parent-child` dependency).
8
+ Turn a piece of work into a tracked bead, classify it for autonomous (AFK) or human-in-the-loop (HITL) execution, and — when AFK — expand it into the standard pipeline: one chore bead per stage plus a HITL PR gate task so a human always reviews the finished branch. Parameters: `title` (required), `description` (required), `priority` (0–3, default 2), `parent` (epic bead ID, optional — sets a `parent-child` dependency). The description must carry the task's required section — `## Acceptance Criteria` — which `--validate` enforces; fill it from agreed answers, not assumptions (see `bd-tool` → Required sections).
9
9
 
10
10
  ## When to use
11
11
 
@@ -21,10 +21,13 @@ Turn a piece of work into a tracked bead, classify it for autonomous (AFK) or hu
21
21
 
22
22
  ## Workflow
23
23
 
24
- 1. **Create the parent bead.**
24
+ 1. **Create the parent bead.** The description must contain the task's required section —
25
+ `## Acceptance Criteria` — or `--validate` rejects creation. Fill it from agreed answers, not
26
+ assumptions (see `bd-tool` → Required sections).
25
27
 
26
28
  ```bash
27
- bd create "<title>" --description "<description>" --priority <priority>
29
+ bd create "<title>" --description "<description>" --priority <priority> --validate
30
+ bd lint <new-id>
28
31
  ```
29
32
 
30
33
  If a `parent` epic was provided, record the dependency:
@@ -65,6 +68,7 @@ Turn a piece of work into a tracked bead, classify it for autonomous (AFK) or hu
65
68
 
66
69
  ## Red Flags
67
70
 
71
+ - **Filling acceptance criteria with assumptions.** Fabricated criteria pass `--validate` but mislead the pipeline. The description is the interview's output — confirm it with the human instead.
68
72
  - **Running the script from the wrong directory.** Chores land in whatever `.beads` tree `bd` resolves from cwd — the target project's root is the only safe cwd.
69
73
  - **Spawning the `classify-bead` subagent when the classifier already resolved the bead.** The deterministic script is the short-circuit; a subagent is only for `escalate: true`.
70
74
  - **Skipping the classifier and classifying by hand.** The rubric is a subagent's job; a hand-rolled guess drifts from it.
@@ -101,6 +105,7 @@ The full bead-property contract (titles, labels, dependencies per chore and for
101
105
  ## Verification checklist
102
106
 
103
107
  - [ ] Parent bead exists with the correct title, description, and priority.
108
+ - [ ] Parent bead created with `--validate`, and `bd lint <id>` reports no missing sections.
104
109
  - [ ] `parent-child` dependency added when a parent epic was supplied.
105
110
  - [ ] `implementation-type` label present on the parent bead (`bd label list <id>`) — resolved by the classifier or, on escalation, the rubric subagent.
106
111
  - [ ] HITL path: no pipeline chores created, caller told the classification. AFK path: script ran from the target project's cwd and its JSON contains every stage ID plus `featurePrReview`.
@@ -101,8 +101,11 @@ const prGateBeadId = run([
101
101
  'bd', 'create', JSON.stringify(`[${parentId}] PR Review and Merge`),
102
102
  '--type', 'task',
103
103
  '--description', JSON.stringify(
104
- 'Human review gate: review and merge the agent PR into the feature branch before closing this feature.',
104
+ 'Human review gate: review and merge the agent PR into the feature branch before closing this feature.\n\n## Acceptance Criteria\n\n- [ ] Agent PR reviewed and merged into the feature branch.',
105
105
  ),
106
+ // The gate is type `task`, so `--validate` enforces its `## Acceptance Criteria`
107
+ // section. Stage chores require no sections and deliberately omit the flag.
108
+ '--validate',
106
109
  '--priority', priority,
107
110
  '--labels', 'implementation-type:hitl,lifecycle:feature-pr',
108
111
  '--parent', parentId,