pincer-workflow 0.4.1 → 0.6.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 +9 -7
- package/bin/pincer.js +58 -5
- package/package.json +3 -3
- package/template/.agents/skills/pincer-code/SKILL.md +117 -12
- package/template/.agents/skills/pincer-evaluate/SKILL.md +57 -10
- package/template/.agents/skills/pincer-narrow/SKILL.md +48 -8
- package/template/.agents/skills/pincer-plan/SKILL.md +12 -4
- package/template/.agents/skills/pincer-release/SKILL.md +32 -3
- package/template/.agents/skills/pincer-status/SKILL.md +25 -2
- package/template/.claude/commands/pincer-code.md +117 -12
- package/template/.claude/commands/pincer-evaluate.md +57 -10
- package/template/.claude/commands/pincer-narrow.md +48 -8
- package/template/.claude/commands/pincer-plan.md +12 -4
- package/template/.claude/commands/pincer-release.md +32 -3
- package/template/.claude/commands/pincer-status.md +25 -2
- package/template/.claude/hooks/hook-policy.cjs +24 -3
- package/template/.claude/references/prd-template.md +11 -4
- package/template/.claude/references/ticket-template.md +4 -0
- package/template/.codex/README.md +3 -2
- package/template/.github/prompts/pincer-code.prompt.md +117 -12
- package/template/.github/prompts/pincer-evaluate.prompt.md +57 -10
- package/template/.github/prompts/pincer-narrow.prompt.md +48 -8
- package/template/.github/prompts/pincer-plan.prompt.md +12 -4
- package/template/.github/prompts/pincer-release.prompt.md +32 -3
- package/template/.github/prompts/pincer-status.prompt.md +25 -2
- package/template/AGENTS.md +22 -0
- package/template/docs/dry-run-checklist.md +70 -6
- package/template/docs/release-checklist.md +5 -2
- package/template/docs/runtime-contracts.md +1683 -0
- package/template/scripts/pincer-evidence.cjs +13 -229
- package/template/scripts/pincer-runtime/adopt.cjs +132 -0
- package/template/scripts/pincer-runtime/agreement.cjs +240 -0
- package/template/scripts/pincer-runtime/authorization.cjs +167 -0
- package/template/scripts/pincer-runtime/changes.cjs +517 -0
- package/template/scripts/pincer-runtime/checks.cjs +48 -0
- package/template/scripts/pincer-runtime/coverage.cjs +361 -0
- package/template/scripts/pincer-runtime/dispositions.cjs +95 -0
- package/template/scripts/pincer-runtime/evidence.cjs +676 -0
- package/template/scripts/pincer-runtime/fsutil.cjs +37 -0
- package/template/scripts/pincer-runtime/gates.cjs +73 -0
- package/template/scripts/pincer-runtime/identity.cjs +163 -0
- package/template/scripts/pincer-runtime/impact.cjs +177 -0
- package/template/scripts/pincer-runtime/io.cjs +41 -0
- package/template/scripts/pincer-runtime/lifecycle.cjs +311 -0
- package/template/scripts/pincer-runtime/locator.cjs +158 -0
- package/template/scripts/pincer-runtime/migrate.cjs +204 -0
- package/template/scripts/pincer-runtime/parse.cjs +316 -0
- package/template/scripts/pincer-runtime/phases.cjs +245 -0
- package/template/scripts/pincer-runtime/readiness.cjs +97 -0
- package/template/scripts/pincer-runtime/requirements.cjs +255 -0
- package/template/scripts/pincer-runtime/resume.cjs +205 -0
- package/template/scripts/pincer-runtime/routing.cjs +54 -0
- package/template/scripts/pincer-runtime/runner.cjs +242 -0
- package/template/scripts/pincer-runtime/sanitize.cjs +63 -0
- package/template/scripts/pincer-runtime/source.cjs +129 -0
- package/template/scripts/pincer-runtime/state.cjs +314 -0
- package/template/scripts/pincer-runtime/status.cjs +514 -0
- package/template/scripts/pincer-runtime/transaction.cjs +200 -0
- package/template/scripts/pincer-runtime/transitions.cjs +134 -0
- package/template/scripts/pincer-runtime.cjs +661 -0
- package/template/scripts/pincer-status.sh +11 -162
- package/template/scripts/pincer-ticket.sh +19 -139
- package/template/scripts/pincer-ticket-lib.sh +0 -321
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER runtime — migration (docs/runtime-contracts.md, "Migration and
|
|
3
|
+
// rollback"). Preview is read-only; apply is one transaction that backs up every
|
|
4
|
+
// authored file it changes, strips legacy receipts into the change record's
|
|
5
|
+
// `legacy.receipts` (history, never runtime evidence), converts a v0.5.0 schema 1
|
|
6
|
+
// binding into a schema 2 record (same id, base and registration; the free-text
|
|
7
|
+
// authorization retained as unvalidated history), rewrites the local candidate
|
|
8
|
+
// pointers that no longer resolve, ignores .pincer/, and selects the change in
|
|
9
|
+
// this worktree. The migrated change is planned with no authorization; existing
|
|
10
|
+
// attempts and evaluations stay history until verified again. Conflicts fail
|
|
11
|
+
// closed before the first write; repeated apply is a no-op.
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const parse = require('./parse.cjs');
|
|
15
|
+
const identity = require('./identity.cjs');
|
|
16
|
+
const changes = require('./changes.cjs');
|
|
17
|
+
const statusModule = require('./status.cjs');
|
|
18
|
+
const state = require('./state.cjs');
|
|
19
|
+
const transaction = require('./transaction.cjs');
|
|
20
|
+
const { fmUnset } = require('./lifecycle.cjs');
|
|
21
|
+
const { nowIso, readJson } = require('./fsutil.cjs');
|
|
22
|
+
|
|
23
|
+
const IGNORE_LINE = '.pincer/';
|
|
24
|
+
const gitignoreHas = root => changes.gitignoreHas(fs.existsSync(path.join(root, '.gitignore')) ? fs.readFileSync(path.join(root, '.gitignore'), 'utf8') : '');
|
|
25
|
+
const OLD_CANDIDATE_KEY = /^candidate:([0-9a-f]{40}):(C-[0-9]{2,6})$/;
|
|
26
|
+
|
|
27
|
+
// Compute the plan. Returns { prd, change, source: 'legacy' | 'binding' | 'record',
|
|
28
|
+
// conflicts, binding: { file, existing, action }, tickets, gitignore, index,
|
|
29
|
+
// selection, alreadyMigrated, partial, authorization }.
|
|
30
|
+
function plan(root, { prd, change, authorization = null } = {}) {
|
|
31
|
+
const conflicts = [];
|
|
32
|
+
const conflict = (code, detail) => conflicts.push({ code, detail });
|
|
33
|
+
const prdResult = parse.validatePrd(root, prd);
|
|
34
|
+
if (!prdResult.ok) { conflict('INPUT_INVALID', `${prdResult.file || prd}: ${prdResult.problems[0]}`); return { prd, conflicts }; }
|
|
35
|
+
let id = change || `prd-v${prd.match(parse.PRD_REF)[1]}`;
|
|
36
|
+
if (!changes.CHANGE_ID.test(id)) conflict('INPUT_INVALID', `change ID must match [a-z0-9][a-z0-9-]{0,63}: ${id}`);
|
|
37
|
+
if (!identity.head(root)) conflict('UNSUPPORTED_INPUT', 'migration needs a git repository with at least one commit');
|
|
38
|
+
const scan = changes.scan(root);
|
|
39
|
+
let source = 'legacy', binding = null, existing = null, bindingFile = null;
|
|
40
|
+
if (scan.mode === 'invalid') for (const p of scan.problems) conflict(p.code, p.detail);
|
|
41
|
+
else if (scan.mode === 'migrated') {
|
|
42
|
+
const bindings = scan.entries.filter(e => e.schema === 1);
|
|
43
|
+
const bad = scan.entries.filter(e => e.code);
|
|
44
|
+
if (bindings.length > 1 || bad.length) conflict('AMBIGUOUS', `several files under .prd/changes/ (${scan.entries.map(e => path.basename(e.file)).join(', ')}); keep exactly one v0.5.0 binding`);
|
|
45
|
+
else {
|
|
46
|
+
const b = bindings[0].doc;
|
|
47
|
+
const invalid = identity.validateBinding(b);
|
|
48
|
+
if (invalid) conflict(/schema|runtime contract/.test(invalid) ? 'UNSUPPORTED_SCHEMA' : 'INPUT_INVALID', `${bindings[0].file}: ${invalid}`);
|
|
49
|
+
else if (path.basename(bindings[0].file, '.json') !== b.change) conflict('INPUT_INVALID', `${bindings[0].file}: filename does not match change "${b.change}"`);
|
|
50
|
+
else if (b.prd !== prd) conflict('AMBIGUOUS', `${bindings[0].file} binds ${b.prd}, not ${prd}; a v0.5.0 binding is converted first — migrate it with: node scripts/pincer-runtime.cjs migrate --preview --prd ${b.prd}, then register ${prd}`);
|
|
51
|
+
else if (change && change !== b.change) conflict('AMBIGUOUS', `${bindings[0].file} already binds ${prd} as change "${b.change}"; pass --change ${b.change}`);
|
|
52
|
+
else { id = b.change; source = 'binding'; binding = b; bindingFile = bindings[0].file; }
|
|
53
|
+
}
|
|
54
|
+
} else if (scan.mode === 'changes') {
|
|
55
|
+
const loaded = changes.loadRecords(root);
|
|
56
|
+
for (const p of loaded.problems) conflict(p.code, p.detail);
|
|
57
|
+
if (!loaded.problems.length) {
|
|
58
|
+
const owner = changes.ownerOf(loaded, prd);
|
|
59
|
+
if (owner && owner[0] !== id) conflict('INPUT_INVALID', `${prd} is owned by change "${owner[0]}" (${owner[1].file}); pass --change ${owner[0]}`);
|
|
60
|
+
else {
|
|
61
|
+
const rec = loaded.records.get(id);
|
|
62
|
+
if (rec && rec.record.prd !== prd) conflict('INPUT_INVALID', `${rec.file} names change "${id}" for ${rec.record.prd}; choose another --change ID for ${prd}`);
|
|
63
|
+
else if (rec) { existing = rec; source = 'record'; }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const set = parse.validateTicketSet(root);
|
|
68
|
+
if (!set.ok) conflict('INPUT_INVALID', `pincer-ticket: ${set.file ? `${set.file}: ` : ''}${set.problems[0]}`);
|
|
69
|
+
const tickets = [];
|
|
70
|
+
if (set.ok) {
|
|
71
|
+
for (const file of set.files) {
|
|
72
|
+
const text = fs.readFileSync(path.join(root, file), 'utf8');
|
|
73
|
+
const fields = parse.validateTicket(file, text).fields;
|
|
74
|
+
const assoc = statusModule.ticketPrd(root, file, fields);
|
|
75
|
+
if (assoc.problem) { conflict('INPUT_INVALID', `pincer: ${assoc.problem}`); continue; }
|
|
76
|
+
if (assoc.prd !== prd) continue;
|
|
77
|
+
if (fields.verified || fields.last_check) tickets.push({ file, id: fields.ticket, verified: fields.verified || null, last_check: fields.last_check || null, text });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const gitignore = !gitignoreHas(root);
|
|
81
|
+
// Local pointers of a converted binding: candidate keys gain the change segment.
|
|
82
|
+
let index = null;
|
|
83
|
+
if (source === 'binding' && state.exists(root)) {
|
|
84
|
+
const read = state.readIndex(root);
|
|
85
|
+
if (read.error) conflict('INPUT_INVALID', read.error);
|
|
86
|
+
else {
|
|
87
|
+
const stale = Object.keys(read.index.current).filter(k => OLD_CANDIDATE_KEY.test(k));
|
|
88
|
+
if (stale.length) index = { stale, doc: read.index };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const sel = changes.readSelection(root);
|
|
92
|
+
const selection = sel.code || sel.change !== id ? id : null;
|
|
93
|
+
const alreadyMigrated = source === 'record' && tickets.length === 0 && !gitignore;
|
|
94
|
+
const partial = source === 'record' && tickets.length > 0;
|
|
95
|
+
return {
|
|
96
|
+
prd, change: id, source, authorization, conflicts, bindingFile,
|
|
97
|
+
binding: { file: changes.recordFile(id), existing: existing ? existing.record : binding, bindingFile, action: source === 'binding' ? 'convert' : source === 'record' ? (partial ? 'complete' : 'keep') : 'register' },
|
|
98
|
+
tickets, gitignore, index, selection, alreadyMigrated, partial,
|
|
99
|
+
historical: { attempts: state.exists(root) ? state.listAttempts(root).filter(a => a.schema === 1).length : 0 },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function renderPlan(p) {
|
|
104
|
+
const lines = [];
|
|
105
|
+
if (p.conflicts.length) {
|
|
106
|
+
for (const c of p.conflicts) lines.push(`conflict ${c.code}: ${c.detail}`);
|
|
107
|
+
lines.push('migration refused: resolve the conflicts above; nothing was written');
|
|
108
|
+
return lines.join('\n') + '\n';
|
|
109
|
+
}
|
|
110
|
+
lines.push(`migration plan for ${p.prd} (change ${p.change})`);
|
|
111
|
+
if (p.alreadyMigrated) { lines.push(' already migrated: change record present, no legacy receipts remain, .pincer/ ignored'); if (p.selection) lines.push(` selection .pincer/runtime/selection.json → ${p.change} is not set in this worktree; select with: node scripts/pincer-runtime.cjs change select ${p.change}`); return lines.join('\n') + '\n'; }
|
|
112
|
+
if (p.source === 'binding') {
|
|
113
|
+
const b = p.binding.existing;
|
|
114
|
+
lines.push(` binding ${p.bindingFile || p.binding.file} (v0.5.0, schema 1) → change record (schema 2) at the same path: change ${b.change}, base ${b.base.slice(0, 7)} and registration ${b.registered} kept; ${Object.keys(b.legacy_receipts).length} receipt(s) already imported carried over`);
|
|
115
|
+
if (b.authorization) lines.push(` note the binding's authorization text "${b.authorization}" is retained as legacy.authorization_text (unvalidated history); it never authorizes execution`);
|
|
116
|
+
} else if (p.source === 'record') lines.push(` record ${p.binding.file} (present; ${p.tickets.length} receipt(s) imported into legacy.receipts by a migrate event)`);
|
|
117
|
+
else lines.push(` record ${p.binding.file} (new schema 2 record; base = HEAD; planned)`);
|
|
118
|
+
if (p.partial) lines.push(' note an earlier migration was partially applied (record present, receipts remain); apply completes it');
|
|
119
|
+
for (const t of p.tickets) lines.push(` ticket ${t.file}: remove ${[t.verified ? 'verified' : null, t.last_check ? 'last_check' : null].filter(Boolean).join(', ')} → legacy.receipts[${t.id}] (history, not runtime evidence)`);
|
|
120
|
+
if (!p.tickets.length) lines.push(' tickets no legacy receipts to import');
|
|
121
|
+
lines.push(p.gitignore ? ` gitignore add \`${IGNORE_LINE}\`` : ' gitignore already ignores .pincer/');
|
|
122
|
+
if (p.index) lines.push(` index .pincer/runtime/index.json: ${p.index.stale.length} candidate pointer(s) dropped — a v0.5.0 attempt record cannot satisfy the change-scoped key, so its check is run again (attempt records untouched, kept as history)`);
|
|
123
|
+
lines.push(` history ${p.historical.attempts} existing attempt(s) and any saved evaluation stay history (HISTORICAL_EVIDENCE) until verified again; the change is planned with no authorization`);
|
|
124
|
+
if (p.selection) lines.push(` selection .pincer/runtime/selection.json → ${p.change} (this worktree only; a fresh clone selects explicitly)`);
|
|
125
|
+
lines.push(' backups .pincer/backups/<timestamp>/ for every changed authored file (tickets, .gitignore, the binding, the local index)');
|
|
126
|
+
if (p.authorization) lines.push(` note --authorization "${p.authorization}" is retained as legacy.authorization_text (unvalidated history); record the user's actual instruction afterwards with: node scripts/pincer-runtime.cjs change authorize ${p.change} --agreement <digest> --reference <text> --excerpt <text>`);
|
|
127
|
+
else if (!(p.source === 'binding' && p.binding.existing.authorization)) lines.push(` note no --authorization given; migration grants no authorization — record the user's instruction afterwards with: node scripts/pincer-runtime.cjs change authorize ${p.change} …`);
|
|
128
|
+
lines.push(`apply with: node scripts/pincer-runtime.cjs migrate --apply --prd ${p.prd}${p.change !== `prd-v${p.prd.match(parse.PRD_REF)[1]}` ? ` --change ${p.change}` : ''}`);
|
|
129
|
+
return lines.join('\n') + '\n';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Apply the plan as one transaction: backups first (copies of the originals),
|
|
133
|
+
// then tickets, .gitignore, the record, the index and the selection together.
|
|
134
|
+
function apply(root, options) {
|
|
135
|
+
const first = plan(root, options);
|
|
136
|
+
if (first.conflicts.length) return { plan: first, applied: false };
|
|
137
|
+
if (first.alreadyMigrated) return { plan: first, applied: false, already: true };
|
|
138
|
+
const stamp = nowIso().replace(/[-:]/g, '');
|
|
139
|
+
const backupDir = path.join(root, '.pincer', 'backups', stamp);
|
|
140
|
+
const backups = [];
|
|
141
|
+
const backup = rel => {
|
|
142
|
+
const src = path.join(root, rel);
|
|
143
|
+
if (!fs.existsSync(src)) return;
|
|
144
|
+
const dest = path.join(backupDir, rel);
|
|
145
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
146
|
+
fs.copyFileSync(src, dest);
|
|
147
|
+
backups.push(`.pincer/backups/${stamp}/${rel}`);
|
|
148
|
+
};
|
|
149
|
+
try {
|
|
150
|
+
const out = transaction.run(root, { command: `migrate --apply ${options.prd}`, hooks: options.hooks || null }, ctx => {
|
|
151
|
+
const p = plan(root, options);
|
|
152
|
+
if (p.conflicts.length) ctx.refuse(p.conflicts[0].code, p.conflicts[0].detail);
|
|
153
|
+
if (p.alreadyMigrated) return { plan: p, already: true };
|
|
154
|
+
for (const t of p.tickets) backup(t.file);
|
|
155
|
+
if (p.gitignore) backup('.gitignore');
|
|
156
|
+
if (p.source === 'binding') backup(p.bindingFile);
|
|
157
|
+
if (p.index) backup(`${state.RUNTIME_DIR}/index.json`);
|
|
158
|
+
const receipts = {};
|
|
159
|
+
for (const t of p.tickets) {
|
|
160
|
+
receipts[t.id] = { verified: t.verified, last_check: t.last_check };
|
|
161
|
+
ctx.write(t.file, fmUnset(fmUnset(t.text, 'verified'), 'last_check'));
|
|
162
|
+
}
|
|
163
|
+
if (p.gitignore) {
|
|
164
|
+
const existing = ctx.text('.gitignore') || '';
|
|
165
|
+
const lead = existing && !existing.endsWith('\n') ? '\n' : '';
|
|
166
|
+
ctx.write('.gitignore', `${existing}${lead}${existing ? '\n' : ''}# pincer runtime state (added by migrate)\n${IGNORE_LINE}\n`);
|
|
167
|
+
}
|
|
168
|
+
let record;
|
|
169
|
+
if (p.source === 'record') {
|
|
170
|
+
record = JSON.parse(JSON.stringify(p.binding.existing));
|
|
171
|
+
record.legacy.receipts = { ...record.legacy.receipts, ...receipts };
|
|
172
|
+
if (options.authorization) record.legacy.authorization_text = options.authorization;
|
|
173
|
+
const sequence = record.sequence + 1;
|
|
174
|
+
record.events.push({ sequence, kind: 'migrate', from: record.lifecycle.state, to: record.lifecycle.state, at: ctx.now, reason: null, agreement: null, authorization: null, decision: null, replacement: null, note: `${Object.keys(receipts).length} legacy receipt(s) imported` });
|
|
175
|
+
record.sequence = sequence;
|
|
176
|
+
} else if (p.source === 'binding') {
|
|
177
|
+
const b = p.binding.existing;
|
|
178
|
+
record = changes.newRecord({ change: b.change, prd: b.prd, base: b.base, now: ctx.now, kind: 'migrate', legacy: { receipts: { ...b.legacy_receipts, ...receipts }, authorization_text: options.authorization || b.authorization || null, migrated_from: 'binding', migrated: ctx.now } });
|
|
179
|
+
record.registered = b.registered;
|
|
180
|
+
record.events[0].note = `converted from the v0.5.0 binding (registered ${b.registered})`;
|
|
181
|
+
} else {
|
|
182
|
+
record = changes.newRecord({ change: p.change, prd: p.prd, base: identity.head(root), now: ctx.now, kind: 'migrate', legacy: { receipts, authorization_text: options.authorization || null, migrated_from: 'legacy', migrated: ctx.now } });
|
|
183
|
+
}
|
|
184
|
+
const invalid = changes.validateRecord(record, p.binding.file);
|
|
185
|
+
if (invalid) ctx.refuse(invalid.code, invalid.problem);
|
|
186
|
+
ctx.write(p.binding.file, record);
|
|
187
|
+
if (p.index) {
|
|
188
|
+
const doc = p.index.doc;
|
|
189
|
+
for (const key of p.index.stale) delete doc.current[key];
|
|
190
|
+
ctx.write(`${state.RUNTIME_DIR}/index.json`, doc);
|
|
191
|
+
}
|
|
192
|
+
if (p.selection) ctx.write(changes.SELECTION_FILE, { schema: 1, change: p.change, selected: ctx.now });
|
|
193
|
+
return { plan: p, record };
|
|
194
|
+
});
|
|
195
|
+
if (out.result.already) return { plan: out.result.plan, applied: false, already: true };
|
|
196
|
+
return { plan: out.result.plan, applied: true, record: out.result.record, backups, backupDir: backups.length ? `.pincer/backups/${stamp}/` : null };
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error.refusal) return { plan: first, applied: false, error: error.message, code: error.code, backups };
|
|
199
|
+
if (error.code === 'STATE_BUSY') return { plan: first, applied: false, error: error.message, code: 'STATE_BUSY', backups };
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = { plan, renderPlan, apply, IGNORE_LINE, gitignoreHas };
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER runtime — parser and validator for the supported Markdown grammar
|
|
3
|
+
// (docs/runtime-contracts.md, "Supported grammar" and "Content revisions").
|
|
4
|
+
// Dependency-free. The rules and diagnostics match the v0.4.1 Bash validators
|
|
5
|
+
// so the two never disagree; the Node module is now the only implementation.
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const crypto = require('node:crypto');
|
|
9
|
+
const { spawnSync } = require('node:child_process');
|
|
10
|
+
|
|
11
|
+
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
|
12
|
+
const PRD_REF = /^\.prd\/prd-v([1-9][0-9]{0,8})\.md$/;
|
|
13
|
+
const HEX40 = /^[0-9a-f]{40}$/;
|
|
14
|
+
const DEFAULT_TIMEOUT = 600;
|
|
15
|
+
const MAX_TIMEOUT = 2147483; // setTimeout's 32-bit millisecond limit, in seconds
|
|
16
|
+
const LIFECYCLE_FIELDS = ['status', 'started', 'finished', 'verified', 'last_check'];
|
|
17
|
+
const CHECKBOX_LINE = /^([ \t]*(?:[-+*]|[0-9]+[.)])[ \t]+)\[([ xX])\]/;
|
|
18
|
+
|
|
19
|
+
const sha256 = text => crypto.createHash('sha256').update(text).digest('hex');
|
|
20
|
+
const lines = text => {
|
|
21
|
+
const all = String(text).split('\n');
|
|
22
|
+
if (all.length && all[all.length - 1] === '') all.pop();
|
|
23
|
+
return all;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// CLI shorthand -> canonical ID ("3" -> "T-03"); null when not a ticket id.
|
|
27
|
+
function normalizeId(input) {
|
|
28
|
+
let n = String(input);
|
|
29
|
+
if (n.startsWith('T-') || n.startsWith('t-')) n = n.slice(2);
|
|
30
|
+
if (!/^[0-9]{1,6}$/.test(n) || Number(n) === 0) return null;
|
|
31
|
+
return `T-${String(Number(n)).padStart(2, '0')}`;
|
|
32
|
+
}
|
|
33
|
+
function canonicalId(s) {
|
|
34
|
+
if (typeof s !== 'string' || !/^T-[0-9][0-9]+$/.test(s)) return false;
|
|
35
|
+
const n = s.slice(2);
|
|
36
|
+
return n.length <= 6 && Number(n) > 0 && `T-${String(Number(n)).padStart(2, '0')}` === s;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Frontmatter: `---`, unindented unique `key: value` lines (inline `#` comments,
|
|
40
|
+
// blank and comment lines allowed), `---`. Returns the fields and the index of
|
|
41
|
+
// the closing line, or the single structural problem.
|
|
42
|
+
function parseFrontmatter(text, kind = 'ticket') {
|
|
43
|
+
const rows = lines(text);
|
|
44
|
+
const fields = {};
|
|
45
|
+
const order = [];
|
|
46
|
+
if (rows[0] !== '---') return { problem: 'frontmatter must begin with ---', rows };
|
|
47
|
+
for (let i = 1; i < rows.length; i++) {
|
|
48
|
+
const line = rows[i];
|
|
49
|
+
if (line === '---') return { fields, order, end: i, rows };
|
|
50
|
+
if (/^[ \t]*(#.*)?$/.test(line)) continue;
|
|
51
|
+
if (!/^[a-z_][a-z0-9_]*:[ \t]*/.test(line)) {
|
|
52
|
+
return { problem: kind === 'ticket' ? 'frontmatter requires unindented key: value fields' : 'expected unindented key: value metadata', rows };
|
|
53
|
+
}
|
|
54
|
+
const key = line.slice(0, line.indexOf(':'));
|
|
55
|
+
if (key in fields) return { problem: `duplicate ${kind === 'ticket' ? 'frontmatter' : 'metadata'} key: ${key}`, rows };
|
|
56
|
+
let value = line.slice(key.length + 1);
|
|
57
|
+
value = value.replace(/#.*/, '').trim();
|
|
58
|
+
fields[key] = value;
|
|
59
|
+
order.push(key);
|
|
60
|
+
}
|
|
61
|
+
return { problem: 'frontmatter must close with ---', rows };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Read one frontmatter field the way the Bash helper did (first match, comment
|
|
65
|
+
// stripped), tolerating files without valid frontmatter.
|
|
66
|
+
function frontmatterField(text, key) {
|
|
67
|
+
const fm = parseFrontmatter(text);
|
|
68
|
+
if (fm.problem && fm.problem !== 'frontmatter must close with ---') {
|
|
69
|
+
if (fm.problem === 'frontmatter must begin with ---') return '';
|
|
70
|
+
}
|
|
71
|
+
for (const line of lines(text).slice(1)) {
|
|
72
|
+
if (line === '---') break;
|
|
73
|
+
if (line.startsWith(`${key}:`)) return line.slice(key.length + 1).replace(/#.*/, '').trim();
|
|
74
|
+
}
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function verificationCommands(text) {
|
|
79
|
+
const out = [];
|
|
80
|
+
let inBlock = false, otherFence = false, inVerify = false;
|
|
81
|
+
for (const line of lines(text)) {
|
|
82
|
+
if (inBlock) { if (/^[ \t]*```[ \t]*$/.test(line)) break; out.push(line); continue; }
|
|
83
|
+
if (otherFence) { if (/^[ \t]*```/.test(line)) otherFence = false; continue; }
|
|
84
|
+
if (/^## Verification[ \t]*$/.test(line)) { inVerify = true; continue; }
|
|
85
|
+
if (inVerify && /^[ \t]*```bash[ \t]*$/.test(line)) { inBlock = true; continue; }
|
|
86
|
+
if (inVerify && /^## /.test(line)) break;
|
|
87
|
+
if (/^[ \t]*```/.test(line)) otherFence = true;
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
// The block as the legacy helper hashed and executed it: joined lines, one trailing newline.
|
|
92
|
+
const blockText = text => { const c = verificationCommands(text); return c.length ? `${c.join('\n')}\n` : ''; };
|
|
93
|
+
const legacyBlockHash = text => sha256(blockText(text)).slice(0, 12);
|
|
94
|
+
|
|
95
|
+
function unticked(text) {
|
|
96
|
+
const out = [];
|
|
97
|
+
let otherFence = false, inAccept = false;
|
|
98
|
+
for (const line of lines(text)) {
|
|
99
|
+
if (otherFence) { if (/^[ \t]*```/.test(line)) otherFence = false; continue; }
|
|
100
|
+
if (/^[ \t]*```/.test(line)) { otherFence = true; continue; }
|
|
101
|
+
if (/^## Acceptance Criteria[ \t]*$/.test(line)) { inAccept = true; continue; }
|
|
102
|
+
if (inAccept && /^## /.test(line)) break;
|
|
103
|
+
if (inAccept && /^[ \t]*([-+*]|[0-9]+[.)])[ \t]+\[ \]/.test(line)) out.push(line);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function effectiveTimeout(fields) {
|
|
109
|
+
return fields.timeout ? Number(fields.timeout) : DEFAULT_TIMEOUT;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Full ticket validation. Returns { ok, problems, fields, timeout }.
|
|
113
|
+
function validateTicket(file, text) {
|
|
114
|
+
const problems = [];
|
|
115
|
+
const fm = parseFrontmatter(text, 'ticket');
|
|
116
|
+
if (fm.problem) return { ok: false, problems: [fm.problem], fields: {} };
|
|
117
|
+
const { fields, rows } = fm;
|
|
118
|
+
let inVerify = false, otherFence = false, section = '';
|
|
119
|
+
let accept = 0, verify = 0, boxes = 0, fences = 0, runnable = 0;
|
|
120
|
+
for (let i = fm.end + 1; i < rows.length; i++) {
|
|
121
|
+
const line = rows[i];
|
|
122
|
+
if (inVerify) {
|
|
123
|
+
if (/^[ \t]*```[ \t]*$/.test(line)) { inVerify = false; continue; }
|
|
124
|
+
if (/^[ \t]*```/.test(line)) { problems.push('Verification requires one closed bash fence'); continue; }
|
|
125
|
+
if (!/^[ \t]*(#.*)?$/.test(line)) runnable++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (otherFence) { if (/^[ \t]*```/.test(line)) otherFence = false; continue; }
|
|
129
|
+
if (/^## Acceptance Criteria[ \t]*$/.test(line)) { if (accept++) problems.push('duplicate Acceptance Criteria section'); section = 'accept'; continue; }
|
|
130
|
+
if (/^## Verification[ \t]*$/.test(line)) { if (verify++) problems.push('duplicate Verification section'); section = 'verify'; continue; }
|
|
131
|
+
if (/^## /.test(line)) { section = ''; continue; }
|
|
132
|
+
if (section === 'accept') {
|
|
133
|
+
if (/^[ \t]*(```|~~~)/.test(line)) { problems.push('Acceptance Criteria must contain visible checkboxes, not fences'); continue; }
|
|
134
|
+
if (/^[ \t]*([-+*]|[0-9]+[.)])[ \t]+/.test(line) || /^[ \t]*([-+*]|[0-9]+[.)])?[ \t]*\[/.test(line)) {
|
|
135
|
+
if (!/^[ \t]*([-+*]|[0-9]+[.)])[ \t]+\[[ xX]\][ \t]+[^ \t]/.test(line)) {
|
|
136
|
+
problems.push('malformed acceptance checkbox; use - [ ] text or - [x] text (indented/list marker variants supported)');
|
|
137
|
+
}
|
|
138
|
+
boxes++;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (/^[ \t]*~~~/.test(line)) { problems.push('use backtick fences; tilde fences are unsupported'); continue; }
|
|
142
|
+
if (/^[ \t]*```/.test(line)) {
|
|
143
|
+
if (section === 'verify') {
|
|
144
|
+
if (!/^[ \t]*```bash[ \t]*$/.test(line) || fences++) problems.push('Verification requires exactly one bash fence');
|
|
145
|
+
inVerify = true;
|
|
146
|
+
} else otherFence = true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!('ticket' in fields) || !canonicalId(fields.ticket)) problems.push('ticket must be a canonical ID between T-01 and T-999999');
|
|
150
|
+
else {
|
|
151
|
+
const base = path.basename(file);
|
|
152
|
+
const prefix = `${fields.ticket}-`;
|
|
153
|
+
if (!base.startsWith(prefix) || !/.+\.md$/.test(base) || base.length <= prefix.length + 3) problems.push('ticket ID must match filename T-NN-slug.md');
|
|
154
|
+
}
|
|
155
|
+
if (!['open', 'in_progress', 'done'].includes(fields.status)) problems.push('status must be open, in_progress, or done');
|
|
156
|
+
if (!['S', 'M', 'L'].includes(fields.size)) problems.push('size must be S, M, or L');
|
|
157
|
+
for (const key of ['started', 'finished']) if (key in fields && !TIMESTAMP.test(fields[key])) problems.push(`${key} must be an ISO UTC timestamp`);
|
|
158
|
+
for (const key of ['verified', 'last_check']) {
|
|
159
|
+
if (!(key in fields)) continue;
|
|
160
|
+
const parts = fields[key].split(/[ \t]+/);
|
|
161
|
+
const expected = key === 'verified' ? 2 : 3;
|
|
162
|
+
if (parts.length !== expected || !TIMESTAMP.test(parts[0]) || !/^[0-9a-f]{12}$/.test(parts[parts.length - 1])) problems.push(`malformed ${key} timestamp/hash`);
|
|
163
|
+
else if (key === 'last_check' && !['running', 'passed', 'failed', 'interrupted'].includes(parts[1])) problems.push('invalid last_check outcome');
|
|
164
|
+
}
|
|
165
|
+
if ('timeout' in fields && (!/^[1-9][0-9]{0,8}$/.test(fields.timeout) || Number(fields.timeout) > MAX_TIMEOUT)) problems.push(`timeout must be a positive integer number of seconds (at most ${MAX_TIMEOUT})`);
|
|
166
|
+
const deps = fields.depends_on;
|
|
167
|
+
if (!('depends_on' in fields) || !/^\[[ \t]*(T-[0-9]+([ \t]*,[ \t]*T-[0-9]+)*)?[ \t]*\]$/.test(deps)) problems.push('depends_on must be an inline list such as [T-01, T-02]');
|
|
168
|
+
else {
|
|
169
|
+
const seen = new Set();
|
|
170
|
+
for (const entry of deps.replace(/^\[[ \t]*/, '').replace(/[ \t]*\]$/, '').split(',')) {
|
|
171
|
+
const dep = entry.trim();
|
|
172
|
+
if (dep === '') continue;
|
|
173
|
+
if (!canonicalId(dep)) problems.push(`depends_on contains noncanonical ticket ID: ${dep}`);
|
|
174
|
+
if (dep === fields.ticket) problems.push('ticket cannot depend on itself');
|
|
175
|
+
if (seen.has(dep)) problems.push(`duplicate depends_on ID: ${dep}`);
|
|
176
|
+
seen.add(dep);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!accept || !boxes) problems.push('Acceptance Criteria requires at least one nonempty checkbox');
|
|
180
|
+
if (!verify || fences !== 1 || inVerify || !runnable) problems.push('Verification requires exactly one closed runnable bash fence');
|
|
181
|
+
if (problems.length === 0) {
|
|
182
|
+
const syntax = spawnSync('bash', ['-n', '-c', blockText(text)], { encoding: 'utf8' });
|
|
183
|
+
if (syntax.error) problems.push(`cannot run bash to check the Verification block (${syntax.error.message})`);
|
|
184
|
+
else if (syntax.status !== 0) problems.push('invalid bash syntax in Verification block');
|
|
185
|
+
}
|
|
186
|
+
return { ok: problems.length === 0, problems, fields, timeout: effectiveTimeout(fields) };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function dependencies(fields) {
|
|
190
|
+
return ((fields.depends_on || '').match(/T-[0-9]+/g) || []);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// PRDs and evaluation notes use the same deliberately small metadata format.
|
|
194
|
+
function validateMetadata(text) {
|
|
195
|
+
const fm = parseFrontmatter(text, 'metadata');
|
|
196
|
+
return fm.problem ? { ok: false, problems: [fm.problem], fields: {} } : { ok: true, problems: [], fields: fm.fields };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function validatePrd(root, ref) {
|
|
200
|
+
const match = typeof ref === 'string' ? ref.match(PRD_REF) : null;
|
|
201
|
+
if (!match) return { ok: false, problems: [`invalid PRD reference ${ref}; use .prd/prd-vN.md`], prefix: 'pincer' };
|
|
202
|
+
const file = path.join(root, ref);
|
|
203
|
+
if (!fs.existsSync(file)) return { ok: false, problems: [`PRD does not exist: ${ref}`], prefix: 'pincer' };
|
|
204
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
205
|
+
const meta = validateMetadata(text);
|
|
206
|
+
if (!meta.ok) return { ok: false, problems: meta.problems, file: ref };
|
|
207
|
+
const problems = [];
|
|
208
|
+
const { fields } = meta;
|
|
209
|
+
if (fields.version !== match[1]) problems.push(`version must match filename (${match[1]})`);
|
|
210
|
+
if (!['draft', 'ticketed', 'built'].includes(fields.status)) problems.push('PRD status must be draft, ticketed, or built');
|
|
211
|
+
if (!['', 'small', 'standard', undefined].includes(fields.profile)) problems.push('profile must be small or standard (omit for standard)');
|
|
212
|
+
return { ok: problems.length === 0, problems, file: ref, fields, text, profile: fields.profile || 'standard' };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Normalizations (contract "Content revisions"): lifecycle fields and checkbox
|
|
216
|
+
// marks are the only exceptions; everything else contributes to identity.
|
|
217
|
+
function normalizeTicket(text) {
|
|
218
|
+
const rows = lines(text);
|
|
219
|
+
const out = [];
|
|
220
|
+
let closed = rows[0] !== '---';
|
|
221
|
+
let otherFence = false, inAccept = false;
|
|
222
|
+
for (let i = 0; i < rows.length; i++) {
|
|
223
|
+
const line = rows[i];
|
|
224
|
+
if (!closed) {
|
|
225
|
+
if (i > 0 && line === '---') closed = true;
|
|
226
|
+
else if (i > 0) {
|
|
227
|
+
const key = (line.match(/^([a-z_][a-z0-9_]*):/) || [])[1];
|
|
228
|
+
if (key && LIFECYCLE_FIELDS.includes(key)) continue;
|
|
229
|
+
}
|
|
230
|
+
out.push(line);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (otherFence) { if (/^[ \t]*```/.test(line)) otherFence = false; out.push(line); continue; }
|
|
234
|
+
if (/^[ \t]*```/.test(line)) { otherFence = true; out.push(line); continue; }
|
|
235
|
+
if (/^## Acceptance Criteria[ \t]*$/.test(line)) inAccept = true;
|
|
236
|
+
else if (/^## /.test(line)) inAccept = false;
|
|
237
|
+
out.push(inAccept ? line.replace(CHECKBOX_LINE, '$1[ ]') : line);
|
|
238
|
+
}
|
|
239
|
+
return `${out.join('\n')}\n`;
|
|
240
|
+
}
|
|
241
|
+
function normalizePrd(text) {
|
|
242
|
+
const rows = lines(text);
|
|
243
|
+
const out = [];
|
|
244
|
+
let closed = rows[0] !== '---';
|
|
245
|
+
for (let i = 0; i < rows.length; i++) {
|
|
246
|
+
const line = rows[i];
|
|
247
|
+
if (!closed) {
|
|
248
|
+
if (i > 0 && line === '---') closed = true;
|
|
249
|
+
else if (i > 0 && /^status:/.test(line)) continue;
|
|
250
|
+
}
|
|
251
|
+
out.push(line);
|
|
252
|
+
}
|
|
253
|
+
return `${out.join('\n')}\n`;
|
|
254
|
+
}
|
|
255
|
+
// Split a ticket text into the parts the structural agreement difference names:
|
|
256
|
+
// the frontmatter, the Acceptance Criteria section, the Verification section and
|
|
257
|
+
// everything else (headings inside fenced blocks are ignored, as elsewhere).
|
|
258
|
+
function ticketSections(text) {
|
|
259
|
+
const rows = lines(text);
|
|
260
|
+
const parts = { frontmatter: [], acceptance: [], verification: [], other: [] };
|
|
261
|
+
let inFront = rows[0] === '---', section = 'other', fence = false;
|
|
262
|
+
for (let i = 0; i < rows.length; i++) {
|
|
263
|
+
const line = rows[i];
|
|
264
|
+
if (inFront) { parts.frontmatter.push(line); if (i > 0 && line === '---') inFront = false; continue; }
|
|
265
|
+
if (fence) { parts[section].push(line); if (/^[ \t]*```/.test(line)) fence = false; continue; }
|
|
266
|
+
if (/^[ \t]*```/.test(line)) { fence = true; parts[section].push(line); continue; }
|
|
267
|
+
if (/^## Acceptance Criteria[ \t]*$/.test(line)) section = 'acceptance';
|
|
268
|
+
else if (/^## Verification[ \t]*$/.test(line)) section = 'verification';
|
|
269
|
+
else if (/^## /.test(line)) section = 'other';
|
|
270
|
+
parts[section].push(line);
|
|
271
|
+
}
|
|
272
|
+
return Object.fromEntries(Object.entries(parts).map(([k, v]) => [k, v.join('\n')]));
|
|
273
|
+
}
|
|
274
|
+
const ticketDigest = text => sha256(normalizeTicket(text));
|
|
275
|
+
const prdDigest = text => sha256(normalizePrd(text));
|
|
276
|
+
const checkDigest = (text, timeoutSeconds = DEFAULT_TIMEOUT) => sha256(`${blockText(text)}timeout=${timeoutSeconds}\n`);
|
|
277
|
+
|
|
278
|
+
// tickets/T-NN-*.md resolution and set validation.
|
|
279
|
+
function ticketFiles(root) {
|
|
280
|
+
const dir = path.join(root, 'tickets');
|
|
281
|
+
if (!fs.existsSync(dir)) return [];
|
|
282
|
+
return fs.readdirSync(dir).filter(name => /^T-[0-9]+.*\.md$/.test(name)).sort().map(name => `tickets/${name}`);
|
|
283
|
+
}
|
|
284
|
+
function ticketFile(root, input) {
|
|
285
|
+
const id = normalizeId(input);
|
|
286
|
+
if (!id) return { problem: `not a ticket id (expected 1..999999): ${input}` };
|
|
287
|
+
const matches = ticketFiles(root).filter(f => path.basename(f).startsWith(`${id}-`));
|
|
288
|
+
if (matches.length > 1) return { problem: `several files match tickets/${id}-*.md` };
|
|
289
|
+
if (matches.length === 0) return { problem: `no ticket file tickets/${id}-*.md` };
|
|
290
|
+
return { id, file: matches[0] };
|
|
291
|
+
}
|
|
292
|
+
function validateTicketSet(root) {
|
|
293
|
+
const files = ticketFiles(root);
|
|
294
|
+
const ids = new Set();
|
|
295
|
+
for (const file of files) {
|
|
296
|
+
const id = frontmatterField(fs.readFileSync(path.join(root, file), 'utf8'), 'ticket');
|
|
297
|
+
if (id) {
|
|
298
|
+
if (ids.has(id)) return { ok: false, problems: [`duplicate ticket ID: ${id}`] };
|
|
299
|
+
ids.add(id);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
for (const file of files) {
|
|
303
|
+
const result = validateTicket(file, fs.readFileSync(path.join(root, file), 'utf8'));
|
|
304
|
+
if (!result.ok) return { ok: false, file, problems: result.problems };
|
|
305
|
+
}
|
|
306
|
+
return { ok: true, files };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
module.exports = {
|
|
310
|
+
TIMESTAMP, PRD_REF, HEX40, DEFAULT_TIMEOUT, MAX_TIMEOUT, LIFECYCLE_FIELDS,
|
|
311
|
+
sha256, lines, normalizeId, canonicalId, parseFrontmatter, frontmatterField,
|
|
312
|
+
verificationCommands, blockText, legacyBlockHash, unticked, effectiveTimeout,
|
|
313
|
+
validateTicket, dependencies, validateMetadata, validatePrd,
|
|
314
|
+
normalizeTicket, normalizePrd, ticketDigest, prdDigest, checkDigest, ticketSections,
|
|
315
|
+
ticketFiles, ticketFile, validateTicketSet,
|
|
316
|
+
};
|