pincer-workflow 0.7.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -193
- package/bin/pincer.js +189 -166
- package/package.json +4 -3
- package/template/.agents/skills/pincer-blueprint/SKILL.md +70 -0
- package/template/.agents/skills/pincer-status/SKILL.md +29 -55
- package/template/.claude/commands/pincer-blueprint.md +7 -0
- package/template/.claude/commands/pincer-status.md +4 -59
- package/template/.claude/hooks/hook-policy.cjs +60 -25
- package/template/.claude/hooks/ticket-guard.sh +6 -3
- package/template/.claude/pincer/library/agents/accessibility-reviewer.md +27 -0
- package/template/.claude/pincer/library/agents/code-quality-reviewer.md +25 -0
- package/template/.claude/pincer/library/agents/codebase-explorer.md +23 -0
- package/template/.claude/pincer/library/agents/fix-agent.md +23 -0
- package/template/.claude/pincer/library/agents/history-reviewer.md +26 -0
- package/template/.claude/pincer/library/agents/implementer.md +24 -0
- package/template/.claude/pincer/library/agents/security-reviewer.md +26 -0
- package/template/.claude/pincer/library/agents/silent-failure-hunter.md +27 -0
- package/template/.claude/pincer/library/agents/standards-reviewer.md +25 -0
- package/template/.claude/pincer/library/agents/test-coverage-reviewer.md +26 -0
- package/template/.claude/pincer/library/agents-skeleton.md +89 -0
- package/template/.claude/pincer/library/agreement.md +46 -0
- package/template/.claude/pincer/library/models.json +6 -0
- package/template/.claude/pincer/library/stages/blueprint/SKILL.md +69 -0
- package/template/.claude/pincer/library/stages/blueprint/formats/harness.md +82 -0
- package/template/.claude/pincer/library/stages/blueprint/formats/roster-rules.md +61 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/confirm-kind.md +19 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/first-job.md +18 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-handoffs.md +21 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-models.md +20 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-pipeline.md +27 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-repo.md +21 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-responsibility.md +30 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-roster.md +20 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-simplify.md +26 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-systems.md +28 -0
- package/template/.claude/pincer/library/stages/code/SKILL.md +59 -0
- package/template/.claude/pincer/library/stages/code/formats/pr.md +55 -0
- package/template/.claude/pincer/library/stages/code/prompts/implement.md +25 -0
- package/template/.claude/pincer/library/stages/evaluate/SKILL.md +51 -0
- package/template/.claude/pincer/library/stages/evaluate/formats/evidence.md +49 -0
- package/template/.claude/pincer/library/stages/evaluate/formats/finding-report.md +72 -0
- package/template/.claude/pincer/library/stages/evaluate/formats/finding.md +35 -0
- package/template/.claude/pincer/library/stages/evaluate/prompts/review.md +25 -0
- package/template/.claude/pincer/library/stages/narrow/SKILL.md +47 -0
- package/template/.claude/pincer/library/stages/narrow/formats/ticket.md +126 -0
- package/template/.claude/pincer/library/stages/narrow/prompts/explore.md +19 -0
- package/template/.claude/pincer/library/stages/plan/SKILL.md +46 -0
- package/template/.claude/pincer/library/stages/plan/formats/spec.md +100 -0
- package/template/.claude/pincer/library/stages/plan/prompts/explore.md +20 -0
- package/template/.claude/pincer/library/stages/release/SKILL.md +48 -0
- package/template/.claude/pincer/library/stages/status/SKILL.md +37 -0
- package/template/.claude/skills/pincer-blueprint/SKILL.md +69 -0
- package/template/.claude/skills/pincer-blueprint/formats/harness.md +82 -0
- package/template/.claude/skills/pincer-blueprint/formats/roster-rules.md +61 -0
- package/template/.claude/skills/pincer-blueprint/prompts/confirm-kind.md +19 -0
- package/template/.claude/skills/pincer-blueprint/prompts/first-job.md +18 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-handoffs.md +21 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-models.md +20 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-pipeline.md +27 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-repo.md +21 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-responsibility.md +30 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-roster.md +20 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-simplify.md +26 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-systems.md +28 -0
- package/template/.claude/skills/pincer-status/SKILL.md +37 -0
- package/template/.codex/README.md +39 -35
- package/template/.github/copilot-instructions.md +19 -14
- package/template/.github/prompts/pincer-blueprint.prompt.md +70 -0
- package/template/.github/prompts/pincer-status.prompt.md +29 -55
- package/template/AGENTS.md +34 -67
- package/template/docs/engine.md +236 -0
- package/template/docs/release-checklist.md +22 -28
- package/template/scripts/pincer/blueprint.cjs +137 -0
- package/template/scripts/pincer/cli.cjs +233 -0
- package/template/scripts/pincer/evidence.cjs +142 -0
- package/template/scripts/pincer/generate.cjs +373 -0
- package/template/scripts/pincer/harness.cjs +269 -0
- package/template/scripts/pincer/models.cjs +121 -0
- package/template/scripts/{pincer-runtime → pincer}/parse.cjs +83 -133
- package/template/scripts/pincer/recon.cjs +205 -0
- package/template/scripts/pincer/status.cjs +161 -0
- package/template/scripts/pincer/ticket.cjs +203 -0
- package/template/scripts/pincer/util.cjs +104 -0
- package/template/scripts/pincer-blueprint.cjs +7 -0
- package/template/scripts/pincer-evidence.cjs +5 -73
- package/template/scripts/pincer-status.sh +10 -12
- package/template/scripts/pincer-ticket.sh +15 -18
- package/template/scripts/sync-prompts.sh +2 -50
- package/template/.agents/skills/pincer-code/SKILL.md +0 -240
- package/template/.agents/skills/pincer-evaluate/SKILL.md +0 -178
- package/template/.agents/skills/pincer-narrow/SKILL.md +0 -148
- package/template/.agents/skills/pincer-plan/SKILL.md +0 -134
- package/template/.agents/skills/pincer-release/SKILL.md +0 -82
- package/template/.claude/agents/code-quality-reviewer.md +0 -38
- package/template/.claude/agents/codebase-explorer.md +0 -23
- package/template/.claude/commands/pincer-code.md +0 -238
- package/template/.claude/commands/pincer-evaluate.md +0 -176
- package/template/.claude/commands/pincer-narrow.md +0 -146
- package/template/.claude/commands/pincer-plan.md +0 -132
- package/template/.claude/commands/pincer-release.md +0 -80
- package/template/.claude/references/prd-template.md +0 -108
- package/template/.claude/references/ticket-template.md +0 -86
- package/template/.github/prompts/pincer-code.prompt.md +0 -240
- package/template/.github/prompts/pincer-evaluate.prompt.md +0 -178
- package/template/.github/prompts/pincer-narrow.prompt.md +0 -148
- package/template/.github/prompts/pincer-plan.prompt.md +0 -134
- package/template/.github/prompts/pincer-release.prompt.md +0 -82
- package/template/docs/dry-run-checklist.md +0 -264
- package/template/docs/runtime-contracts.md +0 -1769
- package/template/scripts/pincer-runtime/adopt.cjs +0 -132
- package/template/scripts/pincer-runtime/agreement.cjs +0 -240
- package/template/scripts/pincer-runtime/authorization.cjs +0 -167
- package/template/scripts/pincer-runtime/changes.cjs +0 -517
- package/template/scripts/pincer-runtime/checks.cjs +0 -48
- package/template/scripts/pincer-runtime/coverage.cjs +0 -361
- package/template/scripts/pincer-runtime/dispositions.cjs +0 -95
- package/template/scripts/pincer-runtime/evidence.cjs +0 -676
- package/template/scripts/pincer-runtime/fsutil.cjs +0 -37
- package/template/scripts/pincer-runtime/gates.cjs +0 -73
- package/template/scripts/pincer-runtime/identity.cjs +0 -163
- package/template/scripts/pincer-runtime/impact.cjs +0 -177
- package/template/scripts/pincer-runtime/io.cjs +0 -41
- package/template/scripts/pincer-runtime/lifecycle.cjs +0 -311
- package/template/scripts/pincer-runtime/locator.cjs +0 -158
- package/template/scripts/pincer-runtime/migrate.cjs +0 -204
- package/template/scripts/pincer-runtime/phases.cjs +0 -245
- package/template/scripts/pincer-runtime/readiness.cjs +0 -102
- package/template/scripts/pincer-runtime/requirements.cjs +0 -255
- package/template/scripts/pincer-runtime/resume.cjs +0 -273
- package/template/scripts/pincer-runtime/routing.cjs +0 -54
- package/template/scripts/pincer-runtime/runner.cjs +0 -242
- package/template/scripts/pincer-runtime/sanitize.cjs +0 -63
- package/template/scripts/pincer-runtime/scaffold.cjs +0 -254
- package/template/scripts/pincer-runtime/source.cjs +0 -129
- package/template/scripts/pincer-runtime/state.cjs +0 -314
- package/template/scripts/pincer-runtime/status.cjs +0 -514
- package/template/scripts/pincer-runtime/transaction.cjs +0 -200
- package/template/scripts/pincer-runtime/transitions.cjs +0 -134
- package/template/scripts/pincer-runtime.cjs +0 -686
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER blueprint — the generator (docs/engine.md, "Generate"). From the library
|
|
3
|
+
// and harness.json it writes, for every stage that is on, the project copy of the
|
|
4
|
+
// stage folder, a thin command and, per platform, the Codex skill and the Copilot
|
|
5
|
+
// prompt derived from the project copy; for every agent in an active roster, its
|
|
6
|
+
// Claude file (the library copy with the model and effort of its tier) and, with
|
|
7
|
+
// Codex installed, its Codex agent file. Baselines live in .pincer.json when the
|
|
8
|
+
// installer wrote one. A target whose hash matches neither its baseline nor the
|
|
9
|
+
// fresh content was edited by hand: it is kept and the fresh content lands beside
|
|
10
|
+
// it as <file>.new. The model and effort lines of an agent file and a project copy
|
|
11
|
+
// are the generator's; the tier belongs in the agreement.
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const crypto = require('node:crypto');
|
|
15
|
+
const util = require('./util.cjs');
|
|
16
|
+
const harness = require('./harness.cjs');
|
|
17
|
+
const models = require('./models.cjs');
|
|
18
|
+
|
|
19
|
+
const LIBRARY = '.claude/pincer/library';
|
|
20
|
+
const MANIFEST = '.pincer.json';
|
|
21
|
+
const PLATFORMS = harness.PLATFORMS;
|
|
22
|
+
const COPILOT_INPUT = '${input:request:Task brief or arguments (optional)}';
|
|
23
|
+
const KEEP_DIRS = new Set(['.claude', '.agents', '.github', '.codex']);
|
|
24
|
+
const MODEL_FIELDS = ['tier', 'model', 'effort'];
|
|
25
|
+
|
|
26
|
+
const sha = (data) => crypto.createHash('sha256').update(data).digest('hex');
|
|
27
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
28
|
+
const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
|
|
29
|
+
const hashOf = (abs) => (fs.existsSync(abs) ? sha(fs.readFileSync(abs)) : null);
|
|
30
|
+
const MARKER_TEXT = 'Generated by scripts/pincer/cli.cjs generate from ';
|
|
31
|
+
const generatedComment = (source) => `<!-- ${MARKER_TEXT}${source} — edit the project copy, not this file -->`;
|
|
32
|
+
const tomlComment = (source) => `# ${MARKER_TEXT}${source} — edit the agreement and the library, not this file`;
|
|
33
|
+
// Derived files (command, Codex skill, Copilot prompt, Codex agent) carry the marker;
|
|
34
|
+
// without a baseline, one that still does is the generator's to overwrite or remove.
|
|
35
|
+
const DERIVED_PATH = /^(\.claude\/commands\/pincer-[a-z-]+\.md|\.agents\/skills\/pincer-[a-z-]+\/SKILL\.md|\.github\/prompts\/pincer-[a-z-]+\.prompt\.md|\.codex\/agents\/[a-z-]+\.toml)$/;
|
|
36
|
+
const carriesMarker = (abs) => { try { return fs.readFileSync(abs, 'utf8').includes(MARKER_TEXT); } catch { return false; } };
|
|
37
|
+
|
|
38
|
+
// A baseline key is usable only when it names a file inside the root: no absolute
|
|
39
|
+
// path, no `..` segment, and the resolved path stays under the root.
|
|
40
|
+
function safeRel(root, rel) {
|
|
41
|
+
if (typeof rel !== 'string' || !rel || path.isAbsolute(rel) || /^[A-Za-z]:/.test(rel)) return false;
|
|
42
|
+
if (rel.split(/[\\/]/).includes('..')) return false;
|
|
43
|
+
const base = path.resolve(root);
|
|
44
|
+
const abs = path.resolve(base, rel);
|
|
45
|
+
return abs !== base && abs.startsWith(base + path.sep);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The project's library when it has one, else the kit's own copy next to this file.
|
|
49
|
+
function libraryDir(root, override) {
|
|
50
|
+
if (override) return path.resolve(root, override);
|
|
51
|
+
const own = path.join(root, LIBRARY);
|
|
52
|
+
return fs.existsSync(own) ? own : path.resolve(__dirname, '..', '..', LIBRARY);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The platforms the installer recorded; all three when there is no usable manifest.
|
|
56
|
+
function manifestPlatforms(root) {
|
|
57
|
+
const manifest = util.readJson(path.join(root, MANIFEST));
|
|
58
|
+
const list = manifest && Array.isArray(manifest.platforms) ? manifest.platforms : null;
|
|
59
|
+
return list && list.length && list.every((p) => PLATFORMS.includes(p)) ? [...new Set(list)] : [...PLATFORMS];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Every file under dir as sorted posix paths relative to it.
|
|
63
|
+
function listFiles(dir, rel = '') {
|
|
64
|
+
const abs = path.join(dir, rel);
|
|
65
|
+
if (!isDir(abs)) return [];
|
|
66
|
+
const out = [];
|
|
67
|
+
const entries = fs.readdirSync(abs, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (entry.name === '.DS_Store') continue;
|
|
70
|
+
const child = rel ? `${rel}/${entry.name}` : entry.name;
|
|
71
|
+
if (entry.isDirectory()) out.push(...listFiles(dir, child));
|
|
72
|
+
else if (entry.isFile()) out.push(child);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// key: value lines between the first two --- lines; quotes around a value are dropped.
|
|
78
|
+
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
79
|
+
function splitFrontmatter(text) {
|
|
80
|
+
const match = FRONTMATTER.exec(text);
|
|
81
|
+
if (!match) return { fields: {}, body: text };
|
|
82
|
+
const fields = {};
|
|
83
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
84
|
+
const kv = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line);
|
|
85
|
+
if (!kv) continue;
|
|
86
|
+
let value = kv[2].trim();
|
|
87
|
+
const q = value[0];
|
|
88
|
+
if (value.length >= 2 && (q === '"' || q === "'") && value.endsWith(q)) value = value.slice(1, -1);
|
|
89
|
+
fields[kv[1]] = value;
|
|
90
|
+
}
|
|
91
|
+
return { fields, body: text.slice(match[0].length) };
|
|
92
|
+
}
|
|
93
|
+
const quoted = (value) => JSON.stringify(String(value));
|
|
94
|
+
const bare = (value) => (/^[A-Za-z0-9._-]+$/.test(String(value)) ? String(value) : quoted(value));
|
|
95
|
+
const bodyText = (body) => { const text = body.replace(/^\n+/, ''); return text.endsWith('\n') ? text : `${text}\n`; };
|
|
96
|
+
const describe = (stage, fields) => fields.description || `PINCER ${stage} stage`;
|
|
97
|
+
const skillDir = (stage) => `.claude/skills/pincer-${stage}`;
|
|
98
|
+
const skillFile = (stage) => `${skillDir(stage)}/SKILL.md`;
|
|
99
|
+
const agentPath = (name) => `.claude/agents/${name}.md`;
|
|
100
|
+
const codexAgentPath = (name) => `.codex/agents/${name}.toml`;
|
|
101
|
+
|
|
102
|
+
// The frontmatter with the `drop` keys removed and the `add` pairs appended before
|
|
103
|
+
// the closing ---; the input unchanged when there is nothing to do.
|
|
104
|
+
function rewriteFrontmatter(text, { drop = [], add = {} } = {}) {
|
|
105
|
+
const entries = Object.entries(add).filter(([, value]) => typeof value === 'string' && value !== '');
|
|
106
|
+
const match = FRONTMATTER.exec(text);
|
|
107
|
+
if (!match) return entries.length ? `---\n${entries.map(([k, v]) => `${k}: ${bare(v)}`).join('\n')}\n---\n${text}` : text;
|
|
108
|
+
const lines = match[1].split(/\r?\n/);
|
|
109
|
+
const kept = lines.filter((line) => { const kv = /^([A-Za-z][\w-]*):/.exec(line); return !(kv && drop.includes(kv[1])); });
|
|
110
|
+
if (kept.length === lines.length && !entries.length) return text;
|
|
111
|
+
return `---\n${[...kept, ...entries.map(([k, v]) => `${k}: ${bare(v)}`)].join('\n')}\n---\n${text.slice(match[0].length)}`;
|
|
112
|
+
}
|
|
113
|
+
const stripModelFields = (text) => rewriteFrontmatter(String(text), { drop: MODEL_FIELDS });
|
|
114
|
+
// The same file once the model, effort and tier lines are set aside.
|
|
115
|
+
const equalIgnoringModelFields = (a, b) => stripModelFields(a) === stripModelFields(b);
|
|
116
|
+
const given = (value) => (typeof value === 'string' && value !== '' ? value : '');
|
|
117
|
+
|
|
118
|
+
// The Claude agent file: the library copy with the tier line dropped and the
|
|
119
|
+
// resolved model and effort appended.
|
|
120
|
+
function renderAgent(text, { model, effort } = {}) {
|
|
121
|
+
const add = {};
|
|
122
|
+
if (given(model)) add.model = model;
|
|
123
|
+
if (given(effort)) add.effort = effort;
|
|
124
|
+
return rewriteFrontmatter(text, { drop: MODEL_FIELDS, add });
|
|
125
|
+
}
|
|
126
|
+
// The Codex agent file: name, description, the model lines and the body as the
|
|
127
|
+
// developer instructions, in TOML.
|
|
128
|
+
function renderCodexAgent(name, text, { model, effort } = {}) {
|
|
129
|
+
const { fields, body } = splitFrontmatter(text);
|
|
130
|
+
const content = bodyText(body);
|
|
131
|
+
if (content.includes("'''")) throw new Error(`agents/${name}.md: the body contains ''' and cannot be a Codex agent`);
|
|
132
|
+
const lines = [tomlComment(`${LIBRARY}/agents/${name}.md`), `name = ${quoted(fields.name || name)}`, `description = ${quoted(fields.description || '')}`];
|
|
133
|
+
if (given(model)) lines.push(`model = ${quoted(model)}`);
|
|
134
|
+
if (given(effort)) lines.push(`model_reasoning_effort = ${quoted(effort)}`);
|
|
135
|
+
lines.push("developer_instructions = '''", content.replace(/\n$/, ''), "'''");
|
|
136
|
+
return `${lines.join('\n')}\n`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// /pincer-x becomes $pincer-x where it is a command mention: at a line start or
|
|
140
|
+
// after a character that cannot be part of a path, so scripts/pincer-status.sh stays.
|
|
141
|
+
const codexMentions = (text) => text.replace(/(^|[^A-Za-z0-9_./])\/pincer-([a-z][a-z-]*)/gm, (_, before, name) => `${before}$pincer-${name}`);
|
|
142
|
+
|
|
143
|
+
// The model and effort lines a stage's tier adds to a frontmatter.
|
|
144
|
+
const modelLines = ({ model, effort } = {}) => `${given(model) ? `model: ${bare(model)}\n` : ''}${given(effort) ? `effort: ${bare(effort)}\n` : ''}`;
|
|
145
|
+
// The Codex skill has no model field: one line tells the session what was agreed.
|
|
146
|
+
function codexHint({ model, effort, tier } = {}) {
|
|
147
|
+
if (given(model)) return `Model: this stage was agreed to run on the ${tier} tier (${model}${given(effort) ? `, effort ${effort}` : ''}). If this session runs on another model, say so before starting or restart with \`codex -m ${model}\`.\n\n`;
|
|
148
|
+
if (given(effort)) return `Effort: this stage was agreed to run at ${effort} effort on the session's model; if this session is set lower, restart with \`codex -c model_reasoning_effort=${effort}\`.\n\n`;
|
|
149
|
+
return '';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderCommand(stage, fields, claude) {
|
|
153
|
+
const hint = fields['argument-hint'] ? `argument-hint: ${quoted(fields['argument-hint'])}\n` : '';
|
|
154
|
+
return `---\ndescription: ${quoted(describe(stage, fields))}\n${hint}${modelLines(claude)}---\n${generatedComment(skillFile(stage))}\n\nRead ${skillFile(stage)} and follow it. Arguments: $ARGUMENTS\n`;
|
|
155
|
+
}
|
|
156
|
+
function renderCodex(stage, fields, body, codex) {
|
|
157
|
+
const optional = !fields['argument-hint'] || /optional/i.test(fields['argument-hint']);
|
|
158
|
+
const sentence = optional
|
|
159
|
+
? `the text that follows the \`$pincer-${stage}\` mention, if any (when omitted, use the skill's documented default)`
|
|
160
|
+
: `the text that follows the \`$pincer-${stage}\` mention in the user's message (ask only if the required input is missing)`;
|
|
161
|
+
const text = codexMentions(body.split('$ARGUMENTS').join(sentence));
|
|
162
|
+
return `---\nname: pincer-${stage}\ndescription: ${quoted(describe(stage, fields))}\n---\n${generatedComment(skillFile(stage))}\n\n${codexHint(codex)}${bodyText(text)}`;
|
|
163
|
+
}
|
|
164
|
+
function renderCopilot(stage, fields, body, copilot) {
|
|
165
|
+
const text = body.split('$ARGUMENTS').join(COPILOT_INPUT);
|
|
166
|
+
const model = copilot && given(copilot.model) ? `model: ${quoted(copilot.model)}\n` : '';
|
|
167
|
+
return `---\nmode: agent\ndescription: ${quoted(describe(stage, fields))}\n${model}---\n${generatedComment(skillFile(stage))}\n\n${bodyText(text)}`;
|
|
168
|
+
}
|
|
169
|
+
// The files derived from a stage's SKILL.md text, keyed by path (every platform).
|
|
170
|
+
// `models` carries the stage's resolved { model, effort, tier } per platform; the
|
|
171
|
+
// derived files take the model from the resolution, never from the copy's frontmatter.
|
|
172
|
+
function derived(stage, skillText, models = {}) {
|
|
173
|
+
const { fields, body } = splitFrontmatter(skillText);
|
|
174
|
+
return {
|
|
175
|
+
[`.claude/commands/pincer-${stage}.md`]: renderCommand(stage, fields, models.claude),
|
|
176
|
+
[`.agents/skills/pincer-${stage}/SKILL.md`]: renderCodex(stage, fields, body, models.codex),
|
|
177
|
+
[`.github/prompts/pincer-${stage}.prompt.md`]: renderCopilot(stage, fields, body, models.copilot),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function derivedPaths(stage, platforms) {
|
|
181
|
+
const out = [`.claude/commands/pincer-${stage}.md`];
|
|
182
|
+
if (platforms.includes('codex')) out.push(`.agents/skills/pincer-${stage}/SKILL.md`);
|
|
183
|
+
if (platforms.includes('copilot')) out.push(`.github/prompts/pincer-${stage}.prompt.md`);
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
// A path belongs to the platforms asked about; others are left alone.
|
|
187
|
+
const inScope = (rel, platforms) => (rel.startsWith('.agents/') || rel.startsWith('.codex/') ? platforms.includes('codex') : rel.startsWith('.github/') ? platforms.includes('copilot') : true);
|
|
188
|
+
|
|
189
|
+
// The agents named by the rosters of the stages that are on (harness.cjs owns it).
|
|
190
|
+
const activeAgents = harness.activeAgents;
|
|
191
|
+
|
|
192
|
+
// Every path the generator owns for this agreement and these platforms. With a
|
|
193
|
+
// library the project copies list the whole stage folder, else SKILL.md alone.
|
|
194
|
+
function generatedPaths(doc, platforms = PLATFORMS, { library } = {}) {
|
|
195
|
+
const h = harness.normalize(doc);
|
|
196
|
+
const out = [];
|
|
197
|
+
for (const stage of harness.STAGES) {
|
|
198
|
+
if (!harness.stageOn(h, stage)) continue;
|
|
199
|
+
const files = library ? listFiles(path.join(library, 'stages', stage)) : [];
|
|
200
|
+
for (const file of files.length ? files : ['SKILL.md']) out.push(`${skillDir(stage)}/${file}`);
|
|
201
|
+
out.push(...derivedPaths(stage, platforms));
|
|
202
|
+
}
|
|
203
|
+
for (const name of activeAgents(h)) {
|
|
204
|
+
out.push(agentPath(name));
|
|
205
|
+
if (platforms.includes('codex')) out.push(codexAgentPath(name));
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Remove empty directories upward, never the platform roots or the project root.
|
|
211
|
+
function pruneEmpty(root, dir) {
|
|
212
|
+
for (;;) {
|
|
213
|
+
const rel = path.relative(root, dir);
|
|
214
|
+
if (!rel || rel.startsWith('..') || KEEP_DIRS.has(rel)) return;
|
|
215
|
+
try { if (fs.readdirSync(dir).length) return; fs.rmdirSync(dir); } catch { return; }
|
|
216
|
+
dir = path.dirname(dir);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function generate(root, { harness: doc, platforms = manifestPlatforms(root), library, overwrite = false } = {}) {
|
|
221
|
+
if (!isObject(doc)) throw new TypeError('generate needs a harness');
|
|
222
|
+
const h = harness.normalize(doc);
|
|
223
|
+
const lib = libraryDir(root, library);
|
|
224
|
+
const manifestFile = path.join(root, MANIFEST);
|
|
225
|
+
const manifest = util.readJson(manifestFile);
|
|
226
|
+
const report = { written: [], skipped: [], conflicted: [], retained: [], removed: [], missing: [], unsafe: [] };
|
|
227
|
+
// Baseline keys that point outside the root are dropped and reported, never acted on.
|
|
228
|
+
const baseline = {};
|
|
229
|
+
for (const [rel, hash] of Object.entries(manifest && isObject(manifest.generated) ? manifest.generated : {})) {
|
|
230
|
+
if (safeRel(root, rel)) baseline[rel] = hash; else report.unsafe.push(rel);
|
|
231
|
+
}
|
|
232
|
+
const next = {};
|
|
233
|
+
const expected = new Set();
|
|
234
|
+
|
|
235
|
+
// Models: the library table with the agreement's map over it. A tier the table
|
|
236
|
+
// cannot translate, or a table that is not there, is reported once per lookup
|
|
237
|
+
// kind and the model field is left out.
|
|
238
|
+
const tableRead = models.readTable(lib);
|
|
239
|
+
const table = tableRead.ok ? models.overlay(tableRead.table, h.models.map) : null;
|
|
240
|
+
// A table that is there but broken is reported by its fault, never as missing.
|
|
241
|
+
const tableProblem = !tableRead.ok && !tableRead.missing ? tableRead.problem : null;
|
|
242
|
+
const eff = models.effective(h, lib);
|
|
243
|
+
const unresolved = new Set();
|
|
244
|
+
const resolve = (platform, entry) => {
|
|
245
|
+
const tier = given(entry && entry.tier), effort = given(entry && entry.effort);
|
|
246
|
+
let model = null;
|
|
247
|
+
if (tier && tier !== 'inherit') {
|
|
248
|
+
if (!table) unresolved.add(tableProblem || models.TABLE_FILE);
|
|
249
|
+
else {
|
|
250
|
+
model = models.modelFor(table, platform, tier);
|
|
251
|
+
if (model === undefined) { unresolved.add(`models/${platform}/${tier}`); model = null; }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { model, effort };
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// Every agent is rendered before anything is written, so a refusal writes nothing.
|
|
258
|
+
const agents = activeAgents(h);
|
|
259
|
+
const rendered = [];
|
|
260
|
+
for (const name of agents) {
|
|
261
|
+
const src = path.join(lib, 'agents', `${name}.md`);
|
|
262
|
+
if (!fs.existsSync(src)) { report.missing.push(`agents/${name}`); continue; }
|
|
263
|
+
const text = fs.readFileSync(src, 'utf8');
|
|
264
|
+
const item = { name, text, claude: renderAgent(text, resolve('claude', eff.agents[name])) };
|
|
265
|
+
if (platforms.includes('codex')) item.codex = renderCodexAgent(name, text, resolve('codex', eff.agents[name]));
|
|
266
|
+
rendered.push(item);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Put fresh content at rel unless the file there was edited by hand; returns the
|
|
270
|
+
// content now on disk, which the derived files follow. `ours` may widen what
|
|
271
|
+
// counts as the generator's own, but only where no baseline can tell an edit
|
|
272
|
+
// from a re-render: with a baseline, a hand-written model line conflicts too.
|
|
273
|
+
const place = (rel, fresh, { ours: alsoOurs } = {}) => {
|
|
274
|
+
expected.add(rel);
|
|
275
|
+
const abs = path.join(root, rel);
|
|
276
|
+
const buf = Buffer.isBuffer(fresh) ? fresh : Buffer.from(fresh, 'utf8');
|
|
277
|
+
const freshHash = sha(buf);
|
|
278
|
+
const current = hashOf(abs);
|
|
279
|
+
let ours = overwrite || current === freshHash || current === baseline[rel] || (baseline[rel] === undefined && DERIVED_PATH.test(rel) && carriesMarker(abs));
|
|
280
|
+
if (!ours && current !== null && baseline[rel] === undefined && typeof alsoOurs === 'function') ours = alsoOurs(fs.readFileSync(abs, 'utf8'));
|
|
281
|
+
if (current !== null && !ours) {
|
|
282
|
+
const sidecar = `${abs}.new`;
|
|
283
|
+
if (hashOf(sidecar) !== freshHash) util.atomicWrite(sidecar, buf);
|
|
284
|
+
report.conflicted.push(rel);
|
|
285
|
+
if (baseline[rel]) next[rel] = baseline[rel];
|
|
286
|
+
return fs.readFileSync(abs);
|
|
287
|
+
}
|
|
288
|
+
if (current === freshHash) report.skipped.push(rel);
|
|
289
|
+
else { util.atomicWrite(abs, buf); report.written.push(rel); }
|
|
290
|
+
next[rel] = freshHash;
|
|
291
|
+
return buf;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
for (const stage of harness.STAGES) {
|
|
295
|
+
if (!harness.stageOn(h, stage)) continue;
|
|
296
|
+
const src = path.join(lib, 'stages', stage);
|
|
297
|
+
if (!fs.existsSync(path.join(src, 'SKILL.md'))) { report.missing.push(stage); continue; }
|
|
298
|
+
const entry = eff.stages[stage];
|
|
299
|
+
const tiers = Object.fromEntries(PLATFORMS.map((platform) => [platform, { ...resolve(platform, entry), tier: given(entry && entry.tier) }]));
|
|
300
|
+
let skillText = '';
|
|
301
|
+
for (const file of listFiles(src)) {
|
|
302
|
+
const raw = fs.readFileSync(path.join(src, file));
|
|
303
|
+
if (file !== 'SKILL.md') { place(`${skillDir(stage)}/${file}`, raw); continue; }
|
|
304
|
+
// The project copy: the library's SKILL.md with the stage's Claude model lines.
|
|
305
|
+
const libraryText = raw.toString('utf8');
|
|
306
|
+
const fresh = rewriteFrontmatter(libraryText, { drop: ['model', 'effort'], add: { model: tiers.claude.model || '', effort: tiers.claude.effort } });
|
|
307
|
+
skillText = place(`${skillDir(stage)}/${file}`, fresh, { ours: (current) => equalIgnoringModelFields(current, libraryText) }).toString('utf8');
|
|
308
|
+
}
|
|
309
|
+
const outputs = derived(stage, skillText, tiers);
|
|
310
|
+
for (const rel of derivedPaths(stage, platforms)) place(rel, outputs[rel]);
|
|
311
|
+
}
|
|
312
|
+
for (const item of rendered) {
|
|
313
|
+
place(agentPath(item.name), item.claude, { ours: (current) => equalIgnoringModelFields(current, item.text) });
|
|
314
|
+
if (item.codex !== undefined) place(codexAgentPath(item.name), item.codex);
|
|
315
|
+
}
|
|
316
|
+
report.missing.push(...[...unresolved].sort());
|
|
317
|
+
|
|
318
|
+
// Removals: what an earlier run recorded, or what an off stage or a dropped agent
|
|
319
|
+
// would own, when it is not expected now and still reads as generated: equal to
|
|
320
|
+
// its baseline or to the library's copy (an agent file: ignoring its model
|
|
321
|
+
// lines), or a derived file carrying the marker.
|
|
322
|
+
const pristine = {};
|
|
323
|
+
const libraryText = {};
|
|
324
|
+
const candidates = new Set();
|
|
325
|
+
for (const rel of Object.keys(baseline)) { if (inScope(rel, platforms)) candidates.add(rel); else next[rel] = baseline[rel]; }
|
|
326
|
+
for (const stage of harness.STAGES) {
|
|
327
|
+
if (harness.stageOn(h, stage)) continue;
|
|
328
|
+
const src = path.join(lib, 'stages', stage);
|
|
329
|
+
for (const file of listFiles(src)) {
|
|
330
|
+
const raw = fs.readFileSync(path.join(src, file));
|
|
331
|
+
pristine[`${skillDir(stage)}/${file}`] = sha(raw);
|
|
332
|
+
if (file === 'SKILL.md') libraryText[skillFile(stage)] = raw.toString('utf8');
|
|
333
|
+
}
|
|
334
|
+
for (const file of listFiles(path.join(root, skillDir(stage)))) if (!file.endsWith('.new')) candidates.add(`${skillDir(stage)}/${file}`);
|
|
335
|
+
for (const rel of derivedPaths(stage, platforms)) candidates.add(rel);
|
|
336
|
+
}
|
|
337
|
+
for (const name of harness.AGENT_NAMES) {
|
|
338
|
+
if (agents.includes(name)) continue;
|
|
339
|
+
const src = path.join(lib, 'agents', `${name}.md`);
|
|
340
|
+
if (fs.existsSync(src)) libraryText[agentPath(name)] = fs.readFileSync(src, 'utf8');
|
|
341
|
+
candidates.add(agentPath(name));
|
|
342
|
+
if (platforms.includes('codex')) candidates.add(codexAgentPath(name));
|
|
343
|
+
}
|
|
344
|
+
for (const rel of [...candidates].sort()) {
|
|
345
|
+
if (expected.has(rel)) continue;
|
|
346
|
+
const abs = path.join(root, rel);
|
|
347
|
+
const current = hashOf(abs);
|
|
348
|
+
if (current === null) continue;
|
|
349
|
+
const generated = overwrite || current === baseline[rel] || current === pristine[rel]
|
|
350
|
+
|| (baseline[rel] === undefined && DERIVED_PATH.test(rel) && carriesMarker(abs))
|
|
351
|
+
|| (baseline[rel] === undefined && libraryText[rel] !== undefined && equalIgnoringModelFields(fs.readFileSync(abs, 'utf8'), libraryText[rel]));
|
|
352
|
+
if (generated) {
|
|
353
|
+
fs.unlinkSync(abs);
|
|
354
|
+
report.removed.push(rel);
|
|
355
|
+
pruneEmpty(root, path.dirname(abs));
|
|
356
|
+
} else {
|
|
357
|
+
// Edited or user-added: kept where it is, reported without a sidecar.
|
|
358
|
+
report.retained.push(rel);
|
|
359
|
+
if (baseline[rel]) next[rel] = baseline[rel];
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (isObject(manifest)) {
|
|
364
|
+
manifest.generated = Object.fromEntries(Object.keys(next).sort().map((rel) => [rel, next[rel]]));
|
|
365
|
+
util.atomicWrite(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
366
|
+
}
|
|
367
|
+
return report;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
module.exports = {
|
|
371
|
+
LIBRARY, MANIFEST, PLATFORMS, MARKER_TEXT, generate, generatedPaths, manifestPlatforms, libraryDir, splitFrontmatter, codexMentions, activeAgents, safeRel,
|
|
372
|
+
rewriteFrontmatter, renderAgent, renderCodexAgent, equalIgnoringModelFields, codexAgentPath,
|
|
373
|
+
};
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER engine — harness.json, the machine-readable agreement every stage
|
|
3
|
+
// reads (docs/engine.md, "harness.json"). Schema 1. Dependency-free.
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { stripUserinfo } = require('./util.cjs');
|
|
7
|
+
|
|
8
|
+
const HARNESS_FILE = 'harness.json';
|
|
9
|
+
const STAGES = ['blueprint', 'plan', 'narrow', 'code', 'evaluate', 'release', 'status'];
|
|
10
|
+
const ALWAYS_ON = ['blueprint', 'status'];
|
|
11
|
+
const OPTIONAL_STAGES = STAGES.filter((name) => !ALWAYS_ON.includes(name));
|
|
12
|
+
const ROSTER_STAGES = ['plan', 'narrow', 'code', 'evaluate'];
|
|
13
|
+
const AGENT_NAMES = [
|
|
14
|
+
'codebase-explorer', 'implementer', 'code-quality-reviewer', 'silent-failure-hunter', 'security-reviewer',
|
|
15
|
+
'test-coverage-reviewer', 'history-reviewer', 'standards-reviewer', 'accessibility-reviewer', 'fix-agent',
|
|
16
|
+
];
|
|
17
|
+
const KINDS = ['greenfield', 'brownfield'];
|
|
18
|
+
const MISSING_MESSAGE = 'no agreement yet: run /pincer-blueprint';
|
|
19
|
+
// Model tiers: what the agreement says about which model a role runs on. The
|
|
20
|
+
// empty string is "not set" (the library's default, else inherit).
|
|
21
|
+
const TIERS = ['strong', 'standard', 'fast', 'inherit'];
|
|
22
|
+
const MAP_TIERS = ['strong', 'standard', 'fast'];
|
|
23
|
+
const EFFORTS = ['low', 'medium', 'high'];
|
|
24
|
+
const PLATFORMS = ['claude', 'codex', 'copilot'];
|
|
25
|
+
const MODEL_ENTRY = { tier: '', effort: '' };
|
|
26
|
+
|
|
27
|
+
const DEFAULT_STAGES = {
|
|
28
|
+
plan: { on: true, spec: 'file', gate: '' },
|
|
29
|
+
narrow: { on: true, tickets: 'file' },
|
|
30
|
+
code: { on: true, implementer: false },
|
|
31
|
+
evaluate: { on: true, where: 'local', evidence: true },
|
|
32
|
+
release: { on: true, audit: 'tree' },
|
|
33
|
+
};
|
|
34
|
+
const DEFAULTS = {
|
|
35
|
+
project: { name: '', reason: '' },
|
|
36
|
+
person: { name: '', role: '', accepts: false },
|
|
37
|
+
systems: { forge: 'none', remote: '', ci: false, deploy: '', secrets: '' },
|
|
38
|
+
repo: { branches: 'main', pr: false, merge: 'merge', protection: '', checks: [] },
|
|
39
|
+
homes: { specs: 'specs', tickets: 'tickets', evidence: 'evidence' },
|
|
40
|
+
models: { stages: {}, agents: {}, map: {} },
|
|
41
|
+
};
|
|
42
|
+
// Allowed values per field; a field not listed here is a string.
|
|
43
|
+
const RULES = {
|
|
44
|
+
'systems.forge': ['github', 'none', 'other'],
|
|
45
|
+
'systems.ci': 'boolean',
|
|
46
|
+
'repo.branches': ['per-ticket', 'main'],
|
|
47
|
+
'repo.pr': 'boolean',
|
|
48
|
+
'repo.merge': ['merge', 'squash', 'rebase'],
|
|
49
|
+
'repo.checks': 'strings',
|
|
50
|
+
'stages.plan.spec': ['file', 'forge'],
|
|
51
|
+
'stages.narrow.tickets': ['file', 'forge'],
|
|
52
|
+
'stages.code.implementer': 'boolean',
|
|
53
|
+
'stages.evaluate.where': ['local', 'pr'],
|
|
54
|
+
'stages.evaluate.evidence': 'boolean',
|
|
55
|
+
'stages.release.audit': ['tree', 'pr'],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
59
|
+
const obj = (value) => (isObject(value) ? value : {});
|
|
60
|
+
const trimSlash = (value) => (typeof value === 'string' ? value.replace(/\/+$/, '') : value);
|
|
61
|
+
|
|
62
|
+
function check(problems, label, value) {
|
|
63
|
+
const rule = RULES[label] || 'string';
|
|
64
|
+
if (Array.isArray(rule)) { if (!rule.includes(value)) problems.push(`${label} must be ${rule.join(' or ')}`); }
|
|
65
|
+
else if (rule === 'boolean') { if (typeof value !== 'boolean') problems.push(`${label} must be true or false`); }
|
|
66
|
+
else if (rule === 'strings') { if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) problems.push(`${label} must be a list of strings`); }
|
|
67
|
+
else if (typeof value !== 'string') problems.push(`${label} must be a string`);
|
|
68
|
+
}
|
|
69
|
+
function checkSection(problems, doc, section, defaults) {
|
|
70
|
+
if (doc[section] === undefined) return;
|
|
71
|
+
if (!isObject(doc[section])) { problems.push(`${section} must be an object`); return; }
|
|
72
|
+
for (const [key, value] of Object.entries(doc[section])) if (key in defaults) check(problems, `${section}.${key}`, value);
|
|
73
|
+
}
|
|
74
|
+
function homeProblem(value) {
|
|
75
|
+
if (typeof value !== 'string' || !value.trim()) return 'must be a relative path';
|
|
76
|
+
if (value.startsWith('/') || /^[A-Za-z]:/.test(value) || value.split(/[\\/]/).includes('..')) return 'must be a relative path inside the repository (no leading / or ..)';
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// models.stages.<name> and models.agents.<name>: a tier word or { tier, effort }.
|
|
81
|
+
function checkModelEntry(problems, label, value) {
|
|
82
|
+
const tierProblem = `${label}.tier must be strong, standard, fast or inherit`;
|
|
83
|
+
if (typeof value === 'string') { if (value !== '' && !TIERS.includes(value)) problems.push(tierProblem); return; }
|
|
84
|
+
if (!isObject(value)) { problems.push(`${label} must be a tier or an object`); return; }
|
|
85
|
+
if ('tier' in value && (typeof value.tier !== 'string' || (value.tier !== '' && !TIERS.includes(value.tier)))) problems.push(tierProblem);
|
|
86
|
+
if ('effort' in value && (typeof value.effort !== 'string' || (value.effort !== '' && !EFFORTS.includes(value.effort)))) problems.push(`${label}.effort must be low, medium or high`);
|
|
87
|
+
}
|
|
88
|
+
function checkModels(problems, doc) {
|
|
89
|
+
if (doc.models === undefined) return;
|
|
90
|
+
if (!isObject(doc.models)) { problems.push('models must be an object'); return; }
|
|
91
|
+
const { stages, agents, map } = doc.models;
|
|
92
|
+
if (stages !== undefined) {
|
|
93
|
+
if (!isObject(stages)) problems.push('models.stages must be an object');
|
|
94
|
+
else {
|
|
95
|
+
for (const [name, value] of Object.entries(stages)) {
|
|
96
|
+
if (ALWAYS_ON.includes(name)) { problems.push(`models.stages.${name}: ${name} is always on and runs on the session's model`); continue; }
|
|
97
|
+
if (!OPTIONAL_STAGES.includes(name)) { problems.push(`models.stages.${name} is not a stage (stages: ${OPTIONAL_STAGES.join(', ')})`); continue; }
|
|
98
|
+
checkModelEntry(problems, `models.stages.${name}`, value);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (agents !== undefined) {
|
|
103
|
+
if (!isObject(agents)) problems.push('models.agents must be an object');
|
|
104
|
+
else {
|
|
105
|
+
for (const [name, value] of Object.entries(agents)) {
|
|
106
|
+
if (!AGENT_NAMES.includes(name)) { problems.push(`models.agents.${name}: unknown agent (agents: ${AGENT_NAMES.join(', ')})`); continue; }
|
|
107
|
+
checkModelEntry(problems, `models.agents.${name}`, value);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (map !== undefined) {
|
|
112
|
+
if (!isObject(map)) problems.push('models.map must be an object');
|
|
113
|
+
else {
|
|
114
|
+
for (const [platform, row] of Object.entries(map)) {
|
|
115
|
+
if (!PLATFORMS.includes(platform)) { problems.push(`models.map.${platform} is not a platform (${PLATFORMS.join(', ')})`); continue; }
|
|
116
|
+
if (!isObject(row)) { problems.push(`models.map.${platform} must be an object`); continue; }
|
|
117
|
+
for (const [tier, value] of Object.entries(row)) {
|
|
118
|
+
if (!MAP_TIERS.includes(tier)) { problems.push(`models.map.${platform}.${tier} is not a tier (${MAP_TIERS.join(', ')})`); continue; }
|
|
119
|
+
if (typeof value !== 'string' || !value.trim()) problems.push(`models.map.${platform}.${tier} must be a non-empty string`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Every problem with the document, in reading order; [] when it is valid.
|
|
127
|
+
function validate(doc) {
|
|
128
|
+
if (!isObject(doc)) return ['must be a JSON object'];
|
|
129
|
+
const problems = [];
|
|
130
|
+
if (doc.schema !== 1) problems.push('schema must be 1');
|
|
131
|
+
const project = obj(doc.project);
|
|
132
|
+
if (!KINDS.includes(project.kind)) problems.push(`project.kind must be ${KINDS.join(' or ')}`);
|
|
133
|
+
for (const key of ['name', 'reason']) if (key in project) check(problems, `project.${key}`, project[key]);
|
|
134
|
+
if (!Array.isArray(doc.people)) problems.push('people must be a list');
|
|
135
|
+
else {
|
|
136
|
+
doc.people.forEach((person, i) => {
|
|
137
|
+
if (!isObject(person) || typeof person.name !== 'string' || !person.name.trim()) problems.push(`people[${i}] needs a name`);
|
|
138
|
+
if (isObject(person) && 'accepts' in person && typeof person.accepts !== 'boolean') problems.push(`people[${i}].accepts must be true or false`);
|
|
139
|
+
});
|
|
140
|
+
if (!doc.people.some((person) => isObject(person) && person.accepts === true)) problems.push('people must include at least one person with accepts: true (who accepts the result)');
|
|
141
|
+
}
|
|
142
|
+
checkSection(problems, doc, 'systems', DEFAULTS.systems);
|
|
143
|
+
checkSection(problems, doc, 'repo', DEFAULTS.repo);
|
|
144
|
+
if (doc.homes !== undefined) {
|
|
145
|
+
if (!isObject(doc.homes)) problems.push('homes must be an object');
|
|
146
|
+
else for (const [key, value] of Object.entries(doc.homes)) { const p = homeProblem(value); if (p) problems.push(`homes.${key} ${p}`); }
|
|
147
|
+
}
|
|
148
|
+
if (doc.stages !== undefined) {
|
|
149
|
+
if (!isObject(doc.stages)) problems.push('stages must be an object');
|
|
150
|
+
else {
|
|
151
|
+
for (const [name, stage] of Object.entries(doc.stages)) {
|
|
152
|
+
if (ALWAYS_ON.includes(name)) { problems.push(`stages.${name}: ${name} is always on; remove the entry`); continue; }
|
|
153
|
+
if (!OPTIONAL_STAGES.includes(name)) { problems.push(`stages.${name} is not a stage (stages: ${OPTIONAL_STAGES.join(', ')})`); continue; }
|
|
154
|
+
if (!isObject(stage)) { problems.push(`stages.${name} must be an object`); continue; }
|
|
155
|
+
if ('on' in stage && typeof stage.on !== 'boolean') problems.push(`stages.${name}.on must be true or false`);
|
|
156
|
+
if ('reason' in stage) check(problems, `stages.${name}.reason`, stage.reason);
|
|
157
|
+
for (const key of Object.keys(DEFAULT_STAGES[name])) if (key !== 'on' && key in stage) check(problems, `stages.${name}.${key}`, stage[key]);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (doc.roster !== undefined) {
|
|
162
|
+
if (!isObject(doc.roster)) problems.push('roster must be an object');
|
|
163
|
+
else {
|
|
164
|
+
for (const [stage, list] of Object.entries(doc.roster)) {
|
|
165
|
+
if (!ROSTER_STAGES.includes(stage)) { problems.push(`roster.${stage} is not a stage with a roster (${ROSTER_STAGES.join(', ')})`); continue; }
|
|
166
|
+
if (!Array.isArray(list)) { problems.push(`roster.${stage} must be a list of agent names`); continue; }
|
|
167
|
+
for (const name of list) if (!AGENT_NAMES.includes(name)) problems.push(`roster.${stage}: unknown agent ${JSON.stringify(name)} (agents: ${AGENT_NAMES.join(', ')})`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
checkModels(problems, doc);
|
|
172
|
+
if (doc.open !== undefined && (!Array.isArray(doc.open) || doc.open.some((item) => typeof item !== 'string'))) problems.push('open must be a list of strings');
|
|
173
|
+
if (doc.firstJob !== undefined) check(problems, 'firstJob', doc.firstJob);
|
|
174
|
+
return problems;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// A copy with every default filled. Unknown keys are kept; the always-on
|
|
178
|
+
// stages never appear under `stages`. Does not validate.
|
|
179
|
+
function normalize(doc) {
|
|
180
|
+
const src = obj(doc);
|
|
181
|
+
const out = { ...src };
|
|
182
|
+
out.schema = src.schema === undefined ? 1 : src.schema;
|
|
183
|
+
out.project = { ...DEFAULTS.project, ...obj(src.project) };
|
|
184
|
+
out.people = (Array.isArray(src.people) ? src.people : []).map((person) => ({ ...DEFAULTS.person, ...obj(person) }));
|
|
185
|
+
out.systems = { ...DEFAULTS.systems, ...obj(src.systems) };
|
|
186
|
+
out.systems.remote = stripUserinfo(out.systems.remote); // never carry credentials into the agreement
|
|
187
|
+
out.repo = { ...DEFAULTS.repo, ...obj(src.repo) };
|
|
188
|
+
out.repo.checks = Array.isArray(out.repo.checks) ? [...out.repo.checks] : [];
|
|
189
|
+
out.homes = Object.fromEntries(Object.entries({ ...DEFAULTS.homes, ...obj(src.homes) }).map(([key, value]) => [key, trimSlash(value)]));
|
|
190
|
+
const stages = obj(src.stages);
|
|
191
|
+
out.stages = {};
|
|
192
|
+
for (const name of OPTIONAL_STAGES) out.stages[name] = { ...DEFAULT_STAGES[name], ...obj(stages[name]) };
|
|
193
|
+
for (const [name, stage] of Object.entries(stages)) if (!(name in out.stages)) out.stages[name] = stage;
|
|
194
|
+
const roster = obj(src.roster);
|
|
195
|
+
out.roster = {};
|
|
196
|
+
for (const stage of ROSTER_STAGES) out.roster[stage] = Array.isArray(roster[stage]) ? [...roster[stage]] : [];
|
|
197
|
+
for (const [stage, list] of Object.entries(roster)) if (!(stage in out.roster)) out.roster[stage] = list;
|
|
198
|
+
const models = obj(src.models);
|
|
199
|
+
const modelEntry = (value) => (typeof value === 'string' ? { tier: value, effort: '' } : { ...MODEL_ENTRY, ...obj(value) });
|
|
200
|
+
const mStages = obj(models.stages), mAgents = obj(models.agents);
|
|
201
|
+
out.models = { stages: {}, agents: {}, map: {} };
|
|
202
|
+
for (const name of OPTIONAL_STAGES) out.models.stages[name] = modelEntry(mStages[name]);
|
|
203
|
+
for (const [name, value] of Object.entries(mStages)) if (!(name in out.models.stages)) out.models.stages[name] = value;
|
|
204
|
+
for (const name of AGENT_NAMES) out.models.agents[name] = modelEntry(mAgents[name]);
|
|
205
|
+
for (const [name, value] of Object.entries(mAgents)) if (!(name in out.models.agents)) out.models.agents[name] = value;
|
|
206
|
+
for (const [platform, row] of Object.entries(obj(models.map))) out.models.map[platform] = isObject(row) ? { ...row } : row;
|
|
207
|
+
out.open = Array.isArray(src.open) ? [...src.open] : [];
|
|
208
|
+
out.firstJob = typeof src.firstJob === 'string' ? src.firstJob : '';
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Read, parse, validate and normalize <root>/harness.json (or another file).
|
|
213
|
+
function load(root, file = path.join(root, HARNESS_FILE)) {
|
|
214
|
+
let raw;
|
|
215
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch (error) {
|
|
216
|
+
if (error.code === 'ENOENT') return { ok: false, missing: true, message: MISSING_MESSAGE };
|
|
217
|
+
return { ok: false, problems: [error.message], message: error.message };
|
|
218
|
+
}
|
|
219
|
+
let doc;
|
|
220
|
+
try { doc = JSON.parse(raw); } catch (error) {
|
|
221
|
+
const problem = `invalid JSON (${error.message})`;
|
|
222
|
+
return { ok: false, problems: [problem], message: problem };
|
|
223
|
+
}
|
|
224
|
+
const problems = validate(doc);
|
|
225
|
+
if (problems.length) return { ok: false, problems, message: problems.join('; ') };
|
|
226
|
+
return { ok: true, harness: normalize(doc), file };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// True when the stage runs: the always-on two, and every other stage unless
|
|
230
|
+
// the agreement switched it off. Unknown names are off.
|
|
231
|
+
function stageOn(harness, name) {
|
|
232
|
+
if (ALWAYS_ON.includes(name)) return true;
|
|
233
|
+
if (!OPTIONAL_STAGES.includes(name)) return false;
|
|
234
|
+
const stage = obj(obj(harness).stages)[name];
|
|
235
|
+
return isObject(stage) ? stage.on !== false : true;
|
|
236
|
+
}
|
|
237
|
+
// Where specs, tickets and evidence live, relative to the root, defaults filled.
|
|
238
|
+
function homes(harness) {
|
|
239
|
+
return Object.fromEntries(Object.entries({ ...DEFAULTS.homes, ...obj(obj(harness).homes) }).map(([key, value]) => [key, trimSlash(value)]));
|
|
240
|
+
}
|
|
241
|
+
// The names in the rosters of the stages that are on, sorted and unique.
|
|
242
|
+
function activeAgents(harness) {
|
|
243
|
+
const h = obj(harness);
|
|
244
|
+
const names = new Set();
|
|
245
|
+
for (const stage of ROSTER_STAGES) {
|
|
246
|
+
if (!stageOn(h, stage)) continue;
|
|
247
|
+
const list = obj(h.roster)[stage];
|
|
248
|
+
if (Array.isArray(list)) for (const name of list) if (typeof name === 'string') names.add(name);
|
|
249
|
+
}
|
|
250
|
+
return [...names].sort();
|
|
251
|
+
}
|
|
252
|
+
// The names of the people who accept results.
|
|
253
|
+
function acceptors(harness) {
|
|
254
|
+
const people = obj(harness).people;
|
|
255
|
+
return (Array.isArray(people) ? people : [])
|
|
256
|
+
.filter((person) => isObject(person) && person.accepts === true && typeof person.name === 'string' && person.name.trim())
|
|
257
|
+
.map((person) => person.name.trim());
|
|
258
|
+
}
|
|
259
|
+
// The entry points' convention for a load failure: one line, exit 3.
|
|
260
|
+
function die(message, exit = 3) {
|
|
261
|
+
process.stderr.write(`${HARNESS_FILE}: ${message}\n`);
|
|
262
|
+
process.exit(exit);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = {
|
|
266
|
+
HARNESS_FILE, STAGES, ALWAYS_ON, OPTIONAL_STAGES, ROSTER_STAGES, AGENT_NAMES, KINDS, DEFAULT_STAGES, DEFAULTS, MISSING_MESSAGE,
|
|
267
|
+
TIERS, MAP_TIERS, EFFORTS, PLATFORMS,
|
|
268
|
+
load, validate, normalize, stageOn, homes, acceptors, activeAgents, die,
|
|
269
|
+
};
|