pincer-workflow 0.6.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 -106
- 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 -41
- package/template/.claude/commands/pincer-blueprint.md +7 -0
- package/template/.claude/commands/pincer-status.md +4 -45
- 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 -41
- 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 -237
- package/template/.agents/skills/pincer-evaluate/SKILL.md +0 -178
- package/template/.agents/skills/pincer-narrow/SKILL.md +0 -138
- 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 -235
- package/template/.claude/commands/pincer-evaluate.md +0 -176
- package/template/.claude/commands/pincer-narrow.md +0 -136
- 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 -237
- package/template/.github/prompts/pincer-evaluate.prompt.md +0 -178
- package/template/.github/prompts/pincer-narrow.prompt.md +0 -138
- 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 -1683
- 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 -97
- package/template/scripts/pincer-runtime/requirements.cjs +0 -255
- package/template/scripts/pincer-runtime/resume.cjs +0 -205
- 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/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 -661
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER engine — model tiers (docs/engine.md, "Models"). The agreement names a
|
|
3
|
+
// tier per role; the library's models.json maps a tier to each platform's model
|
|
4
|
+
// name; the agreement's map overrides a row; a library agent file may carry a
|
|
5
|
+
// tier: default. Pure except the two readers. Dependency-free.
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const harness = require('./harness.cjs');
|
|
9
|
+
|
|
10
|
+
const TABLE_FILE = 'models.json';
|
|
11
|
+
const { TIERS, MAP_TIERS, PLATFORMS, OPTIONAL_STAGES } = harness;
|
|
12
|
+
const SESSION = "the session's model";
|
|
13
|
+
|
|
14
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
15
|
+
const filled = (value) => typeof value === 'string' && value.trim() !== '';
|
|
16
|
+
|
|
17
|
+
// The library's table. A missing entry is not a fault (the generator reports it
|
|
18
|
+
// when a tier needs it); a present entry that is not a non-empty string is.
|
|
19
|
+
function readTable(libraryDir) {
|
|
20
|
+
const file = path.join(libraryDir, TABLE_FILE);
|
|
21
|
+
let raw;
|
|
22
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch (error) {
|
|
23
|
+
if (error.code === 'ENOENT') return { ok: false, missing: true, problem: `${TABLE_FILE}: no such file` };
|
|
24
|
+
return { ok: false, problem: `${TABLE_FILE}: ${error.message}` };
|
|
25
|
+
}
|
|
26
|
+
let table;
|
|
27
|
+
try { table = JSON.parse(raw); } catch (error) { return { ok: false, problem: `${TABLE_FILE}: invalid JSON (${error.message})` }; }
|
|
28
|
+
if (!isObject(table) || table.schema !== 1) return { ok: false, problem: `${TABLE_FILE}: schema must be 1` };
|
|
29
|
+
for (const platform of PLATFORMS) {
|
|
30
|
+
if (table[platform] === undefined) continue;
|
|
31
|
+
if (!isObject(table[platform])) return { ok: false, problem: `${TABLE_FILE}: ${platform} must be an object` };
|
|
32
|
+
for (const tier of MAP_TIERS) {
|
|
33
|
+
const value = table[platform][tier];
|
|
34
|
+
if (value !== undefined && !filled(value)) return { ok: false, problem: `${TABLE_FILE}: ${platform}.${tier} must be a non-empty string` };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { ok: true, table };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The table with the agreement's map laid over it; a copy, the inputs untouched.
|
|
41
|
+
function overlay(table, map) {
|
|
42
|
+
const out = { ...(isObject(table) ? table : {}) };
|
|
43
|
+
for (const platform of PLATFORMS) {
|
|
44
|
+
const row = { ...(isObject(out[platform]) ? out[platform] : {}) };
|
|
45
|
+
const over = isObject(map) && isObject(map[platform]) ? map[platform] : {};
|
|
46
|
+
for (const tier of MAP_TIERS) if (filled(over[tier])) row[tier] = over[tier];
|
|
47
|
+
out[platform] = row;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// null: no model field (inherit or not set); undefined: the table has no entry.
|
|
53
|
+
function modelFor(table, platform, tier) {
|
|
54
|
+
if (!tier || tier === 'inherit') return null;
|
|
55
|
+
const row = isObject(table) && isObject(table[platform]) ? table[platform] : {};
|
|
56
|
+
return filled(row[tier]) ? row[tier] : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The tier: field of a library agent's frontmatter; '' when absent.
|
|
60
|
+
function libraryTier(libraryDir, name) {
|
|
61
|
+
let text;
|
|
62
|
+
try { text = fs.readFileSync(path.join(libraryDir, 'agents', `${name}.md`), 'utf8'); } catch { return ''; }
|
|
63
|
+
const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
64
|
+
const match = block && /^tier:\s*["']?([a-z]+)["']?\s*$/m.exec(block[1]);
|
|
65
|
+
return match && TIERS.includes(match[1]) ? match[1] : '';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// What each role runs on: every optional stage that is on and every active
|
|
69
|
+
// agent. The agreement's tier wins, then the library's default, then ''.
|
|
70
|
+
function effective(doc, libraryDir) {
|
|
71
|
+
const h = harness.normalize(doc);
|
|
72
|
+
const out = { stages: {}, agents: {} };
|
|
73
|
+
for (const stage of OPTIONAL_STAGES) {
|
|
74
|
+
if (!harness.stageOn(h, stage)) continue;
|
|
75
|
+
const entry = isObject(h.models.stages[stage]) ? h.models.stages[stage] : {};
|
|
76
|
+
out.stages[stage] = { tier: filled(entry.tier) ? entry.tier : '', effort: filled(entry.effort) ? entry.effort : '' };
|
|
77
|
+
}
|
|
78
|
+
for (const name of harness.activeAgents(h)) {
|
|
79
|
+
const entry = isObject(h.models.agents[name]) ? h.models.agents[name] : {};
|
|
80
|
+
const agreed = filled(entry.tier) ? entry.tier : '';
|
|
81
|
+
const fallback = agreed ? '' : libraryTier(libraryDir, name);
|
|
82
|
+
out.agents[name] = { tier: agreed || fallback, effort: filled(entry.effort) ? entry.effort : '', source: agreed ? 'agreement' : fallback ? 'library' : 'none' };
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 'strong', 'strong/high', 'effort high', or '' for an unset role.
|
|
88
|
+
function describeEntry(entry) {
|
|
89
|
+
const tier = entry && filled(entry.tier) ? entry.tier : '';
|
|
90
|
+
const effort = entry && filled(entry.effort) ? entry.effort : '';
|
|
91
|
+
if (tier && effort) return `${tier}/${effort}`;
|
|
92
|
+
if (tier) return tier;
|
|
93
|
+
if (effort) return `effort ${effort}`;
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
96
|
+
const agentNames = (eff) => Object.keys(isObject(eff.agents) ? eff.agents : {}).sort();
|
|
97
|
+
const stageNames = (eff) => OPTIONAL_STAGES.filter((stage) => isObject(eff.stages) && eff.stages[stage]);
|
|
98
|
+
|
|
99
|
+
// The status line: stages, then agents; '' when nothing is set.
|
|
100
|
+
function statusLine(eff) {
|
|
101
|
+
const stages = stageNames(eff).filter((s) => describeEntry(eff.stages[s])).map((s) => `${s} ${describeEntry(eff.stages[s])}`);
|
|
102
|
+
const agents = agentNames(eff).filter((n) => describeEntry(eff.agents[n])).map((n) => `${n} ${describeEntry(eff.agents[n])}`);
|
|
103
|
+
return [stages.join(', '), agents.join(', ')].filter(Boolean).join('; ');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The agreement's Models line: stages, then agents grouped by tier.
|
|
107
|
+
function agreementLine(eff) {
|
|
108
|
+
const onWhat = (tier) => (tier && tier !== 'inherit' ? tier : SESSION);
|
|
109
|
+
const note = (entry) => (filled(entry.effort) ? ` (${entry.effort} effort)` : '');
|
|
110
|
+
const list = (items) => (items.length > 1 ? `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}` : items[0]);
|
|
111
|
+
const stages = stageNames(eff).filter((s) => describeEntry(eff.stages[s])).map((s) => `${s} on ${onWhat(eff.stages[s].tier)}${note(eff.stages[s])}`);
|
|
112
|
+
const groups = [];
|
|
113
|
+
for (const tier of [...TIERS, '']) {
|
|
114
|
+
const names = agentNames(eff).filter((n) => describeEntry(eff.agents[n]) && (filled(eff.agents[n].tier) ? eff.agents[n].tier : '') === tier);
|
|
115
|
+
if (names.length) groups.push(`${list(names.map((n) => `${n}${note(eff.agents[n])}`))} on ${onWhat(tier)}`);
|
|
116
|
+
}
|
|
117
|
+
if (!stages.length && !groups.length) return `${SESSION} everywhere.`;
|
|
118
|
+
return [stages.join(', '), ...groups, `everything else on ${SESSION}.`].filter(Boolean).join('; ');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { TABLE_FILE, readTable, overlay, modelFor, libraryTier, effective, describeEntry, statusLine, agreementLine };
|
|
@@ -1,27 +1,29 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// PINCER
|
|
3
|
-
// (docs/
|
|
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.
|
|
2
|
+
// PINCER engine — parser and validator for the ticket and spec grammar
|
|
3
|
+
// (docs/engine.md, "Ticket fields and receipts"). Dependency-free.
|
|
6
4
|
const fs = require('node:fs');
|
|
7
5
|
const path = require('node:path');
|
|
8
6
|
const crypto = require('node:crypto');
|
|
9
7
|
const { spawnSync } = require('node:child_process');
|
|
10
8
|
|
|
11
9
|
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
|
12
|
-
const
|
|
13
|
-
const HEX40 = /^[0-9a-f]{40}$/;
|
|
10
|
+
const SPEC_REF = /^[^\s]+\.md$/;
|
|
14
11
|
const DEFAULT_TIMEOUT = 600;
|
|
15
12
|
const MAX_TIMEOUT = 2147483; // setTimeout's 32-bit millisecond limit, in seconds
|
|
16
|
-
|
|
17
|
-
const
|
|
13
|
+
// The fields only scripts/pincer-ticket.sh writes; the hook protects the same list.
|
|
14
|
+
const LIFECYCLE_FIELDS = ['status', 'started', 'last_check', 'verified', 'finished', 'accepted', 'accepted_by', 'accepted_words'];
|
|
15
|
+
// Keys whose value is kept verbatim: the inline-comment rule (`#` to end of
|
|
16
|
+
// line) does not apply, because the person's words may contain `#`.
|
|
17
|
+
const VERBATIM_KEYS = new Set(['accepted_words']);
|
|
18
|
+
const SPEC_STATUSES = ['draft', 'approved', 'built'];
|
|
18
19
|
|
|
19
|
-
const sha256 = text => crypto.createHash('sha256').update(text).digest('hex');
|
|
20
|
-
const lines = text => {
|
|
20
|
+
const sha256 = (text) => crypto.createHash('sha256').update(text).digest('hex');
|
|
21
|
+
const lines = (text) => {
|
|
21
22
|
const all = String(text).split('\n');
|
|
22
23
|
if (all.length && all[all.length - 1] === '') all.pop();
|
|
23
24
|
return all;
|
|
24
25
|
};
|
|
26
|
+
const fieldValue = (key, raw) => (VERBATIM_KEYS.has(key) ? raw : raw.replace(/#.*/, '')).trim();
|
|
25
27
|
|
|
26
28
|
// CLI shorthand -> canonical ID ("3" -> "T-03"); null when not a ticket id.
|
|
27
29
|
function normalizeId(input) {
|
|
@@ -53,24 +55,20 @@ function parseFrontmatter(text, kind = 'ticket') {
|
|
|
53
55
|
}
|
|
54
56
|
const key = line.slice(0, line.indexOf(':'));
|
|
55
57
|
if (key in fields) return { problem: `duplicate ${kind === 'ticket' ? 'frontmatter' : 'metadata'} key: ${key}`, rows };
|
|
56
|
-
|
|
57
|
-
value = value.replace(/#.*/, '').trim();
|
|
58
|
-
fields[key] = value;
|
|
58
|
+
fields[key] = fieldValue(key, line.slice(key.length + 1));
|
|
59
59
|
order.push(key);
|
|
60
60
|
}
|
|
61
61
|
return { problem: 'frontmatter must close with ---', rows };
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
// Read one frontmatter field
|
|
65
|
-
//
|
|
64
|
+
// Read one frontmatter field (first match), tolerating files whose
|
|
65
|
+
// frontmatter does not validate; '' when absent.
|
|
66
66
|
function frontmatterField(text, key) {
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
for (const line of lines(text).slice(1)) {
|
|
67
|
+
const rows = lines(text);
|
|
68
|
+
if (rows[0] !== '---') return '';
|
|
69
|
+
for (const line of rows.slice(1)) {
|
|
72
70
|
if (line === '---') break;
|
|
73
|
-
if (line.startsWith(`${key}:`)) return line.slice(key.length + 1)
|
|
71
|
+
if (line.startsWith(`${key}:`)) return fieldValue(key, line.slice(key.length + 1));
|
|
74
72
|
}
|
|
75
73
|
return '';
|
|
76
74
|
}
|
|
@@ -88,9 +86,10 @@ function verificationCommands(text) {
|
|
|
88
86
|
}
|
|
89
87
|
return out;
|
|
90
88
|
}
|
|
91
|
-
// The block as
|
|
92
|
-
const blockText = text => { const c = verificationCommands(text); return c.length ? `${c.join('\n')}\n` : ''; };
|
|
93
|
-
|
|
89
|
+
// The block as it is hashed and executed: joined lines, one trailing newline.
|
|
90
|
+
const blockText = (text) => { const c = verificationCommands(text); return c.length ? `${c.join('\n')}\n` : ''; };
|
|
91
|
+
// Twelve hex characters of sha256 over the block: the receipt hash.
|
|
92
|
+
const blockHash = (text) => sha256(blockText(text)).slice(0, 12);
|
|
94
93
|
|
|
95
94
|
function unticked(text) {
|
|
96
95
|
const out = [];
|
|
@@ -109,6 +108,13 @@ function effectiveTimeout(fields) {
|
|
|
109
108
|
return fields.timeout ? Number(fields.timeout) : DEFAULT_TIMEOUT;
|
|
110
109
|
}
|
|
111
110
|
|
|
111
|
+
// A spec reference is a relative path inside the repository to a .md file.
|
|
112
|
+
function specRefProblem(ref) {
|
|
113
|
+
if (typeof ref !== 'string' || !SPEC_REF.test(ref)) return 'must be a relative path to a .md file, such as specs/spec-v1.md';
|
|
114
|
+
if (ref.startsWith('/') || ref.split(/[\\/]/).includes('..')) return 'must stay inside the repository (no leading / or ..)';
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
112
118
|
// Full ticket validation. Returns { ok, problems, fields, timeout }.
|
|
113
119
|
function validateTicket(file, text) {
|
|
114
120
|
const problems = [];
|
|
@@ -154,7 +160,7 @@ function validateTicket(file, text) {
|
|
|
154
160
|
}
|
|
155
161
|
if (!['open', 'in_progress', 'done'].includes(fields.status)) problems.push('status must be open, in_progress, or done');
|
|
156
162
|
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`);
|
|
163
|
+
for (const key of ['started', 'finished', 'accepted']) if (key in fields && !TIMESTAMP.test(fields[key])) problems.push(`${key} must be an ISO UTC timestamp`);
|
|
158
164
|
for (const key of ['verified', 'last_check']) {
|
|
159
165
|
if (!(key in fields)) continue;
|
|
160
166
|
const parts = fields[key].split(/[ \t]+/);
|
|
@@ -162,7 +168,15 @@ function validateTicket(file, text) {
|
|
|
162
168
|
if (parts.length !== expected || !TIMESTAMP.test(parts[0]) || !/^[0-9a-f]{12}$/.test(parts[parts.length - 1])) problems.push(`malformed ${key} timestamp/hash`);
|
|
163
169
|
else if (key === 'last_check' && !['running', 'passed', 'failed', 'interrupted'].includes(parts[1])) problems.push('invalid last_check outcome');
|
|
164
170
|
}
|
|
171
|
+
// The floor: an acceptance is a timestamp, a named person and their words, all three or none.
|
|
172
|
+
const acceptance = ['accepted', 'accepted_by', 'accepted_words'].filter((key) => key in fields);
|
|
173
|
+
if (acceptance.length) {
|
|
174
|
+
for (const key of ['accepted', 'accepted_by', 'accepted_words']) {
|
|
175
|
+
if (!(key in fields) || fields[key] === '') problems.push(`${key} is required when a ticket is accepted (write it with pincer-ticket.sh accept)`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
165
178
|
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})`);
|
|
179
|
+
if ('spec' in fields) { const problem = specRefProblem(fields.spec); if (problem) problems.push(`spec ${problem}`); }
|
|
166
180
|
const deps = fields.depends_on;
|
|
167
181
|
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
182
|
else {
|
|
@@ -190,127 +204,63 @@ function dependencies(fields) {
|
|
|
190
204
|
return ((fields.depends_on || '').match(/T-[0-9]+/g) || []);
|
|
191
205
|
}
|
|
192
206
|
|
|
193
|
-
//
|
|
194
|
-
function
|
|
195
|
-
const
|
|
196
|
-
|
|
207
|
+
// <ticketsDir>/T-NN-*.md resolution, paths relative to the root.
|
|
208
|
+
function ticketFiles(root, ticketsDir = 'tickets') {
|
|
209
|
+
const dir = path.join(root, ticketsDir);
|
|
210
|
+
if (!fs.existsSync(dir)) return [];
|
|
211
|
+
return fs.readdirSync(dir).filter((name) => /^T-[0-9]+.*\.md$/.test(name)).sort().map((name) => path.posix.join(ticketsDir, name));
|
|
212
|
+
}
|
|
213
|
+
function ticketFile(root, ticketsDir, input) {
|
|
214
|
+
const id = normalizeId(input);
|
|
215
|
+
if (!id) return { problem: `not a ticket id (expected 1..999999): ${input}` };
|
|
216
|
+
const matches = ticketFiles(root, ticketsDir).filter((f) => path.basename(f).startsWith(`${id}-`));
|
|
217
|
+
if (matches.length > 1) return { problem: `several files match ${path.posix.join(ticketsDir, id)}-*.md` };
|
|
218
|
+
if (matches.length === 0) return { problem: `no ticket file ${path.posix.join(ticketsDir, id)}-*.md` };
|
|
219
|
+
return { id, file: matches[0] };
|
|
197
220
|
}
|
|
198
221
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
222
|
+
// A spec: closed frontmatter with status draft, approved or built.
|
|
223
|
+
function validateSpec(root, ref) {
|
|
224
|
+
const problem = specRefProblem(ref);
|
|
225
|
+
if (problem) return { ok: false, problems: [`spec ${problem}: ${ref}`], fields: {} };
|
|
202
226
|
const file = path.join(root, ref);
|
|
203
|
-
if (!fs.existsSync(file)) return { ok: false, problems: [`
|
|
227
|
+
if (!fs.existsSync(file)) return { ok: false, problems: [`spec does not exist: ${ref}`], fields: {} };
|
|
204
228
|
const text = fs.readFileSync(file, 'utf8');
|
|
205
|
-
const
|
|
206
|
-
if (
|
|
229
|
+
const fm = parseFrontmatter(text, 'spec');
|
|
230
|
+
if (fm.problem) return { ok: false, problems: [`${ref}: ${fm.problem}`], fields: {} };
|
|
207
231
|
const problems = [];
|
|
208
|
-
|
|
209
|
-
|
|
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' };
|
|
232
|
+
if (!SPEC_STATUSES.includes(fm.fields.status)) problems.push(`${ref}: status must be ${SPEC_STATUSES.join(', ')}`);
|
|
233
|
+
return { ok: problems.length === 0, problems, fields: fm.fields, file: ref, text };
|
|
213
234
|
}
|
|
214
235
|
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
function
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
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`;
|
|
236
|
+
// Set or remove one frontmatter field, keeping every other line as it is. A
|
|
237
|
+
// value is kept to one line. The text must have a closed frontmatter.
|
|
238
|
+
function frontmatterEnd(rows, key) {
|
|
239
|
+
if (rows[0] !== '---') throw new Error(`cannot edit ${key}: frontmatter must begin with ---`);
|
|
240
|
+
const end = rows.indexOf('---', 1);
|
|
241
|
+
if (end === -1) throw new Error(`cannot edit ${key}: frontmatter must close with ---`);
|
|
242
|
+
return end;
|
|
240
243
|
}
|
|
241
|
-
function
|
|
244
|
+
function fmSet(text, key, value) {
|
|
242
245
|
const rows = lines(text);
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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`;
|
|
246
|
+
const end = frontmatterEnd(rows, key);
|
|
247
|
+
const line = `${key}: ${String(value).replace(/\r?\n/g, ' ')}`;
|
|
248
|
+
const at = rows.findIndex((row, i) => i > 0 && i < end && row.startsWith(`${key}:`));
|
|
249
|
+
if (at === -1) rows.splice(end, 0, line); else rows[at] = line;
|
|
250
|
+
return `${rows.join('\n')}\n`;
|
|
254
251
|
}
|
|
255
|
-
|
|
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) {
|
|
252
|
+
function fmUnset(text, key) {
|
|
259
253
|
const rows = lines(text);
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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 };
|
|
254
|
+
const end = frontmatterEnd(rows, key);
|
|
255
|
+
const at = rows.findIndex((row, i) => i > 0 && i < end && row.startsWith(`${key}:`));
|
|
256
|
+
if (at === -1) return text;
|
|
257
|
+
rows.splice(at, 1);
|
|
258
|
+
return `${rows.join('\n')}\n`;
|
|
307
259
|
}
|
|
308
260
|
|
|
309
261
|
module.exports = {
|
|
310
|
-
TIMESTAMP,
|
|
262
|
+
TIMESTAMP, SPEC_REF, DEFAULT_TIMEOUT, MAX_TIMEOUT, LIFECYCLE_FIELDS, SPEC_STATUSES,
|
|
311
263
|
sha256, lines, normalizeId, canonicalId, parseFrontmatter, frontmatterField,
|
|
312
|
-
verificationCommands, blockText,
|
|
313
|
-
validateTicket, dependencies,
|
|
314
|
-
normalizeTicket, normalizePrd, ticketDigest, prdDigest, checkDigest, ticketSections,
|
|
315
|
-
ticketFiles, ticketFile, validateTicketSet,
|
|
264
|
+
verificationCommands, blockText, blockHash, unticked, effectiveTimeout,
|
|
265
|
+
validateTicket, dependencies, ticketFiles, ticketFile, validateSpec, fmSet, fmUnset,
|
|
316
266
|
};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER blueprint — repository recon (docs/engine.md, "Recon"). Reads git, the
|
|
3
|
+
// filesystem and, only when asked, gh; decides greenfield or brownfield and says
|
|
4
|
+
// why; pre-fills the parts of harness.json it could see. Offline by default.
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { execFileSync } = require('node:child_process');
|
|
8
|
+
const util = require('./util.cjs');
|
|
9
|
+
|
|
10
|
+
// Directories never counted as source: dependencies, build output, the kit's own code.
|
|
11
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.claude', 'vendor', '.venv', 'venv', 'dist', 'build', 'target', '__pycache__']);
|
|
12
|
+
const SKIP_PATHS = new Set(['scripts/pincer']);
|
|
13
|
+
const MAX_ENTRIES = 50000; // walk budget; past it sourceFiles is a lower bound
|
|
14
|
+
const LANGUAGES = {
|
|
15
|
+
js: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript', jsx: 'JavaScript',
|
|
16
|
+
ts: 'TypeScript', mts: 'TypeScript', cts: 'TypeScript', tsx: 'TypeScript',
|
|
17
|
+
py: 'Python', go: 'Go', rs: 'Rust', java: 'Java', kt: 'Kotlin', scala: 'Scala',
|
|
18
|
+
rb: 'Ruby', php: 'PHP', cs: 'C#', swift: 'Swift', m: 'Objective-C', dart: 'Dart',
|
|
19
|
+
c: 'C', h: 'C', cpp: 'C++', cc: 'C++', hpp: 'C++', zig: 'Zig',
|
|
20
|
+
sh: 'Shell', bash: 'Shell', zsh: 'Shell', sql: 'SQL', vue: 'Vue', svelte: 'Svelte',
|
|
21
|
+
ex: 'Elixir', exs: 'Elixir', clj: 'Clojure', hs: 'Haskell', lua: 'Lua', r: 'R',
|
|
22
|
+
};
|
|
23
|
+
const TEST_PATH = /(^|\/)(tests?|spec|__tests__)\/|\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)test_[^/]+\.py$|_test\.(py|go|rb|rs)$|_spec\.rb$/;
|
|
24
|
+
const DEPLOY_FILES = ['vercel.json', 'netlify.toml', 'fly.toml', 'Dockerfile', 'render.yaml', 'Procfile', 'serverless.yml', 'wrangler.toml'];
|
|
25
|
+
const INSTRUCTION_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.github/copilot-instructions.md', '.cursorrules', 'CONTRIBUTING.md', 'README.md'];
|
|
26
|
+
const LOCKFILES = [
|
|
27
|
+
['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'], ['bun.lockb', 'bun'], ['bun.lock', 'bun'], ['package-lock.json', 'npm'],
|
|
28
|
+
['package.json', 'npm'], ['uv.lock', 'uv'], ['poetry.lock', 'poetry'], ['Pipfile', 'pipenv'], ['requirements.txt', 'pip'],
|
|
29
|
+
['Cargo.toml', 'cargo'], ['go.mod', 'go'], ['Gemfile', 'bundler'], ['composer.json', 'composer'],
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const exists = (root, rel) => fs.existsSync(path.join(root, rel));
|
|
33
|
+
const isDir = (root, rel) => { try { return fs.statSync(path.join(root, rel)).isDirectory(); } catch { return false; } };
|
|
34
|
+
const listFiles = (root, rel) => (isDir(root, rel)
|
|
35
|
+
? fs.readdirSync(path.join(root, rel), { withFileTypes: true }).filter((e) => e.isFile()).map((e) => `${rel}/${e.name}`).sort()
|
|
36
|
+
: []);
|
|
37
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
38
|
+
|
|
39
|
+
// Source files, languages by count and whether anything looks like a test.
|
|
40
|
+
function walk(root) {
|
|
41
|
+
const counts = new Map();
|
|
42
|
+
let sourceFiles = 0, testFiles = 0, budget = MAX_ENTRIES;
|
|
43
|
+
const visit = (rel) => {
|
|
44
|
+
let entries;
|
|
45
|
+
try { entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true }); } catch { return; }
|
|
46
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (budget-- <= 0) return;
|
|
49
|
+
const child = rel ? `${rel}/${entry.name}` : entry.name;
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
if (!SKIP_DIRS.has(entry.name) && !SKIP_PATHS.has(child)) visit(child);
|
|
52
|
+
} else if (entry.isFile()) {
|
|
53
|
+
const language = LANGUAGES[path.extname(entry.name).slice(1).toLowerCase()];
|
|
54
|
+
if (!language) continue;
|
|
55
|
+
sourceFiles += 1;
|
|
56
|
+
counts.set(language, (counts.get(language) || 0) + 1);
|
|
57
|
+
if (TEST_PATH.test(child)) testFiles += 1;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
visit('');
|
|
62
|
+
const languages = [...counts.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).map(([name]) => name);
|
|
63
|
+
return { sourceFiles, languages, testFiles };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
function gitFacts(root, unavailable) {
|
|
68
|
+
const facts = { git: false, commits: 0, authors: [], branch: '', branches: [], mergeStyle: 'unknown', remote: null, forge: null, tags: 0 };
|
|
69
|
+
if (util.tryGit(root, ['rev-parse', '--is-inside-work-tree']) !== 'true') { unavailable.push('git'); return facts; }
|
|
70
|
+
facts.git = true;
|
|
71
|
+
facts.commits = Number(util.tryGit(root, ['rev-list', '--count', 'HEAD'])) || 0;
|
|
72
|
+
const authors = util.tryGit(root, ['log', '--format=%an']);
|
|
73
|
+
facts.authors = authors ? [...new Set(authors.split('\n').filter(Boolean))].sort() : [];
|
|
74
|
+
facts.branch = util.tryGit(root, ['symbolic-ref', '--short', '-q', 'HEAD']) || util.tryGit(root, ['rev-parse', '--abbrev-ref', 'HEAD']) || '';
|
|
75
|
+
const branches = util.tryGit(root, ['branch', '--format=%(refname:short)']);
|
|
76
|
+
facts.branches = branches ? branches.split('\n').filter(Boolean).sort() : [];
|
|
77
|
+
if (util.tryGit(root, ['log', '--merges', '-1', '--format=%H'])) facts.mergeStyle = 'merge';
|
|
78
|
+
else if (facts.commits >= 5) facts.mergeStyle = 'squash-or-rebase';
|
|
79
|
+
const remotes = (util.tryGit(root, ['remote']) || '').split('\n').filter(Boolean);
|
|
80
|
+
const remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
|
|
81
|
+
const url = remoteName ? util.tryGit(root, ['remote', 'get-url', remoteName]) : null;
|
|
82
|
+
if (url) {
|
|
83
|
+
facts.remote = util.stripUserinfo(url);
|
|
84
|
+
facts.forge = /(^|@|\/\/)github\.com[:/]/i.test(facts.remote) ? 'github' : 'other';
|
|
85
|
+
}
|
|
86
|
+
const tags = util.tryGit(root, ['tag']);
|
|
87
|
+
facts.tags = tags ? tags.split('\n').filter(Boolean).length : 0;
|
|
88
|
+
return facts;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A workflow deploys when its file name, its name, a job id or an action it uses says so.
|
|
92
|
+
function workflowDeploys(root, rel) {
|
|
93
|
+
let text;
|
|
94
|
+
try { text = fs.readFileSync(path.join(root, rel), 'utf8'); } catch { return false; }
|
|
95
|
+
const hits = [path.basename(rel, path.extname(rel))];
|
|
96
|
+
let inJobs = false;
|
|
97
|
+
for (const line of text.split('\n')) {
|
|
98
|
+
const name = /^\s*name:\s*(.+)$/.exec(line);
|
|
99
|
+
const uses = /^\s*-?\s*uses:\s*(.+)$/.exec(line);
|
|
100
|
+
if (name) hits.push(name[1]);
|
|
101
|
+
if (uses) hits.push(uses[1]);
|
|
102
|
+
if (/^jobs:\s*$/.test(line)) { inJobs = true; continue; }
|
|
103
|
+
if (/^\S/.test(line)) inJobs = false;
|
|
104
|
+
const job = inJobs && /^ {2}([A-Za-z0-9_-]+):\s*$/.exec(line);
|
|
105
|
+
if (job) hits.push(job[1]);
|
|
106
|
+
}
|
|
107
|
+
return hits.some((hit) => /deploy|pages/i.test(hit));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function codeFacts(root) {
|
|
111
|
+
const workflows = listFiles(root, '.github/workflows').filter((f) => /\.ya?ml$/.test(f));
|
|
112
|
+
const ci = [...workflows, ...(exists(root, '.gitlab-ci.yml') ? ['.gitlab-ci.yml'] : []), ...listFiles(root, '.circleci')];
|
|
113
|
+
const deploy = [...workflows.filter((f) => workflowDeploys(root, f)), ...DEPLOY_FILES.filter((f) => exists(root, f))].sort();
|
|
114
|
+
const lock = LOCKFILES.find(([file]) => exists(root, file));
|
|
115
|
+
const packageManager = lock ? lock[1] : null;
|
|
116
|
+
const pkg = util.readJson(path.join(root, 'package.json'));
|
|
117
|
+
let runner = null;
|
|
118
|
+
if (pkg && pkg.scripts && typeof pkg.scripts.test === 'string' && pkg.scripts.test.trim()) runner = `${['pnpm', 'yarn', 'bun'].includes(packageManager) ? packageManager : 'npm'} test`;
|
|
119
|
+
else if (exists(root, 'pytest.ini') || exists(root, 'pyproject.toml')) runner = 'pytest';
|
|
120
|
+
else if (exists(root, 'Cargo.toml')) runner = 'cargo test';
|
|
121
|
+
else if (exists(root, 'go.mod')) runner = 'go test ./...';
|
|
122
|
+
const walked = walk(root);
|
|
123
|
+
return {
|
|
124
|
+
ci, deploy,
|
|
125
|
+
tests: { present: walked.testFiles > 0, runner },
|
|
126
|
+
languages: walked.languages,
|
|
127
|
+
packageManager,
|
|
128
|
+
sourceFiles: walked.sourceFiles,
|
|
129
|
+
envExample: ['.env.example', '.env.sample', '.env.template'].some((f) => exists(root, f)),
|
|
130
|
+
instructions: [...INSTRUCTION_FILES.filter((f) => exists(root, f)), ...(isDir(root, 'docs') ? ['docs/'] : [])].sort(),
|
|
131
|
+
name: pkg && typeof pkg.name === 'string' && pkg.name.trim() ? pkg.name.trim() : path.basename(root),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// gh is consulted only when the caller allowed the network and gh is on PATH.
|
|
136
|
+
function ghFacts(root, branch, network, unavailable) {
|
|
137
|
+
const facts = { available: false, issues: null, prs: null, protection: null };
|
|
138
|
+
const gh = (args) => {
|
|
139
|
+
try { return execFileSync('gh', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 20000 }); } catch (error) { return { error }; }
|
|
140
|
+
};
|
|
141
|
+
if (!network || gh(['--version']).error) { unavailable.push('gh'); return facts; }
|
|
142
|
+
facts.available = true;
|
|
143
|
+
const count = (args) => { const r = gh(args); if (r.error) return null; try { return JSON.parse(r).length; } catch { return null; } };
|
|
144
|
+
facts.issues = count(['issue', 'list', '--state', 'all', '--limit', '100', '--json', 'number']);
|
|
145
|
+
facts.prs = count(['pr', 'list', '--state', 'all', '--limit', '100', '--json', 'number']);
|
|
146
|
+
// Protection is asked about the forge's default branch; the local branch may not exist there.
|
|
147
|
+
const view = gh(['repo', 'view', '--json', 'defaultBranchRef', '--jq', '.defaultBranchRef.name']);
|
|
148
|
+
const target = (!view.error && view.trim()) || branch;
|
|
149
|
+
if (target) {
|
|
150
|
+
const r = gh(['api', `repos/{owner}/{repo}/branches/${target}/protection`]);
|
|
151
|
+
if (!r.error) facts.protection = true;
|
|
152
|
+
else if (/not protected/i.test(`${r.error.stdout || ''}${r.error.stderr || ''}`)) facts.protection = false;
|
|
153
|
+
}
|
|
154
|
+
if (facts.issues === null && facts.prs === null && facts.protection === null) unavailable.push('gh');
|
|
155
|
+
return facts;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Brownfield when there is history and code, or CI, or something that deploys.
|
|
159
|
+
function classify(facts) {
|
|
160
|
+
const reasons = [];
|
|
161
|
+
const commits = facts.commits || 0, sourceFiles = facts.sourceFiles || 0;
|
|
162
|
+
const ci = facts.ci || [], deploy = facts.deploy || [];
|
|
163
|
+
const history = facts.git !== false && commits >= 5 && sourceFiles >= 1;
|
|
164
|
+
if (history) reasons.push(`${plural(commits, 'commit')} and ${plural(sourceFiles, 'source file')}: there is history and code to respect.`);
|
|
165
|
+
if (ci.length) reasons.push(`CI is configured (${ci.join(', ')}).`);
|
|
166
|
+
if (deploy.length) reasons.push(`Something deploys (${deploy.join(', ')}): a running system to respect.`);
|
|
167
|
+
if (history || ci.length || deploy.length) return { kind: 'brownfield', reasons };
|
|
168
|
+
if (facts.git === false) reasons.push(sourceFiles ? `Not a git repository: ${plural(sourceFiles, 'source file')} but no history to respect.` : 'Not a git repository: no history to respect.');
|
|
169
|
+
else if (commits < 5) reasons.push(`${plural(commits, 'commit')} and ${plural(sourceFiles, 'source file')}: the first real job is also the first trace.`);
|
|
170
|
+
else reasons.push(`${plural(commits, 'commit')} but no source files: there is no code to respect yet.`);
|
|
171
|
+
reasons.push('No CI or deploy configuration.');
|
|
172
|
+
return { kind: 'greenfield', reasons };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The answers recon can fill in before the interview; the developer confirms each.
|
|
176
|
+
function prefill(facts, kind) {
|
|
177
|
+
const out = {
|
|
178
|
+
project: { name: facts.name, kind },
|
|
179
|
+
systems: { forge: facts.forge === 'github' ? 'github' : facts.remote ? 'other' : 'none', ci: facts.ci.length > 0 },
|
|
180
|
+
repo: {},
|
|
181
|
+
};
|
|
182
|
+
if (facts.remote) out.systems.remote = facts.remote;
|
|
183
|
+
if (facts.mergeStyle === 'merge') out.repo.merge = 'merge';
|
|
184
|
+
if (facts.gh.prs > 0) out.repo.pr = true;
|
|
185
|
+
if (facts.tests.runner) out.repo.checks = [facts.tests.runner];
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function recon(root, { network = false } = {}) {
|
|
190
|
+
const unavailable = [];
|
|
191
|
+
const git = gitFacts(root, unavailable);
|
|
192
|
+
const code = codeFacts(root);
|
|
193
|
+
const { name, ...rest } = code;
|
|
194
|
+
const facts = { ...git, ...rest, gh: ghFacts(root, git.branch, network, unavailable) };
|
|
195
|
+
const { kind, reasons } = classify(facts);
|
|
196
|
+
return { kind, reasons, facts, prefill: prefill({ ...facts, name }, kind), unavailable };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function render(result) {
|
|
200
|
+
const lines = [result.kind, ...result.reasons.map((r) => `- ${r}`)];
|
|
201
|
+
if (result.unavailable.length) lines.push(`could not see: ${result.unavailable.join(', ')}`);
|
|
202
|
+
return `${lines.join('\n')}\n`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = { recon, classify, prefill, render, stripUserinfo: util.stripUserinfo, walk, SKIP_DIRS, LANGUAGES };
|