From 3460301d2edbb94cbcfc89041ab6bddbac54e3f2 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sun, 19 Jul 2026 14:41:14 -0400 Subject: [PATCH] chore: sync github coordination surfaces --- .../github-coordination/lib/attribution.js | 46 +++++ .../github-coordination/lib/pr-issue-link.js | 135 ++++++++++++++ .../lib/resolve-template.js | 84 +++++++++ .../references/issue-plan.md | 67 +++++++ .../templates/announcement.md | 26 +++ .../github-coordination/templates/bug.md | 35 ++++ .../github-coordination/templates/feature.md | 32 ++++ .../templates/parent-issue.md | 25 +++ .../github-coordination/templates/task.md | 28 +++ .../github-coordination/templates/ui-bug.md | 34 ++++ .../github-coordination/templates/ux-issue.md | 28 +++ .opencode/skills/audit-issues/SKILL.md | 171 ++++++++++++++++++ .opencode/skills/guided-issue-filing/SKILL.md | 101 +++++++++++ .opencode/skills/manage-announcement/SKILL.md | 89 +++++++++ .opencode/skills/show-repo-status/SKILL.md | 81 +++++++++ .opencode/skills/to-issues/SKILL.md | 20 ++ .../ai-lab-runtime/canonical-hook-path.js | 28 +++ .../ai-lab-runtime/checked-extensions.js | 55 ++++++ .../map-pi-payload-to-claude.js | 99 ++++++++++ .../ai-lab-runtime/matcher-semantics.js | 32 ++++ .../ai-lab-runtime/package.json | 1 + .../parse-canonical-lifecycle-output.js | 78 ++++++++ .../ai-lab-runtime/parse-hook-decision.js | 83 +++++++++ .../parse-pi-lifecycle-output.js | 37 ++++ .../ai-lab-runtime/project-narrow.js | 65 +++++++ .../ai-lab-runtime/run-canonical-hook.js | 141 +++++++++++++++ .../write-tool-surface-loader.js | 74 ++++++++ .../ai-lab-runtime/write-tool-surface.json | 86 +++++++++ .../hooks/agent-issue-cli-status.js | 76 ++++++++ .../github-coordination/hooks/hooks.json | 17 ++ .../github-coordination/hooks/package.json | 1 + .pi/extensions/github-coordination/index.ts | 144 +++++++++++++++ .../github-coordination/lib/attribution.js | 46 +++++ .../github-coordination/lib/package.json | 1 + .../github-coordination/lib/pr-issue-link.js | 135 ++++++++++++++ .../lib/resolve-template.js | 84 +++++++++ .../github-coordination/lib/attribution.js | 46 +++++ .../github-coordination/lib/pr-issue-link.js | 135 ++++++++++++++ .../lib/resolve-template.js | 84 +++++++++ .../references/issue-plan.md | 67 +++++++ .../templates/announcement.md | 26 +++ .../github-coordination/templates/bug.md | 35 ++++ .../github-coordination/templates/feature.md | 32 ++++ .../templates/parent-issue.md | 25 +++ .../github-coordination/templates/task.md | 28 +++ .../github-coordination/templates/ui-bug.md | 34 ++++ .../github-coordination/templates/ux-issue.md | 28 +++ .pi/skills/audit-issues/SKILL.md | 171 ++++++++++++++++++ .pi/skills/guided-issue-filing/SKILL.md | 101 +++++++++++ .pi/skills/manage-announcement/SKILL.md | 89 +++++++++ .pi/skills/show-repo-status/SKILL.md | 81 +++++++++ .pi/skills/to-issues/SKILL.md | 20 ++ 52 files changed, 3287 insertions(+) create mode 100644 .opencode/skill-assets/github-coordination/lib/attribution.js create mode 100644 .opencode/skill-assets/github-coordination/lib/pr-issue-link.js create mode 100644 .opencode/skill-assets/github-coordination/lib/resolve-template.js create mode 100644 .opencode/skill-assets/github-coordination/references/issue-plan.md create mode 100644 .opencode/skill-assets/github-coordination/templates/announcement.md create mode 100644 .opencode/skill-assets/github-coordination/templates/bug.md create mode 100644 .opencode/skill-assets/github-coordination/templates/feature.md create mode 100644 .opencode/skill-assets/github-coordination/templates/parent-issue.md create mode 100644 .opencode/skill-assets/github-coordination/templates/task.md create mode 100644 .opencode/skill-assets/github-coordination/templates/ui-bug.md create mode 100644 .opencode/skill-assets/github-coordination/templates/ux-issue.md create mode 100644 .opencode/skills/audit-issues/SKILL.md create mode 100644 .opencode/skills/guided-issue-filing/SKILL.md create mode 100644 .opencode/skills/manage-announcement/SKILL.md create mode 100644 .opencode/skills/show-repo-status/SKILL.md create mode 100644 .opencode/skills/to-issues/SKILL.md create mode 100644 .pi/extensions/github-coordination/ai-lab-runtime/canonical-hook-path.js create mode 100644 .pi/extensions/github-coordination/ai-lab-runtime/checked-extensions.js create mode 100644 .pi/extensions/github-coordination/ai-lab-runtime/map-pi-payload-to-claude.js create mode 100644 .pi/extensions/github-coordination/ai-lab-runtime/matcher-semantics.js create mode 100644 .pi/extensions/github-coordination/ai-lab-runtime/package.json create mode 100644 .pi/extensions/github-coordination/ai-lab-runtime/parse-canonical-lifecycle-output.js create mode 100644 .pi/extensions/github-coordination/ai-lab-runtime/parse-hook-decision.js create mode 100644 .pi/extensions/github-coordination/ai-lab-runtime/parse-pi-lifecycle-output.js create mode 100644 .pi/extensions/github-coordination/ai-lab-runtime/project-narrow.js create mode 100644 .pi/extensions/github-coordination/ai-lab-runtime/run-canonical-hook.js create mode 100644 .pi/extensions/github-coordination/ai-lab-runtime/write-tool-surface-loader.js create mode 100644 .pi/extensions/github-coordination/ai-lab-runtime/write-tool-surface.json create mode 100644 .pi/extensions/github-coordination/hooks/agent-issue-cli-status.js create mode 100644 .pi/extensions/github-coordination/hooks/hooks.json create mode 100644 .pi/extensions/github-coordination/hooks/package.json create mode 100644 .pi/extensions/github-coordination/index.ts create mode 100644 .pi/extensions/github-coordination/lib/attribution.js create mode 100644 .pi/extensions/github-coordination/lib/package.json create mode 100644 .pi/extensions/github-coordination/lib/pr-issue-link.js create mode 100644 .pi/extensions/github-coordination/lib/resolve-template.js create mode 100644 .pi/skill-assets/github-coordination/lib/attribution.js create mode 100644 .pi/skill-assets/github-coordination/lib/pr-issue-link.js create mode 100644 .pi/skill-assets/github-coordination/lib/resolve-template.js create mode 100644 .pi/skill-assets/github-coordination/references/issue-plan.md create mode 100644 .pi/skill-assets/github-coordination/templates/announcement.md create mode 100644 .pi/skill-assets/github-coordination/templates/bug.md create mode 100644 .pi/skill-assets/github-coordination/templates/feature.md create mode 100644 .pi/skill-assets/github-coordination/templates/parent-issue.md create mode 100644 .pi/skill-assets/github-coordination/templates/task.md create mode 100644 .pi/skill-assets/github-coordination/templates/ui-bug.md create mode 100644 .pi/skill-assets/github-coordination/templates/ux-issue.md create mode 100644 .pi/skills/audit-issues/SKILL.md create mode 100644 .pi/skills/guided-issue-filing/SKILL.md create mode 100644 .pi/skills/manage-announcement/SKILL.md create mode 100644 .pi/skills/show-repo-status/SKILL.md create mode 100644 .pi/skills/to-issues/SKILL.md diff --git a/.opencode/skill-assets/github-coordination/lib/attribution.js b/.opencode/skill-assets/github-coordination/lib/attribution.js new file mode 100644 index 0000000..d4212e4 --- /dev/null +++ b/.opencode/skill-assets/github-coordination/lib/attribution.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +/** + * Canonical attribution identity for github-coordination writes. + * + * @why One source for the reused dotfiles bot identity so every command, skill, + * and hook stamps the same Bot Attribution label and the same visible Agent + * Attribution Marker โ€” inventing a second bot identity is exactly what the + * coordination contract forbids. + * + * The dotfiles bot identity is the Jakuta Review Bot GitHub App + * (manifests/dev-repos.toml -> jakuta-review-bot; app.yml "Jakuta Review Bot"). + * Agent-filed issue plans use `jakuta-agent-issue apply-plan`, so issue writes + * are authored by the app bot. The visible marker remains the durable + * provenance line for readers. + */ + +"use strict"; + +// The reused dotfiles bot identity. Not the operator's personal git user. +const BOT_ATTRIBUTION_LABEL = "Jakuta Review Bot"; +const BOT_ATTRIBUTION_SLUG = "jakuta-review-bot[bot]"; + +// The literal text that tells a human reader an agent authored this record. +// Must stay visible in the Canonical Issue Body, never hidden metadata alone. +const AGENT_ATTRIBUTION_MARKER = + "> ๐Ÿค– **Agent-authored** โ€” an agent wrote this coordination record. " + + "Attributed to the " + BOT_ATTRIBUTION_LABEL + " (`" + BOT_ATTRIBUTION_SLUG + "`), " + + "the reused dotfiles bot identity; no second bot identity is invented."; + +/** + * @why Appends the visible marker to a body only when it is not already there, + * so re-running a guided flow over an existing body cannot stack duplicate + * markers. + */ +function withAgentMarker(body) { + const text = typeof body === "string" ? body : ""; + if (text.includes("an agent wrote this")) return text; + return text.replace(/\s*$/, "") + "\n\n" + AGENT_ATTRIBUTION_MARKER + "\n"; +} + +module.exports = { + BOT_ATTRIBUTION_LABEL, + BOT_ATTRIBUTION_SLUG, + AGENT_ATTRIBUTION_MARKER, + withAgentMarker, +}; diff --git a/.opencode/skill-assets/github-coordination/lib/pr-issue-link.js b/.opencode/skill-assets/github-coordination/lib/pr-issue-link.js new file mode 100644 index 0000000..22027ee --- /dev/null +++ b/.opencode/skill-assets/github-coordination/lib/pr-issue-link.js @@ -0,0 +1,135 @@ +#!/usr/bin/env node +/** + * Detects whether a `gh pr create` / `gh pr edit` command either closes a + * linked issue or proves an exact development-to-production promotion. + * + * @why Ordinary pull requests implement an issue and must close it. A direct + * development-to-production promotion implements no new issue, so it instead + * carries the evidence that makes that release move reviewable. Kept pure + * (string in, named outcome out) so the hook stays a thin adapter and the rule + * is unit-checkable. + */ + +"use strict"; + +const fs = require("fs"); + +// GitHub's closing keywords, followed by an issue ref (#123 or a full URL). +const CLOSING_REFERENCE = + /\b(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\b\s*:?\s+(#\d+|https?:\/\/github\.com\/[^/\s]+\/[^/\s]+\/issues\/\d+)/i; + +const PROMOTION_PROOF_LINES = [ + /^##\s+Promotion proof\s*$/im, + /^- Development commit:\s+[0-9a-f]{7,40}\s*$/im, + /^- Production commit:\s+[0-9a-f]{7,40}\s*$/im, + /^- Production history:\s+\S.+$/im, + /^- Required checks:\s+\S.+$/im, + /^- Protection:\s+no force-push or bypass\.?\s*$/im, +]; + +/** + * Classifies a shell command string. + * + * Outcome kinds: + * NotPrWrite โ€” not a `gh pr create`/`edit`; the hook ignores it. + * HasClosingReference โ€” a closing keyword + issue ref was found in the body. + * HasPromotionProof โ€” direct development-to-production PR with release proof. + * BodyUnreadable โ€” a --body-file was named but could not be read. + * MissingPromotionProof โ€” direct promotion missing its required proof body. + * MissingClosingRef โ€” a PR write with no resolvable closing reference. + */ +function classifyPrCommand(command) { + const text = typeof command === "string" ? command : ""; + + const isPrWrite = /\bgh\s+pr\s+(create|edit)\b/.test(text); + if (!isPrWrite) return { kind: "NotPrWrite" }; + + const bodyTexts = []; + const inline = extractInlineBody(text); + if (inline !== null) { + bodyTexts.push({ source: "inline", text: inline }); + if (CLOSING_REFERENCE.test(inline)) { + return { kind: "HasClosingReference", source: "inline" }; + } + } + + const bodyFile = extractBodyFilePath(text); + if (bodyFile !== null) { + let fileText; + try { + fileText = fs.readFileSync(bodyFile, "utf8"); + } catch (err) { + return { kind: "BodyUnreadable", path: bodyFile, error: String(err && err.message ? err.message : err) }; + } + bodyTexts.push({ source: "body-file", text: fileText }); + if (CLOSING_REFERENCE.test(fileText)) { + return { kind: "HasClosingReference", source: "body-file" }; + } + } + + // Some callers put the closing ref in --title; honor it so a legitimately + // linked PR is never blocked on a body/title technicality. + const title = extractFlagValue(text, "title") || extractFlagValue(text, "t"); + if (title !== null && CLOSING_REFERENCE.test(title)) { + return { kind: "HasClosingReference", source: "title" }; + } + + for (const bodyText of bodyTexts) { + if (isDevelopmentToProductionPromotion(text) && hasPromotionProof(bodyText.text)) { + return { kind: "HasPromotionProof", source: bodyText.source }; + } + } + + if (isDevelopmentToProductionPromotion(text)) { + return { kind: "MissingPromotionProof" }; + } + + return { kind: "MissingClosingRef" }; +} + +function isDevelopmentToProductionPromotion(text) { + return ( + /\bgh\s+pr\s+create\b/.test(text) && + extractFlagValue(text, "base") === "production" && + extractFlagValue(text, "head") === "development" + ); +} + +function hasPromotionProof(bodyText) { + return PROMOTION_PROOF_LINES.every((requirement) => requirement.test(bodyText)); +} + +function extractInlineBody(text) { + const long = extractFlagValue(text, "body"); + if (long !== null) return long; + const short = extractFlagValue(text, "b"); + if (short !== null) return short; + return null; +} + +function extractBodyFilePath(text) { + return extractFlagValue(text, "body-file") || extractFlagValue(text, "F"); +} + +/** + * Pulls the value of --flag / -f from a command string, handling single + * quotes, double quotes, and bare tokens. Returns null when the flag is absent. + */ +function extractFlagValue(text, flag) { + const dash = flag.length === 1 ? "-" : "--"; + const head = dash + escapeForRegex(flag); + const re = new RegExp( + head + "(?:=|\\s+)(?:'([^']*)'|\"((?:[^\"\\\\]|\\\\.)*)\"|(\\S+))" + ); + const m = text.match(re); + if (!m) return null; + if (m[1] !== undefined) return m[1]; + if (m[2] !== undefined) return m[2].replace(/\\(["\\])/g, "$1"); + return m[3]; +} + +function escapeForRegex(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +module.exports = { classifyPrCommand, CLOSING_REFERENCE }; diff --git a/.opencode/skill-assets/github-coordination/lib/resolve-template.js b/.opencode/skill-assets/github-coordination/lib/resolve-template.js new file mode 100644 index 0000000..6b43e74 --- /dev/null +++ b/.opencode/skill-assets/github-coordination/lib/resolve-template.js @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * Resolve the issue-body template for a Work Type, letting a repo override the + * plugin default. + * + * @why A venture repo often has its own issue format. This is the single lookup + * both guided-issue-filing and manage-announcement use, so the override rule + * cannot drift between them: a committed repo-local template at + * /.github-coordination/templates/.md wins; otherwise the + * plugin's bundled templates/ default is used. + * + * CLI: `node resolve-template.js ` prints the resolved absolute path + * on stdout and the source (RepoOverride | PluginDefault) on stderr. Exits 2 on + * an unknown Work Type so a caller never silently files against the wrong shape. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +// Work Type -> template filename. parent uses parent-issue.md; the rest match. +const WORK_TYPE_FILE = { + bug: "bug.md", + "ui-bug": "ui-bug.md", + "ux-issue": "ux-issue.md", + feature: "feature.md", + task: "task.md", + parent: "parent-issue.md", + announcement: "announcement.md", +}; + +// A venture drops its own templates here, committed and shared with the repo. +const REPO_OVERRIDE_DIR = path.join(".github-coordination", "templates"); + +function repoRoot() { + try { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + // @why Outside a git repo there is no override location; fall back to the + // plugin default rather than failing. + return null; + } +} + +/** + * @why Returns a tagged outcome (UnknownWorkType | RepoOverride | PluginDefault) + * so the CLI and any future caller branch on the source explicitly instead of + * guessing from a bare path string. + */ +function resolveTemplate(workType) { + const file = WORK_TYPE_FILE[workType]; + if (!file) return { kind: "UnknownWorkType", workType }; + + const pluginRoot = path.resolve(__dirname, ".."); + const root = repoRoot(); + if (root) { + const override = path.join(root, REPO_OVERRIDE_DIR, file); + if (fs.existsSync(override)) return { kind: "RepoOverride", path: override }; + } + return { kind: "PluginDefault", path: path.join(pluginRoot, "templates", file) }; +} + +function main() { + const workType = process.argv[2]; + const outcome = resolveTemplate(workType); + if (outcome.kind === "UnknownWorkType") { + process.stderr.write( + "resolve-template: unknown Work Type '" + String(workType) + "'. " + + "Known: " + Object.keys(WORK_TYPE_FILE).join(", ") + "\n" + ); + process.exit(2); + } + process.stderr.write(outcome.kind + "\n"); + process.stdout.write(outcome.path + "\n"); +} + +if (require.main === module) main(); + +module.exports = { resolveTemplate, WORK_TYPE_FILE, REPO_OVERRIDE_DIR }; diff --git a/.opencode/skill-assets/github-coordination/references/issue-plan.md b/.opencode/skill-assets/github-coordination/references/issue-plan.md new file mode 100644 index 0000000..631a014 --- /dev/null +++ b/.opencode/skill-assets/github-coordination/references/issue-plan.md @@ -0,0 +1,67 @@ +# Issue Plan Contract + + +Represent every coordinated issue write as one JSON Issue Plan and submit it once: + +```bash +jakuta-agent-issue apply-plan --repo OWNER/REPO --plan-file +``` + +Use this exact shape: + +```json +{ + "plan_key": "checkout-recovery", + "references": { + "existing-parent": 12 + }, + "issues": [ + { + "key": "retry-failed-payment", + "title": "Retry a failed payment", + "body_file": "bodies/retry-failed-payment.md", + "labels": ["feature"], + "parent": "existing-parent", + "blocked_by": [] + } + ] +} +``` + +For an existing managed issue, add `"issue_number": 34`. Keys use lowercase +words separated by hyphens and stay stable across reruns. Body paths are +relative to the plan file. `parent` is a managed key, a key in `references`, or +`null`. Every `blocked_by` entry is likewise a managed or reference key. + +Before applying a plan, make it complete: + +- `title`, `body_file`, and `labels` are the exact desired issue state. +- `parent` is the exact desired native GitHub parent. +- `blocked_by` is the exact desired native GitHub blocker set. +- `references` names existing issues that supply relationships without becoming + managed issues. +- Every managed body includes the Agent Attribution Marker exactly once. + +When an existing issue enters a plan, read its current title, body, labels, and +native relationships first. Carry forward every value that should remain. Do +not submit a partial representation and expect omitted state to survive. + +Use GitHub reads to inspect native relationships: + +```bash +gh api --paginate repos/OWNER/REPO/issues/NUMBER/sub_issues +gh api --paginate repos/OWNER/REPO/issues/NUMBER/dependencies/blocked_by +gh api graphql -F owner=OWNER -F name=REPO -F number=NUMBER \ + -f query='query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){issue(number:$number){parent{number}}}}' +``` + +Run one mutation command for the whole request. Read the structured result to +report the resolved issue numbers and URLs. A rerun uses the same plan and keys; +the command reconciles instead of creating duplicates. + + + +The plan is the whole desired result, not a sequence of issue-sized operations. +Validation happens before GitHub writes, and the command owns creation, updates, +labels, native parent links, and native blocked-by links together. + diff --git a/.opencode/skill-assets/github-coordination/templates/announcement.md b/.opencode/skill-assets/github-coordination/templates/announcement.md new file mode 100644 index 0000000..b069f5a --- /dev/null +++ b/.opencode/skill-assets/github-coordination/templates/announcement.md @@ -0,0 +1,26 @@ + + +## Headline + + + +## Details + + + +## Applies to + + + +## Links + +- + + diff --git a/.opencode/skill-assets/github-coordination/templates/bug.md b/.opencode/skill-assets/github-coordination/templates/bug.md new file mode 100644 index 0000000..7f95699 --- /dev/null +++ b/.opencode/skill-assets/github-coordination/templates/bug.md @@ -0,0 +1,35 @@ + + +## What's broken + + + +## Steps to reproduce + +1. +2. +3. + +## Expected vs actual + +- **Expected:** +- **Actual:** + +## Environment + + + +## Evidence-Backed Acceptance Criteria + + + +- [ ] The reproduction no longer triggers the failure โ€” Evidence: +- [ ] A regression test covers this path โ€” Evidence: +- [ ] The fix PR references and closes this issue โ€” Evidence: + + diff --git a/.opencode/skill-assets/github-coordination/templates/feature.md b/.opencode/skill-assets/github-coordination/templates/feature.md new file mode 100644 index 0000000..64fa551 --- /dev/null +++ b/.opencode/skill-assets/github-coordination/templates/feature.md @@ -0,0 +1,32 @@ + + +## Outcome + + + +## Why it matters + + + +## Scope + +What's in: +- + +What's out (so the issue does not creep): +- + +## Evidence-Backed Acceptance Criteria + + + +- [ ] A user can perform the outcome end to end. Evidence: +- [ ] The new path has test coverage. Evidence: +- [ ] The implementing PR references and closes this issue. Evidence: + + diff --git a/.opencode/skill-assets/github-coordination/templates/parent-issue.md b/.opencode/skill-assets/github-coordination/templates/parent-issue.md new file mode 100644 index 0000000..c6dbdd1 --- /dev/null +++ b/.opencode/skill-assets/github-coordination/templates/parent-issue.md @@ -0,0 +1,25 @@ + + +## Outcome + + + +## Why this is one piece of work + + + +## Out of scope + + + +## Done when + +This parent closes when every native GitHub child issue is closed with its evidence attached. + + diff --git a/.opencode/skill-assets/github-coordination/templates/task.md b/.opencode/skill-assets/github-coordination/templates/task.md new file mode 100644 index 0000000..49f8d13 --- /dev/null +++ b/.opencode/skill-assets/github-coordination/templates/task.md @@ -0,0 +1,28 @@ + + +## The task + + + +## Why now + + + +## Done looks like + + + +## Evidence-Backed Acceptance Criteria + + + +- [ ] The work is complete. Evidence: +- [ ] Anything it touched still passes its checks. Evidence: +- [ ] The PR references and closes this issue. Evidence: + + diff --git a/.opencode/skill-assets/github-coordination/templates/ui-bug.md b/.opencode/skill-assets/github-coordination/templates/ui-bug.md new file mode 100644 index 0000000..7a5aac5 --- /dev/null +++ b/.opencode/skill-assets/github-coordination/templates/ui-bug.md @@ -0,0 +1,34 @@ + + +## What looks wrong + + + +## Where + + + +## Steps to see it + +1. +2. + +## Expected vs actual appearance + +- **Expected:** +- **Actual:** + +## Evidence-Backed Acceptance Criteria + + + +- [ ] The element renders correctly at the reported viewport โ€” Evidence (before/after screenshot): +- [ ] No regression on the sibling screens that share the component โ€” Evidence: +- [ ] The fix PR references and closes this issue โ€” Evidence: + + diff --git a/.opencode/skill-assets/github-coordination/templates/ux-issue.md b/.opencode/skill-assets/github-coordination/templates/ux-issue.md new file mode 100644 index 0000000..08497f3 --- /dev/null +++ b/.opencode/skill-assets/github-coordination/templates/ux-issue.md @@ -0,0 +1,28 @@ + + +## The friction + + + +## Who hits it and when + + + +## What they expected to happen + + + +## Evidence-Backed Acceptance Criteria + + + +- [ ] A user can complete the task without the reported friction. Evidence: +- [ ] The change does not move the friction somewhere else in the flow. Evidence: +- [ ] The PR references and closes this issue. Evidence: + + diff --git a/.opencode/skills/audit-issues/SKILL.md b/.opencode/skills/audit-issues/SKILL.md new file mode 100644 index 0000000..240842f --- /dev/null +++ b/.opencode/skills/audit-issues/SKILL.md @@ -0,0 +1,171 @@ +--- +name: audit-issues +description: "Audit GitHub issues against repository state or evidence-backed acceptance criteria, then reconcile confirmed issue changes through one complete Issue Plan." +user-invocable: true +disable-model-invocation: true +--- +# Audit Issues + + +The first pass is read-only and returns one verdict per issue. A later confirmed +write is one coordinated plan, so a split, retitle, relabel, and relationship +change cannot land as separate half-finished operations. + + +## Read-only evidence pass + + +Do not close, edit, relabel, retitle, or comment until the read-only report is +complete and explicit user confirmation names the exact rows to mutate. An +initial request that already asks for a named batch update counts as confirmation +only for those named rows and changes. + +Resolve the repository, trunk, issue set, and linked pull requests: + +```bash +gh repo view --json nameWithOwner -q .nameWithOwner +git remote -v +git symbolic-ref --quiet refs/remotes/origin/HEAD +gh issue view --json number,title,body,state,labels,comments,url +gh issue list --state open --limit 200 --json number,title,body,state,labels,updatedAt,url +gh pr list --state all --limit 200 --json number,title,body,state,mergedAt,headRefName,baseRefName,url +``` + +For every issue, inspect its title, body, labels, comments, native parent, +native children, native blocked-by relationships, acceptance criteria, linked +pull requests, merged commits, branches, and exact repository evidence. Use the +native relationship reads from +`.opencode/skill-assets/github-coordination/references/issue-plan.md`; do not infer relationships from +body headings or checklists. + +Gather exact implementation evidence: + +```bash +git fetch --all --prune --quiet +git log --oneline --decorate --all --grep "" +git branch --all --list "**" +rg -n "" . +git diff --stat origin/...HEAD +git log --oneline -- +``` + +Run tests or generated-surface checks named by the issue, pull request, changed +paths, or repository rules. When the issue names exact files, commands, errors, +screenshots, or artifacts, check those exact things. Otherwise label any +conservative search terms as derived. + + +## Evidence-close classification + + +For every acceptance criterion, assign exactly one evidence state: + +- `PROVEN`: checked and backed by a followable pull request, URL, artifact, + screenshot, command result, or log. +- `UNPROVEN`: checked but its evidence is empty, vague, placeholder text, or not + independently verifiable. +- `OPEN`: unchecked. + +An issue is closeable only when every criterion is `PROVEN`. Comments may explain +the work, but the Canonical Issue Body must reference the evidence. + + +## Verdicts + + +Assign exactly one verdict per issue: + +```text +DONE ยท NOT_DONE ยท PARTIAL ยท STALE ยท SUPERSEDED ยท NEEDS_UPDATE +``` + +- `DONE`: every criterion has concrete proof. +- `NOT_DONE`: the requested outcome is absent or proof is missing. +- `PARTIAL`: some criteria have proof and some do not; name both groups. +- `STALE`: the described world no longer exists, but no replacement proves + closure. +- `SUPERSEDED`: a named issue, pull request, commit, branch, or product decision + replaces the issue. +- `NEEDS_UPDATE`: the issue stays open, but its title, body, labels, native + relationships, or acceptance criteria are wrong or incomplete. + +No `probably done`, `looks complete`, `close enough`, `maybe stale`, or free-form +verdicts. Never treat branch-name similarity, age, a closed but unmerged pull +request, a nearby implementation, worktree cleanliness, or memory as proof. + + +## Report + + +Render this read-only table before any write: + +```text +ISSUE VERDICT AUDIT +Repo: +Trunk: origin/ +Scope: + +| Issue | Verdict | Reason | Evidence | Missing proof | Proposed issue action | +|---|---|---|---|---|---| +| #12 Title | DONE | every criterion is implemented | PR #44 merged; src/auth/passkey.ts:38; named test exits 0 | none | close with evidence comment | +| #13 Title | PARTIAL | CLI exists, docs missing | pyproject.toml:27 declares the script | docs criterion | comment + keep open | +| #14 Title | NOT_DONE | requested endpoint is absent | exact search returns no hit; no linked PR | proof the endpoint exists | keep open | +| #15 Title | STALE | premise was removed | commit a1b2c3d deleted the named path | named replacement | comment + keep open | +| #16 Title | SUPERSEDED | newer issue owns the work | #28 and merged PR #44 replace it | none | close with replacement comment | +| #17 Title | NEEDS_UPDATE | issue names the wrong public surface | source exposes only the blessed surface | none | reconcile title and criteria | +``` + +Allowed proposed actions are `close`, `comment`, `retitle`, `relabel`, `split`, +`update native relationships`, and `keep open`. + +For evidence-close mode, append: + +```text +EVIDENCE-CLOSE CHECK โ€” # + +PROVEN (n) + - <criterion> โ€” <evidence> +UNPROVEN (n) + - <criterion> โ€” <missing or unverifiable evidence> +OPEN (n) + - <criterion> + +VERDICT: <CLOSEABLE | NOT_CLOSEABLE โ€” m unproven, k open> +``` +</what-to-do> + +## Apply confirmed changes + +<what-to-do> +After confirmation, divide the requested actions by intent: + +1. For every split, new issue, title, body, label, parent, or blocker change, + read the latest complete state and build one Issue Plan spanning every + affected issue. Include unchanged desired values because plan state is exact. +2. Read `.opencode/skill-assets/github-coordination/references/issue-plan.md` and submit the complete + plan once: + + ```bash + jakuta-agent-issue apply-plan --repo OWNER/REPO --plan-file <plan.json> + ``` + +3. Only after the plan succeeds, perform confirmed comment and closure actions: + + ```bash + gh issue comment <number> --body-file <evidence-summary.md> + gh issue close <number> + ``` + +Skip the plan only when the confirmed batch contains no issue creation, content, +label, parent, or blocker change. Do not close `PARTIAL`, `NOT_DONE`, `STALE`, or +`NEEDS_UPDATE`. Close `SUPERSEDED` only when the replacement is explicitly named +and linked in the confirmed comment. +</what-to-do> + +## Invariants + +<what-to-do> +Keep the first pass read-only, preserve missing proof as missing, use the closed +verdict set, require a concrete proposed action for every row, and send every +confirmed issue-state batch through one complete plan. +</what-to-do> diff --git a/.opencode/skills/guided-issue-filing/SKILL.md b/.opencode/skills/guided-issue-filing/SKILL.md new file mode 100644 index 0000000..b4e22af --- /dev/null +++ b/.opencode/skills/guided-issue-filing/SKILL.md @@ -0,0 +1,101 @@ +--- +name: guided-issue-filing +description: "File or reconcile one or more GitHub issues from Work Type templates through one complete Issue Plan, with clarifying questions, native parent and blocker routing, evidence-backed acceptance criteria, and agent attribution. Use when the user says 'file an issue', 'open a bug', 'log a feature', 'track this task', 'turn these findings into issues', or runs a /github-coordination:file-*-issue or /github-coordination:to-issues command." +user-invocable: true +--- +# Guided Issue Filing + +<supporting-info> +This is the single filing flow behind every Work Type command and the batch +`/github-coordination:to-issues` command. GitHub is the authoritative +coordination record. One Issue Plan expresses the complete result; callers do +not assemble issue writes or relationship updates step by step. +</supporting-info> + +## Confirm the repository and writer + +<what-to-do> +Resolve the repository and verify the bot writer before drafting: + +```bash +gh repo view --json nameWithOwner -q .nameWithOwner +jakuta-agent-issue doctor --repo OWNER/REPO +``` + +If the repository cannot be resolved or GitHub auth is unavailable, stop with +that blocker. If the CLI is missing, repair it through the dotfiles +`jakuta_agent_issue_cli` step. If `doctor` reports missing generated credentials, +repair the dotfiles machine environment and reopen the shell. If the GitHub App +is not installed or lacks issue write permission, surface that repository +blocker. All issue writes must remain on the bot-authored plan path. +</what-to-do> + +## Draft the complete issue set + +<what-to-do> +For each requested issue, resolve and read its Work Type template: + +```bash +node .opencode/skill-assets/github-coordination/lib/resolve-template.js <work-type> +``` + +A committed repository override at +`.github-coordination/templates/<work-type>.md` wins over the bundled template. +Fill every field supported by the user's input. Ask one grouped question only +when a required fact, Work Type, scope boundary, or routing choice remains truly +ambiguous. A batch gets one grouped clarification pass, not one interview per +issue. + +Keep each acceptance criterion concrete and leave its evidence slot empty at +filing time. Append the Agent Attribution Marker exactly once: + +```bash +node -e "process.stdout.write(require('.opencode/skill-assets/github-coordination/lib/attribution.js').AGENT_ATTRIBUTION_MARKER)" +``` + +Write each finished body to a separate file beside the plan file. +</what-to-do> + +## Route through GitHub's native graph + +<what-to-do> +Read open parent candidates and relevant existing issue relationships before +building the plan: + +```bash +gh issue list --state open --label parent --json number,title +``` + +If exactly one parent owns an issue, select it. If several plausibly own it, +ask once with the candidates and a standalone choice. Never invent a parent. +A parent Work Type is a root. Capture dependencies the user named as native +`blocked_by` relationships. Do not copy parents, children, or blockers into +issue-body fields or checklists. +</what-to-do> + +## Apply one complete plan + +<what-to-do> +Read `.opencode/skill-assets/github-coordination/references/issue-plan.md` and build one Issue Plan +covering the whole request. A single-file command produces a one-issue plan; a +batch produces one multi-issue plan. Include the full desired labels: the Work +Type label for every issue and `parent` for a parent Work Type. + +Submit the plan exactly once: + +```bash +jakuta-agent-issue apply-plan --repo OWNER/REPO --plan-file <plan.json> +``` + +Use stable issue keys so rerunning the same plan reconciles the same records. +Read the structured result and report every issue URL, Work Type, native parent +or standalone status, and blocker set in one final block. +</what-to-do> + +## Boundary + +<what-to-do> +Keep branch creation, commits, pushes, and worktrees in `github-workflow`. If the +user also asks to start implementation, hand the filed issue set to that +workflow after this plan succeeds. +</what-to-do> diff --git a/.opencode/skills/manage-announcement/SKILL.md b/.opencode/skills/manage-announcement/SKILL.md new file mode 100644 index 0000000..7a09934 --- /dev/null +++ b/.opencode/skills/manage-announcement/SKILL.md @@ -0,0 +1,89 @@ +--- +name: manage-announcement +description: "Create or update a repo-local announcement through one complete Issue Plan, preserving bot attribution and optionally pinning the resulting issue. Use when the user says 'post an announcement', 'announce the release', 'update the pinned notice', or runs /github-coordination:create-announcement or /github-coordination:update-announcement." +user-invocable: true +--- +# Manage Announcement + +<supporting-info> +An Announcement is a repository-local coordination record. Its issue content is +reconciled through the same bot-authored Issue Plan as every other issue write. +Pinning or publishing a release remains a separate action because it answers a +different user intent. +</supporting-info> + +## Confirm the repository and writer + +<what-to-do> +Resolve the repository and verify the issue writer: + +```bash +gh repo view --json nameWithOwner -q .nameWithOwner +jakuta-agent-issue doctor --repo OWNER/REPO +``` + +Stop on an unresolved repository, unavailable GitHub auth, failed generated +credential setup, missing app installation, or missing issue permission. Repair +a missing CLI through the dotfiles `jakuta_agent_issue_cli` step. Keep issue +writes on the bot-authored plan path. +</what-to-do> + +## Build the announcement body + +<what-to-do> +For a new announcement, resolve the repository override or bundled template: + +```bash +node .opencode/skill-assets/github-coordination/lib/resolve-template.js announcement +``` + +Fill the headline, details, affected surface, and links. Ask one grouped question +only for required facts that remain ambiguous. Append the Agent Attribution +Marker exactly once: + +```bash +node -e "process.stdout.write(require('.opencode/skill-assets/github-coordination/lib/attribution.js').AGENT_ATTRIBUTION_MARKER)" +``` + +For an update, resolve the issue number from the user or this read: + +```bash +gh issue list --state open --label announcement --json number,title +``` + +Read the current title, body, labels, and native relationships. Preserve the +marker and every desired value while changing only the announcement content the +user requested. +</what-to-do> + +## Apply one complete plan + +<what-to-do> +Read `.opencode/skill-assets/github-coordination/references/issue-plan.md`. Build one plan with one +managed announcement. A new announcement omits `issue_number`; an update +includes it. Set the exact labels to include `announcement`, with `parent: null` +and `blocked_by: []` because announcements are outside the work graph. + +Submit it once: + +```bash +jakuta-agent-issue apply-plan --repo OWNER/REPO --plan-file <plan.json> +``` + +Read the structured result for the resolved issue number and URL. If the user +wants a pinned repository notice, pin that issue after the plan succeeds: + +```bash +gh issue pin <number> +``` + +For release notes, a release may also be published with `gh release create`; +the labeled announcement issue remains the coordination record. +</what-to-do> + +## Report + +<what-to-do> +Report the announcement URL, whether its content was created or reconciled, and +whether the separate pin or release action succeeded. +</what-to-do> diff --git a/.opencode/skills/show-repo-status/SKILL.md b/.opencode/skills/show-repo-status/SKILL.md new file mode 100644 index 0000000..6c729f7 --- /dev/null +++ b/.opencode/skills/show-repo-status/SKILL.md @@ -0,0 +1,81 @@ +--- +name: show-repo-status +description: "Show open issue trees, blockers, linked pull requests, announcements, and issues waiting on evidence from GitHub's native coordination state" +user-invocable: true +--- + +Treat the user's invocation text after `show-repo-status` as `$ARGUMENTS`. + +# /show-repo-status โ€” Repo Status View + +Render one read-only summary from the GitHub Coordination Ledger. Every line must +trace to a GitHub read; this command never writes or invents state. + +## Confirm and gather + +```bash +gh repo view --json nameWithOwner -q .nameWithOwner +gh issue list --state open --label parent --json number,title +gh issue list --state open --limit 200 --json number,title,body,labels +gh pr list --state open --json number,title,headRefName,body +gh issue list --state open --label announcement --json number,title +``` + +For each open parent and issue, read native relationships: + +```bash +gh api --paginate repos/OWNER/REPO/issues/NUMBER/sub_issues +gh api --paginate repos/OWNER/REPO/issues/NUMBER/dependencies/blocked_by +``` + +Bound network calls so an unavailable remote fails clearly instead of leaving +the view waiting indefinitely. + +## Derive + +- Build each parent/child tree from the native `sub_issues` results. Issue-body + headings and task lists are content, never relationship evidence. +- List an open issue as blocked when its native `blocked_by` result contains an + open blocker. Include closed blockers only when they explain a recent state + transition. +- Parse open pull request bodies for closing references and group linked pull + requests under their issue. List a pull request with no closing reference as + unlinked without calling it defective. +- Mark an issue waiting on evidence when an acceptance criterion is unchecked or + checked with an empty `Evidence:` slot. + +## Render + +```text +REPO STATUS โ€” <owner/repo> + +PARENT ISSUES (open) + #12 Checkout rewrite + โ”œโ”€ #13 Cart persistence open ยท linked PR #40 + โ”œโ”€ #14 Payment retry closed โœ“ + โ””โ”€ #15 Confirmation email open ยท waiting on evidence (2 of 3 AC) + +BLOCKED ISSUES (open) + #18 Refund settlement โ€” blocked by #17 Payment provider contract + +LINKED PRS (open) + #40 Persist cart โ†’ closes #13 + +ANNOUNCEMENTS (open) + #22 v0.9 release notes (pinned) + +WAITING ON EVIDENCE + #15 Confirmation email โ€” 2 of 3 criteria have no evidence + +UNLINKED PRS (open) + #41 Dependency refresh +``` + +Show only populated sections. An empty repository prints one line saying there +are no open coordination items. + +## Boundary + +This view is read-only. Filing and issue reconciliation go through one Issue +Plan. Pull requests remain in `/open-pull-request`; git sync and worktrees remain +in `github-workflow`. diff --git a/.opencode/skills/to-issues/SKILL.md b/.opencode/skills/to-issues/SKILL.md new file mode 100644 index 0000000..078e442 --- /dev/null +++ b/.opencode/skills/to-issues/SKILL.md @@ -0,0 +1,20 @@ +--- +name: to-issues +description: "Turn a supplied report, audit, or backlog slice into one coordinated issue plan with native parent and blocker relationships" +user-invocable: true +--- + +Treat the user's invocation text after `to-issues` as `$ARGUMENTS`. + +# /to-issues + +Run `github-coordination:guided-issue-filing` in batch mode with the user's +supplied findings. Treat the complete batch as one Issue Plan, even when it mixes +Work Types, creates new parent and child issues together, or updates existing +issues while adding splits. + +Resolve all ambiguity in one grouped question, draft every Canonical Issue Body, +read the current state of every existing managed issue, express relationships in +GitHub's native parent and blocked-by graph, and invoke +`jakuta-agent-issue apply-plan` once for the whole mutation. Report the resolved +issue URLs and graph placement only after the complete plan succeeds. diff --git a/.pi/extensions/github-coordination/ai-lab-runtime/canonical-hook-path.js b/.pi/extensions/github-coordination/ai-lab-runtime/canonical-hook-path.js new file mode 100644 index 0000000..8ff9df4 --- /dev/null +++ b/.pi/extensions/github-coordination/ai-lab-runtime/canonical-hook-path.js @@ -0,0 +1,28 @@ +'use strict'; + +// @why The canonical hook payload contract is exactly one slash-normalized +// `tool_input.file_path`, regardless of host OS or originating client. Node's +// `path.join()` emits backslashes on Windows, so a guard like +// `filePath.includes("/src/")` silently exempts real source files when a Codex, +// Pi, or OpenCode payload originates on Windows. Every client mapper routes its +// synthetic `file_path` through this single builder so no client hand-rolls a +// second normalization lane that can drift. + +const { normalizePathSeparators } = require('./checked-extensions.js'); + +// A path is already absolute under the slash contract when it is POSIX-absolute +// (`/x`) or a Windows drive root with a forward slash (`C:/x`) after separator +// normalization. Both reach the canonical hook unchanged. +const ABSOLUTE_SLASH_PATH_PATTERN = /^(?:\/|[A-Za-z]:\/)/; + +function canonicalFilePath(projectRoot, filePathText) { + const normalizedFilePath = normalizePathSeparators(filePathText); + if (ABSOLUTE_SLASH_PATH_PATTERN.test(normalizedFilePath)) { + return normalizedFilePath; + } + const normalizedRoot = normalizePathSeparators(projectRoot).replace(/\/+$/, ''); + if (normalizedRoot.length === 0) return normalizedFilePath; + return `${normalizedRoot}/${normalizedFilePath}`; +} + +module.exports = { canonicalFilePath }; diff --git a/.pi/extensions/github-coordination/ai-lab-runtime/checked-extensions.js b/.pi/extensions/github-coordination/ai-lab-runtime/checked-extensions.js new file mode 100644 index 0000000..cfd7f50 --- /dev/null +++ b/.pi/extensions/github-coordination/ai-lab-runtime/checked-extensions.js @@ -0,0 +1,55 @@ +'use strict'; + +const pathModule = require('path'); + +const CHECKED_EXTENSIONS = new Set([ + '.adoc', + '.css', + '.dart', + '.htm', + '.html', + '.hs', + '.bash', + '.cjs', + '.cts', + '.js', + '.jsx', + '.less', + '.lhs', + '.lua', + '.luau', + '.md', + '.mdx', + '.mjs', + '.py', + '.rst', + '.sass', + '.scss', + '.sh', + '.json', + '.toml', + '.ts', + '.tsx', + '.txt', + '.vue', + '.svelte', + '.rs', + '.sql', + '.zsh', +]); + +function normalizePathSeparators(filePathText) { + return String(filePathText || '').replace(/\\/g, '/'); +} + +function hasCheckedExtension(filePathText) { + return CHECKED_EXTENSIONS.has( + pathModule.extname(normalizePathSeparators(filePathText)).toLowerCase(), + ); +} + +module.exports = { + CHECKED_EXTENSIONS, + hasCheckedExtension, + normalizePathSeparators, +}; diff --git a/.pi/extensions/github-coordination/ai-lab-runtime/map-pi-payload-to-claude.js b/.pi/extensions/github-coordination/ai-lab-runtime/map-pi-payload-to-claude.js new file mode 100644 index 0000000..9dc22f3 --- /dev/null +++ b/.pi/extensions/github-coordination/ai-lab-runtime/map-pi-payload-to-claude.js @@ -0,0 +1,99 @@ +'use strict'; + +// Translate a Pi extension `tool_call` event into the Claude-shaped hook payload +// the canonical `hooks/*.js` scripts already expect. This is the Pi peer of +// `map-codex-payload-to-claude.js` / `map-opencode-payload-to-claude.js`. +// +// Pi's write surface is small and concrete: the only built-in tools that mutate +// files are `write` ({ path, content }) and `edit` ({ path, edits: [{ oldText, +// newText }] }). Pi has no `apply_patch` and no MCP write tools, so this mapper +// has no patch parser and no MCP path-field table โ€” it is a direct field rename +// from the two Pi tool shapes to the two matching Claude tool shapes: +// +// Pi `write` { path, content } -> Claude `Write` { file_path, content } +// Pi `edit` { path, edits:[{oldText,newText}] } -> Claude `MultiEdit` { file_path, edits:[{old_string,new_string}] } +// +// `MultiEdit` is the honest Claude analogue of Pi's `edit`: Pi's `edit` always +// carries an `edits` array, exactly like Claude's `MultiEdit`. The canonical +// hooks' `parseHookInput` normalizes `MultiEdit` to the same per-file scan as +// `Edit`, so picking `MultiEdit` loses nothing and avoids fabricating the +// single-edit `Edit` shape from a multi-edit source. + +// These shared libs live at runtime/shared/lib in the repo, so this file and its +// tests resolve them directly. The Pi compiler flattens these `../../shared/lib/` +// specifiers to `./` when it vendors the require() closure into one flat +// ai-lab-runtime/ dir; the vendored set is derived from exactly these edges, so an +// added require can never ship without its file. Do not "simplify" the path โ€” the +// repo layout needs it, and the compiler rewrites it for deployment. +const { hasCheckedExtension } = require('./checked-extensions.js'); +const { canonicalFilePath } = require('./canonical-hook-path.js'); +const { matcherAcceptsClaudeToolName } = require('./matcher-semantics.js'); + +function plainObject(candidate) { + return (typeof candidate === 'object' && candidate !== null && !Array.isArray(candidate)) ? candidate : {}; +} + +// Pi `edit` carries `edits: [{ oldText, newText }]`. Claude `MultiEdit` carries +// `edits: [{ old_string, new_string }]`. Rename each pair; drop malformed rows. +function toClaudeEditList(piEdits) { + if (!Array.isArray(piEdits)) return []; + const claudeEdits = []; + for (const piEdit of piEdits) { + const piEditObject = plainObject(piEdit); + claudeEdits.push({ + old_string: String(piEditObject.oldText || ''), + new_string: String(piEditObject.newText || ''), + }); + } + return claudeEdits; +} + +// Build the Claude-shaped PreToolUse payloads for one Pi `tool_call` event. +// Returns [] for non-write tool calls and for write paths whose extension is +// not in the checked set, so the canonical hook is never run pointlessly. +function payloadsForPiToolCall({ piToolName, piToolInput, projectRoot, sessionId }) { + const toolInputObject = plainObject(piToolInput); + const resolvedProjectRoot = String(projectRoot || process.cwd()); + + function buildPayload(toolName, toolInput) { + const p = { + hook_event_name: 'PreToolUse', + tool_name: toolName, + tool_input: toolInput, + cwd: resolvedProjectRoot, + }; + if (sessionId) { + p.session_id = String(sessionId); + } + return p; + } + + if (piToolName === 'write') { + const filePathText = String(toolInputObject.path || ''); + if (!hasCheckedExtension(filePathText)) return []; + return [ + buildPayload('Write', { + file_path: canonicalFilePath(resolvedProjectRoot, filePathText), + content: String(toolInputObject.content || ''), + }), + ]; + } + + if (piToolName === 'edit') { + const filePathText = String(toolInputObject.path || ''); + if (!hasCheckedExtension(filePathText)) return []; + return [ + buildPayload('MultiEdit', { + file_path: canonicalFilePath(resolvedProjectRoot, filePathText), + edits: toClaudeEditList(toolInputObject.edits), + }), + ]; + } + + return []; +} + +module.exports = { + payloadsForPiToolCall, + matcherAcceptsClaudeToolName, +}; diff --git a/.pi/extensions/github-coordination/ai-lab-runtime/matcher-semantics.js b/.pi/extensions/github-coordination/ai-lab-runtime/matcher-semantics.js new file mode 100644 index 0000000..eb17c62 --- /dev/null +++ b/.pi/extensions/github-coordination/ai-lab-runtime/matcher-semantics.js @@ -0,0 +1,32 @@ +'use strict'; + +const { + loadWriteToolSurface, + writeToolKindMap, +} = require('./write-tool-surface-loader.js'); + +const WRITE_TOOL_KINDS = writeToolKindMap(loadWriteToolSurface()); + +function matcherAcceptsClaudeToolName(matcher, claudeToolName) { + if (matcher === null || matcher === undefined || matcher === '') return true; + try { + const re = new RegExp(`^(?:${matcher})$`); + return matcherCandidateToolNames(claudeToolName).some((candidate) => re.test(candidate)); + } catch { + return false; + } +} + +function matcherCandidateToolNames(claudeToolName) { + const rawToolName = String(claudeToolName || ''); + const kind = WRITE_TOOL_KINDS.get(rawToolName); + const candidates = [rawToolName]; + if (kind && kind !== rawToolName) candidates.push(kind); + if (kind === 'MultiEdit' || rawToolName === 'MultiEdit') candidates.push('Edit'); + return [...new Set(candidates)]; +} + +module.exports = { + matcherAcceptsClaudeToolName, + matcherCandidateToolNames, +}; diff --git a/.pi/extensions/github-coordination/ai-lab-runtime/package.json b/.pi/extensions/github-coordination/ai-lab-runtime/package.json new file mode 100644 index 0000000..b731bd6 --- /dev/null +++ b/.pi/extensions/github-coordination/ai-lab-runtime/package.json @@ -0,0 +1 @@ +{"type": "commonjs"} diff --git a/.pi/extensions/github-coordination/ai-lab-runtime/parse-canonical-lifecycle-output.js b/.pi/extensions/github-coordination/ai-lab-runtime/parse-canonical-lifecycle-output.js new file mode 100644 index 0000000..cba9ad1 --- /dev/null +++ b/.pi/extensions/github-coordination/ai-lab-runtime/parse-canonical-lifecycle-output.js @@ -0,0 +1,78 @@ +'use strict'; + +// Parse one canonical Claude hook child-process result into every non-binary +// channel generated runtimes need for lifecycle events. Codex and Pi route +// those channels differently, but the canonical stdout/stderr truth table is +// identical and belongs in one source. + +function nonEmptyString(candidateText) { + return typeof candidateText === 'string' && candidateText.trim().length > 0; +} + +function appendText(textSegments, candidateText) { + if (nonEmptyString(candidateText)) textSegments.push(candidateText.trim()); +} + +function parseStdoutJson(rawStdout) { + const trimmed = String(rawStdout || '').trim(); + if (!trimmed) return { kind: 'empty' }; + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed === 'object' && parsed !== null) return { kind: 'json', parsedPayload: parsed }; + } catch (jsonParseFailure) { + return { kind: 'text', textPayload: trimmed }; + } + return { kind: 'text', textPayload: trimmed }; +} + +function parseCanonicalLifecycleOutput({ stdout, stderr, exitCode, defaultReason }) { + const outcome = { + blockReasons: [], + contextMessages: [], + stopReasons: [], + systemMessages: [], + continueFalse: false, + }; + + if (exitCode === 2) { + appendText(outcome.blockReasons, stderr || defaultReason); + return outcome; + } + + const parsed = parseStdoutJson(stdout); + if (parsed.kind === 'text') { + appendText(outcome.contextMessages, parsed.textPayload); + return outcome; + } + if (parsed.kind !== 'json') { + return outcome; + } + + const canonicalHookJson = parsed.parsedPayload; + if (canonicalHookJson.decision === 'block') { + appendText(outcome.blockReasons, canonicalHookJson.reason || defaultReason); + } + if (canonicalHookJson.continue === false) { + outcome.continueFalse = true; + appendText(outcome.stopReasons, canonicalHookJson.stopReason || canonicalHookJson.reason || defaultReason); + appendText(outcome.blockReasons, canonicalHookJson.stopReason || canonicalHookJson.reason || defaultReason); + } + appendText(outcome.systemMessages, canonicalHookJson.systemMessage); + appendText(outcome.contextMessages, canonicalHookJson.additionalContext); + + const hookSpecificOutput = canonicalHookJson.hookSpecificOutput; + if (typeof hookSpecificOutput === 'object' && hookSpecificOutput !== null) { + appendText(outcome.contextMessages, hookSpecificOutput.additionalContext); + if (hookSpecificOutput.permissionDecision === 'deny') { + appendText(outcome.blockReasons, hookSpecificOutput.permissionDecisionReason || defaultReason); + } + } + + return outcome; +} + +module.exports = { + appendText, + parseCanonicalLifecycleOutput, + parseStdoutJson, +}; diff --git a/.pi/extensions/github-coordination/ai-lab-runtime/parse-hook-decision.js b/.pi/extensions/github-coordination/ai-lab-runtime/parse-hook-decision.js new file mode 100644 index 0000000..c07c1de --- /dev/null +++ b/.pi/extensions/github-coordination/ai-lab-runtime/parse-hook-decision.js @@ -0,0 +1,83 @@ +'use strict'; + +// Translates a Claude hook's child-process result (stdout JSON + exit code + +// stderr text) into an explicit allow|deny decision. The Claude protocol has +// two normal ways to deny: (a) print a JSON object with +// hookSpecificOutput.permissionDecision === "deny" or legacy decision === +// "block"; (b) exit with code 2, in which case stderr carries the +// human-readable reason. Tool-call adapters can also opt into fail-closed +// handling for non-zero crash exits so a broken guard cannot silently allow a +// write. + +const DEFAULT_DENY_REASON = 'Blocked by ai-lab plugin.'; + +function stringOrFallback(preferred, fallback) { + return typeof preferred === 'string' && preferred.trim().length > 0 + ? preferred.trim() + : fallback; +} + +function denyDecisionFromHookSpecific(hookSpecificOutput, fallback) { + if (typeof hookSpecificOutput !== 'object' || hookSpecificOutput === null) return null; + if (hookSpecificOutput.permissionDecision !== 'deny') return null; + return { + kind: 'deny', + reason: stringOrFallback(hookSpecificOutput.permissionDecisionReason, fallback), + }; +} + +function denyDecisionFromLegacyShape(parsedPayload, fallback) { + if (parsedPayload.decision !== 'block') return null; + return { + kind: 'deny', + reason: stringOrFallback(parsedPayload.reason, fallback), + }; +} + +function decisionFromHookStdout(stdout, fallback) { + const trimmed = String(stdout || '').trim(); + if (!trimmed) return { kind: 'allow' }; + let parsed; + try { + parsed = JSON.parse(trimmed); + } catch (jsonParseFailure) { + return { kind: 'allow' }; + } + if (typeof parsed !== 'object' || parsed === null) { return { kind: 'allow' }; } + return ( + denyDecisionFromHookSpecific(parsed.hookSpecificOutput, fallback) + || denyDecisionFromLegacyShape(parsed, fallback) + || { kind: 'allow' } + ); +} + +function decisionFromExitCode(exitCode, stderr, fallback, failClosedOnNonDenyExit = false) { + if (exitCode === 2) return { kind: 'deny', reason: stringOrFallback(stderr, fallback) }; + if (failClosedOnNonDenyExit && exitCode !== 0) { + return { kind: 'deny', reason: stringOrFallback(stderr, fallback) }; + } + return { kind: 'allow' }; +} + +function combineHookResult({ + stdout, + stderr, + exitCode, + defaultReason, + failClosedOnNonDenyExit = false, +}) { + const fallback = stringOrFallback(defaultReason, DEFAULT_DENY_REASON); + const stdoutDecision = decisionFromHookStdout(stdout, fallback); + if (stdoutDecision.kind === 'deny') return stdoutDecision; + return decisionFromExitCode(exitCode, stderr, fallback, failClosedOnNonDenyExit); +} + +module.exports = { + DEFAULT_DENY_REASON, + stringOrFallback, + denyDecisionFromHookSpecific, + denyDecisionFromLegacyShape, + decisionFromHookStdout, + decisionFromExitCode, + combineHookResult, +}; diff --git a/.pi/extensions/github-coordination/ai-lab-runtime/parse-pi-lifecycle-output.js b/.pi/extensions/github-coordination/ai-lab-runtime/parse-pi-lifecycle-output.js new file mode 100644 index 0000000..2655add --- /dev/null +++ b/.pi/extensions/github-coordination/ai-lab-runtime/parse-pi-lifecycle-output.js @@ -0,0 +1,37 @@ +'use strict'; + +// Parse one canonical hook's stdout/stderr for the Pi lifecycle events +// (`session_start`, `input`, `turn_end`, `tool_result`, `session_before_compact`). +// +// `parse-hook-decision.js` answers only the binary allow/deny question the +// `tool_call` path needs. The lifecycle path needs more: a canonical hook for +// `SessionStart` or `UserPromptSubmit` typically emits no block at all, just an +// `additionalContext` / `systemMessage` string that has to reach the model. +// This module extracts every channel a canonical hook can speak on so the +// generated Pi extension can route each to the matching Pi mechanism: +// +// blockReason -> Pi `{ block: true }` / `{ cancel: true }` where the +// event supports it; otherwise surfaced via ctx.ui.notify +// contextText -> injected into the model via pi.sendMessage(...) +// systemMessage -> surfaced to the user via ctx.ui.notify +// +// The parser itself is shared with Codex; this Pi wrapper narrows the output to +// the three channels Pi's event handlers need. + +// Repo-true path; the Pi compiler flattens `../../shared/lib/` to `./` when it +// vendors this file's require() closure into the flat ai-lab-runtime/ dir. +const { parseCanonicalLifecycleOutput } = require('./parse-canonical-lifecycle-output.js'); + +// Reduce a single canonical hook process result to the three Pi-relevant +// channels. `defaultReason` is used when the hook signals a block but gives no +// human-readable reason of its own. +function parsePiLifecycleOutput({ stdout, stderr, exitCode, defaultReason }) { + const parsed = parseCanonicalLifecycleOutput({ stdout, stderr, exitCode, defaultReason }); + return { + blockReasons: parsed.blockReasons, + contextMessages: parsed.contextMessages, + systemMessages: parsed.systemMessages, + }; +} + +module.exports = { parsePiLifecycleOutput }; diff --git a/.pi/extensions/github-coordination/ai-lab-runtime/project-narrow.js b/.pi/extensions/github-coordination/ai-lab-runtime/project-narrow.js new file mode 100644 index 0000000..83ea473 --- /dev/null +++ b/.pi/extensions/github-coordination/ai-lab-runtime/project-narrow.js @@ -0,0 +1,65 @@ +'use strict'; + +const fs = require('fs'); +const nodePath = require('path'); + +function projectAllowsPlugin(pluginName, cwd, clientKey) { + const runtimeManifestPath = findRuntimeManifestPath(cwd); + if (runtimeManifestPath === null) { return true; } + return runtimeManifestAllowsPlugin(runtimeManifestPath, pluginName, clientKey); +} + +function projectIsAiLabManaged(cwd) { + return findAiLabProjectRoot(cwd) !== null; +} + +function findRuntimeManifestPath(cwd) { + let searchDir = nodePath.resolve(typeof cwd === 'string' && cwd.length > 0 ? cwd : process.cwd()); + while (true) { + const runtimeManifestPath = nodePath.join(searchDir, '.ai-lab', 'runtime-manifest.json'); + if (fs.existsSync(runtimeManifestPath)) { return runtimeManifestPath; } + const parentDir = nodePath.dirname(searchDir); + if (parentDir === searchDir) { return null; } + searchDir = parentDir; + } +} + +function findAiLabProjectRoot(cwd) { + let searchDir = nodePath.resolve(typeof cwd === 'string' && cwd.length > 0 ? cwd : process.cwd()); + while (true) { + if ( + fs.existsSync(nodePath.join(searchDir, '.ai-lab', 'plugins.toml')) || + fs.existsSync(nodePath.join(searchDir, '.ai-lab', 'runtime-manifest.json')) + ) { + return searchDir; + } + const parentDir = nodePath.dirname(searchDir); + if (parentDir === searchDir) { return null; } + searchDir = parentDir; + } +} + +function runtimeManifestAllowsPlugin(runtimeManifestPath, pluginName, clientKey) { + const parsed = readJsonObject(runtimeManifestPath); + if (parsed === null) return true; + const clients = parsed.clients; + if (typeof clients !== 'object' || clients === null) return true; + const rows = clients[clientKey]; + if (!Array.isArray(rows)) return true; + return rows.some((row) => ( + typeof row === 'object' && + row !== null && + row.plugin === pluginName + )); +} + +function readJsonObject(jsonPath) { + try { + const parsed = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); + return typeof parsed === 'object' && parsed !== null ? parsed : null; + } catch (error) { + return null; + } +} + +module.exports = { findRuntimeManifestPath, projectAllowsPlugin, projectIsAiLabManaged }; diff --git a/.pi/extensions/github-coordination/ai-lab-runtime/run-canonical-hook.js b/.pi/extensions/github-coordination/ai-lab-runtime/run-canonical-hook.js new file mode 100644 index 0000000..1dd96d7 --- /dev/null +++ b/.pi/extensions/github-coordination/ai-lab-runtime/run-canonical-hook.js @@ -0,0 +1,141 @@ +'use strict'; + +// Runs one plugin's canonical `hooks/*.js` script as a child process. Tool-event +// consumers call `runCanonicalHook` and get an allow|deny decision; lifecycle +// consumers call `runCanonicalHookProcess` and parse the raw stdout/stderr for +// their event-specific channels. Project narrowing is shared here so Codex, +// OpenCode, and Pi cannot drift on runtime-manifest semantics. + +const childProcess = require('child_process'); +const fs = require('fs'); +const nodePath = require('path'); + +const { combineHookResult } = require('./parse-hook-decision.js'); +const { projectAllowsPlugin } = require('./project-narrow.js'); + +const PROJECT_LOCAL_PLUGIN_SOURCE = 'project-local'; +const DEFAULT_CLIENT_KEY = 'codex'; +const DEFAULT_HOOK_TIMEOUT_MS = 5000; +const CLAUDE_PLUGIN_ROOT_TOKEN = '${CLAUDE_PLUGIN_ROOT}'; + +function pluginNameFromRoot(pluginRoot) { + if (typeof pluginRoot !== 'string' || pluginRoot.length === 0) return null; + const normalizedRoot = nodePath.normalize(pluginRoot); + const basename = nodePath.basename(normalizedRoot); + if (basename !== 'local' && !/^\d+\.\d+\.\d+(?:[-+].*)?$/.test(basename)) return basename; + return nodePath.basename(nodePath.dirname(normalizedRoot)) || basename; +} + +function resolvedPluginRoot(scriptPath, pluginRoot) { + return typeof pluginRoot === 'string' && pluginRoot.length > 0 + ? pluginRoot + : nodePath.join(scriptPath, '..', '..'); +} + +function resolvedCwd(cwd, claudePayload) { + if (typeof cwd === 'string' && cwd.length > 0) return cwd; + if ( + typeof claudePayload === 'object' && + claudePayload !== null && + typeof claudePayload.cwd === 'string' && + claudePayload.cwd.length > 0 + ) { + return claudePayload.cwd; + } + return process.cwd(); +} + +function clientSpecificEnv(clientKey, pluginRoot) { + if (clientKey === 'codex') return { CODEX_PLUGIN_ROOT: pluginRoot }; + if (clientKey === 'opencode') return { OPENCODE_PLUGIN_ROOT: pluginRoot }; + if (clientKey === 'pi') return { PI_PLUGIN_ROOT: pluginRoot }; + return {}; +} + +function scriptArgsWithPluginRoot(scriptArgs, pluginRoot) { + if (!Array.isArray(scriptArgs)) return []; + return scriptArgs.map((scriptArg) => { + if (typeof scriptArg !== 'string') return String(scriptArg); + if (scriptArg === CLAUDE_PLUGIN_ROOT_TOKEN) return pluginRoot; + if (scriptArg.startsWith(`${CLAUDE_PLUGIN_ROOT_TOKEN}/`)) { + return nodePath.join(pluginRoot, scriptArg.slice(CLAUDE_PLUGIN_ROOT_TOKEN.length + 1)); + } + return scriptArg; + }); +} + +// Result kinds: +// { kind: 'skipped' } - script missing, or narrowed away +// { kind: 'ran', stdout, stderr, exitCode } - canonical hook executed +function runCanonicalHookProcess({ + scriptPath, + scriptArgs, + pluginRoot, + pluginSource, + claudePayload, + cwd, + timeoutMs, + clientKey = DEFAULT_CLIENT_KEY, +}) { + if (!fs.existsSync(scriptPath)) return { kind: 'skipped' }; + const root = resolvedPluginRoot(scriptPath, pluginRoot); + const processCwd = resolvedCwd(cwd, claudePayload); + + if (pluginSource !== PROJECT_LOCAL_PLUGIN_SOURCE) { + const pluginName = pluginNameFromRoot(root); + if (pluginName && !projectAllowsPlugin(pluginName, processCwd, clientKey)) { + return { kind: 'skipped' }; + } + } + + const hookProcess = childProcess.spawnSync(process.execPath, [ + scriptPath, + ...scriptArgsWithPluginRoot(scriptArgs, root), + ], { + input: JSON.stringify(claudePayload), + encoding: 'utf8', + timeout: typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : DEFAULT_HOOK_TIMEOUT_MS, + cwd: processCwd, + env: { + ...process.env, + CLAUDE_PLUGIN_ROOT: root, + ...clientSpecificEnv(clientKey, root), + }, + }); + return { + kind: 'ran', + stdout: hookProcess.stdout || '', + stderr: hookProcess.stderr || '', + exitCode: typeof hookProcess.status === 'number' ? hookProcess.status : 0, + }; +} + +function runCanonicalHook({ + scriptPath, + scriptArgs, + pluginRoot, + pluginSource, + claudePayload, + defaultReason, + timeoutMs, + clientKey = DEFAULT_CLIENT_KEY, +}) { + const processResult = runCanonicalHookProcess({ + scriptPath, + scriptArgs, + pluginRoot, + pluginSource, + claudePayload, + timeoutMs, + clientKey, + }); + if (processResult.kind === 'skipped') return { kind: 'allow' }; + return combineHookResult({ + stdout: processResult.stdout, + stderr: processResult.stderr, + exitCode: processResult.exitCode, + defaultReason, + }); +} + +module.exports = { pluginNameFromRoot, runCanonicalHook, runCanonicalHookProcess, scriptArgsWithPluginRoot }; diff --git a/.pi/extensions/github-coordination/ai-lab-runtime/write-tool-surface-loader.js b/.pi/extensions/github-coordination/ai-lab-runtime/write-tool-surface-loader.js new file mode 100644 index 0000000..6762137 --- /dev/null +++ b/.pi/extensions/github-coordination/ai-lab-runtime/write-tool-surface-loader.js @@ -0,0 +1,74 @@ +'use strict'; + +const fs = require('fs'); +const pathModule = require('path'); + +function loadWriteToolSurface() { + const candidatePaths = [ + pathModule.join(__dirname, 'write-tool-surface.json'), + pathModule.join(__dirname, '../../../../../plugins/hook-lib/lib/write-tool-surface.json'), + ]; + for (const candidatePath of candidatePaths) { + if (fs.existsSync(candidatePath)) { + return JSON.parse(fs.readFileSync(candidatePath, 'utf-8')); + } + } + throw new Error('write-tool-surface.json not found'); +} + +function mcpWritePathFieldMap(writeToolSurface) { + return new Map(writeToolSurface.mcp.map((spec) => [spec.name, spec.path_field])); +} + +function nativeApplyPatchSurfaces(writeToolSurface, clientName) { + const nativeSurfaces = Array.isArray(writeToolSurface.native) ? writeToolSurface.native : []; + return nativeSurfaces.filter((spec) => ( + spec + && spec.client === clientName + && spec.kind === 'ApplyPatch' + )); +} + +function nativeApplyPatchToolNames(writeToolSurface, clientName) { + const toolNames = new Set(); + for (const spec of nativeApplyPatchSurfaces(writeToolSurface, clientName)) { + const rawToolNames = Array.isArray(spec.tool_names) ? spec.tool_names : []; + for (const toolName of rawToolNames) { + if (typeof toolName === 'string' && toolName.length > 0) toolNames.add(toolName); + } + } + return toolNames; +} + +function nativeApplyPatchTextFields(writeToolSurface, clientName) { + const textFields = []; + for (const spec of nativeApplyPatchSurfaces(writeToolSurface, clientName)) { + const rawTextFields = Array.isArray(spec.text_fields) ? spec.text_fields : []; + for (const fieldName of rawTextFields) { + if (typeof fieldName === 'string' && fieldName.length > 0 && !textFields.includes(fieldName)) { + textFields.push(fieldName); + } + } + } + return textFields; +} + +function writeToolKindMap(writeToolSurface) { + return new Map([ + ...writeToolSurface.builtin.map((name) => [name, name]), + ...writeToolSurface.mcp.map((spec) => [spec.name, spec.kind]), + ]); +} + +function builtinWriteToolNames(writeToolSurface) { + return new Set(writeToolSurface.builtin); +} + +module.exports = { + builtinWriteToolNames, + loadWriteToolSurface, + mcpWritePathFieldMap, + nativeApplyPatchTextFields, + nativeApplyPatchToolNames, + writeToolKindMap, +}; diff --git a/.pi/extensions/github-coordination/ai-lab-runtime/write-tool-surface.json b/.pi/extensions/github-coordination/ai-lab-runtime/write-tool-surface.json new file mode 100644 index 0000000..8bbe370 --- /dev/null +++ b/.pi/extensions/github-coordination/ai-lab-runtime/write-tool-surface.json @@ -0,0 +1,86 @@ +{ + "_doc": "Canonical declarative list of write-tool surfaces across every AI client. Single source for: Claude Code per-plugin hooks.json matchers, Codex compiler matcher + runtime translator, OpenCode compiler matcher + runtime translator, and the JS hook-input-parser normalizer. Adding a new MCP write surface = one entry here + regen pass; nothing else should ever hand-list write tools.", + "builtin": [ + "Edit", + "Write", + "MultiEdit" + ], + "native": [ + { + "client": "codex", + "name": "apply_patch", + "kind": "ApplyPatch", + "tool_names": [ + "apply_patch", + "functions.apply_patch" + ], + "text_fields": [ + "input", + "command", + "patch", + "patchText" + ] + }, + { + "client": "opencode", + "name": "apply_patch", + "kind": "ApplyPatch", + "tool_names": [ + "patch", + "apply_patch" + ], + "text_fields": [ + "patchText", + "patch", + "command", + "input" + ] + } + ], + "mcp": [ + { + "name": "mcp__filesystem__write_file", + "kind": "Write", + "path_field": "path", + "content_field": "content" + }, + { + "name": "mcp__filesystem__edit_file", + "kind": "MultiEdit", + "path_field": "path", + "edits_field": "edits", + "edit_old_field": "oldText", + "edit_new_field": "newText" + }, + { + "name": "mcp__serena__replace_symbol_body", + "kind": "FragmentWrite", + "operation": "replace_symbol_body", + "path_field": "relative_path", + "name_path_field": "name_path", + "body_field": "body" + }, + { + "name": "mcp__serena__insert_after_symbol", + "kind": "FragmentWrite", + "operation": "insert_after_symbol", + "path_field": "relative_path", + "name_path_field": "name_path", + "body_field": "body" + }, + { + "name": "mcp__serena__insert_before_symbol", + "kind": "FragmentWrite", + "operation": "insert_before_symbol", + "path_field": "relative_path", + "name_path_field": "name_path", + "body_field": "body" + }, + { + "name": "mcp__serena__replace_content", + "kind": "Write", + "path_field": "relative_path", + "content_field": "content" + } + ] +} diff --git a/.pi/extensions/github-coordination/hooks/agent-issue-cli-status.js b/.pi/extensions/github-coordination/hooks/agent-issue-cli-status.js new file mode 100644 index 0000000..93dc2db --- /dev/null +++ b/.pi/extensions/github-coordination/hooks/agent-issue-cli-status.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node +/** + * Surface the bot issue CLI setup before an agent reaches issue filing. + * + * @why The issue ledger depends on the Jakuta Review Bot author identity. + * When `jakuta-agent-issue` is missing or cannot read its 1Password-backed + * GitHub App fields, filing fails late and agents rediscover the same setup + * gap. This hook prints the exact dotfiles repair path at session start while + * leaving ordinary shell usage unblocked. + */ + +"use strict"; + +const { spawnSync } = require("child_process"); + +function commandExists(commandName) { + const result = spawnSync("sh", ["-lc", "command -v " + commandName], { + encoding: "utf8", + timeout: 3000, + }); + return result.status === 0; +} + +function isInsideGitRepo() { + const result = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { + encoding: "utf8", + timeout: 3000, + }); + return result.status === 0 && result.stdout.trim() === "true"; +} + +function warn(messageLines) { + process.stderr.write(["github-coordination setup warning:", ...messageLines].join("\n") + "\n"); +} + +function runDoctor() { + return spawnSync("jakuta-agent-issue", ["doctor"], { + encoding: "utf8", + timeout: 15000, + }); +} + +function main() { + if (!isInsideGitRepo()) { + return; + } + if (!commandExists("jakuta-agent-issue")) { + warn([ + "`jakuta-agent-issue` is not installed.", + "Run `dotsync jakuta_agent_issue_cli`, then `jakuta-agent-issue doctor --repo OWNER/REPO`.", + ]); + return; + } + + const doctorResult = runDoctor(); + if (doctorResult.status === 0) { + return; + } + const doctorOutput = [doctorResult.stdout, doctorResult.stderr] + .filter(Boolean) + .join("") + .trim(); + warn([ + "`jakuta-agent-issue doctor` did not pass.", + doctorOutput || "No diagnostic output was produced.", + "Repair path: `dotsync jakuta_agent_issue_cli`; if OP_SERVICE_ACCOUNT_TOKEN is missing, run `dotsync env` and open a new shell; if issues permission is missing, approve the Jakuta Review Bot GitHub App permission update.", + ]); +} + +try { + main(); +} catch (err) { + process.stderr.write( + "agent-issue-cli-status.js crashed: " + (err && err.stack ? err.stack : err) + "\n" + ); +} diff --git a/.pi/extensions/github-coordination/hooks/hooks.json b/.pi/extensions/github-coordination/hooks/hooks.json new file mode 100644 index 0000000..3f52175 --- /dev/null +++ b/.pi/extensions/github-coordination/hooks/hooks.json @@ -0,0 +1,17 @@ +{ + "description": "GitHub coordination hooks: warn on missing bot issue CLI setup.", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/agent-issue-cli-status.js", + "timeout": 20 + } + ] + } + ] + } +} diff --git a/.pi/extensions/github-coordination/hooks/package.json b/.pi/extensions/github-coordination/hooks/package.json new file mode 100644 index 0000000..b731bd6 --- /dev/null +++ b/.pi/extensions/github-coordination/hooks/package.json @@ -0,0 +1 @@ +{"type": "commonjs"} diff --git a/.pi/extensions/github-coordination/index.ts b/.pi/extensions/github-coordination/index.ts new file mode 100644 index 0000000..914c33e --- /dev/null +++ b/.pi/extensions/github-coordination/index.ts @@ -0,0 +1,144 @@ +// Generated by cli/plugin_compiler/pi/compile_pi.py โ€” do not hand-edit. +// Re-run ai-lab generate to refresh. +// +// A Pi extension for the ai-lab plugin "github-coordination": it lowers the +// plugin's canonical Claude hooks into `pi.on(...)` subscriptions. The extension +// is self-contained: `import type` is erased at load, compiler adapter files are +// under ./ai-lab-runtime/, and the plugin's hook runtime trees are vendored next +// to index.ts so the extension carries them wherever it is copied. + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +import nodeFs from "node:fs"; +import nodePath from "node:path"; +import { fileURLToPath } from "node:url"; + +// The project deployer copies this whole extension dir into <project>/.pi/extensions/<plugin>/. +// realpathSync resolves import.meta.url to the real on-disk dir so every +// relative require below resolves next to this file. +const extensionDir = nodeFs.realpathSync( + nodePath.dirname(fileURLToPath(import.meta.url)), +); +const requireFromExtension = (relativePath: string): unknown => + // eslint-disable-next-line @typescript-eslint/no-var-requires + require(nodePath.join(extensionDir, relativePath)); + +const { payloadsForPiToolCall, matcherAcceptsClaudeToolName } = + requireFromExtension("./ai-lab-runtime/map-pi-payload-to-claude.js") as typeof import("./ai-lab-runtime/map-pi-payload-to-claude.js"); +const { runCanonicalHookProcess } = + requireFromExtension("./ai-lab-runtime/run-canonical-hook.js") as typeof import("./ai-lab-runtime/run-canonical-hook.js"); +const { combineHookResult } = + requireFromExtension("./ai-lab-runtime/parse-hook-decision.js") as typeof import("./ai-lab-runtime/parse-hook-decision.js"); +const { parsePiLifecycleOutput } = + requireFromExtension("./ai-lab-runtime/parse-pi-lifecycle-output.js") as typeof import("./ai-lab-runtime/parse-pi-lifecycle-output.js"); + +const PLUGIN_NAME = "github-coordination"; +// @why project-local-source: a project-local plugin is wired explicitly by the +// project owner and must never be narrowed away by the global ai-lab catalog. +// runCanonicalHook skips runtime-manifest narrowing for 'project-local'. +const PLUGIN_SOURCE = "marketplace"; +// The extension is self-contained: the plugin's canonical hooks/ are vendored +// next to index.ts, so every hook script resolves against this dir. +const PLUGIN_ROOT = extensionDir; +const ROWS_BY_EVENT: Record<string, Array<{ + claudeEvent: string; + matcher: string | null; + scriptPath: string; + scriptArgs?: string[]; + timeoutMs: number; +}>> = { + "session_start": [ + { + "claudeEvent": "SessionStart", + "matcher": "startup|resume", + "scriptPath": "hooks/agent-issue-cli-status.js", + "timeoutMs": 20000 + } + ] +}; + +type LifecycleOutcome = { + blockReasons: string[]; + contextMessages: string[]; + systemMessages: string[]; +}; + +function runLifecycleRows( + rows: typeof ROWS_BY_EVENT[string], + claudePayload: Record<string, unknown>, +): LifecycleOutcome { + const outcome: LifecycleOutcome = { + blockReasons: [], + contextMessages: [], + systemMessages: [], + }; + const cwd = typeof claudePayload.cwd === "string" ? claudePayload.cwd : process.cwd(); + for (const row of rows) { + const hookResult = runCanonicalHookProcess({ + scriptPath: nodePath.join(PLUGIN_ROOT, row.scriptPath), + scriptArgs: row.scriptArgs, + pluginRoot: PLUGIN_ROOT, + pluginSource: PLUGIN_SOURCE, + claudePayload, + cwd, + timeoutMs: row.timeoutMs, + clientKey: "pi", + }); + if (hookResult.kind !== "ran") continue; + const parsed = parsePiLifecycleOutput({ + stdout: hookResult.stdout, + stderr: hookResult.stderr, + exitCode: hookResult.exitCode, + defaultReason: `Blocked by ${PLUGIN_NAME}.`, + }); + outcome.blockReasons.push(...parsed.blockReasons); + outcome.contextMessages.push(...parsed.contextMessages); + outcome.systemMessages.push(...parsed.systemMessages); + } + return outcome; +} + +// Pi has no per-event JSON stdout protocol: the only way a canonical hook's +// `additionalContext` reaches the model is an injected message. `nextTurn` +// delivery queues it without interrupting the current turn. +function deliverLifecycleOutcome( + pi: ExtensionAPI, + ctx: { hasUI: boolean; ui: { notify: (text: string, notificationLevel: string) => void } }, + outcome: LifecycleOutcome, +): void { + const messageContentField = "content"; + for (const contextMessage of outcome.contextMessages) { + const messageContent = contextMessage; + pi.sendMessage( + { customType: `ai-lab-${PLUGIN_NAME}`, [messageContentField]: messageContent, display: false }, + { deliverAs: "nextTurn" }, + ); + } + if (ctx.hasUI) { + for (const systemMessage of outcome.systemMessages) { + ctx.ui.notify(`[${PLUGIN_NAME}] ${systemMessage}`, "info"); + } + } +} + +export default function (pi: ExtensionAPI): void { + pi.on("session_start", async (event, ctx) => { + const rows = ROWS_BY_EVENT["session_start"]; + if (!rows || rows.length === 0) return undefined; + const claudePayload: Record<string, unknown> = { + cwd: ctx.cwd, + source: "reason" in event ? (event as { reason: string }).reason : "", + session_id: ctx.sessionManager.getSessionFile() || "", + }; + const outcome = runLifecycleRows(rows, claudePayload); + deliverLifecycleOutcome(pi, ctx, outcome); + if (outcome.blockReasons.length > 0 && ctx.hasUI) { + ctx.ui.notify( + `[${PLUGIN_NAME}] (advisory; Pi cannot block this event) ` + + outcome.blockReasons.join("\n\n"), + "warning", + ); + } + return undefined; + }); +} diff --git a/.pi/extensions/github-coordination/lib/attribution.js b/.pi/extensions/github-coordination/lib/attribution.js new file mode 100644 index 0000000..d4212e4 --- /dev/null +++ b/.pi/extensions/github-coordination/lib/attribution.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +/** + * Canonical attribution identity for github-coordination writes. + * + * @why One source for the reused dotfiles bot identity so every command, skill, + * and hook stamps the same Bot Attribution label and the same visible Agent + * Attribution Marker โ€” inventing a second bot identity is exactly what the + * coordination contract forbids. + * + * The dotfiles bot identity is the Jakuta Review Bot GitHub App + * (manifests/dev-repos.toml -> jakuta-review-bot; app.yml "Jakuta Review Bot"). + * Agent-filed issue plans use `jakuta-agent-issue apply-plan`, so issue writes + * are authored by the app bot. The visible marker remains the durable + * provenance line for readers. + */ + +"use strict"; + +// The reused dotfiles bot identity. Not the operator's personal git user. +const BOT_ATTRIBUTION_LABEL = "Jakuta Review Bot"; +const BOT_ATTRIBUTION_SLUG = "jakuta-review-bot[bot]"; + +// The literal text that tells a human reader an agent authored this record. +// Must stay visible in the Canonical Issue Body, never hidden metadata alone. +const AGENT_ATTRIBUTION_MARKER = + "> ๐Ÿค– **Agent-authored** โ€” an agent wrote this coordination record. " + + "Attributed to the " + BOT_ATTRIBUTION_LABEL + " (`" + BOT_ATTRIBUTION_SLUG + "`), " + + "the reused dotfiles bot identity; no second bot identity is invented."; + +/** + * @why Appends the visible marker to a body only when it is not already there, + * so re-running a guided flow over an existing body cannot stack duplicate + * markers. + */ +function withAgentMarker(body) { + const text = typeof body === "string" ? body : ""; + if (text.includes("an agent wrote this")) return text; + return text.replace(/\s*$/, "") + "\n\n" + AGENT_ATTRIBUTION_MARKER + "\n"; +} + +module.exports = { + BOT_ATTRIBUTION_LABEL, + BOT_ATTRIBUTION_SLUG, + AGENT_ATTRIBUTION_MARKER, + withAgentMarker, +}; diff --git a/.pi/extensions/github-coordination/lib/package.json b/.pi/extensions/github-coordination/lib/package.json new file mode 100644 index 0000000..b731bd6 --- /dev/null +++ b/.pi/extensions/github-coordination/lib/package.json @@ -0,0 +1 @@ +{"type": "commonjs"} diff --git a/.pi/extensions/github-coordination/lib/pr-issue-link.js b/.pi/extensions/github-coordination/lib/pr-issue-link.js new file mode 100644 index 0000000..22027ee --- /dev/null +++ b/.pi/extensions/github-coordination/lib/pr-issue-link.js @@ -0,0 +1,135 @@ +#!/usr/bin/env node +/** + * Detects whether a `gh pr create` / `gh pr edit` command either closes a + * linked issue or proves an exact development-to-production promotion. + * + * @why Ordinary pull requests implement an issue and must close it. A direct + * development-to-production promotion implements no new issue, so it instead + * carries the evidence that makes that release move reviewable. Kept pure + * (string in, named outcome out) so the hook stays a thin adapter and the rule + * is unit-checkable. + */ + +"use strict"; + +const fs = require("fs"); + +// GitHub's closing keywords, followed by an issue ref (#123 or a full URL). +const CLOSING_REFERENCE = + /\b(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\b\s*:?\s+(#\d+|https?:\/\/github\.com\/[^/\s]+\/[^/\s]+\/issues\/\d+)/i; + +const PROMOTION_PROOF_LINES = [ + /^##\s+Promotion proof\s*$/im, + /^- Development commit:\s+[0-9a-f]{7,40}\s*$/im, + /^- Production commit:\s+[0-9a-f]{7,40}\s*$/im, + /^- Production history:\s+\S.+$/im, + /^- Required checks:\s+\S.+$/im, + /^- Protection:\s+no force-push or bypass\.?\s*$/im, +]; + +/** + * Classifies a shell command string. + * + * Outcome kinds: + * NotPrWrite โ€” not a `gh pr create`/`edit`; the hook ignores it. + * HasClosingReference โ€” a closing keyword + issue ref was found in the body. + * HasPromotionProof โ€” direct development-to-production PR with release proof. + * BodyUnreadable โ€” a --body-file was named but could not be read. + * MissingPromotionProof โ€” direct promotion missing its required proof body. + * MissingClosingRef โ€” a PR write with no resolvable closing reference. + */ +function classifyPrCommand(command) { + const text = typeof command === "string" ? command : ""; + + const isPrWrite = /\bgh\s+pr\s+(create|edit)\b/.test(text); + if (!isPrWrite) return { kind: "NotPrWrite" }; + + const bodyTexts = []; + const inline = extractInlineBody(text); + if (inline !== null) { + bodyTexts.push({ source: "inline", text: inline }); + if (CLOSING_REFERENCE.test(inline)) { + return { kind: "HasClosingReference", source: "inline" }; + } + } + + const bodyFile = extractBodyFilePath(text); + if (bodyFile !== null) { + let fileText; + try { + fileText = fs.readFileSync(bodyFile, "utf8"); + } catch (err) { + return { kind: "BodyUnreadable", path: bodyFile, error: String(err && err.message ? err.message : err) }; + } + bodyTexts.push({ source: "body-file", text: fileText }); + if (CLOSING_REFERENCE.test(fileText)) { + return { kind: "HasClosingReference", source: "body-file" }; + } + } + + // Some callers put the closing ref in --title; honor it so a legitimately + // linked PR is never blocked on a body/title technicality. + const title = extractFlagValue(text, "title") || extractFlagValue(text, "t"); + if (title !== null && CLOSING_REFERENCE.test(title)) { + return { kind: "HasClosingReference", source: "title" }; + } + + for (const bodyText of bodyTexts) { + if (isDevelopmentToProductionPromotion(text) && hasPromotionProof(bodyText.text)) { + return { kind: "HasPromotionProof", source: bodyText.source }; + } + } + + if (isDevelopmentToProductionPromotion(text)) { + return { kind: "MissingPromotionProof" }; + } + + return { kind: "MissingClosingRef" }; +} + +function isDevelopmentToProductionPromotion(text) { + return ( + /\bgh\s+pr\s+create\b/.test(text) && + extractFlagValue(text, "base") === "production" && + extractFlagValue(text, "head") === "development" + ); +} + +function hasPromotionProof(bodyText) { + return PROMOTION_PROOF_LINES.every((requirement) => requirement.test(bodyText)); +} + +function extractInlineBody(text) { + const long = extractFlagValue(text, "body"); + if (long !== null) return long; + const short = extractFlagValue(text, "b"); + if (short !== null) return short; + return null; +} + +function extractBodyFilePath(text) { + return extractFlagValue(text, "body-file") || extractFlagValue(text, "F"); +} + +/** + * Pulls the value of --flag / -f from a command string, handling single + * quotes, double quotes, and bare tokens. Returns null when the flag is absent. + */ +function extractFlagValue(text, flag) { + const dash = flag.length === 1 ? "-" : "--"; + const head = dash + escapeForRegex(flag); + const re = new RegExp( + head + "(?:=|\\s+)(?:'([^']*)'|\"((?:[^\"\\\\]|\\\\.)*)\"|(\\S+))" + ); + const m = text.match(re); + if (!m) return null; + if (m[1] !== undefined) return m[1]; + if (m[2] !== undefined) return m[2].replace(/\\(["\\])/g, "$1"); + return m[3]; +} + +function escapeForRegex(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +module.exports = { classifyPrCommand, CLOSING_REFERENCE }; diff --git a/.pi/extensions/github-coordination/lib/resolve-template.js b/.pi/extensions/github-coordination/lib/resolve-template.js new file mode 100644 index 0000000..6b43e74 --- /dev/null +++ b/.pi/extensions/github-coordination/lib/resolve-template.js @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * Resolve the issue-body template for a Work Type, letting a repo override the + * plugin default. + * + * @why A venture repo often has its own issue format. This is the single lookup + * both guided-issue-filing and manage-announcement use, so the override rule + * cannot drift between them: a committed repo-local template at + * <repo>/.github-coordination/templates/<work-type>.md wins; otherwise the + * plugin's bundled templates/ default is used. + * + * CLI: `node resolve-template.js <work-type>` prints the resolved absolute path + * on stdout and the source (RepoOverride | PluginDefault) on stderr. Exits 2 on + * an unknown Work Type so a caller never silently files against the wrong shape. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +// Work Type -> template filename. parent uses parent-issue.md; the rest match. +const WORK_TYPE_FILE = { + bug: "bug.md", + "ui-bug": "ui-bug.md", + "ux-issue": "ux-issue.md", + feature: "feature.md", + task: "task.md", + parent: "parent-issue.md", + announcement: "announcement.md", +}; + +// A venture drops its own templates here, committed and shared with the repo. +const REPO_OVERRIDE_DIR = path.join(".github-coordination", "templates"); + +function repoRoot() { + try { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + // @why Outside a git repo there is no override location; fall back to the + // plugin default rather than failing. + return null; + } +} + +/** + * @why Returns a tagged outcome (UnknownWorkType | RepoOverride | PluginDefault) + * so the CLI and any future caller branch on the source explicitly instead of + * guessing from a bare path string. + */ +function resolveTemplate(workType) { + const file = WORK_TYPE_FILE[workType]; + if (!file) return { kind: "UnknownWorkType", workType }; + + const pluginRoot = path.resolve(__dirname, ".."); + const root = repoRoot(); + if (root) { + const override = path.join(root, REPO_OVERRIDE_DIR, file); + if (fs.existsSync(override)) return { kind: "RepoOverride", path: override }; + } + return { kind: "PluginDefault", path: path.join(pluginRoot, "templates", file) }; +} + +function main() { + const workType = process.argv[2]; + const outcome = resolveTemplate(workType); + if (outcome.kind === "UnknownWorkType") { + process.stderr.write( + "resolve-template: unknown Work Type '" + String(workType) + "'. " + + "Known: " + Object.keys(WORK_TYPE_FILE).join(", ") + "\n" + ); + process.exit(2); + } + process.stderr.write(outcome.kind + "\n"); + process.stdout.write(outcome.path + "\n"); +} + +if (require.main === module) main(); + +module.exports = { resolveTemplate, WORK_TYPE_FILE, REPO_OVERRIDE_DIR }; diff --git a/.pi/skill-assets/github-coordination/lib/attribution.js b/.pi/skill-assets/github-coordination/lib/attribution.js new file mode 100644 index 0000000..d4212e4 --- /dev/null +++ b/.pi/skill-assets/github-coordination/lib/attribution.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +/** + * Canonical attribution identity for github-coordination writes. + * + * @why One source for the reused dotfiles bot identity so every command, skill, + * and hook stamps the same Bot Attribution label and the same visible Agent + * Attribution Marker โ€” inventing a second bot identity is exactly what the + * coordination contract forbids. + * + * The dotfiles bot identity is the Jakuta Review Bot GitHub App + * (manifests/dev-repos.toml -> jakuta-review-bot; app.yml "Jakuta Review Bot"). + * Agent-filed issue plans use `jakuta-agent-issue apply-plan`, so issue writes + * are authored by the app bot. The visible marker remains the durable + * provenance line for readers. + */ + +"use strict"; + +// The reused dotfiles bot identity. Not the operator's personal git user. +const BOT_ATTRIBUTION_LABEL = "Jakuta Review Bot"; +const BOT_ATTRIBUTION_SLUG = "jakuta-review-bot[bot]"; + +// The literal text that tells a human reader an agent authored this record. +// Must stay visible in the Canonical Issue Body, never hidden metadata alone. +const AGENT_ATTRIBUTION_MARKER = + "> ๐Ÿค– **Agent-authored** โ€” an agent wrote this coordination record. " + + "Attributed to the " + BOT_ATTRIBUTION_LABEL + " (`" + BOT_ATTRIBUTION_SLUG + "`), " + + "the reused dotfiles bot identity; no second bot identity is invented."; + +/** + * @why Appends the visible marker to a body only when it is not already there, + * so re-running a guided flow over an existing body cannot stack duplicate + * markers. + */ +function withAgentMarker(body) { + const text = typeof body === "string" ? body : ""; + if (text.includes("an agent wrote this")) return text; + return text.replace(/\s*$/, "") + "\n\n" + AGENT_ATTRIBUTION_MARKER + "\n"; +} + +module.exports = { + BOT_ATTRIBUTION_LABEL, + BOT_ATTRIBUTION_SLUG, + AGENT_ATTRIBUTION_MARKER, + withAgentMarker, +}; diff --git a/.pi/skill-assets/github-coordination/lib/pr-issue-link.js b/.pi/skill-assets/github-coordination/lib/pr-issue-link.js new file mode 100644 index 0000000..22027ee --- /dev/null +++ b/.pi/skill-assets/github-coordination/lib/pr-issue-link.js @@ -0,0 +1,135 @@ +#!/usr/bin/env node +/** + * Detects whether a `gh pr create` / `gh pr edit` command either closes a + * linked issue or proves an exact development-to-production promotion. + * + * @why Ordinary pull requests implement an issue and must close it. A direct + * development-to-production promotion implements no new issue, so it instead + * carries the evidence that makes that release move reviewable. Kept pure + * (string in, named outcome out) so the hook stays a thin adapter and the rule + * is unit-checkable. + */ + +"use strict"; + +const fs = require("fs"); + +// GitHub's closing keywords, followed by an issue ref (#123 or a full URL). +const CLOSING_REFERENCE = + /\b(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\b\s*:?\s+(#\d+|https?:\/\/github\.com\/[^/\s]+\/[^/\s]+\/issues\/\d+)/i; + +const PROMOTION_PROOF_LINES = [ + /^##\s+Promotion proof\s*$/im, + /^- Development commit:\s+[0-9a-f]{7,40}\s*$/im, + /^- Production commit:\s+[0-9a-f]{7,40}\s*$/im, + /^- Production history:\s+\S.+$/im, + /^- Required checks:\s+\S.+$/im, + /^- Protection:\s+no force-push or bypass\.?\s*$/im, +]; + +/** + * Classifies a shell command string. + * + * Outcome kinds: + * NotPrWrite โ€” not a `gh pr create`/`edit`; the hook ignores it. + * HasClosingReference โ€” a closing keyword + issue ref was found in the body. + * HasPromotionProof โ€” direct development-to-production PR with release proof. + * BodyUnreadable โ€” a --body-file was named but could not be read. + * MissingPromotionProof โ€” direct promotion missing its required proof body. + * MissingClosingRef โ€” a PR write with no resolvable closing reference. + */ +function classifyPrCommand(command) { + const text = typeof command === "string" ? command : ""; + + const isPrWrite = /\bgh\s+pr\s+(create|edit)\b/.test(text); + if (!isPrWrite) return { kind: "NotPrWrite" }; + + const bodyTexts = []; + const inline = extractInlineBody(text); + if (inline !== null) { + bodyTexts.push({ source: "inline", text: inline }); + if (CLOSING_REFERENCE.test(inline)) { + return { kind: "HasClosingReference", source: "inline" }; + } + } + + const bodyFile = extractBodyFilePath(text); + if (bodyFile !== null) { + let fileText; + try { + fileText = fs.readFileSync(bodyFile, "utf8"); + } catch (err) { + return { kind: "BodyUnreadable", path: bodyFile, error: String(err && err.message ? err.message : err) }; + } + bodyTexts.push({ source: "body-file", text: fileText }); + if (CLOSING_REFERENCE.test(fileText)) { + return { kind: "HasClosingReference", source: "body-file" }; + } + } + + // Some callers put the closing ref in --title; honor it so a legitimately + // linked PR is never blocked on a body/title technicality. + const title = extractFlagValue(text, "title") || extractFlagValue(text, "t"); + if (title !== null && CLOSING_REFERENCE.test(title)) { + return { kind: "HasClosingReference", source: "title" }; + } + + for (const bodyText of bodyTexts) { + if (isDevelopmentToProductionPromotion(text) && hasPromotionProof(bodyText.text)) { + return { kind: "HasPromotionProof", source: bodyText.source }; + } + } + + if (isDevelopmentToProductionPromotion(text)) { + return { kind: "MissingPromotionProof" }; + } + + return { kind: "MissingClosingRef" }; +} + +function isDevelopmentToProductionPromotion(text) { + return ( + /\bgh\s+pr\s+create\b/.test(text) && + extractFlagValue(text, "base") === "production" && + extractFlagValue(text, "head") === "development" + ); +} + +function hasPromotionProof(bodyText) { + return PROMOTION_PROOF_LINES.every((requirement) => requirement.test(bodyText)); +} + +function extractInlineBody(text) { + const long = extractFlagValue(text, "body"); + if (long !== null) return long; + const short = extractFlagValue(text, "b"); + if (short !== null) return short; + return null; +} + +function extractBodyFilePath(text) { + return extractFlagValue(text, "body-file") || extractFlagValue(text, "F"); +} + +/** + * Pulls the value of --flag / -f from a command string, handling single + * quotes, double quotes, and bare tokens. Returns null when the flag is absent. + */ +function extractFlagValue(text, flag) { + const dash = flag.length === 1 ? "-" : "--"; + const head = dash + escapeForRegex(flag); + const re = new RegExp( + head + "(?:=|\\s+)(?:'([^']*)'|\"((?:[^\"\\\\]|\\\\.)*)\"|(\\S+))" + ); + const m = text.match(re); + if (!m) return null; + if (m[1] !== undefined) return m[1]; + if (m[2] !== undefined) return m[2].replace(/\\(["\\])/g, "$1"); + return m[3]; +} + +function escapeForRegex(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +module.exports = { classifyPrCommand, CLOSING_REFERENCE }; diff --git a/.pi/skill-assets/github-coordination/lib/resolve-template.js b/.pi/skill-assets/github-coordination/lib/resolve-template.js new file mode 100644 index 0000000..6b43e74 --- /dev/null +++ b/.pi/skill-assets/github-coordination/lib/resolve-template.js @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * Resolve the issue-body template for a Work Type, letting a repo override the + * plugin default. + * + * @why A venture repo often has its own issue format. This is the single lookup + * both guided-issue-filing and manage-announcement use, so the override rule + * cannot drift between them: a committed repo-local template at + * <repo>/.github-coordination/templates/<work-type>.md wins; otherwise the + * plugin's bundled templates/ default is used. + * + * CLI: `node resolve-template.js <work-type>` prints the resolved absolute path + * on stdout and the source (RepoOverride | PluginDefault) on stderr. Exits 2 on + * an unknown Work Type so a caller never silently files against the wrong shape. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +// Work Type -> template filename. parent uses parent-issue.md; the rest match. +const WORK_TYPE_FILE = { + bug: "bug.md", + "ui-bug": "ui-bug.md", + "ux-issue": "ux-issue.md", + feature: "feature.md", + task: "task.md", + parent: "parent-issue.md", + announcement: "announcement.md", +}; + +// A venture drops its own templates here, committed and shared with the repo. +const REPO_OVERRIDE_DIR = path.join(".github-coordination", "templates"); + +function repoRoot() { + try { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + // @why Outside a git repo there is no override location; fall back to the + // plugin default rather than failing. + return null; + } +} + +/** + * @why Returns a tagged outcome (UnknownWorkType | RepoOverride | PluginDefault) + * so the CLI and any future caller branch on the source explicitly instead of + * guessing from a bare path string. + */ +function resolveTemplate(workType) { + const file = WORK_TYPE_FILE[workType]; + if (!file) return { kind: "UnknownWorkType", workType }; + + const pluginRoot = path.resolve(__dirname, ".."); + const root = repoRoot(); + if (root) { + const override = path.join(root, REPO_OVERRIDE_DIR, file); + if (fs.existsSync(override)) return { kind: "RepoOverride", path: override }; + } + return { kind: "PluginDefault", path: path.join(pluginRoot, "templates", file) }; +} + +function main() { + const workType = process.argv[2]; + const outcome = resolveTemplate(workType); + if (outcome.kind === "UnknownWorkType") { + process.stderr.write( + "resolve-template: unknown Work Type '" + String(workType) + "'. " + + "Known: " + Object.keys(WORK_TYPE_FILE).join(", ") + "\n" + ); + process.exit(2); + } + process.stderr.write(outcome.kind + "\n"); + process.stdout.write(outcome.path + "\n"); +} + +if (require.main === module) main(); + +module.exports = { resolveTemplate, WORK_TYPE_FILE, REPO_OVERRIDE_DIR }; diff --git a/.pi/skill-assets/github-coordination/references/issue-plan.md b/.pi/skill-assets/github-coordination/references/issue-plan.md new file mode 100644 index 0000000..631a014 --- /dev/null +++ b/.pi/skill-assets/github-coordination/references/issue-plan.md @@ -0,0 +1,67 @@ +# Issue Plan Contract + +<what-to-do> +Represent every coordinated issue write as one JSON Issue Plan and submit it once: + +```bash +jakuta-agent-issue apply-plan --repo OWNER/REPO --plan-file <plan.json> +``` + +Use this exact shape: + +```json +{ + "plan_key": "checkout-recovery", + "references": { + "existing-parent": 12 + }, + "issues": [ + { + "key": "retry-failed-payment", + "title": "Retry a failed payment", + "body_file": "bodies/retry-failed-payment.md", + "labels": ["feature"], + "parent": "existing-parent", + "blocked_by": [] + } + ] +} +``` + +For an existing managed issue, add `"issue_number": 34`. Keys use lowercase +words separated by hyphens and stay stable across reruns. Body paths are +relative to the plan file. `parent` is a managed key, a key in `references`, or +`null`. Every `blocked_by` entry is likewise a managed or reference key. + +Before applying a plan, make it complete: + +- `title`, `body_file`, and `labels` are the exact desired issue state. +- `parent` is the exact desired native GitHub parent. +- `blocked_by` is the exact desired native GitHub blocker set. +- `references` names existing issues that supply relationships without becoming + managed issues. +- Every managed body includes the Agent Attribution Marker exactly once. + +When an existing issue enters a plan, read its current title, body, labels, and +native relationships first. Carry forward every value that should remain. Do +not submit a partial representation and expect omitted state to survive. + +Use GitHub reads to inspect native relationships: + +```bash +gh api --paginate repos/OWNER/REPO/issues/NUMBER/sub_issues +gh api --paginate repos/OWNER/REPO/issues/NUMBER/dependencies/blocked_by +gh api graphql -F owner=OWNER -F name=REPO -F number=NUMBER \ + -f query='query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){issue(number:$number){parent{number}}}}' +``` + +Run one mutation command for the whole request. Read the structured result to +report the resolved issue numbers and URLs. A rerun uses the same plan and keys; +the command reconciles instead of creating duplicates. +</what-to-do> + +<supporting-info> +The plan is the whole desired result, not a sequence of issue-sized operations. +Validation happens before GitHub writes, and the command owns creation, updates, +labels, native parent links, and native blocked-by links together. +</supporting-info> diff --git a/.pi/skill-assets/github-coordination/templates/announcement.md b/.pi/skill-assets/github-coordination/templates/announcement.md new file mode 100644 index 0000000..b069f5a --- /dev/null +++ b/.pi/skill-assets/github-coordination/templates/announcement.md @@ -0,0 +1,26 @@ +<!-- +Work Type: announcement +Audit Skill (optional): none. +Routing: not part of the Parent/Child Tree. An Announcement is a repo-local +coordination item (release note, pinned issue, or pinned discussion). It is not +an org-wide broadcast and never a public gist. +The body below is the Canonical Issue Body for the announcement. +--> + +## Headline + +<one plain line: what changed or what the reader needs to know> + +## Details + +<what shipped, what is affected, what the reader should do, if anything> + +## Applies to + +<version / date window / surface this announcement covers> + +## Links + +- <release, PR, or issue this references> + +<!-- AGENT_ATTRIBUTION_MARKER appended here by manage-announcement --> diff --git a/.pi/skill-assets/github-coordination/templates/bug.md b/.pi/skill-assets/github-coordination/templates/bug.md new file mode 100644 index 0000000..7f95699 --- /dev/null +++ b/.pi/skill-assets/github-coordination/templates/bug.md @@ -0,0 +1,35 @@ +<!-- +Work Type: bug +Audit Skill (optional): github-coordination:audit-issues +Routing: the Issue Plan records any parent or blocker in GitHub's native graph. +The issue body below is the Canonical Issue Body โ€” the authoritative record. +--> + +## What's broken + +<one plain sentence: what the user sees go wrong> + +## Steps to reproduce + +1. <step> +2. <step> +3. <step> + +## Expected vs actual + +- **Expected:** <what should happen> +- **Actual:** <what happens> + +## Environment + +<os / browser / build / commit โ€” whatever pins the bug down> + +## Evidence-Backed Acceptance Criteria + +<!-- Each box needs attached Evidence (link, log, screenshot, PR ref, command output) before it may be checked. A bare checkbox is not done. --> + +- [ ] The reproduction no longer triggers the failure โ€” Evidence: +- [ ] A regression test covers this path โ€” Evidence: +- [ ] The fix PR references and closes this issue โ€” Evidence: + +<!-- AGENT_ATTRIBUTION_MARKER appended here by guided-issue-filing --> diff --git a/.pi/skill-assets/github-coordination/templates/feature.md b/.pi/skill-assets/github-coordination/templates/feature.md new file mode 100644 index 0000000..64fa551 --- /dev/null +++ b/.pi/skill-assets/github-coordination/templates/feature.md @@ -0,0 +1,32 @@ +<!-- +Work Type: feature +Audit Skill (optional): github-coordination:audit-issues +Routing: the Issue Plan records any parent or blocker in GitHub's native graph. +The body below is the Canonical Issue Body. +--> + +## Outcome + +<one plain sentence naming what a user can do after this ships that they cannot do today> + +## Why it matters + +<the problem this solves, for whom> + +## Scope + +What's in: +- <in-scope item> + +What's out (so the issue does not creep): +- <explicitly out-of-scope item> + +## Evidence-Backed Acceptance Criteria + +<!-- Each box needs attached Evidence (demo link, test run, screenshot, PR ref) before it may be checked. --> + +- [ ] A user can perform the outcome end to end. Evidence: +- [ ] The new path has test coverage. Evidence: +- [ ] The implementing PR references and closes this issue. Evidence: + +<!-- AGENT_ATTRIBUTION_MARKER appended here by guided-issue-filing --> diff --git a/.pi/skill-assets/github-coordination/templates/parent-issue.md b/.pi/skill-assets/github-coordination/templates/parent-issue.md new file mode 100644 index 0000000..c6dbdd1 --- /dev/null +++ b/.pi/skill-assets/github-coordination/templates/parent-issue.md @@ -0,0 +1,25 @@ +<!-- +Work Type: parent +Audit Skill (optional): none by default; children carry their own. +Routing: top of a native GitHub parent/child tree. The Issue Plan owns its links. +The body below is the Canonical Issue Body. +A Parent Issue carries the outcome, not the implementation details. +--> + +## Outcome + +<one plain sentence naming the larger result this groups, stated as what becomes true when every child closes> + +## Why this is one piece of work + +<the thread that ties the children together> + +## Out of scope + +<what this parent will not absorb, so the Parent/Child Tree stays bounded and does not drift into a backlog> + +## Done when + +This parent closes when every native GitHub child issue is closed with its evidence attached. + +<!-- AGENT_ATTRIBUTION_MARKER appended here by guided-issue-filing --> diff --git a/.pi/skill-assets/github-coordination/templates/task.md b/.pi/skill-assets/github-coordination/templates/task.md new file mode 100644 index 0000000..49f8d13 --- /dev/null +++ b/.pi/skill-assets/github-coordination/templates/task.md @@ -0,0 +1,28 @@ +<!-- +Work Type: task +Audit Skill (optional): github-coordination:audit-issues +Routing: the Issue Plan records any parent or blocker in GitHub's native graph. +The body below is the Canonical Issue Body. +--> + +## The task + +<one plain sentence: the concrete unit of work to do> + +## Why now + +<what this unblocks, or the deadline driving it> + +## Done looks like + +<a short description a reviewer could check against> + +## Evidence-Backed Acceptance Criteria + +<!-- Each box needs attached Evidence before it may be checked. --> + +- [ ] The work is complete. Evidence: +- [ ] Anything it touched still passes its checks. Evidence: +- [ ] The PR references and closes this issue. Evidence: + +<!-- AGENT_ATTRIBUTION_MARKER appended here by guided-issue-filing --> diff --git a/.pi/skill-assets/github-coordination/templates/ui-bug.md b/.pi/skill-assets/github-coordination/templates/ui-bug.md new file mode 100644 index 0000000..7a5aac5 --- /dev/null +++ b/.pi/skill-assets/github-coordination/templates/ui-bug.md @@ -0,0 +1,34 @@ +<!-- +Work Type: ui-bug +Audit Skill (optional): github-coordination:audit-issues +Routing: the Issue Plan records any parent or blocker in GitHub's native graph. +The body below is the Canonical Issue Body. +--> + +## What looks wrong + +<one plain sentence describing the visual or layout defect> + +## Where + +<screen / route / component, and the device or viewport it shows on> + +## Steps to see it + +1. <step> +2. <step> + +## Expected vs actual appearance + +- **Expected:** <how it should look> +- **Actual:** <how it looks now> + +## Evidence-Backed Acceptance Criteria + +<!-- Every box needs attached Evidence before it may be checked. A screenshot is usually the strongest evidence for a UI bug. --> + +- [ ] The element renders correctly at the reported viewport โ€” Evidence (before/after screenshot): +- [ ] No regression on the sibling screens that share the component โ€” Evidence: +- [ ] The fix PR references and closes this issue โ€” Evidence: + +<!-- AGENT_ATTRIBUTION_MARKER appended here by guided-issue-filing --> diff --git a/.pi/skill-assets/github-coordination/templates/ux-issue.md b/.pi/skill-assets/github-coordination/templates/ux-issue.md new file mode 100644 index 0000000..08497f3 --- /dev/null +++ b/.pi/skill-assets/github-coordination/templates/ux-issue.md @@ -0,0 +1,28 @@ +<!-- +Work Type: ux-issue +Audit Skill (optional): github-coordination:audit-issues +Routing: the Issue Plan records any parent or blocker in GitHub's native graph. +The body below is the Canonical Issue Body. +--> + +## The friction + +<one plain sentence: what makes the experience confusing, slow, or frustrating> + +## Who hits it and when + +<the user, the task they were doing, the moment it bit them> + +## What they expected to happen + +<the outcome a first-time user would assume> + +## Evidence-Backed Acceptance Criteria + +<!-- Each box needs attached Evidence (usability note, recording, before/after flow, metric) before it may be checked. --> + +- [ ] A user can complete the task without the reported friction. Evidence: +- [ ] The change does not move the friction somewhere else in the flow. Evidence: +- [ ] The PR references and closes this issue. Evidence: + +<!-- AGENT_ATTRIBUTION_MARKER appended here by guided-issue-filing --> diff --git a/.pi/skills/audit-issues/SKILL.md b/.pi/skills/audit-issues/SKILL.md new file mode 100644 index 0000000..2859f3b --- /dev/null +++ b/.pi/skills/audit-issues/SKILL.md @@ -0,0 +1,171 @@ +--- +name: audit-issues +description: "Audit GitHub issues against repository state or evidence-backed acceptance criteria, then reconcile confirmed issue changes through one complete Issue Plan." +user-invocable: true +disable-model-invocation: true +--- +# Audit Issues + +<supporting-info> +The first pass is read-only and returns one verdict per issue. A later confirmed +write is one coordinated plan, so a split, retitle, relabel, and relationship +change cannot land as separate half-finished operations. +</supporting-info> + +## Read-only evidence pass + +<what-to-do> +Do not close, edit, relabel, retitle, or comment until the read-only report is +complete and explicit user confirmation names the exact rows to mutate. An +initial request that already asks for a named batch update counts as confirmation +only for those named rows and changes. + +Resolve the repository, trunk, issue set, and linked pull requests: + +```bash +gh repo view --json nameWithOwner -q .nameWithOwner +git remote -v +git symbolic-ref --quiet refs/remotes/origin/HEAD +gh issue view <number> --json number,title,body,state,labels,comments,url +gh issue list --state open --limit 200 --json number,title,body,state,labels,updatedAt,url +gh pr list --state all --limit 200 --json number,title,body,state,mergedAt,headRefName,baseRefName,url +``` + +For every issue, inspect its title, body, labels, comments, native parent, +native children, native blocked-by relationships, acceptance criteria, linked +pull requests, merged commits, branches, and exact repository evidence. Use the +native relationship reads from +`.pi/skill-assets/github-coordination/references/issue-plan.md`; do not infer relationships from +body headings or checklists. + +Gather exact implementation evidence: + +```bash +git fetch --all --prune --quiet +git log --oneline --decorate --all --grep "<issue number or key>" +git branch --all --list "*<issue number or key>*" +rg -n "<exact feature term|symbol|path from issue>" . +git diff --stat origin/<trunk>...HEAD +git log --oneline -- <relevant paths> +``` + +Run tests or generated-surface checks named by the issue, pull request, changed +paths, or repository rules. When the issue names exact files, commands, errors, +screenshots, or artifacts, check those exact things. Otherwise label any +conservative search terms as derived. +</what-to-do> + +## Evidence-close classification + +<what-to-do> +For every acceptance criterion, assign exactly one evidence state: + +- `PROVEN`: checked and backed by a followable pull request, URL, artifact, + screenshot, command result, or log. +- `UNPROVEN`: checked but its evidence is empty, vague, placeholder text, or not + independently verifiable. +- `OPEN`: unchecked. + +An issue is closeable only when every criterion is `PROVEN`. Comments may explain +the work, but the Canonical Issue Body must reference the evidence. +</what-to-do> + +## Verdicts + +<what-to-do> +Assign exactly one verdict per issue: + +```text +DONE ยท NOT_DONE ยท PARTIAL ยท STALE ยท SUPERSEDED ยท NEEDS_UPDATE +``` + +- `DONE`: every criterion has concrete proof. +- `NOT_DONE`: the requested outcome is absent or proof is missing. +- `PARTIAL`: some criteria have proof and some do not; name both groups. +- `STALE`: the described world no longer exists, but no replacement proves + closure. +- `SUPERSEDED`: a named issue, pull request, commit, branch, or product decision + replaces the issue. +- `NEEDS_UPDATE`: the issue stays open, but its title, body, labels, native + relationships, or acceptance criteria are wrong or incomplete. + +No `probably done`, `looks complete`, `close enough`, `maybe stale`, or free-form +verdicts. Never treat branch-name similarity, age, a closed but unmerged pull +request, a nearby implementation, worktree cleanliness, or memory as proof. +</what-to-do> + +## Report + +<what-to-do> +Render this read-only table before any write: + +```text +ISSUE VERDICT AUDIT +Repo: <owner/repo> +Trunk: origin/<trunk> +Scope: <selector> + +| Issue | Verdict | Reason | Evidence | Missing proof | Proposed issue action | +|---|---|---|---|---|---| +| #12 Title | DONE | every criterion is implemented | PR #44 merged; src/auth/passkey.ts:38; named test exits 0 | none | close with evidence comment | +| #13 Title | PARTIAL | CLI exists, docs missing | pyproject.toml:27 declares the script | docs criterion | comment + keep open | +| #14 Title | NOT_DONE | requested endpoint is absent | exact search returns no hit; no linked PR | proof the endpoint exists | keep open | +| #15 Title | STALE | premise was removed | commit a1b2c3d deleted the named path | named replacement | comment + keep open | +| #16 Title | SUPERSEDED | newer issue owns the work | #28 and merged PR #44 replace it | none | close with replacement comment | +| #17 Title | NEEDS_UPDATE | issue names the wrong public surface | source exposes only the blessed surface | none | reconcile title and criteria | +``` + +Allowed proposed actions are `close`, `comment`, `retitle`, `relabel`, `split`, +`update native relationships`, and `keep open`. + +For evidence-close mode, append: + +```text +EVIDENCE-CLOSE CHECK โ€” #<number> <title> + +PROVEN (n) + - <criterion> โ€” <evidence> +UNPROVEN (n) + - <criterion> โ€” <missing or unverifiable evidence> +OPEN (n) + - <criterion> + +VERDICT: <CLOSEABLE | NOT_CLOSEABLE โ€” m unproven, k open> +``` +</what-to-do> + +## Apply confirmed changes + +<what-to-do> +After confirmation, divide the requested actions by intent: + +1. For every split, new issue, title, body, label, parent, or blocker change, + read the latest complete state and build one Issue Plan spanning every + affected issue. Include unchanged desired values because plan state is exact. +2. Read `.pi/skill-assets/github-coordination/references/issue-plan.md` and submit the complete + plan once: + + ```bash + jakuta-agent-issue apply-plan --repo OWNER/REPO --plan-file <plan.json> + ``` + +3. Only after the plan succeeds, perform confirmed comment and closure actions: + + ```bash + gh issue comment <number> --body-file <evidence-summary.md> + gh issue close <number> + ``` + +Skip the plan only when the confirmed batch contains no issue creation, content, +label, parent, or blocker change. Do not close `PARTIAL`, `NOT_DONE`, `STALE`, or +`NEEDS_UPDATE`. Close `SUPERSEDED` only when the replacement is explicitly named +and linked in the confirmed comment. +</what-to-do> + +## Invariants + +<what-to-do> +Keep the first pass read-only, preserve missing proof as missing, use the closed +verdict set, require a concrete proposed action for every row, and send every +confirmed issue-state batch through one complete plan. +</what-to-do> diff --git a/.pi/skills/guided-issue-filing/SKILL.md b/.pi/skills/guided-issue-filing/SKILL.md new file mode 100644 index 0000000..728f83c --- /dev/null +++ b/.pi/skills/guided-issue-filing/SKILL.md @@ -0,0 +1,101 @@ +--- +name: guided-issue-filing +description: "File or reconcile one or more GitHub issues from Work Type templates through one complete Issue Plan, with clarifying questions, native parent and blocker routing, evidence-backed acceptance criteria, and agent attribution. Use when the user says 'file an issue', 'open a bug', 'log a feature', 'track this task', 'turn these findings into issues', or runs a /github-coordination:file-*-issue or /github-coordination:to-issues command." +user-invocable: true +--- +# Guided Issue Filing + +<supporting-info> +This is the single filing flow behind every Work Type command and the batch +`/github-coordination:to-issues` command. GitHub is the authoritative +coordination record. One Issue Plan expresses the complete result; callers do +not assemble issue writes or relationship updates step by step. +</supporting-info> + +## Confirm the repository and writer + +<what-to-do> +Resolve the repository and verify the bot writer before drafting: + +```bash +gh repo view --json nameWithOwner -q .nameWithOwner +jakuta-agent-issue doctor --repo OWNER/REPO +``` + +If the repository cannot be resolved or GitHub auth is unavailable, stop with +that blocker. If the CLI is missing, repair it through the dotfiles +`jakuta_agent_issue_cli` step. If `doctor` reports missing generated credentials, +repair the dotfiles machine environment and reopen the shell. If the GitHub App +is not installed or lacks issue write permission, surface that repository +blocker. All issue writes must remain on the bot-authored plan path. +</what-to-do> + +## Draft the complete issue set + +<what-to-do> +For each requested issue, resolve and read its Work Type template: + +```bash +node .pi/skill-assets/github-coordination/lib/resolve-template.js <work-type> +``` + +A committed repository override at +`.github-coordination/templates/<work-type>.md` wins over the bundled template. +Fill every field supported by the user's input. Ask one grouped question only +when a required fact, Work Type, scope boundary, or routing choice remains truly +ambiguous. A batch gets one grouped clarification pass, not one interview per +issue. + +Keep each acceptance criterion concrete and leave its evidence slot empty at +filing time. Append the Agent Attribution Marker exactly once: + +```bash +node -e "process.stdout.write(require('.pi/skill-assets/github-coordination/lib/attribution.js').AGENT_ATTRIBUTION_MARKER)" +``` + +Write each finished body to a separate file beside the plan file. +</what-to-do> + +## Route through GitHub's native graph + +<what-to-do> +Read open parent candidates and relevant existing issue relationships before +building the plan: + +```bash +gh issue list --state open --label parent --json number,title +``` + +If exactly one parent owns an issue, select it. If several plausibly own it, +ask once with the candidates and a standalone choice. Never invent a parent. +A parent Work Type is a root. Capture dependencies the user named as native +`blocked_by` relationships. Do not copy parents, children, or blockers into +issue-body fields or checklists. +</what-to-do> + +## Apply one complete plan + +<what-to-do> +Read `.pi/skill-assets/github-coordination/references/issue-plan.md` and build one Issue Plan +covering the whole request. A single-file command produces a one-issue plan; a +batch produces one multi-issue plan. Include the full desired labels: the Work +Type label for every issue and `parent` for a parent Work Type. + +Submit the plan exactly once: + +```bash +jakuta-agent-issue apply-plan --repo OWNER/REPO --plan-file <plan.json> +``` + +Use stable issue keys so rerunning the same plan reconciles the same records. +Read the structured result and report every issue URL, Work Type, native parent +or standalone status, and blocker set in one final block. +</what-to-do> + +## Boundary + +<what-to-do> +Keep branch creation, commits, pushes, and worktrees in `github-workflow`. If the +user also asks to start implementation, hand the filed issue set to that +workflow after this plan succeeds. +</what-to-do> diff --git a/.pi/skills/manage-announcement/SKILL.md b/.pi/skills/manage-announcement/SKILL.md new file mode 100644 index 0000000..5aad138 --- /dev/null +++ b/.pi/skills/manage-announcement/SKILL.md @@ -0,0 +1,89 @@ +--- +name: manage-announcement +description: "Create or update a repo-local announcement through one complete Issue Plan, preserving bot attribution and optionally pinning the resulting issue. Use when the user says 'post an announcement', 'announce the release', 'update the pinned notice', or runs /github-coordination:create-announcement or /github-coordination:update-announcement." +user-invocable: true +--- +# Manage Announcement + +<supporting-info> +An Announcement is a repository-local coordination record. Its issue content is +reconciled through the same bot-authored Issue Plan as every other issue write. +Pinning or publishing a release remains a separate action because it answers a +different user intent. +</supporting-info> + +## Confirm the repository and writer + +<what-to-do> +Resolve the repository and verify the issue writer: + +```bash +gh repo view --json nameWithOwner -q .nameWithOwner +jakuta-agent-issue doctor --repo OWNER/REPO +``` + +Stop on an unresolved repository, unavailable GitHub auth, failed generated +credential setup, missing app installation, or missing issue permission. Repair +a missing CLI through the dotfiles `jakuta_agent_issue_cli` step. Keep issue +writes on the bot-authored plan path. +</what-to-do> + +## Build the announcement body + +<what-to-do> +For a new announcement, resolve the repository override or bundled template: + +```bash +node .pi/skill-assets/github-coordination/lib/resolve-template.js announcement +``` + +Fill the headline, details, affected surface, and links. Ask one grouped question +only for required facts that remain ambiguous. Append the Agent Attribution +Marker exactly once: + +```bash +node -e "process.stdout.write(require('.pi/skill-assets/github-coordination/lib/attribution.js').AGENT_ATTRIBUTION_MARKER)" +``` + +For an update, resolve the issue number from the user or this read: + +```bash +gh issue list --state open --label announcement --json number,title +``` + +Read the current title, body, labels, and native relationships. Preserve the +marker and every desired value while changing only the announcement content the +user requested. +</what-to-do> + +## Apply one complete plan + +<what-to-do> +Read `.pi/skill-assets/github-coordination/references/issue-plan.md`. Build one plan with one +managed announcement. A new announcement omits `issue_number`; an update +includes it. Set the exact labels to include `announcement`, with `parent: null` +and `blocked_by: []` because announcements are outside the work graph. + +Submit it once: + +```bash +jakuta-agent-issue apply-plan --repo OWNER/REPO --plan-file <plan.json> +``` + +Read the structured result for the resolved issue number and URL. If the user +wants a pinned repository notice, pin that issue after the plan succeeds: + +```bash +gh issue pin <number> +``` + +For release notes, a release may also be published with `gh release create`; +the labeled announcement issue remains the coordination record. +</what-to-do> + +## Report + +<what-to-do> +Report the announcement URL, whether its content was created or reconciled, and +whether the separate pin or release action succeeded. +</what-to-do> diff --git a/.pi/skills/show-repo-status/SKILL.md b/.pi/skills/show-repo-status/SKILL.md new file mode 100644 index 0000000..6c729f7 --- /dev/null +++ b/.pi/skills/show-repo-status/SKILL.md @@ -0,0 +1,81 @@ +--- +name: show-repo-status +description: "Show open issue trees, blockers, linked pull requests, announcements, and issues waiting on evidence from GitHub's native coordination state" +user-invocable: true +--- + +Treat the user's invocation text after `show-repo-status` as `$ARGUMENTS`. + +# /show-repo-status โ€” Repo Status View + +Render one read-only summary from the GitHub Coordination Ledger. Every line must +trace to a GitHub read; this command never writes or invents state. + +## Confirm and gather + +```bash +gh repo view --json nameWithOwner -q .nameWithOwner +gh issue list --state open --label parent --json number,title +gh issue list --state open --limit 200 --json number,title,body,labels +gh pr list --state open --json number,title,headRefName,body +gh issue list --state open --label announcement --json number,title +``` + +For each open parent and issue, read native relationships: + +```bash +gh api --paginate repos/OWNER/REPO/issues/NUMBER/sub_issues +gh api --paginate repos/OWNER/REPO/issues/NUMBER/dependencies/blocked_by +``` + +Bound network calls so an unavailable remote fails clearly instead of leaving +the view waiting indefinitely. + +## Derive + +- Build each parent/child tree from the native `sub_issues` results. Issue-body + headings and task lists are content, never relationship evidence. +- List an open issue as blocked when its native `blocked_by` result contains an + open blocker. Include closed blockers only when they explain a recent state + transition. +- Parse open pull request bodies for closing references and group linked pull + requests under their issue. List a pull request with no closing reference as + unlinked without calling it defective. +- Mark an issue waiting on evidence when an acceptance criterion is unchecked or + checked with an empty `Evidence:` slot. + +## Render + +```text +REPO STATUS โ€” <owner/repo> + +PARENT ISSUES (open) + #12 Checkout rewrite + โ”œโ”€ #13 Cart persistence open ยท linked PR #40 + โ”œโ”€ #14 Payment retry closed โœ“ + โ””โ”€ #15 Confirmation email open ยท waiting on evidence (2 of 3 AC) + +BLOCKED ISSUES (open) + #18 Refund settlement โ€” blocked by #17 Payment provider contract + +LINKED PRS (open) + #40 Persist cart โ†’ closes #13 + +ANNOUNCEMENTS (open) + #22 v0.9 release notes (pinned) + +WAITING ON EVIDENCE + #15 Confirmation email โ€” 2 of 3 criteria have no evidence + +UNLINKED PRS (open) + #41 Dependency refresh +``` + +Show only populated sections. An empty repository prints one line saying there +are no open coordination items. + +## Boundary + +This view is read-only. Filing and issue reconciliation go through one Issue +Plan. Pull requests remain in `/open-pull-request`; git sync and worktrees remain +in `github-workflow`. diff --git a/.pi/skills/to-issues/SKILL.md b/.pi/skills/to-issues/SKILL.md new file mode 100644 index 0000000..078e442 --- /dev/null +++ b/.pi/skills/to-issues/SKILL.md @@ -0,0 +1,20 @@ +--- +name: to-issues +description: "Turn a supplied report, audit, or backlog slice into one coordinated issue plan with native parent and blocker relationships" +user-invocable: true +--- + +Treat the user's invocation text after `to-issues` as `$ARGUMENTS`. + +# /to-issues + +Run `github-coordination:guided-issue-filing` in batch mode with the user's +supplied findings. Treat the complete batch as one Issue Plan, even when it mixes +Work Types, creates new parent and child issues together, or updates existing +issues while adding splits. + +Resolve all ambiguity in one grouped question, draft every Canonical Issue Body, +read the current state of every existing managed issue, express relationships in +GitHub's native parent and blocked-by graph, and invoke +`jakuta-agent-issue apply-plan` once for the whole mutation. Report the resolved +issue URLs and graph placement only after the complete plan succeeds.