From 770e7b61e65cf3edfb2f8230ecc45e01a70ea8ca Mon Sep 17 00:00:00 2001 From: cortex-agent Date: Fri, 23 Oct 2026 15:29:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(review):=20PR=20review=20=E5=9B=9E?= =?UTF-8?q?=E5=86=99=E9=80=9A=E9=81=93=E4=B8=8E=20finding=20=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对比 PR-Agent / Qodo v2 后确认的最小缺口:cortex 能产出经过机械校验的 review artifact,但没有出口——finding 只活在文件里,没有人在 PR 上看到它。 治理仓侧(.agent gitlink → 19d50601,PR #27): - vcs-pr github backend 新增 getDiff / listComments / listReviews / createComment / submitReview - CLI 新增 diff / comments / reviews / comment / review - comment 与 review 归入 GATE_USER_ONLY——发布到别人会读的 PR - backend 未实现某操作时报 capability_unavailable,不以空列表冒充数据 - agent-review-benchmark 新增 render-pr-comment.js:artifact → PR 评论体 - 新增 CI workflow 覆盖此前未被任何 workflow 触达的两个 skill 主仓侧(L1 分发面 + 测试): - 同步 _shared / en 两层的 vcs-pr 实现与 SKILL.md - _shared 的 vcs-pr SKILL.md 此前缺少 update 命令、fail-closed run 关联 等章节,zh 层项目拿到的是它——已补齐为与 en 层同构 - 同步治理仓 #23 引入的 4 个实现文件与 2 个 schema 漂移 - 新增 tests/review-surface/(双副本:治理仓 + L1 各跑一遍) - issue-15 测试改为断言新契约:decisions resolve 不再释放 Waitpoint 验证:484/484 全量通过,三道门禁零违规。 --- .agent | 2 +- .../.agent/decisions/decision.schema.json | 7 +- .../skills/agent-review-benchmark/SKILL.md | 16 + .../scripts/render-pr-comment.js | 236 +++++++++++ .../scripts/render-pr-comment.test.js | 165 ++++++++ .../skills/knowledge-lint/scripts/index.js | 150 ++++++- .../skills/management-api/scripts/index.js | 47 +-- .../runtime-continuity/scripts/index.js | 21 + .../_shared/.agent/skills/vcs-pr/SKILL.md | 88 ++++- .../skills/vcs-pr/scripts/backends/github.js | 109 +++++- .../.agent/skills/vcs-pr/scripts/index.js | 126 +++++- .../.agent/waitpoints/waitpoint.schema.json | 7 +- .../evolution-pipeline/scripts/evolve.js | 369 +++++++++++++++++- .../skills/knowledge-lint/scripts/index.js | 150 ++++++- .../skills/management-api/scripts/index.js | 47 +-- .../runtime-continuity/scripts/index.js | 21 + templates/en/.agent/skills/vcs-pr/SKILL.md | 36 +- .../skills/vcs-pr/scripts/backends/github.js | 109 +++++- .../en/.agent/skills/vcs-pr/scripts/index.js | 126 +++++- .../evolution-pipeline/scripts/evolve.js | 369 +++++++++++++++++- .../skills/knowledge-lint/scripts/index.js | 150 ++++++- .../skills/management-api/scripts/index.js | 47 +-- .../runtime-continuity/scripts/index.js | 21 + .../vcs-pr-review-surface.test.js | 123 ++++++ ...ssue-15-waitpoints-help-state-sync.test.js | 34 +- tests/guided-review/pr-review-loop.test.js | 215 ++++++++++ tests/review-surface/github-backend.test.js | 296 ++++++++++++++ .../review-surface/render-pr-comment.test.js | 180 +++++++++ 28 files changed, 3026 insertions(+), 241 deletions(-) create mode 100644 templates/_shared/.agent/skills/agent-review-benchmark/scripts/render-pr-comment.js create mode 100644 templates/_shared/.agent/skills/agent-review-benchmark/scripts/render-pr-comment.test.js create mode 100644 tests/agent-skills/vcs-pr-review-surface.test.js create mode 100644 tests/guided-review/pr-review-loop.test.js create mode 100644 tests/review-surface/github-backend.test.js create mode 100644 tests/review-surface/render-pr-comment.test.js diff --git a/.agent b/.agent index 1d4de9bc..19d50601 160000 --- a/.agent +++ b/.agent @@ -1 +1 @@ -Subproject commit 1d4de9bc9d13fcaea79ad2f8600aa36738993a92 +Subproject commit 19d50601e20fee03e7cdc33e774114077b88b0e6 diff --git a/templates/_shared/.agent/decisions/decision.schema.json b/templates/_shared/.agent/decisions/decision.schema.json index 22dab18a..cb20b348 100644 --- a/templates/_shared/.agent/decisions/decision.schema.json +++ b/templates/_shared/.agent/decisions/decision.schema.json @@ -73,7 +73,12 @@ "pattern": "^D-[A-Za-z0-9][A-Za-z0-9._-]*$" }, "created_at": { "type": "string", "format": "date-time" }, - "updated_at": { "type": "string", "format": "date-time" } + "updated_at": { "type": "string", "format": "date-time" }, + "workflow_gate": { + "type": "string", + "enum": ["mission","agent","user","requester"], + "description": "Caller-supplied workflow provenance label only; not authenticated identity, ownership, approval, or permission to execute." + } }, "allOf": [ { diff --git a/templates/_shared/.agent/skills/agent-review-benchmark/SKILL.md b/templates/_shared/.agent/skills/agent-review-benchmark/SKILL.md index 439d200e..b6701a1a 100644 --- a/templates/_shared/.agent/skills/agent-review-benchmark/SKILL.md +++ b/templates/_shared/.agent/skills/agent-review-benchmark/SKILL.md @@ -67,6 +67,22 @@ node .agent/skills/agent-review-benchmark/scripts/index.js review \ 4. Check that every group explains intent, impact, risk, files, validation evidence, and follow-ups. 5. Attach the output to `/ship`, Artifact Bus, or the owning Mission milestone. Treat missing evidence as incomplete, not passed. +### Publish to a pull request + +An artifact that never reaches the pull request is evidence nobody reads. The renderer carries a validated artifact to the comment body, and `vcs-pr` posts it: + +```bash +# 1. artifact → markdown comment body +node .agent/skills/agent-review-benchmark/scripts/render-pr-comment.js \ + --input --output + +# 2. post it to the pull request (user gate — the comment is public) +node .agent/skills/vcs-pr/scripts/index.js comment \ + --pr-number --body-file --gate user +``` + +The renderer is pure and total: it never touches the network, never reads the workspace, never invents content, and always produces the same bytes for the same artifact. A finding with no follow-ups contributes no follow-up section rather than a placeholder that reads like content. + ## Benchmark 1. Create a versioned dataset of cases and blocking assertions. Record candidate identity, passed/total assertions, cost in integer microunits, duration in milliseconds, and evidence refs. diff --git a/templates/_shared/.agent/skills/agent-review-benchmark/scripts/render-pr-comment.js b/templates/_shared/.agent/skills/agent-review-benchmark/scripts/render-pr-comment.js new file mode 100644 index 00000000..df2458e8 --- /dev/null +++ b/templates/_shared/.agent/skills/agent-review-benchmark/scripts/render-pr-comment.js @@ -0,0 +1,236 @@ +#!/usr/bin/env node +"use strict"; + +// --------------------------------------------------------------------------- +// .agent/skills/agent-review-benchmark/scripts/render-pr-comment.js +// --------------------------------------------------------------------------- +// Renders a guided-review artifact into the markdown body of a pull request +// comment. +// +// Why this exists: the review loop was open at both ends. `reviewer-backend` +// produces a validated artifact, but nothing could carry it to the place a +// human actually reads — the pull request. This renderer is the missing +// middle: artifact in, comment body out. +// +// The renderer is deliberately pure and total: +// +// * It never touches the network and never reads the workspace. The +// artifact is already validated by the backend; re-validating here would +// create a second, weaker gate. +// * It never invents content. Every line it emits comes from a field of +// the artifact. A finding with an empty follow-up list contributes no +// follow-up section — not a placeholder that reads like content. +// * It is deterministic. The same artifact renders to the same bytes, so +// a re-render can be diffed against the previous one. +// +// Usage: +// node .../render-pr-comment.js --input [--output ] +// +// The output is written to stdout when --output is absent. +// --------------------------------------------------------------------------- + +const fs = require("node:fs"); +const path = require("node:path"); + +const RISK_ORDER = ["critical", "high", "medium", "low"]; + +// The CLI reports failures as JSON on stdout and exits non-zero. As a +// library the same conditions throw, so a caller (or a test) can observe the +// failure instead of the process disappearing mid-assertion. +function fail(error, details, code = 1) { + const failure = new Error(typeof details === "string" ? details : `${error}: ${JSON.stringify(details)}`); + failure.code = code; + failure.error = error; + failure.details = details; + throw failure; +} + +// The CLI has no subcommand: every argument is a flag. The parser starts at +// index 0 — treating argv[0] as a command would swallow `--input`. +function parseArgs(argv) { + const args = {}; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (!token.startsWith("--")) continue; + const key = token.slice(2); + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + args[key] = true; + continue; + } + args[key] = value; + index += 1; + } + return args; +} + +function readJson(file, label) { + if (!file || file === true) fail(`${label}_required`, `Pass --${label}.`, 2); + try { + return JSON.parse(fs.readFileSync(path.resolve(file), "utf8")); + } catch (error) { + fail(`${label}_unreadable`, { file, message: error.message }, 2); + return null; + } +} + +// A heading must not contain a newline — a finding title with an embedded +// newline would break the markdown structure of everything after it. +function oneLine(value) { + return String(value == null ? "" : value).replace(/\s*\n+\s*/g, " ").trim(); +} + +function headingFor(risk) { + const labels = { + critical: "Critical", + high: "High", + medium: "Medium", + low: "Low", + }; + return labels[risk] || "Unknown"; +} + +// Findings are grouped by risk so the reader sees the severe ones first. +// Within a group the order is the artifact's own order — the backend already +// sorted findings by group_id, and re-sorting here would hide that. +function groupByRisk(groups) { + const buckets = new Map(); + for (const group of groups) { + const risk = group && group.risk ? group.risk : "low"; + if (!buckets.has(risk)) buckets.set(risk, []); + buckets.get(risk).push(group); + } + const ordered = []; + for (const risk of RISK_ORDER) { + if (buckets.has(risk)) ordered.push({ risk, groups: buckets.get(risk) }); + } + // Any risk value outside the known set still gets rendered — dropping a + // finding because its risk label is unknown would hide a real finding. + for (const [risk, groups] of buckets) { + if (!RISK_ORDER.includes(risk)) ordered.push({ risk, groups }); + } + return ordered; +} + +function renderFinding(group, index) { + const lines = []; + const title = oneLine(group.title); + lines.push(`#### ${index}. ${title}`); + lines.push(""); + lines.push(`- **Risk**: ${headingFor(group.risk)}`); + if (group.category) lines.push(`- **Category**: ${oneLine(group.category)}`); + const files = Array.isArray(group.files) ? group.files : []; + if (files.length) { + lines.push(`- **Files**: ${files.map((file) => `\`${file}\``).join(", ")}`); + } + lines.push(""); + + if (group.summary) { + lines.push(oneLine(group.summary)); + lines.push(""); + } + if (group.intent) { + lines.push(`**Intent**: ${oneLine(group.intent)}`); + lines.push(""); + } + if (group.impact) { + lines.push(`**Impact**: ${oneLine(group.impact)}`); + lines.push(""); + } + const validationRefs = Array.isArray(group.validation_refs) ? group.validation_refs : []; + if (validationRefs.length) { + lines.push("**Validation**:"); + lines.push(""); + for (const ref of validationRefs) lines.push(`- ${oneLine(ref)}`); + lines.push(""); + } + const followUps = Array.isArray(group.follow_ups) ? group.follow_ups : []; + if (followUps.length) { + lines.push("**Follow-ups**:"); + lines.push(""); + for (const followUp of followUps) lines.push(`- ${oneLine(followUp)}`); + lines.push(""); + } + return lines; +} + +function renderSummary(summary) { + const lines = []; + const riskCounts = (summary && summary.risk_counts) || {}; + const parts = RISK_ORDER.filter((risk) => riskCounts[risk]).map( + (risk) => `${riskCounts[risk]} ${headingFor(risk).toLowerCase()}` + ); + const counts = []; + if (summary && typeof summary.group_count === "number") counts.push(`${summary.group_count} finding(s)`); + if (summary && typeof summary.file_count === "number") counts.push(`${summary.file_count} file(s)`); + if (parts.length) counts.push(parts.join(", ")); + if (counts.length) lines.push(counts.join(" · ")); + return lines; +} + +function renderComment(artifact) { + if (!artifact || typeof artifact !== "object") { + fail("invalid_artifact", "The artifact must be a JSON object.", 2); + } + const groups = Array.isArray(artifact.groups) ? artifact.groups : []; + if (!groups.length) { + fail("no_findings_to_render", "The artifact carries no findings.", 2); + } + + const lines = []; + lines.push("## Guided review"); + lines.push(""); + const summaryLines = renderSummary(artifact.summary); + if (summaryLines.length) { + lines.push(summaryLines.join("")); + lines.push(""); + } + + let counter = 0; + for (const bucket of groupByRisk(groups)) { + // The risk bucket is a level-3 heading and each finding a level-4 one, + // so a finding reads as *inside* its risk group rather than beside it. + lines.push(`### ${headingFor(bucket.risk)} risk`); + lines.push(""); + for (const group of bucket.groups) { + counter += 1; + lines.push(...renderFinding(group, counter)); + } + } + + // The trailing blank line keeps the rendered body from ending mid-block, + // and the marker makes the comment identifiable in a PR timeline. + lines.push("---"); + lines.push(""); + lines.push(`_Guided review artifact \`${oneLine(artifact.artifact_id)}\`_`); + lines.push(""); + + return lines.join("\n"); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + try { + const artifact = readJson(args.input, "input"); + const body = renderComment(artifact); + if (args.output && args.output !== true) { + const target = path.resolve(args.output); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, body); + process.stdout.write(`${JSON.stringify({ ok: true, output: target, bytes: Buffer.byteLength(body) })}\n`); + return; + } + process.stdout.write(body); + } catch (error) { + process.stdout.write( + `${JSON.stringify({ ok: false, error: error.error || "render_failed", details: error.details || error.message })}\n` + ); + process.exit(error.code || 1); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { groupByRisk, oneLine, parseArgs, renderComment }; diff --git a/templates/_shared/.agent/skills/agent-review-benchmark/scripts/render-pr-comment.test.js b/templates/_shared/.agent/skills/agent-review-benchmark/scripts/render-pr-comment.test.js new file mode 100644 index 00000000..641473e1 --- /dev/null +++ b/templates/_shared/.agent/skills/agent-review-benchmark/scripts/render-pr-comment.test.js @@ -0,0 +1,165 @@ +"use strict"; + +// Regression coverage for the guided-review → pull request comment renderer. +// +// The renderer is the piece that carries a validated review artifact to the +// place a human reads. Its contract is narrow on purpose: +// +// * It never invents content. Every emitted line comes from an artifact +// field. A finding without follow-ups contributes no follow-up section +// — a placeholder there would read like content that was never found. +// * It never drops a finding. An unrecognized risk label still renders; +// hiding it would hide a real finding behind a label typo. +// * It is deterministic. The same artifact renders to the same bytes, so +// a re-render can be diffed against the previous one. +// +// These cases exercise the pure function directly — no filesystem, no +// network, no workspace — which is what makes the "invents nothing" claim +// checkable in the first place. + +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { groupByRisk, oneLine, renderComment } = require("./render-pr-comment.js"); + +function finding(overrides) { + return { + group_id: "G-1", + title: "Null guard missing", + intent: "Guard the lookup result before use", + files: ["lib/app.js"], + summary: "The lookup result is dereferenced without a null check.", + impact: "A missing key crashes the request handler.", + risk: "high", + category: "quality", + validation_refs: ["tests/app.test.js::handles missing key"], + follow_ups: ["Add the null guard"], + ...overrides, + }; +} + +function artifact(overrides) { + return { + type: "guided_review", + artifact_id: "GR-abcdef123456", + task_id: "T-1", + run_id: "R-1", + session_id: "S-1", + workspace_ids: ["WS-1"], + base_revision: "aaaaaaa", + head_revision: "bbbbbbb", + groups: [finding()], + summary: { + group_count: 1, + file_count: 1, + risk_counts: { low: 0, medium: 0, high: 1, critical: 0 }, + validation_ref_count: 1, + follow_up_count: 1, + }, + generated_at: "2026-10-23T00:00:00Z", + ...overrides, + }; +} + +test("renders every artifact field into the comment body", () => { + const body = renderComment(artifact()); + assert.match(body, /## Guided review/); + assert.match(body, /#### 1\. Null guard missing/); + assert.match(body, /\*\*Risk\*\*: High/); + assert.match(body, /\*\*Category\*\*: quality/); + assert.match(body, /`lib\/app\.js`/); + assert.match(body, /The lookup result is dereferenced without a null check\./); + assert.match(body, /\*\*Intent\*\*: Guard the lookup result before use/); + assert.match(body, /\*\*Impact\*\*: A missing key crashes the request handler\./); + assert.match(body, /tests\/app\.test\.js::handles missing key/); + assert.match(body, /Add the null guard/); + assert.match(body, /GR-abcdef123456/); +}); + +test("omits sections the artifact does not carry instead of emitting placeholders", () => { + const body = renderComment( + artifact({ + groups: [ + finding({ validation_refs: [], follow_ups: [], impact: "", intent: "" }), + ], + }) + ); + assert.doesNotMatch(body, /\*\*Validation\*\*/); + assert.doesNotMatch(body, /\*\*Follow-ups\*\*/); + assert.doesNotMatch(body, /\*\*Impact\*\*/); + assert.doesNotMatch(body, /\*\*Intent\*\*/); + // The finding itself is still present. + assert.match(body, /Null guard missing/); +}); + +test("groups findings by risk, severe first, and numbers them across groups", () => { + const body = renderComment( + artifact({ + groups: [ + finding({ group_id: "G-1", title: "Low risk item", risk: "low" }), + finding({ group_id: "G-2", title: "Critical risk item", risk: "critical" }), + finding({ group_id: "G-3", title: "Medium risk item", risk: "medium" }), + ], + }) + ); + + const criticalAt = body.indexOf("Critical risk item"); + const mediumAt = body.indexOf("Medium risk item"); + const lowAt = body.indexOf("Low risk item"); + assert.ok(criticalAt < mediumAt, "critical must precede medium"); + assert.ok(mediumAt < lowAt, "medium must precede low"); + + assert.match(body, /### Critical risk/); + assert.match(body, /### Medium risk/); + assert.match(body, /### Low risk/); + + // Numbering runs across the whole comment, not per risk bucket. + assert.match(body, /#### 1\. Critical risk item/); + assert.match(body, /#### 2\. Medium risk item/); + assert.match(body, /#### 3\. Low risk item/); +}); + +test("an unrecognized risk label still renders the finding", () => { + const body = renderComment( + artifact({ groups: [finding({ risk: "catastrophic", title: "Odd risk item" })] }) + ); + assert.match(body, /Odd risk item/); + assert.match(body, /\*\*Risk\*\*: Unknown/); +}); + +test("a newline inside a title cannot break the markdown structure", () => { + const body = renderComment( + artifact({ groups: [finding({ title: "First line\n### Injected heading" })] }) + ); + assert.match(body, /#### 1\. First line ### Injected heading/); + // The injected text must not become its own heading. + assert.doesNotMatch(body, /\n### Injected heading/); +}); + +test("an empty artifact is rejected rather than rendered as an empty comment", () => { + assert.throws( + () => renderComment(artifact({ groups: [] })), + (error) => error.error === "no_findings_to_render" + ); +}); + +test("rendering is deterministic", () => { + const input = artifact(); + assert.equal(renderComment(input), renderComment(input)); +}); + +test("groupByRisk keeps every finding exactly once", () => { + const groups = [ + finding({ group_id: "G-1", risk: "low" }), + finding({ group_id: "G-2", risk: "low" }), + finding({ group_id: "G-3", risk: "high" }), + ]; + const buckets = groupByRisk(groups); + const total = buckets.reduce((sum, bucket) => sum + bucket.groups.length, 0); + assert.equal(total, groups.length); + assert.equal(buckets.length, 2); +}); + +test("oneLine collapses newlines and trims", () => { + assert.equal(oneLine(" a\n\nb "), "a b"); + assert.equal(oneLine(null), ""); +}); diff --git a/templates/_shared/.agent/skills/knowledge-lint/scripts/index.js b/templates/_shared/.agent/skills/knowledge-lint/scripts/index.js index dca9bfb5..bfea6277 100644 --- a/templates/_shared/.agent/skills/knowledge-lint/scripts/index.js +++ b/templates/_shared/.agent/skills/knowledge-lint/scripts/index.js @@ -78,25 +78,112 @@ function extractHeadings(content) { } function shouldCheckLink(rawLink) { - if (!rawLink) return false; - if (rawLink.includes('{') || rawLink.includes('}')) return false; - if (rawLink.startsWith('http://')) return false; - if (rawLink.startsWith('https://')) return false; - if (rawLink.startsWith('mailto:')) return false; - if (rawLink.startsWith('app://')) return false; - if (rawLink.startsWith('plugin://')) return false; - return true; + if (!rawLink || /[{}]/.test(rawLink)) return false; + // URI schemes are not repository paths (RFC 3986); file: is reported separately. + return !/^[a-z][a-z0-9+.-]*:/i.test(rawLink) && !rawLink.startsWith('//'); +} + +function isEscaped(text, index) { + let slashes = 0; + while (index > 0 && text[--index] === '\\') slashes++; + return slashes % 2 === 1; +} + +function maskMarkdownCode(content) { + let fence = null; + const prose = content.split('\n').map((line) => { + const marker = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/); + if (fence) { + if (marker && marker[1][0] === fence[0] && + marker[1].length >= fence.length && !marker[2].trim()) fence = null; + return ''; + } + if (marker && !(marker[1][0] === '`' && marker[2].includes('`'))) { + fence = marker[1]; + return ''; + } + return line; + }).join('\n'); + // Blocks take precedence over inline spans. In particular, an unmatched + // tick in a paragraph must not consume links in the following heading/list. + const blocks = []; + let block = []; + const flush = () => { if (block.length) blocks.push(block.join('\n')); block = []; }; + for (const line of prose.split('\n')) { + const heading = /^ {0,3}#{1,6}(?:[ \t]|$)/.test(line); + const separator = /^ {0,3}(?:(?:\*[ \t]*){3,}|(?:_[ \t]*){3,}|(?:-[ \t]*){3,}|=+[ \t]*|-+[ \t]*)$/.test(line); + const listMarker = /^ {0,3}(?:[-+*]|\d{1,9}[.)])[ \t]+/; + // Ordered lists can interrupt a paragraph only when starting at 1. + const list = listMarker.test(line) && (!block.length || listMarker.test(block[0]) || + /^ {0,3}(?:[-+*]|1[.)])[ \t]+/.test(line)); + const quote = /^ {0,3}>/.test(line); + const previousQuote = block.length && /^ {0,3}>/.test(block[block.length - 1]); + if (!line.trim() || heading || separator || list || (quote && !previousQuote)) flush(); + block.push(line); + if (!line.trim() || heading || separator) flush(); + } + flush(); + return blocks.map(maskInlineCode).join('\n'); +} + +function maskInlineCode(prose) { + // Only equal-length backtick runs delimit a span; unmatched runs are prose. + const runs = [...prose.matchAll(/`+/g)]; + const masked = prose.split(''); + for (let i = 0; i < runs.length; i++) { + if (isEscaped(prose, runs[i].index)) continue; + let end = i + 1; + while (end < runs.length && runs[end][0].length !== runs[i][0].length) end++; + if (end === runs.length) continue; + masked.fill(' ', runs[i].index, runs[end].index + runs[end][0].length); + i = end; + } + return masked.join(''); +} + +function hasLinkClose(suffix) { + // CommonMark titles can contain escaped delimiters and line endings, but + // not blank lines. Match only the suffix so following paragraphs don't matter. + const close = suffix.match(/^\s*(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^()\\])*\))?\s*\)/); + return Boolean(close && !/\r?\n[ \t]*\r?\n/.test(close[0])); } function extractMarkdownLinks(content) { + const text = maskMarkdownCode(content); const links = []; - const pattern = /\[[^\]]+\]\(([^)]+)\)/g; + const opener = /\[(?:\\.|[^\]\\])*\]\(\s*/g; let match; - - while ((match = pattern.exec(content))) { - links.push(match[1].trim()); + while ((match = opener.exec(text))) { + if (isEscaped(text, match.index)) continue; + let index = opener.lastIndex; + const angle = text[index] === '<'; + if (angle) index++; + let target = ''; + let depth = 0; + let closed = false; + for (; index < text.length; index++) { + const ch = text[index]; + if (ch === '\\' && /[\\()[\]<>]/.test(text[index + 1] || '')) { + target += text[++index]; + } else if (angle ? ch === '>' : ch === ')' && depth === 0) { + closed = true; + break; + } else if (!angle && /\s/.test(ch) && depth === 0) { + closed = true; + break; + } else { + if (!angle && ch === '(') depth++; + if (!angle && ch === ')') depth--; + target += ch; + } + } + // Validate the outer close and optional Markdown title before accepting. + const suffix = text.slice(index + (angle ? 1 : 0)); + if (closed && hasLinkClose(suffix)) { + links.push(target); + opener.lastIndex = index + 1; + } } - return links; } @@ -118,6 +205,7 @@ function resolveTargetFile(sourceFile, filePart) { function collectBrokenLinks(markdownFiles) { const brokenLinks = []; const brokenAnchors = []; + const unverifiedLinks = []; const headingCache = new Map(); for (const sourceFile of markdownFiles) { @@ -125,6 +213,15 @@ function collectBrokenLinks(markdownFiles) { const links = extractMarkdownLinks(content); for (const rawLink of links) { + if (/^file:/i.test(rawLink)) { + // This scanner does not validate host-local URI availability. Report + // that limit explicitly; it is not a requirement to re-audit past business acceptance. + unverifiedLinks.push({ + source: path.relative(root, sourceFile), target: rawLink, + status: 'unverified', reason: 'host_local_uri_not_checked', + }); + continue; + } if (!shouldCheckLink(rawLink)) continue; const { filePart, anchor } = splitLink(rawLink); @@ -159,7 +256,7 @@ function collectBrokenLinks(markdownFiles) { } } - return { brokenLinks, brokenAnchors }; + return { brokenLinks, brokenAnchors, unverifiedLinks }; } function collectMissingReadmes() { @@ -360,6 +457,17 @@ function collectArchitectureDocMismatches() { // 新增 experimental (POC/试运行但未 ship) 与 reference (文档化但未 ship, 仅做知识参考)。 // 注: experimental 与 reference 区别于 stable (已 ship + 主路径使用) 与 draft (草稿/编写中)。 const OKF_STATUS_VALUES = new Set(['stable', 'draft', 'deprecated', 'experimental', 'reference']); +// references/runtime-adapters/_schema.md defines deployment status, not OKF lifecycle. +const RUNTIME_ADAPTER_STATUS_VALUES = new Set(['shipped', 'reference', 'pending']); + +function statusSchemaFor(filepath, fm) { + const adapterDir = path.join(root, '.agent', 'references', 'runtime-adapters'); + const adapterDocument = path.dirname(filepath) === adapterDir && + typeof fm.agent === 'string' && fm.agent === path.basename(filepath, '.md'); + return adapterDocument + ? { values: RUNTIME_ADAPTER_STATUS_VALUES, label: 'runtime-adapter' } + : { values: OKF_STATUS_VALUES, label: 'OKF V0.2' }; +} // 简单 frontmatter 解析:仅识别 key-value / inline-list / block-list // 不依赖 yaml 包(零依赖) @@ -417,12 +525,13 @@ function collectFrontmatterViolations() { } // R-LINT-008: status 非法值 (warning) - if (fm.status !== undefined && !OKF_STATUS_VALUES.has(fm.status)) { + const statusSchema = statusSchemaFor(filepath, fm); + if (fm.status !== undefined && !statusSchema.values.has(fm.status)) { violations.push({ rule_id: 'R-LINT-008', severity: 'warning', file: rel, - message: `status "${fm.status}" 不是 OKF V0.2 枚举 (stable / draft / deprecated / experimental / reference)`, + message: `status "${fm.status}" 不是 ${statusSchema.label} 枚举 (${[...statusSchema.values].join(' / ')})`, }); } } @@ -433,6 +542,7 @@ function collectFrontmatterViolations() { function computeHealthScore(summary) { const penalty = summary.broken_links * 12 + + (summary.unverified_links || 0) * 12 + // Same penalty: unresolved evidence is not acceptance summary.broken_anchors * 6 + summary.missing_readmes * 8 + summary.plan_issues * 10 + @@ -486,7 +596,7 @@ function runFrontmatterValidator() { function run() { const markdownFiles = internalDocRoots.flatMap((dir) => listMarkdownFiles(dir)); - const { brokenLinks, brokenAnchors } = collectBrokenLinks(markdownFiles); + const { brokenLinks, brokenAnchors, unverifiedLinks } = collectBrokenLinks(markdownFiles); const missingReadmes = collectMissingReadmes(); const planIssues = collectPlanIssues(); const architectureDocMismatches = collectArchitectureDocMismatches(); @@ -497,6 +607,7 @@ function run() { const summary = { markdown_files_scanned: markdownFiles.length, broken_links: brokenLinks.length, + unverified_links: unverifiedLinks.length, broken_anchors: brokenAnchors.length, missing_readmes: missingReadmes.length, plan_issues: planIssues.length, @@ -513,6 +624,7 @@ function run() { summary, findings: { broken_links: brokenLinks, + unverified_links: unverifiedLinks, broken_anchors: brokenAnchors, missing_readmes: missingReadmes, plan_issues: planIssues, @@ -528,6 +640,7 @@ function run() { console.log('--- 📚 Knowledge Lint ---'); console.log(`Scanned markdown files: ${summary.markdown_files_scanned}`); console.log(`Broken links: ${summary.broken_links}`); + console.log(`Unverified local evidence: ${summary.unverified_links}`); console.log(`Broken anchors: ${summary.broken_anchors}`); console.log(`Missing READMEs: ${summary.missing_readmes}`); console.log(`Plan issues: ${summary.plan_issues}`); @@ -538,4 +651,5 @@ function run() { console.log(`Output: ${path.relative(root, outputPath)}`); } -run(); +if (require.main === module) run(); +module.exports = { extractMarkdownLinks, collectBrokenLinks, computeHealthScore, collectFrontmatterViolations }; diff --git a/templates/_shared/.agent/skills/management-api/scripts/index.js b/templates/_shared/.agent/skills/management-api/scripts/index.js index 1f3f01d0..919634dd 100755 --- a/templates/_shared/.agent/skills/management-api/scripts/index.js +++ b/templates/_shared/.agent/skills/management-api/scripts/index.js @@ -1074,19 +1074,15 @@ function resolveDecision() { resource_ref: next.gate.resource_ref, updated_at: timestamp, }, (entry, current) => entry.decision_id === current.decision_id); - // Release any matching waitpoints - const released = releaseMatchingWaitpoints(decisionId, resolvedBy, rationale, timestamp); + // Resolving a Decision records the user's choice only. The owning workflow + // must separately validate and release or cancel its Waitpoint. printJson({ ok: true, action: "decisions resolve", path: rel(file), decision: next, - changed_paths: [ - rel(file), - rel(path.join(agentRoot, "decisions", "index.json")), - ...released.paths, - ], - changed_resources: [`decision:${decisionId}`, ...released.resources], + changed_paths: [rel(file), rel(path.join(agentRoot, "decisions", "index.json"))], + changed_resources: [`decision:${decisionId}`], }); } @@ -1399,41 +1395,6 @@ function releaseWaitpoint() { }); } -function releaseMatchingWaitpoints(decisionId, resolvedBy, rationale, timestamp) { - const dir = path.join(agentRoot, "waitpoints"); - const changedPaths = []; - const changedResources = []; - for (const { file, data } of listJsonObjects(dir)) { - if (data.decision_id !== decisionId) continue; - if (!["pending", "blocked"].includes(data.status)) continue; - const next = { - ...data, - status: "released", - released_by: resolvedBy, - released_at: timestamp, - release_note: rationale || `Auto-released by decision ${decisionId}.`, - evidence_refs: Array.isArray(data.evidence_refs) && data.evidence_refs.length > 0 ? data.evidence_refs : [`decision:${decisionId}`], - updated_at: timestamp, - }; - writeJson(file, next); - changedPaths.push(rel(file)); - changedResources.push(`waitpoint:${data.waitpoint_id}`); - upsertIndexEntry( - "waitpoints", - "waitpoints", - waitpointIndexEntry(next, file), - (entry, current) => entry.waitpoint_id === current.waitpoint_id, - ); - } - if (changedPaths.length > 0) { - changedPaths.push(rel(path.join(agentRoot, "waitpoints", "index.json"))); - } - return { - paths: [...new Set(changedPaths)], - resources: [...new Set(changedResources)], - }; -} - function upsertIndexEntry(dir, key, entry, match) { const indexFile = path.join(agentRoot, dir, "index.json"); const data = readJson(indexFile) || {}; diff --git a/templates/_shared/.agent/skills/runtime-continuity/scripts/index.js b/templates/_shared/.agent/skills/runtime-continuity/scripts/index.js index 47e50fa9..27dc7d6d 100644 --- a/templates/_shared/.agent/skills/runtime-continuity/scripts/index.js +++ b/templates/_shared/.agent/skills/runtime-continuity/scripts/index.js @@ -495,6 +495,26 @@ function archiveProject(project, note, opts = {}) { catch (_) { fs.copyFileSync(file, latest); } const markdownArchive = { archivePath: file, latestPath: latest, stamp }; const structured = createStructuredArchive(project, note || {}, opts, markdownArchive); + if (opts.queueLearningProposal === true) { + // Explicit project-local proposal intake only. Never enable this for the + // automatic continuity guard, invoke a Host, or write memory/index here. + let learningProposal; + try { + const evolution = require("../../evolution-pipeline/scripts/evolve.js"); + const queued = evolution.enqueueFromArchive(structured.archiveJsonPath, { + proposalOnly: true, expectedProject: project, + }); + learningProposal = { + status: queued.deduplicated ? "already_queued" : "queued", + task_id: queued.task_id, source_sha256: queued.source_archive.sha256, + execution_authorized: false, + }; + } catch (error) { + // Preserve the successful archive even when proposal intake is unavailable. + learningProposal = { status: "unavailable", reason_code: error.code || "ERR_PROPOSAL_INTAKE", execution_authorized: false }; + } + return { ...markdownArchive, ...structured, learning_proposal: learningProposal }; + } return { ...markdownArchive, ...structured }; } @@ -1857,6 +1877,7 @@ function main() { target_host: flag("--to-host", argv) || null, reason: flag("--reason", argv) || null, full: argv.includes("--full"), + queueLearningProposal: argv.includes("--queue-learning-proposal"), }); } catch (err) { fail("archive_failed", err.message); diff --git a/templates/_shared/.agent/skills/vcs-pr/SKILL.md b/templates/_shared/.agent/skills/vcs-pr/SKILL.md index 609881a0..82cdb02b 100644 --- a/templates/_shared/.agent/skills/vcs-pr/SKILL.md +++ b/templates/_shared/.agent/skills/vcs-pr/SKILL.md @@ -10,6 +10,7 @@ description: VCS Pull Request 创建 / 状态 / 合并 — 通过 secret:// ## When to Use - Phase 1 session-triage 开启的 mission 干完一轮验证后 → `vcs-pr create --head feat/foo --base main --title "..."` +- PR Ready promotion / 标签补全 / reviewer 调整 → `vcs-pr update --pr-number N --ready --reviewers @x --labels a,b,c` - 想看 PR 现在 merged/closed 没 → `vcs-pr status --pr-number 5` - 显式 user gate 合并 PR → `vcs-pr merge --pr-number 5 --gate user` - 列已开 PR → `vcs-pr list --state open` @@ -22,11 +23,24 @@ node .agent/skills/vcs-pr/scripts/index.js create \ --head feat/foo --base main \ --title "feat: ..." \ [--body-file .agent/pr-bodies/foo.md | --body-from-run R-session-001] \ + [--labels "area/swe,mission/M-XXX"] \ + --run-id R-session-001 --gate user + +node .agent/skills/vcs-pr/scripts/index.js update \ + --backend gitlab \ + --pr-number 5 \ + [--title "..."] [--body-file .agent/pr-bodies/foo.md] \ + [--reviewers "@alice,@bob"] [--labels "area/swe,mission/M-XXX"] \ + [--add-labels "milestone/MS-001"] [--remove-labels "stale/old"] \ + [--ready | --close] [--remove-source-branch] [--squash] \ --run-id R-session-001 --gate user node .agent/skills/vcs-pr/scripts/index.js status \ --pr-number 5 [--backend gitea] --gate agent +node .agent/skills/vcs-pr/scripts/index.js delivery-status \ + --pr-number 5 --gate agent + node .agent/skills/vcs-pr/scripts/index.js merge \ --pr-number 5 [--commit-message "..."] --gate user @@ -34,11 +48,60 @@ node .agent/skills/vcs-pr/scripts/index.js list \ [--state open|merged|closed|all] --gate agent ``` +### Review surface + +```bash +# read what the pull request changed +node .agent/skills/vcs-pr/scripts/index.js diff --pr-number 5 --gate agent + +# read what has already been said about it +node .agent/skills/vcs-pr/scripts/index.js comments --pr-number 5 --gate agent +node .agent/skills/vcs-pr/scripts/index.js reviews --pr-number 5 --gate agent + +# write a finding back (user gate — the comment is public) +node .agent/skills/vcs-pr/scripts/index.js comment \ + --pr-number 5 --body-file --gate user + +node .agent/skills/vcs-pr/scripts/index.js review \ + --pr-number 5 --body-file \ + [--event APPROVE|REQUEST_CHANGES|COMMENT] \ + [--comments-file ] --gate user +``` + +`diff` returns the raw unified patch. `comment` and `review` require `--gate user` because they publish to a pull request other people read; `diff` / `comments` / `reviews` are read-only and accept any allowed gate. + +A backend that does not implement one of these operations reports `capability_unavailable` — it never returns an empty list to stand in for data it never fetched. + +To publish a guided review, render the artifact first: + +```bash +node .agent/skills/agent-review-benchmark/scripts/render-pr-comment.js \ + --input --output +node .agent/skills/vcs-pr/scripts/index.js comment \ + --pr-number 5 --body-file --gate user +``` + PR 创建后,vcs-pr 自动: 1. 调 `secrets get --ref --no-mask --gate user` 取 token(只 vcs-pr 自己拿,**不**透传 agent) 2. 调对应 backend 的 `createPR()` — token 注入 `Authorization` header -3. 写 `runs/.json#events[]` 一条 `vcs_pr_opened` event(pr_number / url / head / base) -4. 输出 JSON `{ok: true, action: "create", number, url, ...}`,**不含** token +3. 写 `runs/.json#events[]` 一条 `vcs_pr_opened` event(pr_number / url / head / base / labels) +4. 输出 JSON `{ok: true, action: "create", number, url, ..., labels: [...]}`,**不含** token + +### run 关联是 fail-closed 的(2026-10-13 起) + +省略 `--run-id` 时,vcs-pr 只在能**证明**有活跃 run 的情况下自动关联: +run 的 `status` 必须是 `running` 或 `queued`,且 `updated_at` / `started_at` 在 +24 小时内。找不到就返回 `run_id: null` 并**跳过**事件写入,绝不猜测。 + +设计原因:旧实现兜底返回"mtime 最新的任意 run",在只含终态或陈旧 run 的目录里 +会把 `vcs_pr_opened` 写到无关 run 上,并覆盖其 `last_event` / `updated_at`。 +`active` / `in_progress` / `started` 这类语义暧昧的状态也不参与关联 —— 它们通常 +表示等待人类或已搁置的工作。 + +**所以:正式交付的 PR 请显式传 `--run-id`。** 输出里 `run_id: null` 表示事件 +没有落档,需要追溯时手动补写,不要为了填这个字段去猜一个 run。 + +`update` 操作写一条 `vcs_pr_updated` event(含 pr_number / reviewers / add_labels / remove_labels / ready 状态)。GitLab PUT MR API 的 labels 是 add/remove 语义,`--labels` 默认映射 add_labels;set 语义需要 GET-then-PUT,留 v1.3。 ## Configuration @@ -59,6 +122,9 @@ default: 默认 body 模板(vcs-pr `defaultBody` 函数): ``` +## 受管创建 +- 创建入口:vcs-pr + ## 提交范围 - branch: → - repo: / @@ -67,11 +133,23 @@ default: ## 相关功能 - (填写) +## 模块 MCP 服务化 +- (填写服务化说明;不适用时说明理由) + +## 兼容性与边界 +- (填写) + ## 验证结果 - (填写) ## 已知事项 - (无) + +## 风险与回滚 +- (填写) + +## 组件分支吸收与清理 +- 不适用 ``` `--body-file` / `--body-from-run` 覆盖。 @@ -95,7 +173,8 @@ default: - **零依赖**:纯 stdlib + 3 backend 各 ~80 行 - **不接触 token**:orchestrator 拿 token 后只进 HTTP header,stdout 不输出值(redact.js 二道防护) -- **写 gate**:`merge` 必须 `--gate user`;`create / status / list` 任意 gate +- **写 gate**:`create / update / merge / comment / review` 必须 `--gate user`;`status / delivery-status / list / diff / comments / reviews` 可用只读 gate +- **能力显式**:backend 未实现某操作时报 `capability_unavailable`,不以空列表冒充「没有数据」 - **可审计**:每条 PR 事件写 `runs/.json#events[]`(Phase 1 基础设施复用) - **可分发**:L1 双语 template;init/upgrade 自动落用户项目 @@ -104,7 +183,8 @@ default: - ❌ 不读 token 给 agent - ❌ 不主动创建 PR(必须 host 显式调用) - ❌ 不做 auto-merge 默认 — merge 必须 user gate -- ❌ 不做 review / assign / label(留 v1.1) +- ❌ 不做 set-labels 语义(GitLab PUT 只支持 add/remove;set 需要 GET-then-PUT,留 v1.3) +- ❌ 不做 gitea/github backend labels(留 v1.3;v1.2 仅 GitLab) - ❌ 不替代项目级 `.agent/workflows/pull-request.md` 工作流(workflow orchestration 在该层,本 skill 是底层) ## Relation diff --git a/templates/_shared/.agent/skills/vcs-pr/scripts/backends/github.js b/templates/_shared/.agent/skills/vcs-pr/scripts/backends/github.js index b9cf59d2..becca88d 100644 --- a/templates/_shared/.agent/skills/vcs-pr/scripts/backends/github.js +++ b/templates/_shared/.agent/skills/vcs-pr/scripts/backends/github.js @@ -8,7 +8,7 @@ const https = require("https"); const http = require("http"); const { URL } = require("url"); -function send(method, baseUrl, path, token, body) { +function send(method, baseUrl, path, token, body, extraHeaders) { return new Promise((resolve, reject) => { const url = new URL(baseUrl + path); const lib = url.protocol === "https:" ? https : http; @@ -24,6 +24,7 @@ function send(method, baseUrl, path, token, body) { "User-Agent": "cortex-agent-vcs-pr/1.0", ...(token ? { "Authorization": `Bearer ${token}` } : {}), ...(payload ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } : {}), + ...(extraHeaders || {}), }, }; const req = lib.request(opts, (res) => { @@ -94,4 +95,108 @@ async function list(opts) { })) : []; } -module.exports = { backend: "github", createPR, getStatus, merge, list }; +// ─── review surface ───────────────────────────────────────────────────────── +// +// The four operations below are the missing half of the review loop: reading +// what a PR actually changed, reading what has already been said about it, +// and writing a finding back so a human can act on it. +// +// Two GitHub routing facts drive the implementation: +// +// * PR *discussion comments* live under `issues/{n}/comments`, not under +// `pulls/{n}/comments` — the latter 404s. A PR is an issue underneath. +// * PR *reviews* live under `pulls/{n}/reviews` and carry an `event` +// (APPROVE / REQUEST_CHANGES / COMMENT). A review body alone is a +// top-level review; per-line comments ride in the `comments` array. +// +// The diff comes back as a unified patch from `pulls/{n}` with the diff +// media type. `Accept: application/vnd.github.v3.diff` yields the raw +// patch, which is what a reviewer needs — the JSON form of the same endpoint +// returns file metadata but not the hunks. + +async function getDiff(opts) { + const { config, token, pr_number } = opts; + const { owner, repo } = mapRepo(opts, config); + const res = await send("GET", config.host, `/repos/${owner}/${repo}/pulls/${pr_number}`, token, null, { + Accept: "application/vnd.github.v3.diff", + }); + if (res.status !== 200) throw new Error(`github_diff_failed: HTTP ${res.status} ${res.raw?.slice(0, 200)}`); + return { number: pr_number, patch: res.raw }; +} + +async function listComments(opts) { + const { config, token, pr_number } = opts; + const { owner, repo } = mapRepo(opts, config); + const res = await send("GET", config.host, `/repos/${owner}/${repo}/issues/${pr_number}/comments?per_page=100`, token); + if (res.status !== 200) throw new Error(`github_comments_failed: HTTP ${res.status} ${res.raw?.slice(0, 200)}`); + return Array.isArray(res.body) ? res.body.map((comment) => ({ + id: comment.id, + author: comment.user?.login || null, + body: comment.body || "", + created_at: comment.created_at || null, + url: comment.html_url || null, + })) : []; +} + +async function listReviews(opts) { + const { config, token, pr_number } = opts; + const { owner, repo } = mapRepo(opts, config); + const res = await send("GET", config.host, `/repos/${owner}/${repo}/pulls/${pr_number}/reviews?per_page=100`, token); + if (res.status !== 200) throw new Error(`github_reviews_failed: HTTP ${res.status} ${res.raw?.slice(0, 200)}`); + return Array.isArray(res.body) ? res.body.map((review) => ({ + id: review.id, + author: review.user?.login || null, + state: review.state || null, + body: review.body || "", + submitted_at: review.submitted_at || null, + url: review.html_url || null, + })) : []; +} + +async function createComment(opts) { + const { config, token, pr_number, body } = opts; + const { owner, repo } = mapRepo(opts, config); + if (!body || !String(body).trim()) throw new Error("missing_comment_body"); + const res = await send("POST", config.host, `/repos/${owner}/${repo}/issues/${pr_number}/comments`, token, { + body: String(body), + }); + if (res.status !== 201) throw new Error(`github_comment_failed: HTTP ${res.status} ${res.raw?.slice(0, 200)}`); + return { + id: res.body.id, + url: res.body.html_url || null, + author: res.body.user?.login || null, + created_at: res.body.created_at || null, + }; +} + +// `event` is one of APPROVE / REQUEST_CHANGES / COMMENT. A review carrying +// inline comments must use the COMMENT or REQUEST_CHANGES event — GitHub +// rejects line-anchored comments on an APPROVE review. +async function submitReview(opts) { + const { config, token, pr_number, body, event, comments } = opts; + const { owner, repo } = mapRepo(opts, config); + const payload = { body: body || "" }; + if (event) payload.event = event; + if (Array.isArray(comments) && comments.length) payload.comments = comments; + const res = await send("POST", config.host, `/repos/${owner}/${repo}/pulls/${pr_number}/reviews`, token, payload); + if (res.status !== 200) throw new Error(`github_review_failed: HTTP ${res.status} ${res.raw?.slice(0, 200)}`); + return { + id: res.body.id, + state: res.body.state || null, + url: res.body.html_url || null, + submitted_at: res.body.submitted_at || null, + }; +} + +module.exports = { + backend: "github", + createPR, + getStatus, + merge, + list, + getDiff, + listComments, + listReviews, + createComment, + submitReview, +}; diff --git a/templates/_shared/.agent/skills/vcs-pr/scripts/index.js b/templates/_shared/.agent/skills/vcs-pr/scripts/index.js index d4c989d5..597f3279 100644 --- a/templates/_shared/.agent/skills/vcs-pr/scripts/index.js +++ b/templates/_shared/.agent/skills/vcs-pr/scripts/index.js @@ -19,7 +19,7 @@ const { spawnSync } = require("child_process"); const AGENT_ROOT = path.join(process.cwd(), ".agent"); const BACKENDS = new Set(["gitea", "github", "gitlab"]); -const GATE_USER_ONLY = new Set(["create", "update", "merge"]); +const GATE_USER_ONLY = new Set(["create", "update", "merge", "comment", "review"]); function flag(name, argv) { const i = argv.indexOf(name); @@ -264,8 +264,12 @@ function loadBody(opts) { async function main() { const argv = process.argv.slice(2); const [command] = argv; - if (!command || !["create", "update", "status", "delivery-status", "pipeline-jobs", "job-trace", "merge", "list"].includes(command)) { - fail("unknown_command", "Usage: vcs-pr create|update|status|delivery-status|pipeline-jobs|job-trace|merge|list [...opts]"); + const COMMANDS = [ + "create", "update", "status", "delivery-status", "pipeline-jobs", "job-trace", + "merge", "list", "diff", "comments", "reviews", "comment", "review", + ]; + if (!command || !COMMANDS.includes(command)) { + fail("unknown_command", `Usage: vcs-pr ${COMMANDS.join("|")} [...opts]`); } const cfg = loadConfig(); if (!cfg || !cfg.backend) { @@ -439,6 +443,122 @@ async function main() { emit({ ok: true, action: "list", count: result.length, items: result }); return; } + + // ─── review surface ─────────────────────────────────────────────────────── + // + // The commands below close the loop that `create`/`merge` left open: read + // the diff, read what has been said, write a finding back. + // + // Read commands degrade gracefully — a backend that does not implement one + // reports `capability_unavailable` instead of pretending the data is + // empty. Write commands (`comment`, `review`) are gated at `--gate user` + // above, because they publish to a pull request that other people read. + + if (command === "diff") { + if (typeof backend.getDiff !== "function") { + fail("capability_unavailable", `${cfg.backend} backend does not support diff.`); + } + const result = await backend.getDiff({ ...optsBase, pr_number: Number(flag("--pr-number", argv)) }); + emit({ ok: true, action: "diff", ...result }); + return; + } + if (command === "comments") { + if (typeof backend.listComments !== "function") { + fail("capability_unavailable", `${cfg.backend} backend does not support comments.`); + } + const result = await backend.listComments({ ...optsBase, pr_number: Number(flag("--pr-number", argv)) }); + emit({ ok: true, action: "comments", count: result.length, items: result }); + return; + } + if (command === "reviews") { + if (typeof backend.listReviews !== "function") { + fail("capability_unavailable", `${cfg.backend} backend does not support reviews.`); + } + const result = await backend.listReviews({ ...optsBase, pr_number: Number(flag("--pr-number", argv)) }); + emit({ ok: true, action: "reviews", count: result.length, items: result }); + return; + } + if (command === "comment") { + if (typeof backend.createComment !== "function") { + fail("capability_unavailable", `${cfg.backend} backend does not support comment.`); + } + const body = readReviewBody(argv); + const result = await backend.createComment({ + ...optsBase, + pr_number: Number(flag("--pr-number", argv)), + body, + }); + if (runId) { + appendRunEvent(runId, { + type: "vcs_pr_comment", + backend: cfg.backend, + pr_number: Number(flag("--pr-number", argv)), + comment_id: result.id, + }); + } + emit({ ok: true, action: "comment", ...result }); + return; + } + if (command === "review") { + if (typeof backend.submitReview !== "function") { + fail("capability_unavailable", `${cfg.backend} backend does not support review.`); + } + const body = readReviewBody(argv); + const event = (flag("--event", argv) || "COMMENT").toUpperCase(); + if (!["APPROVE", "REQUEST_CHANGES", "COMMENT"].includes(event)) { + fail("invalid_review_event", "--event must be one of: APPROVE, REQUEST_CHANGES, COMMENT."); + } + const commentsFile = flag("--comments-file", argv); + let comments = null; + if (commentsFile) { + const raw = fs.readFileSync( + path.isAbsolute(commentsFile) ? commentsFile : path.join(process.cwd(), commentsFile), + "utf8" + ); + try { + comments = JSON.parse(raw); + } catch (err) { + fail("invalid_comments_json", err.message); + } + if (!Array.isArray(comments)) { + fail("invalid_comments_json", "--comments-file must contain a JSON array of review comments."); + } + } + const result = await backend.submitReview({ + ...optsBase, + pr_number: Number(flag("--pr-number", argv)), + body, + event, + comments, + }); + if (runId) { + appendRunEvent(runId, { + type: "vcs_pr_review", + backend: cfg.backend, + pr_number: Number(flag("--pr-number", argv)), + review_id: result.id, + review_state: result.state, + }); + } + emit({ ok: true, action: "review", ...result }); + return; + } +} + +// Review bodies are long. Reading them from a file keeps the command line +// free of multi-kilobyte payloads and keeps the body out of process listings. +// `--body` is still accepted for short one-liners. +function readReviewBody(argv) { + const bodyFile = flag("--body-file", argv); + if (bodyFile) { + const target = path.isAbsolute(bodyFile) ? bodyFile : path.join(process.cwd(), bodyFile); + if (!fs.existsSync(target)) fail("body_file_not_found", target); + return fs.readFileSync(target, "utf8"); + } + const inline = flag("--body", argv); + if (inline && inline.trim()) return inline; + fail("missing_review_body", "Provide --body-file or --body ."); + return null; } if (require.main === module) { diff --git a/templates/_shared/.agent/waitpoints/waitpoint.schema.json b/templates/_shared/.agent/waitpoints/waitpoint.schema.json index c4af6914..8ce5a76c 100644 --- a/templates/_shared/.agent/waitpoints/waitpoint.schema.json +++ b/templates/_shared/.agent/waitpoints/waitpoint.schema.json @@ -64,7 +64,12 @@ "additionalProperties": false }, "created_at": { "type": "string", "format": "date-time" }, - "updated_at": { "type": "string", "format": "date-time" } + "updated_at": { "type": "string", "format": "date-time" }, + "workflow_gate": { + "type": "string", + "enum": ["mission","agent","user","owner"], + "description": "Caller-supplied workflow provenance label only; not authenticated identity, ownership, approval, or permission to execute." + } }, "allOf": [ { diff --git a/templates/en/.agent/skills/evolution-pipeline/scripts/evolve.js b/templates/en/.agent/skills/evolution-pipeline/scripts/evolve.js index e990b103..8ad3fa42 100755 --- a/templates/en/.agent/skills/evolution-pipeline/scripts/evolve.js +++ b/templates/en/.agent/skills/evolution-pipeline/scripts/evolve.js @@ -32,6 +32,7 @@ const fs = require("fs"); const path = require("path"); +const crypto = require("crypto"); const ROOT = process.cwd(); const TASKS_DIR = path.join(ROOT, ".agent", "tasks", "evolution"); @@ -40,6 +41,264 @@ const MEMORY_DIR = path.join(ROOT, ".agent", "memory"); const ARCHIVES_DIR = path.join(ROOT, ".agent", "runtime-continuity", "archives"); const LATEST_ARCHIVE = path.join(ARCHIVES_DIR, "latest.json"); + +const PROPOSAL_KIND = "archive_learning_proposal"; +const PROPOSALS_DIR = path.join(TASKS_DIR, "_proposals"); +const PROPOSAL_ID = /^EVO-P-[0-9a-f]{64}$/; +const MAX_ARCHIVE_BYTES = 1024 * 1024; + +function proposalError(code) { + const error = new Error(code); + error.code = code; + return error; +} +function digest(bytes) { + return crypto.createHash("sha256").update(bytes).digest("hex"); +} +function proposalBytes(value) { + return JSON.stringify(value, null, 2) + "\n"; +} +function proposalScope(project) { + if (typeof project !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(project) + || project.includes("..")) throw proposalError("ERR_PROPOSAL_PROJECT"); + return { + project, + workspace_id: "workspace-sha256:" + digest(fs.realpathSync(ROOT)), + }; +} +// Fixed project-local paths only; reject observed symlinked components. +// These checks are not fencing against an adversarial filesystem writer. +function checkedParents(file, create = false) { + const relative = path.relative(ROOT, path.dirname(file)); + if (relative.startsWith("..") || path.isAbsolute(relative)) throw proposalError("ERR_PROPOSAL_PATH"); + let cursor = ROOT; + for (const segment of relative.split(path.sep).filter(Boolean)) { + cursor = path.join(cursor, segment); + if (!fs.existsSync(cursor)) { + if (!create) return false; + try { fs.mkdirSync(cursor); } catch (error) { if (error.code !== "EEXIST") throw error; } + } + const stat = fs.lstatSync(cursor); + if (stat.isSymbolicLink() || !stat.isDirectory()) throw proposalError("ERR_PROPOSAL_PATH"); + } + return true; +} +function readProposalBytes(file) { + if (!checkedParents(file)) throw proposalError("ERR_PROPOSAL_NOT_FOUND"); + if (!Number.isInteger(fs.constants.O_NOFOLLOW) || !Number.isInteger(fs.constants.O_NONBLOCK) + || fs.constants.O_NOFOLLOW === 0 || fs.constants.O_NONBLOCK === 0) { + throw proposalError("ERR_PROPOSAL_SAFE_OPEN_UNSUPPORTED"); + } + function regular(stat) { + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > BigInt(MAX_ARCHIVE_BYTES)) { + throw proposalError("ERR_PROPOSAL_FILE"); + } + if (stat.nlink !== 1n) throw proposalError("ERR_PROPOSAL_FILE_LINKS"); + } + const before = fs.lstatSync(file, { bigint: true }); + regular(before); + let fd; + try { + fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); + const opened = fs.fstatSync(fd, { bigint: true }); + regular(opened); + if (opened.dev !== before.dev || opened.ino !== before.ino + || opened.size !== before.size || opened.mtimeNs !== before.mtimeNs + || opened.ctimeNs !== before.ctimeNs) throw proposalError("ERR_PROPOSAL_FILE_CHANGED"); + const buffer = Buffer.alloc(Number(opened.size) + 1); + let size = 0; + while (size < buffer.length) { + const count = fs.readSync(fd, buffer, size, buffer.length - size, null); + if (count === 0) break; + size += count; + } + const after = fs.fstatSync(fd, { bigint: true }); + const named = fs.lstatSync(file, { bigint: true }); + regular(after); regular(named); + if (size > MAX_ARCHIVE_BYTES || BigInt(size) !== opened.size + || after.dev !== opened.dev || after.ino !== opened.ino + || named.dev !== opened.dev || named.ino !== opened.ino + || after.size !== opened.size || named.size !== opened.size + || after.mtimeNs !== opened.mtimeNs || after.ctimeNs !== opened.ctimeNs + || named.mtimeNs !== opened.mtimeNs || named.ctimeNs !== opened.ctimeNs) { + throw proposalError("ERR_PROPOSAL_FILE_CHANGED"); + } + return buffer.subarray(0, size); + } finally { + if (fd !== undefined) fs.closeSync(fd); + } +} +function readProposalFile(file) { + return readProposalBytes(file).toString("utf8"); +} +function sameProposalFile(file, value) { + if (readProposalFile(file) !== proposalBytes(value)) throw proposalError("ERR_PROPOSAL_RECORD_CONFLICT"); +} +// Publish a complete immutable file, or verify the existing identical record. +// An exit before link leaves an ignored .tmp. An exit between link/unlink can +// leave a multi-link record: readers block and preserve it for explicit repair. +// No overwrite, sequence allocation, queue rewrite or index. +function verifyPublishedProposal(file, value) { + // A concurrent publisher immediately removes its temporary hard link. + // Never read multi-link bytes; wait only a bounded 100 ms for that transition. + for (let attempt = 0; ; attempt++) { + try { sameProposalFile(file, value); return; } + catch (error) { + if (error.code !== "ERR_PROPOSAL_FILE_LINKS" || attempt >= 20) throw error; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5); + } + } +} +function publishProposalFile(file, value) { + if (fs.existsSync(file)) { + verifyPublishedProposal(file, value); + return false; + } + checkedParents(file, true); + const temporary = path.join(path.dirname(file), ".proposal-" + crypto.randomBytes(12).toString("hex") + ".tmp"); + let fd; + let ownedTemporary = false; + try { + fd = fs.openSync(temporary, "wx", 0o600); + ownedTemporary = true; + fs.writeFileSync(fd, proposalBytes(value), "utf8"); + fs.fsyncSync(fd); + fs.closeSync(fd); + fd = undefined; + try { fs.linkSync(temporary, file); } + catch (error) { + if (error.code !== "EEXIST") throw error; + verifyPublishedProposal(file, value); + return false; + } + fs.unlinkSync(temporary); + ownedTemporary = false; + verifyPublishedProposal(file, value); + return true; + } finally { + if (fd !== undefined) fs.closeSync(fd); + if (ownedTemporary) { + try { fs.unlinkSync(temporary); } catch (error) { if (error.code !== "ENOENT") throw error; } + } + } +} +function proposalArchive(archivePath, expectedProject) { + const file = path.resolve(archivePath); + if (path.dirname(file) !== ARCHIVES_DIR || path.basename(file) === "latest.json") { + throw proposalError("ERR_PROPOSAL_ARCHIVE_PATH"); + } + const bytes = readProposalBytes(file); + const raw = bytes.toString("utf8"); + if (!Buffer.from(raw, "utf8").equals(bytes)) throw proposalError("ERR_PROPOSAL_ARCHIVE_ENCODING"); + const archive = JSON.parse(raw); + if (!archive || typeof archive !== "object" || Array.isArray(archive) + || typeof archive.archive_id !== "string" || !/^RC-[A-Za-z0-9_-]+$/.test(archive.archive_id) + || path.basename(file) !== archive.archive_id + ".json") { + throw proposalError("ERR_PROPOSAL_ARCHIVE_ID"); + } + const scope = proposalScope(expectedProject); + if (archive.project !== scope.project) throw proposalError("ERR_PROPOSAL_PROJECT_MISMATCH"); + if (typeof archive.created_at !== "string" || !Number.isFinite(Date.parse(archive.created_at)) + || new Date(archive.created_at).toISOString() !== archive.created_at) { + throw proposalError("ERR_PROPOSAL_ARCHIVE_TIME"); + } + const state = archive.state; + if (!state || typeof state !== "object" || Array.isArray(state)) throw proposalError("ERR_PROPOSAL_ARCHIVE_STATE"); + const counts = {}; + for (const [field, label] of [["done", "reported_done"], ["next", "planned_next"], ["blockers", "reported_blockers"]]) { + const values = state[field] === undefined ? [] : state[field]; + if (!Array.isArray(values) || values.some((v) => typeof v !== "string" || !v.trim())) { + throw proposalError("ERR_PROPOSAL_ARCHIVE_STATE"); + } + counts[label] = values.length; + } + if (state.in_progress != null && typeof state.in_progress !== "string") throw proposalError("ERR_PROPOSAL_ARCHIVE_STATE"); + counts.reported_in_progress = typeof state.in_progress === "string" && state.in_progress.trim() ? 1 : 0; + const sha256 = digest(bytes); + const taskId = "EVO-P-" + digest(JSON.stringify([scope, archive.archive_id, sha256])); + return { + schema_version: "1", kind: PROPOSAL_KIND, task_id: taskId, + project_scope: scope, + source_archive: { + archive_id: archive.archive_id, + archive_path: path.relative(ROOT, file).split(path.sep).join("/"), + sha256, created_at: archive.created_at, + }, + created_at: archive.created_at, updated_at: archive.created_at, status: "pending", + observation_counts: counts, execution_authorized: false, allowed_effects: [], + }; +} +function proposalFor(task) { + return { + schema_version: "1", kind: "archive_learning_proposal_result", proposal_id: task.task_id, + project_scope: task.project_scope, source_archive: task.source_archive, + status: "proposal_only", verification_status: "not_run", + observation_counts: task.observation_counts, + proposed_checks: [ + "Review the referenced archive inside this project before selecting evidence.", + "Verify reported outcomes independently before proposing any change.", + ], + limitations: [ + "Archive contents are unverified observations; this document contains no extracted private text.", + "No memory, index, Task, Host run, promotion, or execution permission is created.", + ], + execution_authorized: false, allowed_effects: [], + }; +} +function loadProposalTask(taskId) { + if (typeof taskId !== "string" || !PROPOSAL_ID.test(taskId)) throw proposalError("ERR_PROPOSAL_TASK_ID"); + const file = path.join(TASKS_DIR, taskId + ".json"); + const stored = JSON.parse(readProposalFile(file)); + if (stored.kind !== PROPOSAL_KIND || stored.task_id !== taskId || !stored.source_archive + || !stored.project_scope) throw proposalError("ERR_PROPOSAL_TASK_SHAPE"); + if (typeof stored.source_archive.archive_path !== "string") throw proposalError("ERR_PROPOSAL_ARCHIVE_PATH"); + const task = proposalArchive(path.resolve(ROOT, stored.source_archive.archive_path), stored.project_scope.project); + if (task.task_id !== taskId) throw proposalError("ERR_PROPOSAL_SOURCE_CHANGED"); + sameProposalFile(file, task); + return task; +} +function enqueueProposalFromArchive(archivePath, opts) { + const task = proposalArchive(archivePath, opts.expectedProject); + const file = path.join(TASKS_DIR, task.task_id + ".json"); + if (opts.dryRun) { + if (fs.existsSync(file)) sameProposalFile(file, task); + return { + dry_run: true, task_id: null, would_enqueue: !fs.existsSync(file), + project_scope: task.project_scope, source_archive: task.source_archive, + execution_authorized: false, allowed_effects: [], + }; + } + const created = publishProposalFile(file, task); + return { ...task, deduplicated: !created }; +} +function processProposalTask(taskId, opts = {}) { + const task = loadProposalTask(taskId); + const proposal = proposalFor(task); + const file = path.join(PROPOSALS_DIR, taskId + ".json"); + if (opts.dryRun) { + if (fs.existsSync(file)) sameProposalFile(file, proposal); + return { ...task, status: "proposal_only", dry_run: true, proposal_ref: null, would_write_proposal: !fs.existsSync(file) }; + } + const created = publishProposalFile(file, proposal); + return { + ...task, status: "proposal_only", deduplicated: !created, + proposal_ref: path.relative(ROOT, file).split(path.sep).join("/"), + }; +} +function observedProposalTask(taskId) { + try { + const current = loadProposalTask(taskId); + const file = path.join(PROPOSALS_DIR, taskId + ".json"); + if (!fs.existsSync(file)) return current; + sameProposalFile(file, proposalFor(current)); + return { ...current, status: "proposal_only", proposal_ref: path.relative(ROOT, file).split(path.sep).join("/") }; + } catch (error) { + return { schema_version: "1", kind: PROPOSAL_KIND, task_id: taskId, + created_at: "", status: "blocked", execution_authorized: false, allowed_effects: [], + proposal_error: error.code || "ERR_PROPOSAL_READ" }; + } +} + function parseArgs() { const args = {}; for (let i = 2; i < process.argv.length; i++) { @@ -252,10 +511,13 @@ function writeMemoryItem(scope, item, archiveId) { * Returns the updated task. */ function processTask(taskId, opts = {}) { + if (typeof taskId === "string" && taskId.startsWith("EVO-P-")) return processProposalTask(taskId, opts); const file = path.join(TASKS_DIR, `${taskId}.json`); if (!fs.existsSync(file)) throw new Error(`task not found: ${taskId}`); const task = readJson(file); + if (task.kind === PROPOSAL_KIND) return processProposalTask(taskId, opts); task.status = "running"; + if (opts.dryRun) task.dry_run = true; task.started_at = new Date().toISOString(); task.updated_at = task.started_at; task.errors = task.errors || []; @@ -286,26 +548,49 @@ function processTask(taskId, opts = {}) { task.status = "completed"; task.completed_at = new Date().toISOString(); task.updated_at = task.completed_at; - writeJson(file, task); + if (!opts.dryRun) writeJson(file, task); } catch (err) { task.errors.push({ at: new Date().toISOString(), stage: "process", message: err.message }); task.retry_count = (task.retry_count || 0) + 1; task.updated_at = new Date().toISOString(); if (task.retry_count >= (task.max_retries || 3)) { task.status = "dead"; - // write to dead dir, then delete original (rename would not save updated status) - ensureDir(DEAD_DIR); - writeJson(path.join(DEAD_DIR, `${taskId}.json`), task); - fs.unlinkSync(file); + if (!opts.dryRun) { + // write to dead dir, then delete original (rename would not save updated status) + ensureDir(DEAD_DIR); + writeJson(path.join(DEAD_DIR, `${taskId}.json`), task); + fs.unlinkSync(file); + } } else { task.status = "failed"; - writeJson(file, task); + if (!opts.dryRun) writeJson(file, task); } } return task; } +/** Read-only preview: do not allocate a queue ID or write to disk. */ +function previewEnqueueFromArchive(archivePath) { + if (!fs.existsSync(archivePath)) throw new Error(`archive not found: ${archivePath}`); + const archive = readJson(archivePath); + if (!archive || typeof archive !== "object" || Array.isArray(archive)) { + throw new Error(`archive must be a JSON object: ${archivePath}`); + } + return { + dry_run: true, + would_enqueue: true, + task_id: null, + source_archive: { + archive_id: archive.archive_id || archIdFromFile(archivePath), + archive_path: path.relative(ROOT, archivePath), + created_at: archive.created_at || null, + }, + }; +} + function enqueueFromArchive(archivePath, opts = {}) { + if (opts.proposalOnly === true) return enqueueProposalFromArchive(archivePath, opts); + if (opts.dryRun) return previewEnqueueFromArchive(archivePath); if (!fs.existsSync(archivePath)) throw new Error(`archive not found: ${archivePath}`); const archive = readJson(archivePath); const taskId = nextTaskId(); @@ -330,11 +615,14 @@ function enqueueFromArchive(archivePath, opts = {}) { } function listTasks(filter = {}) { + if (filter.proposalOnly === true && !checkedParents(path.join(TASKS_DIR, "reserved.json"))) return []; if (!fs.existsSync(TASKS_DIR)) return []; const files = fs.readdirSync(TASKS_DIR).filter((f) => f.endsWith(".json")); const out = []; for (const f of files) { - const t = readJson(path.join(TASKS_DIR, f)); + if (filter.proposalOnly === true && !f.startsWith("EVO-P-")) continue; + const t = f.startsWith("EVO-P-") + ? observedProposalTask(f.slice(0, -5)) : readJson(path.join(TASKS_DIR, f)); if (filter.status && t.status !== filter.status) continue; out.push(t); } @@ -364,6 +652,28 @@ function main() { const args = parseArgs(); try { + if (args["dry-run"] && (args.replay || (args.worker && args.loop))) { + throw new Error("--dry-run does not support replay or a polling worker loop"); + } + + if (args["proposal-only"] !== undefined && args["proposal-only"] !== true) { + throw proposalError("ERR_PROPOSAL_MODE_FLAG"); + } + if (args["proposal-only"] && (args["enqueue-from-latest"] || args["dead-letter"] || args.loop)) { + throw proposalError("ERR_PROPOSAL_UNSUPPORTED_MODE"); + } + if (args["proposal-only"] && [args.enqueue, args.worker, args.replay, args.list, args.status && !args.list].filter(Boolean).length !== 1) { + throw proposalError("ERR_PROPOSAL_ACTION_REQUIRED"); + } + if (args["proposal-only"] && args.worker && !args.once) { + throw proposalError("ERR_PROPOSAL_WORKER_REQUIRES_ONCE"); + } + if (args["proposal-only"] && args.replay) { + const task = processProposalTask(args.replay); + console.log(JSON.stringify({ ok: true, replayed: task }, null, 2)); + return; + } + if (args.enqueue || args["enqueue-from-latest"]) { let entries = []; if (args.archive) { @@ -382,21 +692,36 @@ function main() { } else { throw new Error("specify --archive or --enqueue-from-latest"); } - const created = entries.map((e) => enqueueFromArchive(e)); - console.log(JSON.stringify({ ok: true, enqueued: created.length, tasks: created.map((t) => t.task_id) }, null, 2)); + const dryRun = !!args["dry-run"]; + const created = entries.map((e) => enqueueFromArchive(e, { + dryRun, proposalOnly: args["proposal-only"] === true, expectedProject: args.project, + })); + if (dryRun) { + console.log(JSON.stringify({ ok: true, dry_run: true, enqueued: 0, previews: created }, null, 2)); + } else { + console.log(JSON.stringify({ ok: true, enqueued: created.filter((t) => !t.deduplicated).length, + deduplicated: created.filter((t) => t.deduplicated).length, tasks: created.map((t) => t.task_id) }, null, 2)); + } return; } if (args.worker) { - const pending = listTasks({ status: "pending" }).concat(listTasks({ status: "failed" })); + const proposalOnly = args["proposal-only"] === true; + const pending = listTasks({ status: "pending", proposalOnly }) + .concat(listTasks({ status: "failed", proposalOnly })); if (args.once) { if (!pending.length) { console.log(JSON.stringify({ ok: true, processed: 0, message: "no pending tasks" }, null, 2)); return; } const task = pending[0]; - const result = processTask(task.task_id, { dryRun: args["dry-run"] }); - console.log(JSON.stringify({ ok: true, processed: 1, task: result }, null, 2)); + const dryRun = !!args["dry-run"]; + const result = processTask(task.task_id, { dryRun }); + if (dryRun) { + console.log(JSON.stringify({ ok: true, dry_run: true, processed: 0, would_process: 1, task: result }, null, 2)); + } else { + console.log(JSON.stringify({ ok: true, processed: 1, task: result }, null, 2)); + } return; } if (args.loop) { @@ -419,9 +744,11 @@ function main() { if (args.replay) { const taskId = args.replay; + if (typeof taskId === "string" && taskId.startsWith("EVO-P-")) throw proposalError("ERR_PROPOSAL_REPLAY_REQUIRES_MODE"); let task; if (fs.existsSync(path.join(TASKS_DIR, `${taskId}.json`))) { task = readJson(path.join(TASKS_DIR, `${taskId}.json`)); + if (task.kind === PROPOSAL_KIND) throw proposalError("ERR_PROPOSAL_REPLAY_REQUIRES_MODE"); task.status = "pending"; task.retry_count = 0; task.errors = []; @@ -443,15 +770,15 @@ function main() { if (args.list) { const filt = args.status || null; - const tasks = listTasks(filt ? { status: filt } : {}); - console.log(JSON.stringify({ ok: true, count: tasks.length, tasks: tasks.map((t) => ({ task_id: t.task_id, status: t.status, archive: t.source_archive.archive_id, retry_count: t.retry_count, classification: t.classification })) }, null, 2)); + const tasks = listTasks({ ...(filt ? { status: filt } : {}), proposalOnly: args["proposal-only"] === true }); + console.log(JSON.stringify({ ok: true, count: tasks.length, tasks: tasks.map((t) => ({ task_id: t.task_id, status: t.status, archive: t.source_archive ? t.source_archive.archive_id : null, retry_count: t.retry_count, classification: t.classification, proposal_error: t.proposal_error || null })) }, null, 2)); return; } if (args.status) { - const tasks = listTasks(); + const tasks = listTasks({ proposalOnly: args["proposal-only"] === true }); const by = tasks.reduce((acc, t) => { acc[t.status] = (acc[t.status] || 0) + 1; return acc; }, {}); - const dead = deadLetterTasks().length; + const dead = args["proposal-only"] ? 0 : deadLetterTasks().length; console.log(JSON.stringify({ ok: true, total: tasks.length, @@ -487,4 +814,12 @@ if (require.main === module) { main(); } -module.exports = { classifyArchive, processTask, enqueueFromArchive, listTasks, deadLetterTasks }; +module.exports = { + classifyArchive, + processTask, + processProposalTask, + previewEnqueueFromArchive, + enqueueFromArchive, + listTasks, + deadLetterTasks, +}; diff --git a/templates/en/.agent/skills/knowledge-lint/scripts/index.js b/templates/en/.agent/skills/knowledge-lint/scripts/index.js index dca9bfb5..bfea6277 100644 --- a/templates/en/.agent/skills/knowledge-lint/scripts/index.js +++ b/templates/en/.agent/skills/knowledge-lint/scripts/index.js @@ -78,25 +78,112 @@ function extractHeadings(content) { } function shouldCheckLink(rawLink) { - if (!rawLink) return false; - if (rawLink.includes('{') || rawLink.includes('}')) return false; - if (rawLink.startsWith('http://')) return false; - if (rawLink.startsWith('https://')) return false; - if (rawLink.startsWith('mailto:')) return false; - if (rawLink.startsWith('app://')) return false; - if (rawLink.startsWith('plugin://')) return false; - return true; + if (!rawLink || /[{}]/.test(rawLink)) return false; + // URI schemes are not repository paths (RFC 3986); file: is reported separately. + return !/^[a-z][a-z0-9+.-]*:/i.test(rawLink) && !rawLink.startsWith('//'); +} + +function isEscaped(text, index) { + let slashes = 0; + while (index > 0 && text[--index] === '\\') slashes++; + return slashes % 2 === 1; +} + +function maskMarkdownCode(content) { + let fence = null; + const prose = content.split('\n').map((line) => { + const marker = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/); + if (fence) { + if (marker && marker[1][0] === fence[0] && + marker[1].length >= fence.length && !marker[2].trim()) fence = null; + return ''; + } + if (marker && !(marker[1][0] === '`' && marker[2].includes('`'))) { + fence = marker[1]; + return ''; + } + return line; + }).join('\n'); + // Blocks take precedence over inline spans. In particular, an unmatched + // tick in a paragraph must not consume links in the following heading/list. + const blocks = []; + let block = []; + const flush = () => { if (block.length) blocks.push(block.join('\n')); block = []; }; + for (const line of prose.split('\n')) { + const heading = /^ {0,3}#{1,6}(?:[ \t]|$)/.test(line); + const separator = /^ {0,3}(?:(?:\*[ \t]*){3,}|(?:_[ \t]*){3,}|(?:-[ \t]*){3,}|=+[ \t]*|-+[ \t]*)$/.test(line); + const listMarker = /^ {0,3}(?:[-+*]|\d{1,9}[.)])[ \t]+/; + // Ordered lists can interrupt a paragraph only when starting at 1. + const list = listMarker.test(line) && (!block.length || listMarker.test(block[0]) || + /^ {0,3}(?:[-+*]|1[.)])[ \t]+/.test(line)); + const quote = /^ {0,3}>/.test(line); + const previousQuote = block.length && /^ {0,3}>/.test(block[block.length - 1]); + if (!line.trim() || heading || separator || list || (quote && !previousQuote)) flush(); + block.push(line); + if (!line.trim() || heading || separator) flush(); + } + flush(); + return blocks.map(maskInlineCode).join('\n'); +} + +function maskInlineCode(prose) { + // Only equal-length backtick runs delimit a span; unmatched runs are prose. + const runs = [...prose.matchAll(/`+/g)]; + const masked = prose.split(''); + for (let i = 0; i < runs.length; i++) { + if (isEscaped(prose, runs[i].index)) continue; + let end = i + 1; + while (end < runs.length && runs[end][0].length !== runs[i][0].length) end++; + if (end === runs.length) continue; + masked.fill(' ', runs[i].index, runs[end].index + runs[end][0].length); + i = end; + } + return masked.join(''); +} + +function hasLinkClose(suffix) { + // CommonMark titles can contain escaped delimiters and line endings, but + // not blank lines. Match only the suffix so following paragraphs don't matter. + const close = suffix.match(/^\s*(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^()\\])*\))?\s*\)/); + return Boolean(close && !/\r?\n[ \t]*\r?\n/.test(close[0])); } function extractMarkdownLinks(content) { + const text = maskMarkdownCode(content); const links = []; - const pattern = /\[[^\]]+\]\(([^)]+)\)/g; + const opener = /\[(?:\\.|[^\]\\])*\]\(\s*/g; let match; - - while ((match = pattern.exec(content))) { - links.push(match[1].trim()); + while ((match = opener.exec(text))) { + if (isEscaped(text, match.index)) continue; + let index = opener.lastIndex; + const angle = text[index] === '<'; + if (angle) index++; + let target = ''; + let depth = 0; + let closed = false; + for (; index < text.length; index++) { + const ch = text[index]; + if (ch === '\\' && /[\\()[\]<>]/.test(text[index + 1] || '')) { + target += text[++index]; + } else if (angle ? ch === '>' : ch === ')' && depth === 0) { + closed = true; + break; + } else if (!angle && /\s/.test(ch) && depth === 0) { + closed = true; + break; + } else { + if (!angle && ch === '(') depth++; + if (!angle && ch === ')') depth--; + target += ch; + } + } + // Validate the outer close and optional Markdown title before accepting. + const suffix = text.slice(index + (angle ? 1 : 0)); + if (closed && hasLinkClose(suffix)) { + links.push(target); + opener.lastIndex = index + 1; + } } - return links; } @@ -118,6 +205,7 @@ function resolveTargetFile(sourceFile, filePart) { function collectBrokenLinks(markdownFiles) { const brokenLinks = []; const brokenAnchors = []; + const unverifiedLinks = []; const headingCache = new Map(); for (const sourceFile of markdownFiles) { @@ -125,6 +213,15 @@ function collectBrokenLinks(markdownFiles) { const links = extractMarkdownLinks(content); for (const rawLink of links) { + if (/^file:/i.test(rawLink)) { + // This scanner does not validate host-local URI availability. Report + // that limit explicitly; it is not a requirement to re-audit past business acceptance. + unverifiedLinks.push({ + source: path.relative(root, sourceFile), target: rawLink, + status: 'unverified', reason: 'host_local_uri_not_checked', + }); + continue; + } if (!shouldCheckLink(rawLink)) continue; const { filePart, anchor } = splitLink(rawLink); @@ -159,7 +256,7 @@ function collectBrokenLinks(markdownFiles) { } } - return { brokenLinks, brokenAnchors }; + return { brokenLinks, brokenAnchors, unverifiedLinks }; } function collectMissingReadmes() { @@ -360,6 +457,17 @@ function collectArchitectureDocMismatches() { // 新增 experimental (POC/试运行但未 ship) 与 reference (文档化但未 ship, 仅做知识参考)。 // 注: experimental 与 reference 区别于 stable (已 ship + 主路径使用) 与 draft (草稿/编写中)。 const OKF_STATUS_VALUES = new Set(['stable', 'draft', 'deprecated', 'experimental', 'reference']); +// references/runtime-adapters/_schema.md defines deployment status, not OKF lifecycle. +const RUNTIME_ADAPTER_STATUS_VALUES = new Set(['shipped', 'reference', 'pending']); + +function statusSchemaFor(filepath, fm) { + const adapterDir = path.join(root, '.agent', 'references', 'runtime-adapters'); + const adapterDocument = path.dirname(filepath) === adapterDir && + typeof fm.agent === 'string' && fm.agent === path.basename(filepath, '.md'); + return adapterDocument + ? { values: RUNTIME_ADAPTER_STATUS_VALUES, label: 'runtime-adapter' } + : { values: OKF_STATUS_VALUES, label: 'OKF V0.2' }; +} // 简单 frontmatter 解析:仅识别 key-value / inline-list / block-list // 不依赖 yaml 包(零依赖) @@ -417,12 +525,13 @@ function collectFrontmatterViolations() { } // R-LINT-008: status 非法值 (warning) - if (fm.status !== undefined && !OKF_STATUS_VALUES.has(fm.status)) { + const statusSchema = statusSchemaFor(filepath, fm); + if (fm.status !== undefined && !statusSchema.values.has(fm.status)) { violations.push({ rule_id: 'R-LINT-008', severity: 'warning', file: rel, - message: `status "${fm.status}" 不是 OKF V0.2 枚举 (stable / draft / deprecated / experimental / reference)`, + message: `status "${fm.status}" 不是 ${statusSchema.label} 枚举 (${[...statusSchema.values].join(' / ')})`, }); } } @@ -433,6 +542,7 @@ function collectFrontmatterViolations() { function computeHealthScore(summary) { const penalty = summary.broken_links * 12 + + (summary.unverified_links || 0) * 12 + // Same penalty: unresolved evidence is not acceptance summary.broken_anchors * 6 + summary.missing_readmes * 8 + summary.plan_issues * 10 + @@ -486,7 +596,7 @@ function runFrontmatterValidator() { function run() { const markdownFiles = internalDocRoots.flatMap((dir) => listMarkdownFiles(dir)); - const { brokenLinks, brokenAnchors } = collectBrokenLinks(markdownFiles); + const { brokenLinks, brokenAnchors, unverifiedLinks } = collectBrokenLinks(markdownFiles); const missingReadmes = collectMissingReadmes(); const planIssues = collectPlanIssues(); const architectureDocMismatches = collectArchitectureDocMismatches(); @@ -497,6 +607,7 @@ function run() { const summary = { markdown_files_scanned: markdownFiles.length, broken_links: brokenLinks.length, + unverified_links: unverifiedLinks.length, broken_anchors: brokenAnchors.length, missing_readmes: missingReadmes.length, plan_issues: planIssues.length, @@ -513,6 +624,7 @@ function run() { summary, findings: { broken_links: brokenLinks, + unverified_links: unverifiedLinks, broken_anchors: brokenAnchors, missing_readmes: missingReadmes, plan_issues: planIssues, @@ -528,6 +640,7 @@ function run() { console.log('--- 📚 Knowledge Lint ---'); console.log(`Scanned markdown files: ${summary.markdown_files_scanned}`); console.log(`Broken links: ${summary.broken_links}`); + console.log(`Unverified local evidence: ${summary.unverified_links}`); console.log(`Broken anchors: ${summary.broken_anchors}`); console.log(`Missing READMEs: ${summary.missing_readmes}`); console.log(`Plan issues: ${summary.plan_issues}`); @@ -538,4 +651,5 @@ function run() { console.log(`Output: ${path.relative(root, outputPath)}`); } -run(); +if (require.main === module) run(); +module.exports = { extractMarkdownLinks, collectBrokenLinks, computeHealthScore, collectFrontmatterViolations }; diff --git a/templates/en/.agent/skills/management-api/scripts/index.js b/templates/en/.agent/skills/management-api/scripts/index.js index 1f3f01d0..919634dd 100755 --- a/templates/en/.agent/skills/management-api/scripts/index.js +++ b/templates/en/.agent/skills/management-api/scripts/index.js @@ -1074,19 +1074,15 @@ function resolveDecision() { resource_ref: next.gate.resource_ref, updated_at: timestamp, }, (entry, current) => entry.decision_id === current.decision_id); - // Release any matching waitpoints - const released = releaseMatchingWaitpoints(decisionId, resolvedBy, rationale, timestamp); + // Resolving a Decision records the user's choice only. The owning workflow + // must separately validate and release or cancel its Waitpoint. printJson({ ok: true, action: "decisions resolve", path: rel(file), decision: next, - changed_paths: [ - rel(file), - rel(path.join(agentRoot, "decisions", "index.json")), - ...released.paths, - ], - changed_resources: [`decision:${decisionId}`, ...released.resources], + changed_paths: [rel(file), rel(path.join(agentRoot, "decisions", "index.json"))], + changed_resources: [`decision:${decisionId}`], }); } @@ -1399,41 +1395,6 @@ function releaseWaitpoint() { }); } -function releaseMatchingWaitpoints(decisionId, resolvedBy, rationale, timestamp) { - const dir = path.join(agentRoot, "waitpoints"); - const changedPaths = []; - const changedResources = []; - for (const { file, data } of listJsonObjects(dir)) { - if (data.decision_id !== decisionId) continue; - if (!["pending", "blocked"].includes(data.status)) continue; - const next = { - ...data, - status: "released", - released_by: resolvedBy, - released_at: timestamp, - release_note: rationale || `Auto-released by decision ${decisionId}.`, - evidence_refs: Array.isArray(data.evidence_refs) && data.evidence_refs.length > 0 ? data.evidence_refs : [`decision:${decisionId}`], - updated_at: timestamp, - }; - writeJson(file, next); - changedPaths.push(rel(file)); - changedResources.push(`waitpoint:${data.waitpoint_id}`); - upsertIndexEntry( - "waitpoints", - "waitpoints", - waitpointIndexEntry(next, file), - (entry, current) => entry.waitpoint_id === current.waitpoint_id, - ); - } - if (changedPaths.length > 0) { - changedPaths.push(rel(path.join(agentRoot, "waitpoints", "index.json"))); - } - return { - paths: [...new Set(changedPaths)], - resources: [...new Set(changedResources)], - }; -} - function upsertIndexEntry(dir, key, entry, match) { const indexFile = path.join(agentRoot, dir, "index.json"); const data = readJson(indexFile) || {}; diff --git a/templates/en/.agent/skills/runtime-continuity/scripts/index.js b/templates/en/.agent/skills/runtime-continuity/scripts/index.js index 47e50fa9..27dc7d6d 100644 --- a/templates/en/.agent/skills/runtime-continuity/scripts/index.js +++ b/templates/en/.agent/skills/runtime-continuity/scripts/index.js @@ -495,6 +495,26 @@ function archiveProject(project, note, opts = {}) { catch (_) { fs.copyFileSync(file, latest); } const markdownArchive = { archivePath: file, latestPath: latest, stamp }; const structured = createStructuredArchive(project, note || {}, opts, markdownArchive); + if (opts.queueLearningProposal === true) { + // Explicit project-local proposal intake only. Never enable this for the + // automatic continuity guard, invoke a Host, or write memory/index here. + let learningProposal; + try { + const evolution = require("../../evolution-pipeline/scripts/evolve.js"); + const queued = evolution.enqueueFromArchive(structured.archiveJsonPath, { + proposalOnly: true, expectedProject: project, + }); + learningProposal = { + status: queued.deduplicated ? "already_queued" : "queued", + task_id: queued.task_id, source_sha256: queued.source_archive.sha256, + execution_authorized: false, + }; + } catch (error) { + // Preserve the successful archive even when proposal intake is unavailable. + learningProposal = { status: "unavailable", reason_code: error.code || "ERR_PROPOSAL_INTAKE", execution_authorized: false }; + } + return { ...markdownArchive, ...structured, learning_proposal: learningProposal }; + } return { ...markdownArchive, ...structured }; } @@ -1857,6 +1877,7 @@ function main() { target_host: flag("--to-host", argv) || null, reason: flag("--reason", argv) || null, full: argv.includes("--full"), + queueLearningProposal: argv.includes("--queue-learning-proposal"), }); } catch (err) { fail("archive_failed", err.message); diff --git a/templates/en/.agent/skills/vcs-pr/SKILL.md b/templates/en/.agent/skills/vcs-pr/SKILL.md index 656c81a4..62203a8b 100644 --- a/templates/en/.agent/skills/vcs-pr/SKILL.md +++ b/templates/en/.agent/skills/vcs-pr/SKILL.md @@ -50,6 +50,39 @@ node .agent/skills/vcs-pr/scripts/index.js list \ [--state open|merged|closed|all] --gate agent ``` +### Review surface + +```bash +# read what the pull request changed +node .agent/skills/vcs-pr/scripts/index.js diff --pr-number 5 --gate agent + +# read what has already been said about it +node .agent/skills/vcs-pr/scripts/index.js comments --pr-number 5 --gate agent +node .agent/skills/vcs-pr/scripts/index.js reviews --pr-number 5 --gate agent + +# write a finding back (user gate — the comment is public) +node .agent/skills/vcs-pr/scripts/index.js comment \ + --pr-number 5 --body-file --gate user + +node .agent/skills/vcs-pr/scripts/index.js review \ + --pr-number 5 --body-file \ + [--event APPROVE|REQUEST_CHANGES|COMMENT] \ + [--comments-file ] --gate user +``` + +`diff` returns the raw unified patch. `comment` and `review` require `--gate user` because they publish to a pull request other people read; `diff` / `comments` / `reviews` are read-only and accept any allowed gate. + +A backend that does not implement one of these operations reports `capability_unavailable` — it never returns an empty list to stand in for data it never fetched. + +To publish a guided review, render the artifact first: + +```bash +node .agent/skills/agent-review-benchmark/scripts/render-pr-comment.js \ + --input --output +node .agent/skills/vcs-pr/scripts/index.js comment \ + --pr-number 5 --body-file --gate user +``` + PR 创建后,vcs-pr 自动: 1. 调 `secrets get --ref --no-mask --gate user` 取 token(只 vcs-pr 自己拿,**不**透传 agent) 2. 调对应 backend 的 `createPR()` — token 注入 `Authorization` header @@ -142,7 +175,8 @@ default: - **零依赖**:纯 stdlib + 3 backend 各 ~80 行 - **不接触 token**:orchestrator 拿 token 后只进 HTTP header,stdout 不输出值(redact.js 二道防护) -- **写 gate**:`create / update / merge` 必须 `--gate user`;`status / delivery-status / list` 可用只读 gate +- **写 gate**:`create / update / merge / comment / review` 必须 `--gate user`;`status / delivery-status / list / diff / comments / reviews` 可用只读 gate +- **能力显式**:backend 未实现某操作时报 `capability_unavailable`,不以空列表冒充「没有数据」 - **可审计**:每条 PR 事件写 `runs/.json#events[]`(Phase 1 基础设施复用) - **可分发**:L1 双语 template;init/upgrade 自动落用户项目 diff --git a/templates/en/.agent/skills/vcs-pr/scripts/backends/github.js b/templates/en/.agent/skills/vcs-pr/scripts/backends/github.js index b9cf59d2..becca88d 100644 --- a/templates/en/.agent/skills/vcs-pr/scripts/backends/github.js +++ b/templates/en/.agent/skills/vcs-pr/scripts/backends/github.js @@ -8,7 +8,7 @@ const https = require("https"); const http = require("http"); const { URL } = require("url"); -function send(method, baseUrl, path, token, body) { +function send(method, baseUrl, path, token, body, extraHeaders) { return new Promise((resolve, reject) => { const url = new URL(baseUrl + path); const lib = url.protocol === "https:" ? https : http; @@ -24,6 +24,7 @@ function send(method, baseUrl, path, token, body) { "User-Agent": "cortex-agent-vcs-pr/1.0", ...(token ? { "Authorization": `Bearer ${token}` } : {}), ...(payload ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } : {}), + ...(extraHeaders || {}), }, }; const req = lib.request(opts, (res) => { @@ -94,4 +95,108 @@ async function list(opts) { })) : []; } -module.exports = { backend: "github", createPR, getStatus, merge, list }; +// ─── review surface ───────────────────────────────────────────────────────── +// +// The four operations below are the missing half of the review loop: reading +// what a PR actually changed, reading what has already been said about it, +// and writing a finding back so a human can act on it. +// +// Two GitHub routing facts drive the implementation: +// +// * PR *discussion comments* live under `issues/{n}/comments`, not under +// `pulls/{n}/comments` — the latter 404s. A PR is an issue underneath. +// * PR *reviews* live under `pulls/{n}/reviews` and carry an `event` +// (APPROVE / REQUEST_CHANGES / COMMENT). A review body alone is a +// top-level review; per-line comments ride in the `comments` array. +// +// The diff comes back as a unified patch from `pulls/{n}` with the diff +// media type. `Accept: application/vnd.github.v3.diff` yields the raw +// patch, which is what a reviewer needs — the JSON form of the same endpoint +// returns file metadata but not the hunks. + +async function getDiff(opts) { + const { config, token, pr_number } = opts; + const { owner, repo } = mapRepo(opts, config); + const res = await send("GET", config.host, `/repos/${owner}/${repo}/pulls/${pr_number}`, token, null, { + Accept: "application/vnd.github.v3.diff", + }); + if (res.status !== 200) throw new Error(`github_diff_failed: HTTP ${res.status} ${res.raw?.slice(0, 200)}`); + return { number: pr_number, patch: res.raw }; +} + +async function listComments(opts) { + const { config, token, pr_number } = opts; + const { owner, repo } = mapRepo(opts, config); + const res = await send("GET", config.host, `/repos/${owner}/${repo}/issues/${pr_number}/comments?per_page=100`, token); + if (res.status !== 200) throw new Error(`github_comments_failed: HTTP ${res.status} ${res.raw?.slice(0, 200)}`); + return Array.isArray(res.body) ? res.body.map((comment) => ({ + id: comment.id, + author: comment.user?.login || null, + body: comment.body || "", + created_at: comment.created_at || null, + url: comment.html_url || null, + })) : []; +} + +async function listReviews(opts) { + const { config, token, pr_number } = opts; + const { owner, repo } = mapRepo(opts, config); + const res = await send("GET", config.host, `/repos/${owner}/${repo}/pulls/${pr_number}/reviews?per_page=100`, token); + if (res.status !== 200) throw new Error(`github_reviews_failed: HTTP ${res.status} ${res.raw?.slice(0, 200)}`); + return Array.isArray(res.body) ? res.body.map((review) => ({ + id: review.id, + author: review.user?.login || null, + state: review.state || null, + body: review.body || "", + submitted_at: review.submitted_at || null, + url: review.html_url || null, + })) : []; +} + +async function createComment(opts) { + const { config, token, pr_number, body } = opts; + const { owner, repo } = mapRepo(opts, config); + if (!body || !String(body).trim()) throw new Error("missing_comment_body"); + const res = await send("POST", config.host, `/repos/${owner}/${repo}/issues/${pr_number}/comments`, token, { + body: String(body), + }); + if (res.status !== 201) throw new Error(`github_comment_failed: HTTP ${res.status} ${res.raw?.slice(0, 200)}`); + return { + id: res.body.id, + url: res.body.html_url || null, + author: res.body.user?.login || null, + created_at: res.body.created_at || null, + }; +} + +// `event` is one of APPROVE / REQUEST_CHANGES / COMMENT. A review carrying +// inline comments must use the COMMENT or REQUEST_CHANGES event — GitHub +// rejects line-anchored comments on an APPROVE review. +async function submitReview(opts) { + const { config, token, pr_number, body, event, comments } = opts; + const { owner, repo } = mapRepo(opts, config); + const payload = { body: body || "" }; + if (event) payload.event = event; + if (Array.isArray(comments) && comments.length) payload.comments = comments; + const res = await send("POST", config.host, `/repos/${owner}/${repo}/pulls/${pr_number}/reviews`, token, payload); + if (res.status !== 200) throw new Error(`github_review_failed: HTTP ${res.status} ${res.raw?.slice(0, 200)}`); + return { + id: res.body.id, + state: res.body.state || null, + url: res.body.html_url || null, + submitted_at: res.body.submitted_at || null, + }; +} + +module.exports = { + backend: "github", + createPR, + getStatus, + merge, + list, + getDiff, + listComments, + listReviews, + createComment, + submitReview, +}; diff --git a/templates/en/.agent/skills/vcs-pr/scripts/index.js b/templates/en/.agent/skills/vcs-pr/scripts/index.js index d4c989d5..597f3279 100644 --- a/templates/en/.agent/skills/vcs-pr/scripts/index.js +++ b/templates/en/.agent/skills/vcs-pr/scripts/index.js @@ -19,7 +19,7 @@ const { spawnSync } = require("child_process"); const AGENT_ROOT = path.join(process.cwd(), ".agent"); const BACKENDS = new Set(["gitea", "github", "gitlab"]); -const GATE_USER_ONLY = new Set(["create", "update", "merge"]); +const GATE_USER_ONLY = new Set(["create", "update", "merge", "comment", "review"]); function flag(name, argv) { const i = argv.indexOf(name); @@ -264,8 +264,12 @@ function loadBody(opts) { async function main() { const argv = process.argv.slice(2); const [command] = argv; - if (!command || !["create", "update", "status", "delivery-status", "pipeline-jobs", "job-trace", "merge", "list"].includes(command)) { - fail("unknown_command", "Usage: vcs-pr create|update|status|delivery-status|pipeline-jobs|job-trace|merge|list [...opts]"); + const COMMANDS = [ + "create", "update", "status", "delivery-status", "pipeline-jobs", "job-trace", + "merge", "list", "diff", "comments", "reviews", "comment", "review", + ]; + if (!command || !COMMANDS.includes(command)) { + fail("unknown_command", `Usage: vcs-pr ${COMMANDS.join("|")} [...opts]`); } const cfg = loadConfig(); if (!cfg || !cfg.backend) { @@ -439,6 +443,122 @@ async function main() { emit({ ok: true, action: "list", count: result.length, items: result }); return; } + + // ─── review surface ─────────────────────────────────────────────────────── + // + // The commands below close the loop that `create`/`merge` left open: read + // the diff, read what has been said, write a finding back. + // + // Read commands degrade gracefully — a backend that does not implement one + // reports `capability_unavailable` instead of pretending the data is + // empty. Write commands (`comment`, `review`) are gated at `--gate user` + // above, because they publish to a pull request that other people read. + + if (command === "diff") { + if (typeof backend.getDiff !== "function") { + fail("capability_unavailable", `${cfg.backend} backend does not support diff.`); + } + const result = await backend.getDiff({ ...optsBase, pr_number: Number(flag("--pr-number", argv)) }); + emit({ ok: true, action: "diff", ...result }); + return; + } + if (command === "comments") { + if (typeof backend.listComments !== "function") { + fail("capability_unavailable", `${cfg.backend} backend does not support comments.`); + } + const result = await backend.listComments({ ...optsBase, pr_number: Number(flag("--pr-number", argv)) }); + emit({ ok: true, action: "comments", count: result.length, items: result }); + return; + } + if (command === "reviews") { + if (typeof backend.listReviews !== "function") { + fail("capability_unavailable", `${cfg.backend} backend does not support reviews.`); + } + const result = await backend.listReviews({ ...optsBase, pr_number: Number(flag("--pr-number", argv)) }); + emit({ ok: true, action: "reviews", count: result.length, items: result }); + return; + } + if (command === "comment") { + if (typeof backend.createComment !== "function") { + fail("capability_unavailable", `${cfg.backend} backend does not support comment.`); + } + const body = readReviewBody(argv); + const result = await backend.createComment({ + ...optsBase, + pr_number: Number(flag("--pr-number", argv)), + body, + }); + if (runId) { + appendRunEvent(runId, { + type: "vcs_pr_comment", + backend: cfg.backend, + pr_number: Number(flag("--pr-number", argv)), + comment_id: result.id, + }); + } + emit({ ok: true, action: "comment", ...result }); + return; + } + if (command === "review") { + if (typeof backend.submitReview !== "function") { + fail("capability_unavailable", `${cfg.backend} backend does not support review.`); + } + const body = readReviewBody(argv); + const event = (flag("--event", argv) || "COMMENT").toUpperCase(); + if (!["APPROVE", "REQUEST_CHANGES", "COMMENT"].includes(event)) { + fail("invalid_review_event", "--event must be one of: APPROVE, REQUEST_CHANGES, COMMENT."); + } + const commentsFile = flag("--comments-file", argv); + let comments = null; + if (commentsFile) { + const raw = fs.readFileSync( + path.isAbsolute(commentsFile) ? commentsFile : path.join(process.cwd(), commentsFile), + "utf8" + ); + try { + comments = JSON.parse(raw); + } catch (err) { + fail("invalid_comments_json", err.message); + } + if (!Array.isArray(comments)) { + fail("invalid_comments_json", "--comments-file must contain a JSON array of review comments."); + } + } + const result = await backend.submitReview({ + ...optsBase, + pr_number: Number(flag("--pr-number", argv)), + body, + event, + comments, + }); + if (runId) { + appendRunEvent(runId, { + type: "vcs_pr_review", + backend: cfg.backend, + pr_number: Number(flag("--pr-number", argv)), + review_id: result.id, + review_state: result.state, + }); + } + emit({ ok: true, action: "review", ...result }); + return; + } +} + +// Review bodies are long. Reading them from a file keeps the command line +// free of multi-kilobyte payloads and keeps the body out of process listings. +// `--body` is still accepted for short one-liners. +function readReviewBody(argv) { + const bodyFile = flag("--body-file", argv); + if (bodyFile) { + const target = path.isAbsolute(bodyFile) ? bodyFile : path.join(process.cwd(), bodyFile); + if (!fs.existsSync(target)) fail("body_file_not_found", target); + return fs.readFileSync(target, "utf8"); + } + const inline = flag("--body", argv); + if (inline && inline.trim()) return inline; + fail("missing_review_body", "Provide --body-file or --body ."); + return null; } if (require.main === module) { diff --git a/templates/zh/.agent/skills/evolution-pipeline/scripts/evolve.js b/templates/zh/.agent/skills/evolution-pipeline/scripts/evolve.js index e990b103..8ad3fa42 100755 --- a/templates/zh/.agent/skills/evolution-pipeline/scripts/evolve.js +++ b/templates/zh/.agent/skills/evolution-pipeline/scripts/evolve.js @@ -32,6 +32,7 @@ const fs = require("fs"); const path = require("path"); +const crypto = require("crypto"); const ROOT = process.cwd(); const TASKS_DIR = path.join(ROOT, ".agent", "tasks", "evolution"); @@ -40,6 +41,264 @@ const MEMORY_DIR = path.join(ROOT, ".agent", "memory"); const ARCHIVES_DIR = path.join(ROOT, ".agent", "runtime-continuity", "archives"); const LATEST_ARCHIVE = path.join(ARCHIVES_DIR, "latest.json"); + +const PROPOSAL_KIND = "archive_learning_proposal"; +const PROPOSALS_DIR = path.join(TASKS_DIR, "_proposals"); +const PROPOSAL_ID = /^EVO-P-[0-9a-f]{64}$/; +const MAX_ARCHIVE_BYTES = 1024 * 1024; + +function proposalError(code) { + const error = new Error(code); + error.code = code; + return error; +} +function digest(bytes) { + return crypto.createHash("sha256").update(bytes).digest("hex"); +} +function proposalBytes(value) { + return JSON.stringify(value, null, 2) + "\n"; +} +function proposalScope(project) { + if (typeof project !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(project) + || project.includes("..")) throw proposalError("ERR_PROPOSAL_PROJECT"); + return { + project, + workspace_id: "workspace-sha256:" + digest(fs.realpathSync(ROOT)), + }; +} +// Fixed project-local paths only; reject observed symlinked components. +// These checks are not fencing against an adversarial filesystem writer. +function checkedParents(file, create = false) { + const relative = path.relative(ROOT, path.dirname(file)); + if (relative.startsWith("..") || path.isAbsolute(relative)) throw proposalError("ERR_PROPOSAL_PATH"); + let cursor = ROOT; + for (const segment of relative.split(path.sep).filter(Boolean)) { + cursor = path.join(cursor, segment); + if (!fs.existsSync(cursor)) { + if (!create) return false; + try { fs.mkdirSync(cursor); } catch (error) { if (error.code !== "EEXIST") throw error; } + } + const stat = fs.lstatSync(cursor); + if (stat.isSymbolicLink() || !stat.isDirectory()) throw proposalError("ERR_PROPOSAL_PATH"); + } + return true; +} +function readProposalBytes(file) { + if (!checkedParents(file)) throw proposalError("ERR_PROPOSAL_NOT_FOUND"); + if (!Number.isInteger(fs.constants.O_NOFOLLOW) || !Number.isInteger(fs.constants.O_NONBLOCK) + || fs.constants.O_NOFOLLOW === 0 || fs.constants.O_NONBLOCK === 0) { + throw proposalError("ERR_PROPOSAL_SAFE_OPEN_UNSUPPORTED"); + } + function regular(stat) { + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > BigInt(MAX_ARCHIVE_BYTES)) { + throw proposalError("ERR_PROPOSAL_FILE"); + } + if (stat.nlink !== 1n) throw proposalError("ERR_PROPOSAL_FILE_LINKS"); + } + const before = fs.lstatSync(file, { bigint: true }); + regular(before); + let fd; + try { + fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); + const opened = fs.fstatSync(fd, { bigint: true }); + regular(opened); + if (opened.dev !== before.dev || opened.ino !== before.ino + || opened.size !== before.size || opened.mtimeNs !== before.mtimeNs + || opened.ctimeNs !== before.ctimeNs) throw proposalError("ERR_PROPOSAL_FILE_CHANGED"); + const buffer = Buffer.alloc(Number(opened.size) + 1); + let size = 0; + while (size < buffer.length) { + const count = fs.readSync(fd, buffer, size, buffer.length - size, null); + if (count === 0) break; + size += count; + } + const after = fs.fstatSync(fd, { bigint: true }); + const named = fs.lstatSync(file, { bigint: true }); + regular(after); regular(named); + if (size > MAX_ARCHIVE_BYTES || BigInt(size) !== opened.size + || after.dev !== opened.dev || after.ino !== opened.ino + || named.dev !== opened.dev || named.ino !== opened.ino + || after.size !== opened.size || named.size !== opened.size + || after.mtimeNs !== opened.mtimeNs || after.ctimeNs !== opened.ctimeNs + || named.mtimeNs !== opened.mtimeNs || named.ctimeNs !== opened.ctimeNs) { + throw proposalError("ERR_PROPOSAL_FILE_CHANGED"); + } + return buffer.subarray(0, size); + } finally { + if (fd !== undefined) fs.closeSync(fd); + } +} +function readProposalFile(file) { + return readProposalBytes(file).toString("utf8"); +} +function sameProposalFile(file, value) { + if (readProposalFile(file) !== proposalBytes(value)) throw proposalError("ERR_PROPOSAL_RECORD_CONFLICT"); +} +// Publish a complete immutable file, or verify the existing identical record. +// An exit before link leaves an ignored .tmp. An exit between link/unlink can +// leave a multi-link record: readers block and preserve it for explicit repair. +// No overwrite, sequence allocation, queue rewrite or index. +function verifyPublishedProposal(file, value) { + // A concurrent publisher immediately removes its temporary hard link. + // Never read multi-link bytes; wait only a bounded 100 ms for that transition. + for (let attempt = 0; ; attempt++) { + try { sameProposalFile(file, value); return; } + catch (error) { + if (error.code !== "ERR_PROPOSAL_FILE_LINKS" || attempt >= 20) throw error; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5); + } + } +} +function publishProposalFile(file, value) { + if (fs.existsSync(file)) { + verifyPublishedProposal(file, value); + return false; + } + checkedParents(file, true); + const temporary = path.join(path.dirname(file), ".proposal-" + crypto.randomBytes(12).toString("hex") + ".tmp"); + let fd; + let ownedTemporary = false; + try { + fd = fs.openSync(temporary, "wx", 0o600); + ownedTemporary = true; + fs.writeFileSync(fd, proposalBytes(value), "utf8"); + fs.fsyncSync(fd); + fs.closeSync(fd); + fd = undefined; + try { fs.linkSync(temporary, file); } + catch (error) { + if (error.code !== "EEXIST") throw error; + verifyPublishedProposal(file, value); + return false; + } + fs.unlinkSync(temporary); + ownedTemporary = false; + verifyPublishedProposal(file, value); + return true; + } finally { + if (fd !== undefined) fs.closeSync(fd); + if (ownedTemporary) { + try { fs.unlinkSync(temporary); } catch (error) { if (error.code !== "ENOENT") throw error; } + } + } +} +function proposalArchive(archivePath, expectedProject) { + const file = path.resolve(archivePath); + if (path.dirname(file) !== ARCHIVES_DIR || path.basename(file) === "latest.json") { + throw proposalError("ERR_PROPOSAL_ARCHIVE_PATH"); + } + const bytes = readProposalBytes(file); + const raw = bytes.toString("utf8"); + if (!Buffer.from(raw, "utf8").equals(bytes)) throw proposalError("ERR_PROPOSAL_ARCHIVE_ENCODING"); + const archive = JSON.parse(raw); + if (!archive || typeof archive !== "object" || Array.isArray(archive) + || typeof archive.archive_id !== "string" || !/^RC-[A-Za-z0-9_-]+$/.test(archive.archive_id) + || path.basename(file) !== archive.archive_id + ".json") { + throw proposalError("ERR_PROPOSAL_ARCHIVE_ID"); + } + const scope = proposalScope(expectedProject); + if (archive.project !== scope.project) throw proposalError("ERR_PROPOSAL_PROJECT_MISMATCH"); + if (typeof archive.created_at !== "string" || !Number.isFinite(Date.parse(archive.created_at)) + || new Date(archive.created_at).toISOString() !== archive.created_at) { + throw proposalError("ERR_PROPOSAL_ARCHIVE_TIME"); + } + const state = archive.state; + if (!state || typeof state !== "object" || Array.isArray(state)) throw proposalError("ERR_PROPOSAL_ARCHIVE_STATE"); + const counts = {}; + for (const [field, label] of [["done", "reported_done"], ["next", "planned_next"], ["blockers", "reported_blockers"]]) { + const values = state[field] === undefined ? [] : state[field]; + if (!Array.isArray(values) || values.some((v) => typeof v !== "string" || !v.trim())) { + throw proposalError("ERR_PROPOSAL_ARCHIVE_STATE"); + } + counts[label] = values.length; + } + if (state.in_progress != null && typeof state.in_progress !== "string") throw proposalError("ERR_PROPOSAL_ARCHIVE_STATE"); + counts.reported_in_progress = typeof state.in_progress === "string" && state.in_progress.trim() ? 1 : 0; + const sha256 = digest(bytes); + const taskId = "EVO-P-" + digest(JSON.stringify([scope, archive.archive_id, sha256])); + return { + schema_version: "1", kind: PROPOSAL_KIND, task_id: taskId, + project_scope: scope, + source_archive: { + archive_id: archive.archive_id, + archive_path: path.relative(ROOT, file).split(path.sep).join("/"), + sha256, created_at: archive.created_at, + }, + created_at: archive.created_at, updated_at: archive.created_at, status: "pending", + observation_counts: counts, execution_authorized: false, allowed_effects: [], + }; +} +function proposalFor(task) { + return { + schema_version: "1", kind: "archive_learning_proposal_result", proposal_id: task.task_id, + project_scope: task.project_scope, source_archive: task.source_archive, + status: "proposal_only", verification_status: "not_run", + observation_counts: task.observation_counts, + proposed_checks: [ + "Review the referenced archive inside this project before selecting evidence.", + "Verify reported outcomes independently before proposing any change.", + ], + limitations: [ + "Archive contents are unverified observations; this document contains no extracted private text.", + "No memory, index, Task, Host run, promotion, or execution permission is created.", + ], + execution_authorized: false, allowed_effects: [], + }; +} +function loadProposalTask(taskId) { + if (typeof taskId !== "string" || !PROPOSAL_ID.test(taskId)) throw proposalError("ERR_PROPOSAL_TASK_ID"); + const file = path.join(TASKS_DIR, taskId + ".json"); + const stored = JSON.parse(readProposalFile(file)); + if (stored.kind !== PROPOSAL_KIND || stored.task_id !== taskId || !stored.source_archive + || !stored.project_scope) throw proposalError("ERR_PROPOSAL_TASK_SHAPE"); + if (typeof stored.source_archive.archive_path !== "string") throw proposalError("ERR_PROPOSAL_ARCHIVE_PATH"); + const task = proposalArchive(path.resolve(ROOT, stored.source_archive.archive_path), stored.project_scope.project); + if (task.task_id !== taskId) throw proposalError("ERR_PROPOSAL_SOURCE_CHANGED"); + sameProposalFile(file, task); + return task; +} +function enqueueProposalFromArchive(archivePath, opts) { + const task = proposalArchive(archivePath, opts.expectedProject); + const file = path.join(TASKS_DIR, task.task_id + ".json"); + if (opts.dryRun) { + if (fs.existsSync(file)) sameProposalFile(file, task); + return { + dry_run: true, task_id: null, would_enqueue: !fs.existsSync(file), + project_scope: task.project_scope, source_archive: task.source_archive, + execution_authorized: false, allowed_effects: [], + }; + } + const created = publishProposalFile(file, task); + return { ...task, deduplicated: !created }; +} +function processProposalTask(taskId, opts = {}) { + const task = loadProposalTask(taskId); + const proposal = proposalFor(task); + const file = path.join(PROPOSALS_DIR, taskId + ".json"); + if (opts.dryRun) { + if (fs.existsSync(file)) sameProposalFile(file, proposal); + return { ...task, status: "proposal_only", dry_run: true, proposal_ref: null, would_write_proposal: !fs.existsSync(file) }; + } + const created = publishProposalFile(file, proposal); + return { + ...task, status: "proposal_only", deduplicated: !created, + proposal_ref: path.relative(ROOT, file).split(path.sep).join("/"), + }; +} +function observedProposalTask(taskId) { + try { + const current = loadProposalTask(taskId); + const file = path.join(PROPOSALS_DIR, taskId + ".json"); + if (!fs.existsSync(file)) return current; + sameProposalFile(file, proposalFor(current)); + return { ...current, status: "proposal_only", proposal_ref: path.relative(ROOT, file).split(path.sep).join("/") }; + } catch (error) { + return { schema_version: "1", kind: PROPOSAL_KIND, task_id: taskId, + created_at: "", status: "blocked", execution_authorized: false, allowed_effects: [], + proposal_error: error.code || "ERR_PROPOSAL_READ" }; + } +} + function parseArgs() { const args = {}; for (let i = 2; i < process.argv.length; i++) { @@ -252,10 +511,13 @@ function writeMemoryItem(scope, item, archiveId) { * Returns the updated task. */ function processTask(taskId, opts = {}) { + if (typeof taskId === "string" && taskId.startsWith("EVO-P-")) return processProposalTask(taskId, opts); const file = path.join(TASKS_DIR, `${taskId}.json`); if (!fs.existsSync(file)) throw new Error(`task not found: ${taskId}`); const task = readJson(file); + if (task.kind === PROPOSAL_KIND) return processProposalTask(taskId, opts); task.status = "running"; + if (opts.dryRun) task.dry_run = true; task.started_at = new Date().toISOString(); task.updated_at = task.started_at; task.errors = task.errors || []; @@ -286,26 +548,49 @@ function processTask(taskId, opts = {}) { task.status = "completed"; task.completed_at = new Date().toISOString(); task.updated_at = task.completed_at; - writeJson(file, task); + if (!opts.dryRun) writeJson(file, task); } catch (err) { task.errors.push({ at: new Date().toISOString(), stage: "process", message: err.message }); task.retry_count = (task.retry_count || 0) + 1; task.updated_at = new Date().toISOString(); if (task.retry_count >= (task.max_retries || 3)) { task.status = "dead"; - // write to dead dir, then delete original (rename would not save updated status) - ensureDir(DEAD_DIR); - writeJson(path.join(DEAD_DIR, `${taskId}.json`), task); - fs.unlinkSync(file); + if (!opts.dryRun) { + // write to dead dir, then delete original (rename would not save updated status) + ensureDir(DEAD_DIR); + writeJson(path.join(DEAD_DIR, `${taskId}.json`), task); + fs.unlinkSync(file); + } } else { task.status = "failed"; - writeJson(file, task); + if (!opts.dryRun) writeJson(file, task); } } return task; } +/** Read-only preview: do not allocate a queue ID or write to disk. */ +function previewEnqueueFromArchive(archivePath) { + if (!fs.existsSync(archivePath)) throw new Error(`archive not found: ${archivePath}`); + const archive = readJson(archivePath); + if (!archive || typeof archive !== "object" || Array.isArray(archive)) { + throw new Error(`archive must be a JSON object: ${archivePath}`); + } + return { + dry_run: true, + would_enqueue: true, + task_id: null, + source_archive: { + archive_id: archive.archive_id || archIdFromFile(archivePath), + archive_path: path.relative(ROOT, archivePath), + created_at: archive.created_at || null, + }, + }; +} + function enqueueFromArchive(archivePath, opts = {}) { + if (opts.proposalOnly === true) return enqueueProposalFromArchive(archivePath, opts); + if (opts.dryRun) return previewEnqueueFromArchive(archivePath); if (!fs.existsSync(archivePath)) throw new Error(`archive not found: ${archivePath}`); const archive = readJson(archivePath); const taskId = nextTaskId(); @@ -330,11 +615,14 @@ function enqueueFromArchive(archivePath, opts = {}) { } function listTasks(filter = {}) { + if (filter.proposalOnly === true && !checkedParents(path.join(TASKS_DIR, "reserved.json"))) return []; if (!fs.existsSync(TASKS_DIR)) return []; const files = fs.readdirSync(TASKS_DIR).filter((f) => f.endsWith(".json")); const out = []; for (const f of files) { - const t = readJson(path.join(TASKS_DIR, f)); + if (filter.proposalOnly === true && !f.startsWith("EVO-P-")) continue; + const t = f.startsWith("EVO-P-") + ? observedProposalTask(f.slice(0, -5)) : readJson(path.join(TASKS_DIR, f)); if (filter.status && t.status !== filter.status) continue; out.push(t); } @@ -364,6 +652,28 @@ function main() { const args = parseArgs(); try { + if (args["dry-run"] && (args.replay || (args.worker && args.loop))) { + throw new Error("--dry-run does not support replay or a polling worker loop"); + } + + if (args["proposal-only"] !== undefined && args["proposal-only"] !== true) { + throw proposalError("ERR_PROPOSAL_MODE_FLAG"); + } + if (args["proposal-only"] && (args["enqueue-from-latest"] || args["dead-letter"] || args.loop)) { + throw proposalError("ERR_PROPOSAL_UNSUPPORTED_MODE"); + } + if (args["proposal-only"] && [args.enqueue, args.worker, args.replay, args.list, args.status && !args.list].filter(Boolean).length !== 1) { + throw proposalError("ERR_PROPOSAL_ACTION_REQUIRED"); + } + if (args["proposal-only"] && args.worker && !args.once) { + throw proposalError("ERR_PROPOSAL_WORKER_REQUIRES_ONCE"); + } + if (args["proposal-only"] && args.replay) { + const task = processProposalTask(args.replay); + console.log(JSON.stringify({ ok: true, replayed: task }, null, 2)); + return; + } + if (args.enqueue || args["enqueue-from-latest"]) { let entries = []; if (args.archive) { @@ -382,21 +692,36 @@ function main() { } else { throw new Error("specify --archive or --enqueue-from-latest"); } - const created = entries.map((e) => enqueueFromArchive(e)); - console.log(JSON.stringify({ ok: true, enqueued: created.length, tasks: created.map((t) => t.task_id) }, null, 2)); + const dryRun = !!args["dry-run"]; + const created = entries.map((e) => enqueueFromArchive(e, { + dryRun, proposalOnly: args["proposal-only"] === true, expectedProject: args.project, + })); + if (dryRun) { + console.log(JSON.stringify({ ok: true, dry_run: true, enqueued: 0, previews: created }, null, 2)); + } else { + console.log(JSON.stringify({ ok: true, enqueued: created.filter((t) => !t.deduplicated).length, + deduplicated: created.filter((t) => t.deduplicated).length, tasks: created.map((t) => t.task_id) }, null, 2)); + } return; } if (args.worker) { - const pending = listTasks({ status: "pending" }).concat(listTasks({ status: "failed" })); + const proposalOnly = args["proposal-only"] === true; + const pending = listTasks({ status: "pending", proposalOnly }) + .concat(listTasks({ status: "failed", proposalOnly })); if (args.once) { if (!pending.length) { console.log(JSON.stringify({ ok: true, processed: 0, message: "no pending tasks" }, null, 2)); return; } const task = pending[0]; - const result = processTask(task.task_id, { dryRun: args["dry-run"] }); - console.log(JSON.stringify({ ok: true, processed: 1, task: result }, null, 2)); + const dryRun = !!args["dry-run"]; + const result = processTask(task.task_id, { dryRun }); + if (dryRun) { + console.log(JSON.stringify({ ok: true, dry_run: true, processed: 0, would_process: 1, task: result }, null, 2)); + } else { + console.log(JSON.stringify({ ok: true, processed: 1, task: result }, null, 2)); + } return; } if (args.loop) { @@ -419,9 +744,11 @@ function main() { if (args.replay) { const taskId = args.replay; + if (typeof taskId === "string" && taskId.startsWith("EVO-P-")) throw proposalError("ERR_PROPOSAL_REPLAY_REQUIRES_MODE"); let task; if (fs.existsSync(path.join(TASKS_DIR, `${taskId}.json`))) { task = readJson(path.join(TASKS_DIR, `${taskId}.json`)); + if (task.kind === PROPOSAL_KIND) throw proposalError("ERR_PROPOSAL_REPLAY_REQUIRES_MODE"); task.status = "pending"; task.retry_count = 0; task.errors = []; @@ -443,15 +770,15 @@ function main() { if (args.list) { const filt = args.status || null; - const tasks = listTasks(filt ? { status: filt } : {}); - console.log(JSON.stringify({ ok: true, count: tasks.length, tasks: tasks.map((t) => ({ task_id: t.task_id, status: t.status, archive: t.source_archive.archive_id, retry_count: t.retry_count, classification: t.classification })) }, null, 2)); + const tasks = listTasks({ ...(filt ? { status: filt } : {}), proposalOnly: args["proposal-only"] === true }); + console.log(JSON.stringify({ ok: true, count: tasks.length, tasks: tasks.map((t) => ({ task_id: t.task_id, status: t.status, archive: t.source_archive ? t.source_archive.archive_id : null, retry_count: t.retry_count, classification: t.classification, proposal_error: t.proposal_error || null })) }, null, 2)); return; } if (args.status) { - const tasks = listTasks(); + const tasks = listTasks({ proposalOnly: args["proposal-only"] === true }); const by = tasks.reduce((acc, t) => { acc[t.status] = (acc[t.status] || 0) + 1; return acc; }, {}); - const dead = deadLetterTasks().length; + const dead = args["proposal-only"] ? 0 : deadLetterTasks().length; console.log(JSON.stringify({ ok: true, total: tasks.length, @@ -487,4 +814,12 @@ if (require.main === module) { main(); } -module.exports = { classifyArchive, processTask, enqueueFromArchive, listTasks, deadLetterTasks }; +module.exports = { + classifyArchive, + processTask, + processProposalTask, + previewEnqueueFromArchive, + enqueueFromArchive, + listTasks, + deadLetterTasks, +}; diff --git a/templates/zh/.agent/skills/knowledge-lint/scripts/index.js b/templates/zh/.agent/skills/knowledge-lint/scripts/index.js index dca9bfb5..bfea6277 100644 --- a/templates/zh/.agent/skills/knowledge-lint/scripts/index.js +++ b/templates/zh/.agent/skills/knowledge-lint/scripts/index.js @@ -78,25 +78,112 @@ function extractHeadings(content) { } function shouldCheckLink(rawLink) { - if (!rawLink) return false; - if (rawLink.includes('{') || rawLink.includes('}')) return false; - if (rawLink.startsWith('http://')) return false; - if (rawLink.startsWith('https://')) return false; - if (rawLink.startsWith('mailto:')) return false; - if (rawLink.startsWith('app://')) return false; - if (rawLink.startsWith('plugin://')) return false; - return true; + if (!rawLink || /[{}]/.test(rawLink)) return false; + // URI schemes are not repository paths (RFC 3986); file: is reported separately. + return !/^[a-z][a-z0-9+.-]*:/i.test(rawLink) && !rawLink.startsWith('//'); +} + +function isEscaped(text, index) { + let slashes = 0; + while (index > 0 && text[--index] === '\\') slashes++; + return slashes % 2 === 1; +} + +function maskMarkdownCode(content) { + let fence = null; + const prose = content.split('\n').map((line) => { + const marker = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/); + if (fence) { + if (marker && marker[1][0] === fence[0] && + marker[1].length >= fence.length && !marker[2].trim()) fence = null; + return ''; + } + if (marker && !(marker[1][0] === '`' && marker[2].includes('`'))) { + fence = marker[1]; + return ''; + } + return line; + }).join('\n'); + // Blocks take precedence over inline spans. In particular, an unmatched + // tick in a paragraph must not consume links in the following heading/list. + const blocks = []; + let block = []; + const flush = () => { if (block.length) blocks.push(block.join('\n')); block = []; }; + for (const line of prose.split('\n')) { + const heading = /^ {0,3}#{1,6}(?:[ \t]|$)/.test(line); + const separator = /^ {0,3}(?:(?:\*[ \t]*){3,}|(?:_[ \t]*){3,}|(?:-[ \t]*){3,}|=+[ \t]*|-+[ \t]*)$/.test(line); + const listMarker = /^ {0,3}(?:[-+*]|\d{1,9}[.)])[ \t]+/; + // Ordered lists can interrupt a paragraph only when starting at 1. + const list = listMarker.test(line) && (!block.length || listMarker.test(block[0]) || + /^ {0,3}(?:[-+*]|1[.)])[ \t]+/.test(line)); + const quote = /^ {0,3}>/.test(line); + const previousQuote = block.length && /^ {0,3}>/.test(block[block.length - 1]); + if (!line.trim() || heading || separator || list || (quote && !previousQuote)) flush(); + block.push(line); + if (!line.trim() || heading || separator) flush(); + } + flush(); + return blocks.map(maskInlineCode).join('\n'); +} + +function maskInlineCode(prose) { + // Only equal-length backtick runs delimit a span; unmatched runs are prose. + const runs = [...prose.matchAll(/`+/g)]; + const masked = prose.split(''); + for (let i = 0; i < runs.length; i++) { + if (isEscaped(prose, runs[i].index)) continue; + let end = i + 1; + while (end < runs.length && runs[end][0].length !== runs[i][0].length) end++; + if (end === runs.length) continue; + masked.fill(' ', runs[i].index, runs[end].index + runs[end][0].length); + i = end; + } + return masked.join(''); +} + +function hasLinkClose(suffix) { + // CommonMark titles can contain escaped delimiters and line endings, but + // not blank lines. Match only the suffix so following paragraphs don't matter. + const close = suffix.match(/^\s*(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^()\\])*\))?\s*\)/); + return Boolean(close && !/\r?\n[ \t]*\r?\n/.test(close[0])); } function extractMarkdownLinks(content) { + const text = maskMarkdownCode(content); const links = []; - const pattern = /\[[^\]]+\]\(([^)]+)\)/g; + const opener = /\[(?:\\.|[^\]\\])*\]\(\s*/g; let match; - - while ((match = pattern.exec(content))) { - links.push(match[1].trim()); + while ((match = opener.exec(text))) { + if (isEscaped(text, match.index)) continue; + let index = opener.lastIndex; + const angle = text[index] === '<'; + if (angle) index++; + let target = ''; + let depth = 0; + let closed = false; + for (; index < text.length; index++) { + const ch = text[index]; + if (ch === '\\' && /[\\()[\]<>]/.test(text[index + 1] || '')) { + target += text[++index]; + } else if (angle ? ch === '>' : ch === ')' && depth === 0) { + closed = true; + break; + } else if (!angle && /\s/.test(ch) && depth === 0) { + closed = true; + break; + } else { + if (!angle && ch === '(') depth++; + if (!angle && ch === ')') depth--; + target += ch; + } + } + // Validate the outer close and optional Markdown title before accepting. + const suffix = text.slice(index + (angle ? 1 : 0)); + if (closed && hasLinkClose(suffix)) { + links.push(target); + opener.lastIndex = index + 1; + } } - return links; } @@ -118,6 +205,7 @@ function resolveTargetFile(sourceFile, filePart) { function collectBrokenLinks(markdownFiles) { const brokenLinks = []; const brokenAnchors = []; + const unverifiedLinks = []; const headingCache = new Map(); for (const sourceFile of markdownFiles) { @@ -125,6 +213,15 @@ function collectBrokenLinks(markdownFiles) { const links = extractMarkdownLinks(content); for (const rawLink of links) { + if (/^file:/i.test(rawLink)) { + // This scanner does not validate host-local URI availability. Report + // that limit explicitly; it is not a requirement to re-audit past business acceptance. + unverifiedLinks.push({ + source: path.relative(root, sourceFile), target: rawLink, + status: 'unverified', reason: 'host_local_uri_not_checked', + }); + continue; + } if (!shouldCheckLink(rawLink)) continue; const { filePart, anchor } = splitLink(rawLink); @@ -159,7 +256,7 @@ function collectBrokenLinks(markdownFiles) { } } - return { brokenLinks, brokenAnchors }; + return { brokenLinks, brokenAnchors, unverifiedLinks }; } function collectMissingReadmes() { @@ -360,6 +457,17 @@ function collectArchitectureDocMismatches() { // 新增 experimental (POC/试运行但未 ship) 与 reference (文档化但未 ship, 仅做知识参考)。 // 注: experimental 与 reference 区别于 stable (已 ship + 主路径使用) 与 draft (草稿/编写中)。 const OKF_STATUS_VALUES = new Set(['stable', 'draft', 'deprecated', 'experimental', 'reference']); +// references/runtime-adapters/_schema.md defines deployment status, not OKF lifecycle. +const RUNTIME_ADAPTER_STATUS_VALUES = new Set(['shipped', 'reference', 'pending']); + +function statusSchemaFor(filepath, fm) { + const adapterDir = path.join(root, '.agent', 'references', 'runtime-adapters'); + const adapterDocument = path.dirname(filepath) === adapterDir && + typeof fm.agent === 'string' && fm.agent === path.basename(filepath, '.md'); + return adapterDocument + ? { values: RUNTIME_ADAPTER_STATUS_VALUES, label: 'runtime-adapter' } + : { values: OKF_STATUS_VALUES, label: 'OKF V0.2' }; +} // 简单 frontmatter 解析:仅识别 key-value / inline-list / block-list // 不依赖 yaml 包(零依赖) @@ -417,12 +525,13 @@ function collectFrontmatterViolations() { } // R-LINT-008: status 非法值 (warning) - if (fm.status !== undefined && !OKF_STATUS_VALUES.has(fm.status)) { + const statusSchema = statusSchemaFor(filepath, fm); + if (fm.status !== undefined && !statusSchema.values.has(fm.status)) { violations.push({ rule_id: 'R-LINT-008', severity: 'warning', file: rel, - message: `status "${fm.status}" 不是 OKF V0.2 枚举 (stable / draft / deprecated / experimental / reference)`, + message: `status "${fm.status}" 不是 ${statusSchema.label} 枚举 (${[...statusSchema.values].join(' / ')})`, }); } } @@ -433,6 +542,7 @@ function collectFrontmatterViolations() { function computeHealthScore(summary) { const penalty = summary.broken_links * 12 + + (summary.unverified_links || 0) * 12 + // Same penalty: unresolved evidence is not acceptance summary.broken_anchors * 6 + summary.missing_readmes * 8 + summary.plan_issues * 10 + @@ -486,7 +596,7 @@ function runFrontmatterValidator() { function run() { const markdownFiles = internalDocRoots.flatMap((dir) => listMarkdownFiles(dir)); - const { brokenLinks, brokenAnchors } = collectBrokenLinks(markdownFiles); + const { brokenLinks, brokenAnchors, unverifiedLinks } = collectBrokenLinks(markdownFiles); const missingReadmes = collectMissingReadmes(); const planIssues = collectPlanIssues(); const architectureDocMismatches = collectArchitectureDocMismatches(); @@ -497,6 +607,7 @@ function run() { const summary = { markdown_files_scanned: markdownFiles.length, broken_links: brokenLinks.length, + unverified_links: unverifiedLinks.length, broken_anchors: brokenAnchors.length, missing_readmes: missingReadmes.length, plan_issues: planIssues.length, @@ -513,6 +624,7 @@ function run() { summary, findings: { broken_links: brokenLinks, + unverified_links: unverifiedLinks, broken_anchors: brokenAnchors, missing_readmes: missingReadmes, plan_issues: planIssues, @@ -528,6 +640,7 @@ function run() { console.log('--- 📚 Knowledge Lint ---'); console.log(`Scanned markdown files: ${summary.markdown_files_scanned}`); console.log(`Broken links: ${summary.broken_links}`); + console.log(`Unverified local evidence: ${summary.unverified_links}`); console.log(`Broken anchors: ${summary.broken_anchors}`); console.log(`Missing READMEs: ${summary.missing_readmes}`); console.log(`Plan issues: ${summary.plan_issues}`); @@ -538,4 +651,5 @@ function run() { console.log(`Output: ${path.relative(root, outputPath)}`); } -run(); +if (require.main === module) run(); +module.exports = { extractMarkdownLinks, collectBrokenLinks, computeHealthScore, collectFrontmatterViolations }; diff --git a/templates/zh/.agent/skills/management-api/scripts/index.js b/templates/zh/.agent/skills/management-api/scripts/index.js index 1f3f01d0..919634dd 100755 --- a/templates/zh/.agent/skills/management-api/scripts/index.js +++ b/templates/zh/.agent/skills/management-api/scripts/index.js @@ -1074,19 +1074,15 @@ function resolveDecision() { resource_ref: next.gate.resource_ref, updated_at: timestamp, }, (entry, current) => entry.decision_id === current.decision_id); - // Release any matching waitpoints - const released = releaseMatchingWaitpoints(decisionId, resolvedBy, rationale, timestamp); + // Resolving a Decision records the user's choice only. The owning workflow + // must separately validate and release or cancel its Waitpoint. printJson({ ok: true, action: "decisions resolve", path: rel(file), decision: next, - changed_paths: [ - rel(file), - rel(path.join(agentRoot, "decisions", "index.json")), - ...released.paths, - ], - changed_resources: [`decision:${decisionId}`, ...released.resources], + changed_paths: [rel(file), rel(path.join(agentRoot, "decisions", "index.json"))], + changed_resources: [`decision:${decisionId}`], }); } @@ -1399,41 +1395,6 @@ function releaseWaitpoint() { }); } -function releaseMatchingWaitpoints(decisionId, resolvedBy, rationale, timestamp) { - const dir = path.join(agentRoot, "waitpoints"); - const changedPaths = []; - const changedResources = []; - for (const { file, data } of listJsonObjects(dir)) { - if (data.decision_id !== decisionId) continue; - if (!["pending", "blocked"].includes(data.status)) continue; - const next = { - ...data, - status: "released", - released_by: resolvedBy, - released_at: timestamp, - release_note: rationale || `Auto-released by decision ${decisionId}.`, - evidence_refs: Array.isArray(data.evidence_refs) && data.evidence_refs.length > 0 ? data.evidence_refs : [`decision:${decisionId}`], - updated_at: timestamp, - }; - writeJson(file, next); - changedPaths.push(rel(file)); - changedResources.push(`waitpoint:${data.waitpoint_id}`); - upsertIndexEntry( - "waitpoints", - "waitpoints", - waitpointIndexEntry(next, file), - (entry, current) => entry.waitpoint_id === current.waitpoint_id, - ); - } - if (changedPaths.length > 0) { - changedPaths.push(rel(path.join(agentRoot, "waitpoints", "index.json"))); - } - return { - paths: [...new Set(changedPaths)], - resources: [...new Set(changedResources)], - }; -} - function upsertIndexEntry(dir, key, entry, match) { const indexFile = path.join(agentRoot, dir, "index.json"); const data = readJson(indexFile) || {}; diff --git a/templates/zh/.agent/skills/runtime-continuity/scripts/index.js b/templates/zh/.agent/skills/runtime-continuity/scripts/index.js index 47e50fa9..27dc7d6d 100644 --- a/templates/zh/.agent/skills/runtime-continuity/scripts/index.js +++ b/templates/zh/.agent/skills/runtime-continuity/scripts/index.js @@ -495,6 +495,26 @@ function archiveProject(project, note, opts = {}) { catch (_) { fs.copyFileSync(file, latest); } const markdownArchive = { archivePath: file, latestPath: latest, stamp }; const structured = createStructuredArchive(project, note || {}, opts, markdownArchive); + if (opts.queueLearningProposal === true) { + // Explicit project-local proposal intake only. Never enable this for the + // automatic continuity guard, invoke a Host, or write memory/index here. + let learningProposal; + try { + const evolution = require("../../evolution-pipeline/scripts/evolve.js"); + const queued = evolution.enqueueFromArchive(structured.archiveJsonPath, { + proposalOnly: true, expectedProject: project, + }); + learningProposal = { + status: queued.deduplicated ? "already_queued" : "queued", + task_id: queued.task_id, source_sha256: queued.source_archive.sha256, + execution_authorized: false, + }; + } catch (error) { + // Preserve the successful archive even when proposal intake is unavailable. + learningProposal = { status: "unavailable", reason_code: error.code || "ERR_PROPOSAL_INTAKE", execution_authorized: false }; + } + return { ...markdownArchive, ...structured, learning_proposal: learningProposal }; + } return { ...markdownArchive, ...structured }; } @@ -1857,6 +1877,7 @@ function main() { target_host: flag("--to-host", argv) || null, reason: flag("--reason", argv) || null, full: argv.includes("--full"), + queueLearningProposal: argv.includes("--queue-learning-proposal"), }); } catch (err) { fail("archive_failed", err.message); diff --git a/tests/agent-skills/vcs-pr-review-surface.test.js b/tests/agent-skills/vcs-pr-review-surface.test.js new file mode 100644 index 00000000..798c8494 --- /dev/null +++ b/tests/agent-skills/vcs-pr-review-surface.test.js @@ -0,0 +1,123 @@ +"use strict"; + +// Regression coverage for the vcs-pr review surface at the CLI layer. +// +// The backend cases (github.test.js) pin the HTTP routing. These cases pin +// the contract the CLI adds on top of it, because each of those rules +// exists to stop a specific silent failure: +// +// * `comment` / `review` publish to a pull request other people read, so +// they require `--gate user`. A missing gate must fail *before* any +// network call, not after a comment is already posted. +// * A backend that does not implement an operation must report +// `capability_unavailable`. Returning an empty list instead would tell +// a caller "this PR has no comments and no reviews" — a claim the +// backend never verified. +// * An invalid review event and a missing body must fail on their own +// terms, so the caller learns which input was wrong. +// +// The fixture supplies a stub secrets skill: `vcs-pr` resolves its token +// through that skill, and the point of these cases is the CLI's own +// validation, not the secrets machinery. + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); +const { describe, test } = require("node:test"); + +const SCRIPT = path.resolve( + __dirname, + "../../templates/en/.agent/skills/vcs-pr/scripts/index.js" +); + +const STUB_SECRETS = `"use strict"; +const args = process.argv.slice(2); +process.stdout.write(JSON.stringify({ ok: true, value: "stub-token" }) + "\\n"); +`; + +function makeProject(backend) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-vcspr-review-")); + const configDir = path.join(root, ".agent", "config"); + const secretsDir = path.join(root, ".agent", "skills", "secrets", "scripts"); + fs.mkdirSync(configDir, { recursive: true }); + fs.mkdirSync(secretsDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, "vcs.yml"), + `backend: ${backend}\nhost: "http://127.0.0.1:9"\ntoken_ref: gh-test\ndefault:\n org: kucell\n repo: cortex-agent\n` + ); + fs.writeFileSync(path.join(secretsDir, "index.js"), STUB_SECRETS); + return root; +} + +function run(root, args) { + try { + const stdout = execFileSync(process.execPath, [SCRIPT, ...args], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { code: 0, payload: JSON.parse(stdout) }; + } catch (err) { + const stdout = err.stdout || ""; + let payload = null; + try { payload = JSON.parse(stdout); } catch (_) { /* keep null */ } + return { code: err.status, payload }; + } +} + +describe("vcs-pr review surface CLI", () => { + test("comment requires --gate user and fails before touching the network", () => { + const root = makeProject("github"); + const result = run(root, ["comment", "--pr-number", "42", "--body", "x"]); + assert.equal(result.payload.ok, false); + assert.equal(result.payload.error, "workflow_gate_required"); + assert.match(result.payload.message, /comment requires --gate user/); + }); + + test("review requires --gate user", () => { + const root = makeProject("github"); + const result = run(root, ["review", "--pr-number", "42", "--body", "x"]); + assert.equal(result.payload.ok, false); + assert.equal(result.payload.error, "workflow_gate_required"); + assert.match(result.payload.message, /review requires --gate user/); + }); + + test("an unknown review event is rejected with its own error", () => { + const root = makeProject("github"); + const result = run(root, [ + "review", "--pr-number", "42", "--body", "x", "--event", "BOGUS", "--gate", "user", + ]); + assert.equal(result.payload.ok, false); + assert.equal(result.payload.error, "invalid_review_event"); + }); + + test("a missing review body is rejected with its own error", () => { + const root = makeProject("github"); + const result = run(root, ["comment", "--pr-number", "42", "--gate", "user"]); + assert.equal(result.payload.ok, false); + assert.equal(result.payload.error, "missing_review_body"); + }); + + test("a backend without the operation reports capability_unavailable, not empty", () => { + const root = makeProject("gitea"); + for (const command of ["diff", "comments", "reviews", "comment", "review"]) { + const result = run(root, [command, "--pr-number", "42", "--body", "x", "--gate", "user"]); + assert.equal(result.payload.ok, false, `${command} should not succeed`); + assert.equal(result.payload.error, "capability_unavailable", `${command} should report the missing capability`); + assert.match(result.payload.message, /gitea backend does not support/); + } + }); + + test("read commands reach the network layer on any allowed gate", () => { + const root = makeProject("github"); + // The host is a closed port, so a read command that got past its own + // validation fails at connect time — which is exactly the proof we want: + // it was not rejected by the gate. + const result = run(root, ["diff", "--pr-number", "42", "--gate", "agent"]); + assert.equal(result.payload.ok, false); + assert.equal(result.payload.error, "orchestrator_error"); + assert.match(result.payload.message, /ECONNREFUSED/); + }); +}); diff --git a/tests/cli/issue-15-waitpoints-help-state-sync.test.js b/tests/cli/issue-15-waitpoints-help-state-sync.test.js index 4a41e042..561f1567 100644 --- a/tests/cli/issue-15-waitpoints-help-state-sync.test.js +++ b/tests/cli/issue-15-waitpoints-help-state-sync.test.js @@ -316,7 +316,13 @@ test("issue #15: successful waitpoints write DOES push state (positive control)" assert.ok(beforeDirty.length > 0, "fixture must start with unrelated dirty state"); }); -test("decision resolve reports every auto-released waitpoint path", (t) => { +test("decision resolve records only the decision, never releasing its waitpoint", (t) => { + // Decision resolution used to auto-release every Waitpoint bound to the + // decision. That coupled two independent lifecycles: resolving a record + // of a choice silently advanced the gate that was waiting on it. The + // owning workflow must now perform a separate, validated release or + // cancel — so this test pins the *absence* of the old side effect, not + // merely the presence of the decision path. const { project } = mkInnerRepoWithOrigin(); t.after(() => fs.rmSync(project, { recursive: true, force: true })); seedManagementScripts(project); @@ -347,6 +353,11 @@ test("decision resolve reports every auto-released waitpoint path", (t) => { ], env); assert.equal(result.status, 0, result.stderr + result.stdout); + const waitpointBefore = fs.readFileSync( + path.join(project, ".agent", "waitpoints", "WP-MULTI.json"), + "utf8", + ); + result = runCli(project, [ "decisions", "resolve", "--decision-id", "D-MULTI", @@ -360,18 +371,29 @@ test("decision resolve reports every auto-released waitpoint path", (t) => { const body = JSON.parse(result.stdout); const paths = new Set(body.changed_paths || []); - for (const expected of [ - ".agent/decisions/D-MULTI.json", - ".agent/decisions/index.json", + + // The decision and its index are the only paths a resolution may touch. + assert.ok(paths.has(".agent/decisions/D-MULTI.json"), "missing changed path .agent/decisions/D-MULTI.json"); + assert.ok(paths.has(".agent/decisions/index.json"), "missing changed path .agent/decisions/index.json"); + for (const forbidden of [ ".agent/waitpoints/WP-MULTI.json", ".agent/waitpoints/index.json", ]) { - assert.ok(paths.has(expected), "missing changed path " + expected); + assert.equal(paths.has(forbidden), false, "resolve must not touch " + forbidden); } + assert.deepEqual( new Set(body.changed_resources || []), - new Set(["decision:D-MULTI", "waitpoint:WP-MULTI"]), + new Set(["decision:D-MULTI"]), + ); + + // The waitpoint itself must be byte-identical: no status, no release + // timestamp, no release note smuggled in by the resolution. + const waitpointAfter = fs.readFileSync( + path.join(project, ".agent", "waitpoints", "WP-MULTI.json"), + "utf8", ); + assert.equal(waitpointAfter, waitpointBefore, "resolve must not modify the waitpoint"); }); // helpers ───────────────────────────────────────────────────────────────────── diff --git a/tests/guided-review/pr-review-loop.test.js b/tests/guided-review/pr-review-loop.test.js new file mode 100644 index 00000000..64899b2c --- /dev/null +++ b/tests/guided-review/pr-review-loop.test.js @@ -0,0 +1,215 @@ +"use strict"; + +// End-to-end coverage for the guided-review → pull request comment loop. +// +// This is the seam the comparison against PR-Agent / Qodo identified as the +// single blocking gap: cortex could *produce* a validated review artifact +// but had no way to carry it to the pull request, where a human actually +// reads it. The backend cases pin each end; this case pins the seam. +// +// The chain exercised here is the real one: +// +// raw findings +// → reviewer-backend (schema / changeset / file-existence gates) +// → guided-review.json +// → render-pr-comment (artifact → markdown) +// → the body vcs-pr would POST +// +// Two properties are asserted beyond "it produced output": +// +// * A finding about a file outside the changeset never reaches the +// comment. The gate that stops it is mechanical, and this case proves +// the gate is upstream of the renderer rather than beside it. +// * The rendered body carries no content the artifact did not contain. +// A renderer that padded its output would make a review look fuller +// than the evidence behind it. +// +// No network is involved: the comment body is what vcs-pr would POST, so +// the transport stays out of scope and the test stays hermetic. + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); +const { describe, test } = require("node:test"); + +const ROOT = path.resolve(__dirname, "..", ".."); + +// The loop is exercised against the governance copy (L3, the source of truth) +// and against the L1 distribution layer that ships it. A downstream project +// installs the L1 copy, so the L1 path is the one that actually runs in the +// field — a passing L3-only suite would prove nothing about it. +const SKILL_COPIES = [ + { label: "L3 governance", dir: path.join(ROOT, ".agent", "skills", "agent-review-benchmark", "scripts") }, + { label: "L1 _shared", dir: path.join(ROOT, "templates", "_shared", ".agent", "skills", "agent-review-benchmark", "scripts") }, +].filter((copy) => fs.existsSync(path.join(copy.dir, "reviewer-backend.js"))); + +function run(script, args) { + return execFileSync(process.execPath, [script, ...args], { encoding: "utf8" }); +} + +function makeWorkspace() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-review-loop-")); + fs.mkdirSync(path.join(root, "lib"), { recursive: true }); + fs.writeFileSync(path.join(root, "lib", "app.js"), "module.exports = {};\n"); + return root; +} + +function rawFindings(root) { + return { + task_id: "T-H29", + run_id: "R-1", + session_id: "S-1", + workspace_ids: ["WS-1"], + base_revision: "aaaaaaa", + head_revision: "bbbbbbb", + changed_files: ["lib/app.js"], + generated_at: "2026-10-23T00:00:00Z", + findings: [ + { + group_id: "G-1", + title: "Null guard missing", + intent: "Guard the lookup result before use", + files: ["lib/app.js"], + summary: "The lookup result is dereferenced without a null check.", + impact: "A missing key crashes the request handler.", + risk: "high", + category: "quality", + validation_refs: ["tests/app.test.js::handles missing key"], + follow_ups: ["Add the null guard"], + }, + { + group_id: "G-2", + title: "Finding about a file outside the changeset", + intent: "Probe the changeset gate", + files: ["lib/not-in-changeset.js"], + summary: "This must never reach the pull request.", + impact: "This must never reach the pull request.", + risk: "low", + category: "docs", + validation_refs: [], + follow_ups: [], + }, + ], + }; +} + +for (const copy of SKILL_COPIES) { +const BACKEND = path.join(copy.dir, "reviewer-backend.js"); +const RENDER = path.join(copy.dir, "render-pr-comment.js"); + +describe(`guided review to pull request comment (${copy.label})`, () => { + test(`[${copy.label}] a validated artifact renders into a comment body that excludes rejected findings`, () => { + const root = makeWorkspace(); + const rawPath = path.join(root, "raw.json"); + const artifactPath = path.join(root, "guided-review.json"); + fs.writeFileSync(rawPath, JSON.stringify(rawFindings(root), null, 2)); + + // 1. The backend accepts one finding and rejects the one outside the + // changeset. + const backendOut = JSON.parse( + run(BACKEND, [ + "--backend", "builtin", + "--input", rawPath, + "--output", artifactPath, + "--workspace", root, + ]) + ); + assert.equal(backendOut.ok, true); + assert.equal(backendOut.findings, 1); + assert.equal(backendOut.rejected, 1); + + // 2. The rejection is recorded with its reason, not silently dropped. + const diagnostics = JSON.parse(fs.readFileSync(`${artifactPath}.diagnostics.json`, "utf8")); + assert.equal(diagnostics.rejected.length, 1); + assert.equal(diagnostics.rejected[0].group_id, "G-2"); + assert.equal(diagnostics.rejected[0].reason, "file_not_in_changeset"); + + // 3. The artifact carries only the accepted finding. + const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8")); + assert.equal(artifact.groups.length, 1); + assert.equal(artifact.groups[0].group_id, "G-1"); + + // 4. The renderer produces the body vcs-pr would POST. + const body = run(RENDER, ["--input", artifactPath]); + assert.match(body, /## Guided review/); + assert.match(body, /#### 1\. Null guard missing/); + assert.match(body, /\*\*Risk\*\*: High/); + assert.match(body, /Add the null guard/); + + // 5. The rejected finding is nowhere in the body. + assert.doesNotMatch(body, /G-2/); + assert.doesNotMatch(body, /not-in-changeset/); + assert.doesNotMatch(body, /must never reach/); + }); + + test(`[${copy.label}] the rendered body carries no content the artifact did not contain`, () => { + const root = makeWorkspace(); + const rawPath = path.join(root, "raw.json"); + const artifactPath = path.join(root, "guided-review.json"); + fs.writeFileSync(rawPath, JSON.stringify(rawFindings(root), null, 2)); + run(BACKEND, ["--backend", "builtin", "--input", rawPath, "--output", artifactPath, "--workspace", root]); + + const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8")); + const body = run(RENDER, ["--input", artifactPath]); + + // Every artifact field the body quotes must appear verbatim. + for (const group of artifact.groups) { + assert.ok(body.includes(group.title), `title must appear: ${group.title}`); + assert.ok(body.includes(group.summary), `summary must appear: ${group.summary}`); + for (const ref of group.validation_refs) assert.ok(body.includes(ref)); + for (const followUp of group.follow_ups) assert.ok(body.includes(followUp)); + } + + // And the body must not invent a validation or follow-up section for a + // finding that has none. An empty section would read as "there were + // validations, listed below" with nothing listed. + // + // The check is structural: a Validation / Follow-ups heading is present + // only when it is immediately followed by a list item. + for (const heading of ["Validation", "Follow-ups"]) { + const pattern = new RegExp(`\\*\\*${heading}\\*\\*:\\n(\\n(?:- .*\\n)+)?`); + const match = body.match(pattern); + assert.ok(match, `${heading} heading, if present, must be followed by its list`); + if (match) assert.ok(match[1], `${heading} heading must not be empty`); + } + }); + + test(`[${copy.label}] the same artifact renders to the same bytes every time`, () => { + const root = makeWorkspace(); + const rawPath = path.join(root, "raw.json"); + const artifactPath = path.join(root, "guided-review.json"); + fs.writeFileSync(rawPath, JSON.stringify(rawFindings(root), null, 2)); + run(BACKEND, ["--backend", "builtin", "--input", rawPath, "--output", artifactPath, "--workspace", root]); + + assert.equal(run(RENDER, ["--input", artifactPath]), run(RENDER, ["--input", artifactPath])); + }); + + test(`[${copy.label}] a finding about a file that does not exist is rejected before rendering`, () => { + const root = makeWorkspace(); + const rawPath = path.join(root, "raw.json"); + const artifactPath = path.join(root, "guided-review.json"); + const raw = rawFindings(root); + // Point the accepted finding at a path that is not on disk: the + // hallucinated-path guard must catch it. + raw.findings[0].files = ["lib/does-not-exist.js"]; + raw.changed_files = ["lib/does-not-exist.js"]; + fs.writeFileSync(rawPath, JSON.stringify(raw, null, 2)); + + let failed = false; + let stdout = ""; + try { + stdout = run(BACKEND, ["--backend", "builtin", "--input", rawPath, "--output", artifactPath, "--workspace", root]); + } catch (err) { + failed = true; + stdout = err.stdout || ""; + } + assert.equal(failed, true, "the backend must fail when no finding survives"); + const parsed = JSON.parse(stdout); + assert.equal(parsed.ok, false); + assert.equal(parsed.error, "no_acceptable_findings"); + assert.equal(parsed.details.rejected[0].reason, "file_not_found"); + }); +}); +} diff --git a/tests/review-surface/github-backend.test.js b/tests/review-surface/github-backend.test.js new file mode 100644 index 00000000..b7ff7426 --- /dev/null +++ b/tests/review-surface/github-backend.test.js @@ -0,0 +1,296 @@ +"use strict"; + +// Regression coverage for the GitHub review surface. +// +// The review loop was half-missing: `vcs-pr` could open and merge a pull +// request but could not read what the pull request changed, could not read +// what had already been said about it, and could not write a finding back. +// These cases pin the routing and payload shape of the four operations that +// close that loop. +// +// Two routing facts are the reason this file exists: +// +// * PR discussion comments live under `issues/{n}/comments`. The +// `pulls/{n}/comments` path 404s — a pull request is an issue +// underneath, and GitHub exposes its timeline through the issue API. +// Getting this wrong is silent until a 404 turns into a failed review. +// * Reviews live under `pulls/{n}/reviews` and carry an `event` +// (APPROVE / REQUEST_CHANGES / COMMENT). +// +// The diff request must ask for the diff media type: the default JSON form +// of `pulls/{n}` returns file metadata but not the hunks, so a reviewer +// built on it would see *which* files changed but never *what* changed. + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const http = require("node:http"); +const path = require("node:path"); +const test = require("node:test"); +const ROOT = path.resolve(__dirname, "..", ".."); + +// The backend ships in three places: the governance repo (L3, the source of +// truth) and the two L1 distribution layers that carry it. Running the same +// suite against all three proves the shipped copies behave the same as the +// source — the content-parity gate proves the bytes match, this proves the +// behaviour matches. +const BACKEND_COPIES = [ + { label: "L3 governance", dir: path.join(ROOT, ".agent", "skills", "vcs-pr", "scripts", "backends") }, + { label: "L1 _shared", dir: path.join(ROOT, "templates", "_shared", ".agent", "skills", "vcs-pr", "scripts", "backends") }, + { label: "L1 en", dir: path.join(ROOT, "templates", "en", ".agent", "skills", "vcs-pr", "scripts", "backends") }, +].filter((copy) => fs.existsSync(path.join(copy.dir, "github.js"))); + +function startServer(t, handler) { + const requests = []; + const server = http.createServer((request, response) => { + requests.push({ url: request.url, method: request.method, headers: request.headers }); + handler(request, response); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + t.after(() => server.close()); + resolve({ requests, host: `http://127.0.0.1:${address.port}` }); + }); + }); +} + +for (const copy of BACKEND_COPIES) { +const github = require(path.join(copy.dir, "github.js")); + +test(`[${copy.label}] getDiff asks for the diff media type and returns the raw patch`, async (t) => { + const patch = [ + "diff --git a/lib/app.js b/lib/app.js", + "index 111..222 100644", + "--- a/lib/app.js", + "+++ b/lib/app.js", + "@@ -1,3 +1,4 @@", + " const a = 1;", + "+const b = 2;", + "", + ].join("\n"); + + const { requests, host } = await startServer(t, (request, response) => { + response.setHeader("content-type", "application/vnd.github.v3.diff"); + response.end(patch); + }); + + const result = await github.getDiff({ + config: { host, default: { org: "kucell", repo: "cortex-agent" } }, + token: "test-token", + pr_number: 42, + }); + + assert.equal(result.number, 42); + assert.equal(result.patch, patch); + assert.match(result.patch, /\+const b = 2;/); + + assert.equal(requests.length, 1); + assert.equal(requests[0].url, "/repos/kucell/cortex-agent/pulls/42"); + assert.equal(requests[0].headers.accept, "application/vnd.github.v3.diff"); +}); + +test(`[${copy.label}] listComments reads the issue timeline, not the pull request path`, async (t) => { + const { requests, host } = await startServer(t, (request, response) => { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify([ + { + id: 501, + body: "finding: null guard missing on line 12", + user: { login: "reviewer" }, + created_at: "2026-10-23T00:00:00Z", + html_url: "https://github.invalid/kucell/cortex-agent/pull/42#issuecomment-501", + }, + ])); + }); + + const result = await github.listComments({ + config: { host, default: { org: "kucell", repo: "cortex-agent" } }, + token: "test-token", + pr_number: 42, + }); + + assert.equal(result.length, 1); + assert.equal(result[0].id, 501); + assert.equal(result[0].author, "reviewer"); + assert.match(result[0].body, /null guard/); + + // The path is the assertion that matters: `pulls/42/comments` 404s. + assert.match(requests[0].url, /^\/repos\/kucell\/cortex-agent\/issues\/42\/comments/); + assert.doesNotMatch(requests[0].url, /pulls\/42\/comments/); +}); + +test(`[${copy.label}] listReviews reads reviews and normalizes the review state`, async (t) => { + const { requests, host } = await startServer(t, (request, response) => { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify([ + { + id: 77, + state: "CHANGES_REQUESTED", + body: "two findings", + user: { login: "reviewer" }, + submitted_at: "2026-10-23T01:00:00Z", + html_url: "https://github.invalid/kucell/cortex-agent/pull/42#pullrequestreview-77", + }, + ])); + }); + + const result = await github.listReviews({ + config: { host, default: { org: "kucell", repo: "cortex-agent" } }, + token: "test-token", + pr_number: 42, + }); + + assert.equal(result.length, 1); + assert.equal(result[0].id, 77); + assert.equal(result[0].state, "CHANGES_REQUESTED"); + assert.equal(result[0].author, "reviewer"); + + assert.match(requests[0].url, /^\/repos\/kucell\/cortex-agent\/pulls\/42\/reviews/); +}); + +test(`[${copy.label}] createComment posts to the issue timeline and returns the created id`, async (t) => { + let received = null; + const { requests, host } = await startServer(t, (request, response) => { + const chunks = []; + request.on("data", (chunk) => chunks.push(chunk)); + request.on("end", () => { + received = Buffer.concat(chunks).toString("utf8"); + response.statusCode = 201; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ + id: 902, + html_url: "https://github.invalid/kucell/cortex-agent/pull/42#issuecomment-902", + user: { login: "cortex-agent" }, + created_at: "2026-10-23T02:00:00Z", + })); + }); + }); + + const result = await github.createComment({ + config: { host, default: { org: "kucell", repo: "cortex-agent" } }, + token: "test-token", + pr_number: 42, + body: "## Guided review\n\n- finding: null guard missing", + }); + + assert.equal(result.id, 902); + assert.equal(result.author, "cortex-agent"); + assert.match(result.url, /#issuecomment-902/); + + assert.equal(requests[0].method, "POST"); + assert.match(requests[0].url, /^\/repos\/kucell\/cortex-agent\/issues\/42\/comments/); + assert.deepEqual(JSON.parse(received), { + body: "## Guided review\n\n- finding: null guard missing", + }); +}); + +test(`[${copy.label}] createComment refuses an empty body before touching the network`, async (t) => { + const { requests, host } = await startServer(t, (request, response) => { + response.end("[]"); + }); + + await assert.rejects( + () => github.createComment({ + config: { host, default: { org: "kucell", repo: "cortex-agent" } }, + token: "test-token", + pr_number: 42, + body: " ", + }), + /missing_comment_body/ + ); + + assert.equal(requests.length, 0); +}); + +test(`[${copy.label}] submitReview sends the event and the inline comments`, async (t) => { + let received = null; + const { requests, host } = await startServer(t, (request, response) => { + const chunks = []; + request.on("data", (chunk) => chunks.push(chunk)); + request.on("end", () => { + received = Buffer.concat(chunks).toString("utf8"); + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ + id: 333, + state: "changes_requested", + submitted_at: "2026-10-23T03:00:00Z", + html_url: "https://github.invalid/kucell/cortex-agent/pull/42#pullrequestreview-333", + })); + }); + }); + + const result = await github.submitReview({ + config: { host, default: { org: "kucell", repo: "cortex-agent" } }, + token: "test-token", + pr_number: 42, + body: "Two findings, both mechanical.", + event: "REQUEST_CHANGES", + comments: [ + { path: "lib/app.js", line: 12, body: "null guard missing" }, + ], + }); + + assert.equal(result.id, 333); + assert.equal(result.state, "changes_requested"); + + assert.equal(requests[0].method, "POST"); + assert.match(requests[0].url, /^\/repos\/kucell\/cortex-agent\/pulls\/42\/reviews/); + + const payload = JSON.parse(received); + assert.equal(payload.event, "REQUEST_CHANGES"); + assert.equal(payload.body, "Two findings, both mechanical."); + assert.equal(payload.comments.length, 1); + assert.equal(payload.comments[0].path, "lib/app.js"); + assert.equal(payload.comments[0].line, 12); +}); + +test(`[${copy.label}] submitReview omits the comments array when there are no inline comments`, async (t) => { + let received = null; + const { requests, host } = await startServer(t, (request, response) => { + const chunks = []; + request.on("data", (chunk) => chunks.push(chunk)); + request.on("end", () => { + received = Buffer.concat(chunks).toString("utf8"); + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ id: 334, state: "commented" })); + }); + }); + + await github.submitReview({ + config: { host, default: { org: "kucell", repo: "cortex-agent" } }, + token: "test-token", + pr_number: 42, + body: "Top-level note only.", + event: "COMMENT", + }); + + const payload = JSON.parse(received); + assert.equal(payload.event, "COMMENT"); + assert.equal("comments" in payload, false); +}); + +test(`[${copy.label}] review surface operations surface upstream failures instead of returning empty`, async (t) => { + const { host } = await startServer(t, (request, response) => { + response.statusCode = 404; + response.end(JSON.stringify({ message: "Not Found" })); + }); + + const opts = { + config: { host, default: { org: "kucell", repo: "cortex-agent" } }, + token: "test-token", + pr_number: 42, + }; + + await assert.rejects(() => github.getDiff(opts), /github_diff_failed: HTTP 404/); + await assert.rejects(() => github.listComments(opts), /github_comments_failed: HTTP 404/); + await assert.rejects(() => github.listReviews(opts), /github_reviews_failed: HTTP 404/); + await assert.rejects( + () => github.createComment({ ...opts, body: "x" }), + /github_comment_failed: HTTP 404/ + ); + await assert.rejects( + () => github.submitReview({ ...opts, body: "x", event: "COMMENT" }), + /github_review_failed: HTTP 404/ + ); +}); +} diff --git a/tests/review-surface/render-pr-comment.test.js b/tests/review-surface/render-pr-comment.test.js new file mode 100644 index 00000000..3e861237 --- /dev/null +++ b/tests/review-surface/render-pr-comment.test.js @@ -0,0 +1,180 @@ +"use strict"; + +// Regression coverage for the guided-review → pull request comment renderer. +// +// The renderer is the piece that carries a validated review artifact to the +// place a human reads. Its contract is narrow on purpose: +// +// * It never invents content. Every emitted line comes from an artifact +// field. A finding without follow-ups contributes no follow-up section +// — a placeholder there would read like content that was never found. +// * It never drops a finding. An unrecognized risk label still renders; +// hiding it would hide a real finding behind a label typo. +// * It is deterministic. The same artifact renders to the same bytes, so +// a re-render can be diffed against the previous one. +// +// These cases exercise the pure function directly — no filesystem, no +// network, no workspace — which is what makes the "invents nothing" claim +// checkable in the first place. + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const ROOT = path.resolve(__dirname, "..", ".."); + +// The renderer ships in the governance repo (L3, the source of truth) and in +// the L1 distribution layer that carries it. Running the suite against both +// proves the shipped copy behaves like the source: the content-parity gate +// proves the bytes match, this proves the behaviour matches. +const RENDERER_COPIES = [ + { label: "L3 governance", file: path.join(ROOT, ".agent", "skills", "agent-review-benchmark", "scripts", "render-pr-comment.js") }, + { label: "L1 _shared", file: path.join(ROOT, "templates", "_shared", ".agent", "skills", "agent-review-benchmark", "scripts", "render-pr-comment.js") }, +].filter((copy) => fs.existsSync(copy.file)); + +function finding(overrides) { + return { + group_id: "G-1", + title: "Null guard missing", + intent: "Guard the lookup result before use", + files: ["lib/app.js"], + summary: "The lookup result is dereferenced without a null check.", + impact: "A missing key crashes the request handler.", + risk: "high", + category: "quality", + validation_refs: ["tests/app.test.js::handles missing key"], + follow_ups: ["Add the null guard"], + ...overrides, + }; +} + +function artifact(overrides) { + return { + type: "guided_review", + artifact_id: "GR-abcdef123456", + task_id: "T-1", + run_id: "R-1", + session_id: "S-1", + workspace_ids: ["WS-1"], + base_revision: "aaaaaaa", + head_revision: "bbbbbbb", + groups: [finding()], + summary: { + group_count: 1, + file_count: 1, + risk_counts: { low: 0, medium: 0, high: 1, critical: 0 }, + validation_ref_count: 1, + follow_up_count: 1, + }, + generated_at: "2026-10-23T00:00:00Z", + ...overrides, + }; +} + +for (const copy of RENDERER_COPIES) { +const { groupByRisk, oneLine, renderComment } = require(copy.file); + +test(`[${copy.label}] renders every artifact field into the comment body`, () => { + const body = renderComment(artifact()); + assert.match(body, /## Guided review/); + assert.match(body, /#### 1\. Null guard missing/); + assert.match(body, /\*\*Risk\*\*: High/); + assert.match(body, /\*\*Category\*\*: quality/); + assert.match(body, /`lib\/app\.js`/); + assert.match(body, /The lookup result is dereferenced without a null check\./); + assert.match(body, /\*\*Intent\*\*: Guard the lookup result before use/); + assert.match(body, /\*\*Impact\*\*: A missing key crashes the request handler\./); + assert.match(body, /tests\/app\.test\.js::handles missing key/); + assert.match(body, /Add the null guard/); + assert.match(body, /GR-abcdef123456/); +}); + +test(`[${copy.label}] omits sections the artifact does not carry instead of emitting placeholders`, () => { + const body = renderComment( + artifact({ + groups: [ + finding({ validation_refs: [], follow_ups: [], impact: "", intent: "" }), + ], + }) + ); + assert.doesNotMatch(body, /\*\*Validation\*\*/); + assert.doesNotMatch(body, /\*\*Follow-ups\*\*/); + assert.doesNotMatch(body, /\*\*Impact\*\*/); + assert.doesNotMatch(body, /\*\*Intent\*\*/); + // The finding itself is still present. + assert.match(body, /Null guard missing/); +}); + +test(`[${copy.label}] groups findings by risk, severe first, and numbers them across groups`, () => { + const body = renderComment( + artifact({ + groups: [ + finding({ group_id: "G-1", title: "Low risk item", risk: "low" }), + finding({ group_id: "G-2", title: "Critical risk item", risk: "critical" }), + finding({ group_id: "G-3", title: "Medium risk item", risk: "medium" }), + ], + }) + ); + + const criticalAt = body.indexOf("Critical risk item"); + const mediumAt = body.indexOf("Medium risk item"); + const lowAt = body.indexOf("Low risk item"); + assert.ok(criticalAt < mediumAt, "critical must precede medium"); + assert.ok(mediumAt < lowAt, "medium must precede low"); + + assert.match(body, /### Critical risk/); + assert.match(body, /### Medium risk/); + assert.match(body, /### Low risk/); + + // Numbering runs across the whole comment, not per risk bucket. + assert.match(body, /#### 1\. Critical risk item/); + assert.match(body, /#### 2\. Medium risk item/); + assert.match(body, /#### 3\. Low risk item/); +}); + +test(`[${copy.label}] an unrecognized risk label still renders the finding`, () => { + const body = renderComment( + artifact({ groups: [finding({ risk: "catastrophic", title: "Odd risk item" })] }) + ); + assert.match(body, /Odd risk item/); + assert.match(body, /\*\*Risk\*\*: Unknown/); +}); + +test(`[${copy.label}] a newline inside a title cannot break the markdown structure`, () => { + const body = renderComment( + artifact({ groups: [finding({ title: "First line\n### Injected heading" })] }) + ); + assert.match(body, /#### 1\. First line ### Injected heading/); + // The injected text must not become its own heading. + assert.doesNotMatch(body, /\n### Injected heading/); +}); + +test(`[${copy.label}] an empty artifact is rejected rather than rendered as an empty comment`, () => { + assert.throws( + () => renderComment(artifact({ groups: [] })), + (error) => error.error === "no_findings_to_render" + ); +}); + +test(`[${copy.label}] rendering is deterministic`, () => { + const input = artifact(); + assert.equal(renderComment(input), renderComment(input)); +}); + +test(`[${copy.label}] groupByRisk keeps every finding exactly once`, () => { + const groups = [ + finding({ group_id: "G-1", risk: "low" }), + finding({ group_id: "G-2", risk: "low" }), + finding({ group_id: "G-3", risk: "high" }), + ]; + const buckets = groupByRisk(groups); + const total = buckets.reduce((sum, bucket) => sum + bucket.groups.length, 0); + assert.equal(total, groups.length); + assert.equal(buckets.length, 2); +}); + +test(`[${copy.label}] oneLine collapses newlines and trims`, () => { + assert.equal(oneLine(" a\n\nb "), "a b"); + assert.equal(oneLine(null), ""); +}); +}