paqad-ai
Version:
Spec-driven development framework — AI agents that think before they type
418 lines (391 loc) • 14.6 kB
JavaScript
import { createRequire as __paqadCreateRequire } from 'module'; const require = __paqadCreateRequire(import.meta.url);
import {
init_esm_shims
} from "./chunk-IDFRWBMN.js";
// src/git-workflow/defaults.ts
init_esm_shims();
// src/core/team-file-schema.ts
init_esm_shims();
var TEAM_FILE_SCHEMA_VERSION = 1;
var NOT_A_SETTING_LINE = "# Not a paqad setting, so paqad ignores it (kept as written):";
var SCHEMA_KEY = /^schema_version[ \t]*:(?:[ \t]|$)/u;
var LEGACY_KEY = /^paqad_version[ \t]*:(?:[ \t]|$)/u;
var SEMVER = /^\d+\.\d+\.\d+/u;
var POSITIVE_INTEGER = /^[1-9]\d*$/u;
var LINE_END = /\r?\n$/u;
function parseStampInteger(value) {
if (typeof value === "number") return Number.isInteger(value) && value >= 1 ? value : null;
return typeof value === "string" && POSITIVE_INTEGER.test(value) ? Number(value) : null;
}
function isHigherStamp(found, running) {
const parsed = parseStampInteger(found);
return parsed !== null && parsed > running;
}
function rawLines(text) {
return text.split(/(?<=\n)/u).filter((line) => line !== "").map((line) => {
const end = LINE_END.exec(line)?.[0] ?? "";
return { body: line.slice(0, line.length - end.length), end };
});
}
function withoutBom(body) {
return body.startsWith("\uFEFF") ? body.slice(1) : body;
}
function scalarAfterColon(body) {
const rest = body.slice(body.indexOf(":") + 1);
const match = /^(.*?)([ \t]+#.*)?$/u.exec(rest);
return { value: (match?.[1] ?? "").trim(), comment: match?.[2] ?? "" };
}
function legacyValue(raw) {
return raw.replace(/^(["'])(.*)\1$/u, "$2");
}
function findLine(lines, key) {
return lines.findIndex((line) => key.test(withoutBom(line.body)));
}
function stampOf(lines) {
const schema = findLine(lines, SCHEMA_KEY);
if (schema >= 0) {
const version = parseStampInteger(scalarAfterColon(lines[schema].body).value);
return version === null ? { kind: "malformed" } : { kind: "current", version };
}
const legacy = findLine(lines, LEGACY_KEY);
if (legacy < 0) return { kind: "none" };
return SEMVER.test(legacyValue(scalarAfterColon(lines[legacy].body).value)) ? { kind: "legacy" } : { kind: "malformed" };
}
function readTeamFileStamp(text) {
return stampOf(rawLines(text));
}
function isNewerTeamFile(text) {
const stamp = readTeamFileStamp(text);
return stamp.kind === "current" && isHigherStamp(stamp.version, TEAM_FILE_SCHEMA_VERSION);
}
function newerTeamFileLine(file) {
return `${file} was written by a newer paqad, so it wasn't changed. Update paqad-ai.`;
}
function removeLineAt(lines, index) {
lines.splice(index, 1);
const before = lines[index - 1];
const after = lines[index];
const emptied = after === void 0 || after.body.trim() === "";
if (before?.body.trim() !== NOT_A_SETTING_LINE || !emptied) return;
lines.splice(index - 1, 1);
if (lines[index - 2]?.body.trim() === "") lines.splice(index - 2, 1);
}
function swappedLine(line, version) {
const bom = line.body.startsWith("\uFEFF") ? "\uFEFF" : "";
const { comment } = scalarAfterColon(line.body);
return { body: `${bom}schema_version: ${version}${comment}`, end: line.end };
}
function swapTeamFileStamp(text) {
const lines = rawLines(text);
const stamp = stampOf(lines);
if (stamp.kind === "malformed" || stamp.kind === "none") return text;
if (stamp.kind === "current" && isHigherStamp(stamp.version, TEAM_FILE_SCHEMA_VERSION)) {
return text;
}
const legacy = findLine(lines, LEGACY_KEY);
if (legacy < 0) return text;
if (stamp.kind === "legacy") {
lines[legacy] = swappedLine(lines[legacy], TEAM_FILE_SCHEMA_VERSION);
return lines.map((line) => `${line.body}${line.end}`).join("");
}
const schema = findLine(lines, SCHEMA_KEY);
if (legacy < schema) {
lines[legacy] = swappedLine(lines[legacy], stamp.version);
removeLineAt(lines, schema);
} else {
lines.splice(legacy, 1);
}
return lines.map((line) => `${line.body}${line.end}`).join("");
}
function unreadableStampLine(file) {
return `${file} has a schema_version or paqad_version line paqad can't read, so it wasn't changed. Fix that line, then try again.`;
}
function renderWriteBlock(text, file) {
if (isNewerTeamFile(text)) return newerTeamFileLine(file);
return readTeamFileStamp(text).kind === "malformed" ? unreadableStampLine(file) : null;
}
// src/git-workflow/defaults.ts
var DEFAULT_BASE_BRANCH = "main";
var EXAMPLE_GITHUB_REF = "#123";
var DEFAULT_TRACKER_PATTERNS = {
jira: "[A-Z][A-Z0-9]+-\\d+",
linear: "[A-Z][A-Z0-9]+-\\d+",
servicenow: "(INC|RITM|CHG|PRB|TASK)\\d{7}",
"github-issues": "#\\d+",
generic: null
};
function defaultGitWorkflow(baseBranch = DEFAULT_BASE_BRANCH) {
return {
tickets: {
require_ticket: false,
sources: {},
write_back_refined: "ask",
intake_decisions: {
auto_resolve_from_priors: true,
auto_resolve_from_rules: true,
confirm_auto_resolutions: "batched",
max_options_per_packet: 4,
fingerprint_scope: ["ticket_type", "module", "category"]
}
},
branches: {
base_branch: baseBranch,
template: "{prefix}/{ticket}-{title_slug}",
slug_max_length: 50,
types: {
feature: defaultBranchType("feature", "feat", baseBranch),
bugfix: defaultBranchType("bugfix", "fix", baseBranch),
hotfix: defaultBranchType("hotfix", "fix", baseBranch)
},
default_type: "feature"
},
commits: {
template: "{ticket}: {summary}",
sign_off: false,
ask_when_unspecified: true,
summary: { style: "plain", max_length: 72 }
},
pull_requests: {
host: "github",
host_server: "",
title_template: "{ticket}: {summary}",
body_template: ".paqad/templates/pr-body.md",
draft: false,
reviewers: [],
labels: [],
link_ticket: true,
also_open_against: [],
ci: { gate: "wait_for_green", timeout_minutes: 30, on_red: "stop" }
},
enforcement: { enabled: true, agents: "block", people: "warn" },
design_sign_off: defaultDesignSignOff()
};
}
function defaultDesignSignOff() {
return {
enabled: false,
lanes: ["graduated", "full"],
approval_channel: { server: "", channel: "" },
format: { summary_max_words: 200, detail_max_words: 800 },
wait_for_approval: true,
approve_by: "either",
approve_words: ["approved", "lgtm", "sign off"],
approvers: [],
move_to_on_post: "",
approved_status: "",
allow_skip_with_reason: true
};
}
function defaultBranchType(prefix, commitType, baseBranch) {
return {
prefix,
commit_type: commitType,
tickets: [],
ticket_types: [],
base_branch: baseBranch,
labels: [],
template: "{prefix}/{ticket}-{title_slug}"
};
}
var TICKETS_YAML = `# Where this team's tickets come from and how they move.
#
# paqad matches a ticket key (like PROJ-123 or INC0012345) against each
# source's pattern, top to bottom. The first match wins.
#
# Each source has:
# tracker: jira | servicenow | github-issues | linear | generic
# pattern: regular expression for this source's ticket keys
# (each tracker has a default; generic needs one)
# server: MCP server name; "" means the first connected server of that tracker
# transitions: the status to move the ticket to, "" means don't move it
# on_pr_open: when the PR opens
# on_ci_green: when CI passes on the PR
schema_version: <schema>
# true means every change needs a ticket, and deliver stops without one.
require_ticket: false
# No sources means this team works without tickets.
sources: {}
# Write refined requirements back to the ticket: never | ask | always.
# Not applied yet.
write_back_refined: ask
# How ticket-intake decisions are settled automatically.
intake_decisions:
auto_resolve_from_priors: true
auto_resolve_from_rules: true
confirm_auto_resolutions: batched # always | batched | never
max_options_per_packet: 4
fingerprint_scope: [ticket_type, module, category]
`;
var BRANCHES_YAML = `# How branches are named and where changes start.
#
# base_branch: the branch a change starts from and its PR goes back to.
# A glob like "release/*" means the newest matching branch on the remote.
# template: how a branch name is built. Placeholders:
# {prefix} the branch type's prefix, like feature
# {ticket} the ticket key, like PROJ-123 (a leading # is dropped)
# {title_slug} the change title in lower-case words joined by -
#
# types: the kinds of change this team makes. Each type can set:
# prefix: branch prefix (default: the type's name)
# commit_type: value of {type} in commit and PR templates (default: the type's name)
# tickets: which ticket sources this type uses (default: any)
# ticket_types: tracker issue types that mean this type, like Bug (default: none)
# base_branch: overrides the base_branch above for this type
# labels: PR labels added for this type
# template: overrides the template above for this type
schema_version: <schema>
base_branch: <base>
template: "{prefix}/{ticket}-{title_slug}"
slug_max_length: 50
types:
feature:
prefix: feature
commit_type: feat
bugfix:
prefix: bugfix
commit_type: fix
hotfix:
prefix: hotfix
commit_type: fix
# The type used when nothing else decides it.
default_type: feature
`;
var AI_ATTRIBUTION_COMMENT_LINES = [
"# Whether the attribution of the coding agent stays on your commits and pull requests (keep or strip), while the paqad delivery footer is never changed.",
"# ai_attribution: strip"
];
var AI_ATTRIBUTION_COMMENT_BLOCK = new RegExp(
`(?<=^|\\n)(?:\\r?\\n)?${AI_ATTRIBUTION_COMMENT_LINES.map(
(line) => line.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")
).join("\\r?\\n")}(?:\\r?\\n|$)`,
"u"
);
function removeAiAttributionComment(text) {
return text.replace(AI_ATTRIBUTION_COMMENT_BLOCK, "");
}
var COMMITS_YAML = `# How commit messages are written.
#
# template placeholders: {ticket} {summary} {type} {scope} {title}
# summary.style:
# plain the summary is written for a person who doesn't read code
# free no style check
schema_version: <schema>
template: "{ticket}: {summary}"
# true adds a Signed-off-by line (git commit -s).
sign_off: false
# When the developer didn't say whether to commit, ask once at the end of
# the change and record the answer.
ask_when_unspecified: true
summary:
style: plain
max_length: 72
${AI_ATTRIBUTION_COMMENT_LINES.join("\n")}
`;
var PULL_REQUESTS_YAML = `# How pull requests are opened.
#
# title_template placeholders: {ticket} {summary} {type} {scope} {title}
# body_template: the team's PR template. If it has placeholders like
# {summary} paqad fills them. If it has none, the agent fills it in and
# passes it with --body-file.
# labels: added to every PR. Branch type labels are added on top.
# also_open_against: extra branches to open the same change against,
# like a qa branch. Each gets its own PR.
schema_version: <schema>
host: github # only github for now
host_server: ""
title_template: "{ticket}: {summary}"
body_template: .paqad/templates/pr-body.md
draft: false
reviewers: []
labels: []
# Add the ticket to the PR body: "Closes ${EXAMPLE_GITHUB_REF}" for GitHub issues,
# "Ticket: PROJ-123" for anything else.
link_ticket: true
also_open_against: []
ci:
gate: wait_for_green # wait_for_green | warn_only | off
timeout_minutes: 30
on_red: stop # stop | comment_and_stop
`;
var ENFORCEMENT_YAML = `# How strictly paqad holds everyone to these rules.
#
# enabled: false turns off paqad's delivery help and checks completely.
#
# agents: work done through a coding agent
# block a turn, commit or PR that doesn't match is refused until the agent fixes it
# warn paqad points it out and the agent should fix it
#
# people: commits and pushes made by people, checked by git hooks
# block the commit or push is refused (git commit --no-verify still skips it)
# warn the hook prints what doesn't match and lets it through
# off no git hooks for people
schema_version: <schema>
enabled: true
agents: block
people: warn
`;
var DESIGN_SIGN_OFF_YAML = `# A short, plain-language design of each change for the team to sign off
# in the ticket or an approval channel, after the spec is frozen and before
# any code is written. Off until the team turns it on.
#
# lanes: which kinds of change post a design (fast never does)
# approval_channel: where to post it; empty means the change's own ticket
# server: the chat or tracker connected in the coding agent, like ms-teams or slack
# channel: the channel or chat name that server understands
# format: the most words above and below the divider
# wait_for_approval: true waits for sign-off before the build starts,
# false posts it for the team to read and carries on
# approve_by: reply | status | either
# approve_words: words in a reply that sign the design off
# approvers: who may sign off; empty means anyone but the developer
# move_to_on_post: ticket status to move to once posted; "" means don't move it
# approved_status: ticket status that means signed off; "" means status never signs off
# allow_skip_with_reason: true lets the developer build before sign-off with a reason
schema_version: <schema>
enabled: false
lanes: [graduated, full]
approval_channel:
server: ""
channel: ""
format:
summary_max_words: 200
detail_max_words: 800
wait_for_approval: true
approve_by: either
approve_words: [approved, lgtm, "sign off"]
approvers: []
move_to_on_post: ""
approved_status: ""
allow_skip_with_reason: true
`;
var FILE_TEMPLATES = {
"tickets.yaml": TICKETS_YAML,
"branches.yaml": BRANCHES_YAML,
"commits.yaml": COMMITS_YAML,
"pull-requests.yaml": PULL_REQUESTS_YAML,
"enforcement.yaml": ENFORCEMENT_YAML,
"design-sign-off.yaml": DESIGN_SIGN_OFF_YAML
};
function renderDefaultGitWorkflowFile(file, options = {}) {
const base = options.baseBranch ?? DEFAULT_BASE_BRANCH;
return FILE_TEMPLATES[file].replace("<schema>", String(TEAM_FILE_SCHEMA_VERSION)).replace("<base>", yamlScalar(base));
}
function yamlScalar(value) {
return /^[A-Za-z0-9._/-]+$/.test(value) && !value.includes("*") ? value : JSON.stringify(value);
}
export {
TEAM_FILE_SCHEMA_VERSION,
NOT_A_SETTING_LINE,
parseStampInteger,
isHigherStamp,
readTeamFileStamp,
isNewerTeamFile,
newerTeamFileLine,
swapTeamFileStamp,
renderWriteBlock,
DEFAULT_BASE_BRANCH,
DEFAULT_TRACKER_PATTERNS,
defaultGitWorkflow,
removeAiAttributionComment,
renderDefaultGitWorkflowFile
};