trellis2-official-task-contract 采用官方 Trellis 执行合同
这个提交包含在:
@@ -25,7 +25,17 @@ const requiredDocMentions = [
|
||||
".agents/skills/hyb-oa-delivery/SKILL.md",
|
||||
];
|
||||
|
||||
const issueIdPattern = /^TRELLIS\d+-\d+$/;
|
||||
const validTaskStatuses = new Set(["planning", "in_progress", "review", "completed"]);
|
||||
const requiredPrdSections = [
|
||||
{ code: "goal", pattern: /(^|\n)#{1,4}\s*(Goal|目标)\b/i },
|
||||
{ code: "scope", pattern: /(^|\n)#{1,4}\s*(Scope|范围)\b/i },
|
||||
{ code: "out_of_scope", pattern: /(^|\n)#{1,4}\s*(Out Of Scope|Out of Scope|不做|范围外|边界)\b/i },
|
||||
{ code: "acceptance", pattern: /(^|\n)#{1,4}\s*(Acceptance|验收|完成口径)\b/i },
|
||||
{ code: "test_plan", pattern: /(^|\n)#{1,4}\s*(Test Plan|测试计划|验证|Test Method)\b/i },
|
||||
];
|
||||
|
||||
const complexTaskPattern =
|
||||
/\b(api|schema|migration|prisma|permission|role|deployment|production|database|remote|wecom|payment)\b|权限|状态机|审批|部署|公网|数据库|数据迁移|企业微信|付款|流程治理|workflow-governance/i;
|
||||
|
||||
function readText(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
|
||||
@@ -60,46 +70,54 @@ function listTaskDirs() {
|
||||
.map((entry) => `.trellis/tasks/${entry.name}`);
|
||||
}
|
||||
|
||||
function parseCsvLine(line) {
|
||||
const cells = [];
|
||||
let current = "";
|
||||
let quoted = false;
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
const next = line[index + 1];
|
||||
if (quoted && char === "\"" && next === "\"") {
|
||||
current += "\"";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "\"") {
|
||||
quoted = !quoted;
|
||||
continue;
|
||||
}
|
||||
if (!quoted && char === ",") {
|
||||
cells.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
cells.push(current);
|
||||
return cells;
|
||||
}
|
||||
|
||||
function issueIdsFromCsv(relativePath) {
|
||||
function parseJsonl(relativePath) {
|
||||
const text = readText(relativePath);
|
||||
const lines = text.split(/\r?\n/).filter((line) => line.trim());
|
||||
if (lines.length < 2) return new Set();
|
||||
const header = parseCsvLine(lines[0]);
|
||||
const idIndex = header.indexOf("ID");
|
||||
if (idIndex < 0) return new Set();
|
||||
return new Set(lines.slice(1).map((line) => parseCsvLine(line)[idIndex]).filter(Boolean));
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line, index) => {
|
||||
try {
|
||||
return { value: JSON.parse(line), line: index + 1 };
|
||||
} catch (error) {
|
||||
throw new Error(`${relativePath}:${index + 1} invalid JSONL: ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeIssueCsv(value) {
|
||||
if (typeof value !== "string") return "";
|
||||
return value.trim().replace(/^\.?\//, "");
|
||||
function checkContextManifest(result, taskDir, action) {
|
||||
const relativePath = `${taskDir}/${action}.jsonl`;
|
||||
if (!exists(relativePath)) {
|
||||
fail(result, `task.${action}_missing`, `${taskDir} missing ${action}.jsonl`);
|
||||
return;
|
||||
}
|
||||
const entries = parseJsonl(relativePath);
|
||||
let realEntryCount = 0;
|
||||
for (const { value, line } of entries) {
|
||||
if (value._example) {
|
||||
fail(result, `task.${action}_seed`, `${relativePath}:${line} still contains seed _example row`);
|
||||
continue;
|
||||
}
|
||||
if (typeof value.file !== "string" || !value.file.trim()) {
|
||||
fail(result, `task.${action}_file`, `${relativePath}:${line} missing file`);
|
||||
continue;
|
||||
}
|
||||
if (typeof value.reason !== "string" || !value.reason.trim()) {
|
||||
fail(result, `task.${action}_reason`, `${relativePath}:${line} missing reason`);
|
||||
continue;
|
||||
}
|
||||
if (!exists(value.file)) {
|
||||
fail(result, `task.${action}_file_missing`, `${relativePath}:${line} references missing ${value.file}`);
|
||||
continue;
|
||||
}
|
||||
if (!value.file.startsWith(".trellis/spec/") && !value.file.includes("/research/")) {
|
||||
warn(result, `task.${action}_non_spec_context`, `${relativePath}:${line} should usually reference .trellis/spec/ or task research`);
|
||||
}
|
||||
realEntryCount += 1;
|
||||
}
|
||||
if (realEntryCount === 0) {
|
||||
fail(result, `task.${action}_empty`, `${relativePath} must contain at least one real context entry`);
|
||||
}
|
||||
}
|
||||
|
||||
function createResult() {
|
||||
@@ -143,6 +161,9 @@ function checkCoreFiles(result) {
|
||||
} else {
|
||||
pass(result, "agents.workflow_pointer", "AGENTS.md points to Trellis workflow");
|
||||
}
|
||||
if (/每个 task 必须绑定|先有 Issue CSV 再开发/.test(agents)) {
|
||||
fail(result, "agents.legacy_issue_required", "AGENTS.md still requires Issue CSV before development");
|
||||
}
|
||||
}
|
||||
|
||||
if (exists(".trellis/config.yaml")) {
|
||||
@@ -158,7 +179,7 @@ function checkCoreFiles(result) {
|
||||
}
|
||||
}
|
||||
|
||||
function checkTaskBindings(result) {
|
||||
function checkTaskContracts(result) {
|
||||
const taskDirs = listTaskDirs();
|
||||
if (taskDirs.length === 0) {
|
||||
fail(result, "tasks.none", "no .trellis/tasks/* directories found");
|
||||
@@ -173,36 +194,37 @@ function checkTaskBindings(result) {
|
||||
continue;
|
||||
}
|
||||
const task = JSON.parse(readText(taskJsonPath));
|
||||
const issueCsv = normalizeIssueCsv(task.meta?.issueCsv);
|
||||
const issueIds = Array.isArray(task.meta?.issueIds) ? task.meta.issueIds : [];
|
||||
const prdText = exists(prdPath) ? readText(prdPath) : "";
|
||||
const csvFromPrd = prdText.match(/issues\/[A-Za-z0-9_.-]+\.csv/)?.[0] ?? "";
|
||||
const boundCsv = issueCsv || csvFromPrd;
|
||||
|
||||
if (!boundCsv) {
|
||||
fail(result, "task.issue_csv", `${dir} must bind an Issue CSV in task.json meta or prd.md`);
|
||||
if (!task.id || !task.name || !task.title) {
|
||||
fail(result, "task.identity", `${dir} task.json must include id, name and title`);
|
||||
}
|
||||
if (!validTaskStatuses.has(task.status)) {
|
||||
fail(result, "task.status", `${dir} has invalid status ${task.status}`);
|
||||
}
|
||||
if (typeof task.description !== "string" || !task.description.trim()) {
|
||||
fail(result, "task.description", `${dir} task.json must include description`);
|
||||
}
|
||||
if (!exists(prdPath)) {
|
||||
fail(result, "task.prd_missing", `${dir} missing prd.md`);
|
||||
continue;
|
||||
}
|
||||
if (!exists(boundCsv)) {
|
||||
fail(result, "task.issue_csv_missing", `${dir} references missing ${boundCsv}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ids = issueIds.length > 0 ? issueIds : [...prdText.matchAll(/TRELLIS\d+-\d+/g)].map((match) => match[0]);
|
||||
if (ids.length === 0) {
|
||||
fail(result, "task.issue_ids", `${dir} must bind at least one TRELLIS Issue ID`);
|
||||
continue;
|
||||
}
|
||||
const csvIds = issueIdsFromCsv(boundCsv);
|
||||
for (const id of ids) {
|
||||
if (!issueIdPattern.test(id)) {
|
||||
fail(result, "task.issue_id_format", `${dir} has invalid Issue ID ${id}`);
|
||||
} else if (!csvIds.has(id)) {
|
||||
fail(result, "task.issue_id_missing", `${dir} references ${id}, not found in ${boundCsv}`);
|
||||
for (const section of requiredPrdSections) {
|
||||
if (!section.pattern.test(prdText)) {
|
||||
fail(result, `task.prd_${section.code}`, `${prdPath} missing ${section.code} section`);
|
||||
}
|
||||
}
|
||||
if (!task.scope && (!Array.isArray(task.relatedFiles) || task.relatedFiles.length === 0)) {
|
||||
fail(result, "task.scope", `${dir} must declare scope or relatedFiles in task.json`);
|
||||
}
|
||||
const complexText = [task.title, task.description, task.scope, task.notes, prdText].filter(Boolean).join("\n");
|
||||
if (complexTaskPattern.test(complexText) && !exists(`${dir}/design.md`) && !exists(`${dir}/implement.md`)) {
|
||||
fail(result, "task.complex_design", `${dir} complex task must include design.md or implement.md`);
|
||||
}
|
||||
checkContextManifest(result, dir, "implement");
|
||||
checkContextManifest(result, dir, "check");
|
||||
}
|
||||
if (result.status === "PASS") pass(result, "task_bindings", `${taskDirs.length} task directories are bound`);
|
||||
if (result.status === "PASS") pass(result, "task_contracts", `${taskDirs.length} task directories have Trellis contracts`);
|
||||
}
|
||||
|
||||
function checkDocsRegistration(result) {
|
||||
@@ -212,9 +234,9 @@ function checkDocsRegistration(result) {
|
||||
continue;
|
||||
}
|
||||
const text = readText(file);
|
||||
const hasTrellis = text.includes("TRELLIS1") || text.includes(".trellis") || text.includes("Trellis");
|
||||
const hasTrellis = text.includes("TRELLIS2") || text.includes(".trellis") || text.includes("Trellis");
|
||||
if (!hasTrellis) {
|
||||
fail(result, "docs.trellis_registration", `${file} must mention Trellis/TRELLIS1`);
|
||||
fail(result, "docs.trellis_registration", `${file} must mention Trellis/TRELLIS2`);
|
||||
}
|
||||
}
|
||||
if (result.status === "PASS") pass(result, "docs_registration", `${requiredDocMentions.length} fact sources mention Trellis`);
|
||||
@@ -246,7 +268,7 @@ function checkSensitiveData(result) {
|
||||
const result = createResult();
|
||||
try {
|
||||
checkCoreFiles(result);
|
||||
checkTaskBindings(result);
|
||||
checkTaskContracts(result);
|
||||
checkDocsRegistration(result);
|
||||
checkSensitiveData(result);
|
||||
} catch (error) {
|
||||
|
||||
在新工单中引用
屏蔽一个用户