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.
Files changed (63) hide show
  1. package/README.md +9 -7
  2. package/bin/pincer.js +58 -5
  3. package/package.json +3 -3
  4. package/template/.agents/skills/pincer-code/SKILL.md +117 -12
  5. package/template/.agents/skills/pincer-evaluate/SKILL.md +57 -10
  6. package/template/.agents/skills/pincer-narrow/SKILL.md +48 -8
  7. package/template/.agents/skills/pincer-plan/SKILL.md +12 -4
  8. package/template/.agents/skills/pincer-release/SKILL.md +32 -3
  9. package/template/.agents/skills/pincer-status/SKILL.md +25 -2
  10. package/template/.claude/commands/pincer-code.md +117 -12
  11. package/template/.claude/commands/pincer-evaluate.md +57 -10
  12. package/template/.claude/commands/pincer-narrow.md +48 -8
  13. package/template/.claude/commands/pincer-plan.md +12 -4
  14. package/template/.claude/commands/pincer-release.md +32 -3
  15. package/template/.claude/commands/pincer-status.md +25 -2
  16. package/template/.claude/hooks/hook-policy.cjs +24 -3
  17. package/template/.claude/references/prd-template.md +11 -4
  18. package/template/.claude/references/ticket-template.md +4 -0
  19. package/template/.codex/README.md +3 -2
  20. package/template/.github/prompts/pincer-code.prompt.md +117 -12
  21. package/template/.github/prompts/pincer-evaluate.prompt.md +57 -10
  22. package/template/.github/prompts/pincer-narrow.prompt.md +48 -8
  23. package/template/.github/prompts/pincer-plan.prompt.md +12 -4
  24. package/template/.github/prompts/pincer-release.prompt.md +32 -3
  25. package/template/.github/prompts/pincer-status.prompt.md +25 -2
  26. package/template/AGENTS.md +22 -0
  27. package/template/docs/dry-run-checklist.md +70 -6
  28. package/template/docs/release-checklist.md +5 -2
  29. package/template/docs/runtime-contracts.md +1683 -0
  30. package/template/scripts/pincer-evidence.cjs +13 -229
  31. package/template/scripts/pincer-runtime/adopt.cjs +132 -0
  32. package/template/scripts/pincer-runtime/agreement.cjs +240 -0
  33. package/template/scripts/pincer-runtime/authorization.cjs +167 -0
  34. package/template/scripts/pincer-runtime/changes.cjs +517 -0
  35. package/template/scripts/pincer-runtime/checks.cjs +48 -0
  36. package/template/scripts/pincer-runtime/coverage.cjs +361 -0
  37. package/template/scripts/pincer-runtime/dispositions.cjs +95 -0
  38. package/template/scripts/pincer-runtime/evidence.cjs +676 -0
  39. package/template/scripts/pincer-runtime/fsutil.cjs +37 -0
  40. package/template/scripts/pincer-runtime/gates.cjs +73 -0
  41. package/template/scripts/pincer-runtime/identity.cjs +163 -0
  42. package/template/scripts/pincer-runtime/impact.cjs +177 -0
  43. package/template/scripts/pincer-runtime/io.cjs +41 -0
  44. package/template/scripts/pincer-runtime/lifecycle.cjs +311 -0
  45. package/template/scripts/pincer-runtime/locator.cjs +158 -0
  46. package/template/scripts/pincer-runtime/migrate.cjs +204 -0
  47. package/template/scripts/pincer-runtime/parse.cjs +316 -0
  48. package/template/scripts/pincer-runtime/phases.cjs +245 -0
  49. package/template/scripts/pincer-runtime/readiness.cjs +97 -0
  50. package/template/scripts/pincer-runtime/requirements.cjs +255 -0
  51. package/template/scripts/pincer-runtime/resume.cjs +205 -0
  52. package/template/scripts/pincer-runtime/routing.cjs +54 -0
  53. package/template/scripts/pincer-runtime/runner.cjs +242 -0
  54. package/template/scripts/pincer-runtime/sanitize.cjs +63 -0
  55. package/template/scripts/pincer-runtime/source.cjs +129 -0
  56. package/template/scripts/pincer-runtime/state.cjs +314 -0
  57. package/template/scripts/pincer-runtime/status.cjs +514 -0
  58. package/template/scripts/pincer-runtime/transaction.cjs +200 -0
  59. package/template/scripts/pincer-runtime/transitions.cjs +134 -0
  60. package/template/scripts/pincer-runtime.cjs +661 -0
  61. package/template/scripts/pincer-status.sh +11 -162
  62. package/template/scripts/pincer-ticket.sh +19 -139
  63. package/template/scripts/pincer-ticket-lib.sh +0 -321
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+ // PINCER runtime — the resume report (docs/runtime-contracts.md, "Resume
3
+ // report"). Read-only inspection of the selected (or named) change for a fresh
4
+ // session: identity, lifecycle, repository view, agreement and authorization,
5
+ // decisions, references to the authored artifacts, tickets and attempts, the
6
+ // candidate, the authored handoff (labeled, never an input), the blockers in
7
+ // gate order and one next action chosen by the documented precedence. Distinct
8
+ // from `change resume`, the lifecycle operation. Launches nothing, writes nothing.
9
+ const fs = require('node:fs');
10
+ const path = require('node:path');
11
+ const parse = require('./parse.cjs');
12
+ const changes = require('./changes.cjs');
13
+ const agreement = require('./agreement.cjs');
14
+ const status = require('./status.cjs');
15
+ const state = require('./state.cjs');
16
+ const transaction = require('./transaction.cjs');
17
+ const gates = require('./gates.cjs');
18
+
19
+ // Resume JSON schema 2 (PRD v6): schema 1 plus the strict coverage summary; the
20
+ // coverage codes join rule 4 of the next-action precedence.
21
+ const SCHEMA = 2;
22
+ const STATE_CODES = ['INPUT_INVALID', 'MALFORMED', 'UNSUPPORTED_SCHEMA', 'HISTORY_INVALID', 'STATE_INCOMPLETE'];
23
+ const COVERAGE_CODES = ['INVENTORY_INVALID', 'COVERAGE_INVALID', 'COVERAGE_INCOMPLETE', 'OBLIGATION_MISSING', 'SCOPE_UNAUTHORIZED'];
24
+ const CANDIDATE_COVERAGE_CODES = ['REVIEW_MISSING', 'ADEQUACY_REQUIRED'];
25
+ const phases = require('./phases.cjs');
26
+ const routing = require('./routing.cjs');
27
+ const SELECTION_CODES = ['SELECTION_REQUIRED', 'SELECTION_INVALID'];
28
+ const AGREEMENT_CODES = ['DECISION_REQUIRED', 'AUTHORIZATION_REQUIRED', 'AGREEMENT_CHANGED'];
29
+ const RUNTIME = 'node scripts/pincer-runtime.cjs';
30
+
31
+ const firstLine = (text, heading) => {
32
+ const rows = parse.lines(text);
33
+ const i = rows.findIndex(l => l.trim() === heading);
34
+ if (i === -1) return null;
35
+ const next = rows.slice(i + 1).find(l => l.trim() !== '');
36
+ return next ? next.trim() : null;
37
+ };
38
+ const title = text => { const m = parse.lines(text).find(l => /^# /.test(l)); return m ? m.replace(/^# /, '').trim() : null; };
39
+
40
+ // build(root, { change }) → { json, blockers, next, gathered } — the whole report.
41
+ function build(root, { change = null } = {}) {
42
+ const st = status.render(root, { change });
43
+ const j = st.json;
44
+ const generated = j.generated;
45
+ const report = { schema: SCHEMA, runtime: changes.RUNTIME_STRICT, generated, root, mode: j.mode, selection: null, change: null, agreement: null, references: null, tickets: [], attempts: [], candidate: null, coverage: j.coverage || { strict: false, label: 'unverified', reason: null, structure: null, implementation: null, candidate: null, next: null }, handoff: null, blockers: [], next: null };
46
+ const blockers = [];
47
+ const push = (code, detail) => { if (!blockers.some(b => b.code === code && b.detail === detail)) blockers.push({ code, detail }); };
48
+ if (j.mode !== 'changes') {
49
+ report.selection = { change: null, problem: { code: j.mode === 'invalid' ? 'INPUT_INVALID' : 'CHANGE_REQUIRED', detail: j.mode === 'legacy' ? 'this project keeps no change records; register with: node scripts/pincer-runtime.cjs register --prd .prd/prd-vN.md' : j.mode === 'migrated' ? 'this project keeps a v0.5.0 binding; migrate it with: node scripts/pincer-runtime.cjs migrate --preview --prd <prd>' : (j.reasons[0] ? j.reasons[0].detail : 'unreadable change records') } };
50
+ push(report.selection.problem.code, report.selection.problem.detail);
51
+ report.blockers = blockers;
52
+ report.next = { action: 'repair or migrate', command: report.selection.problem.detail.replace(/^.*?: /, ''), ticket: null, check: null, rule: 1 };
53
+ return { json: report, text: render(report), exit: st.exit };
54
+ }
55
+ report.selection = j.selection;
56
+ // Rule 1: invalid or missing state, or no usable selection.
57
+ for (const r of j.reasons) if (STATE_CODES.includes(r.code)) push(r.code, r.detail);
58
+ if (!j.change) {
59
+ const problem = j.selection.problem || (j.reasons[0] ? j.reasons[0] : { code: 'SELECTION_REQUIRED', detail: 'no change selected' });
60
+ push(problem.code, problem.detail);
61
+ report.blockers = blockers;
62
+ const first = blockers[0];
63
+ report.next = STATE_CODES.includes(first.code)
64
+ ? { action: first.code === 'STATE_INCOMPLETE' ? 'complete the interrupted transaction' : 'repair the change records by hand', command: first.code === 'STATE_INCOMPLETE' ? `${RUNTIME} recover` : `${RUNTIME} change list`, ticket: null, check: null, rule: 1 }
65
+ : { action: 'select the change to work on', command: `${RUNTIME} change select <id>`, ticket: null, check: null, rule: 1 };
66
+ return { json: report, text: render(report), exit: st.exit };
67
+ }
68
+ const id = j.change.id;
69
+ const resolved = changes.resolveSelected(root, { change: id });
70
+ const record = resolved.record;
71
+ report.change = { id, prd: j.change.prd, prd_revision: j.change.prd_revision, base: j.change.base, registered: record ? record.registered : null, sequence: j.change.sequence, lifecycle: j.change.lifecycle, view: j.change.view };
72
+ const ag = j.change.agreement;
73
+ report.agreement = {
74
+ current: ag.current,
75
+ authorized: ag.authorized ? { ...ag.authorized, reference: (record.authorizations.find(a => a.id === ag.authorized.id) || {}).reference || null, excerpt: (record.authorizations.find(a => a.id === ag.authorized.id) || {}).excerpt || null, constraints: (record.authorizations.find(a => a.id === ag.authorized.id) || {}).constraints || null } : null,
76
+ verdict: ag.verdict, difference: ag.difference,
77
+ decisions: { open: record.decisions.filter(d => d.status === 'open').map(d => ({ id: d.id, summary: d.summary, raised: d.raised })), resolved: record.decisions.filter(d => d.status === 'resolved').map(d => ({ id: d.id, summary: d.summary, reference: d.reference, resolved: d.resolved })) },
78
+ };
79
+ // References: the authored artifacts the agreement is read from (titles and first lines only).
80
+ let prdText = null;
81
+ try { prdText = fs.readFileSync(path.join(root, record.prd), 'utf8'); } catch { prdText = null; }
82
+ const authorizedEntry = ag.authorized ? record.agreements.find(g => g.id === ag.authorized.agreement) : null;
83
+ report.references = {
84
+ prd: { path: record.prd, title: prdText ? title(prdText) : null },
85
+ snapshot: authorizedEntry ? authorizedEntry.snapshot : null,
86
+ tickets: (st.gathered && st.gathered.tickets ? st.gathered.tickets : []).map(t => ({ id: t.fields.ticket, file: t.file, objective: firstLine(t.text, '## Objective') })),
87
+ };
88
+ report.tickets = j.tickets;
89
+ const idx = state.exists(root) ? state.readIndex(root) : { index: null };
90
+ if (idx.index) {
91
+ for (const t of j.tickets) {
92
+ const key = state.contextKey({ kind: 'ticket', change: id, ticket: t.id });
93
+ for (const a of state.listAttempts(root, key)) report.attempts.push({ ticket: t.id, check: null, id: a.id, outcome: a.outcome, started: a.started, finished: a.finished, current: idx.index.current[key] === a.id });
94
+ }
95
+ if (j.candidate && j.candidate.evaluation) {
96
+ for (const a of state.listAttempts(root)) {
97
+ if (a.context && a.context.kind === 'candidate' && a.context.change === id && a.context.candidate === j.candidate.evaluation.candidate) {
98
+ const key = state.contextKey({ ...a.context, mode: 'changes' });
99
+ report.attempts.push({ ticket: null, check: a.context.check, id: a.id, outcome: a.outcome, started: a.started, finished: a.finished, current: idx.index.current[key] === a.id });
100
+ }
101
+ }
102
+ }
103
+ }
104
+ report.candidate = j.candidate;
105
+ const lc = record.lifecycle;
106
+ report.handoff = lc.reason || lc.note ? { kind: record.events.filter(e => ['pause', 'reopen'].includes(e.kind)).at(-1)?.kind || null, reason: lc.reason, note: lc.note, since: lc.since, authored: true } : null;
107
+ // Blockers in gate order, then the readiness and candidate reasons.
108
+ const running = transaction.runningAttempts(root, id);
109
+ const viewProblems = j.reasons.filter(r => r.code === 'BASE_MISMATCH');
110
+ for (const r of viewProblems) push(r.code, r.detail);
111
+ if (running.length) push('ATTEMPT_RUNNING', `attempt ${running[0].id} of change ${id} is running${running[0].alive === false ? ' (its owner is no longer running)' : ''}`);
112
+ if (changes.TERMINAL.includes(lc.state)) push('LIFECYCLE_BLOCKED', `change ${id} is ${lc.state}${lc.superseded_by ? ` by ${lc.superseded_by}` : ''}; it cannot execute`);
113
+ if (ag.verdict && ag.verdict !== 'current') push(ag.verdict, ag.verdict_detail || ag.verdict);
114
+ // Rule 4 also covers the structural coverage gaps of a strict change.
115
+ const selected = !resolved.selection || resolved.selection.change === id;
116
+ const cov = changes.isStrict(record) ? phases.report(root, record, { gathered: st.gathered, generated, selected }) : null;
117
+ if (cov) for (const p of cov.structure.problems) push(p.code, p.detail);
118
+ for (const t of j.tickets) for (const r of t.readiness.reasons) if (t.status === 'done' || t.status === 'in_progress') push(r.code, `${t.id}: ${r.detail}`);
119
+ // A missing or stale evaluation blocks only a completed change: before completion no candidate is expected.
120
+ if (j.candidate && lc.state === 'completed') for (const r of j.candidate.reasons) push(r.code, r.detail);
121
+ if (cov && lc.state === 'completed') for (const p of cov.candidate.problems) if (p.code !== 'EVIDENCE_MISSING' || !cov.candidate.evaluated) push(p.code, p.detail);
122
+ report.blockers = blockers;
123
+ report.next = routing.qualify(decide({ id, lc, ag, j, running, viewProblems, gathered: st.gathered, record, cov, selected }), { id, selected });
124
+ return { json: report, text: render(report), exit: st.exit };
125
+ }
126
+
127
+ // The documented next-action precedence (rules 1..8); rule 1 is handled by the caller.
128
+ function decide({ id, lc, ag, j, running, viewProblems, gathered, record, cov = null, selected = true }) {
129
+ const cmd = (action, command, extra = {}) => ({ action, command, ticket: null, check: null, ...extra });
130
+ const change = sub => `${RUNTIME} change ${sub} ${id}`;
131
+ // Rules 2 and 3 are shared with the coverage report (routing.cjs) so the two
132
+ // cannot drift apart again.
133
+ const ahead = routing.preface({ id, lifecycle: lc, base: record.base, running, viewProblems });
134
+ if (ahead) return ahead;
135
+ const gap = ag.verdict && ag.verdict !== 'current';
136
+ if (gap) {
137
+ if (ag.verdict === 'DECISION_REQUIRED') return cmd('record the user\'s decision', `${change('decide')} --resolve ${ag.open_decisions[0]} --reference <text> --excerpt <text>`, { rule: 4 });
138
+ if (ag.verdict === 'AUTHORIZATION_REQUIRED') return cmd('record the user\'s authorization', `${change('authorize')} --agreement ${ag.current} --reference <text> --excerpt <text>`, { rule: 4 });
139
+ if (ag.verdict === 'AGREEMENT_CHANGED') return cmd('record the disposition of the changed agreement', `${change('authorize')} --agreement ${ag.current} --reference <text> --excerpt <text> (user) or --delegated --basis ${ag.authorized ? ag.authorized.id : (record.authorizations.at(-1) || {}).id || 'A-NN'} --explanation <text>`, { rule: 4 });
140
+ return cmd('repair the agreement inputs', `${ag.verdict}: ${ag.verdict_detail}`, { rule: 4 });
141
+ }
142
+ // Rule 4 (strict coverage): a structural coverage gap precedes any verification work.
143
+ if (cov && cov.structure.problems.length) return { ...cov.next, rule: 4 };
144
+ const staged = routing.lifecycleAction({ id, lifecycle: lc });
145
+ if (staged) return staged;
146
+ const tickets = j.tickets;
147
+ const inProgress = tickets.find(t => t.status === 'in_progress');
148
+ // A fresh clone without local attempt history relies on the saved candidate record (as `ready` does): a done
149
+ // ticket whose only reason is EVIDENCE_MISSING is that stated limit, not stale work.
150
+ const localUnavailable = Boolean(j.candidate && j.candidate.local_attempts === 'unavailable');
151
+ const stale = tickets.find(t => t.status === 'done' && !t.readiness.ready && !(localUnavailable && t.readiness.reasons.every(r => r.code === 'EVIDENCE_MISSING')));
152
+ const open = tickets.filter(t => t.status === 'open');
153
+ const blocked = t => t.readiness.reasons.some(x => x.code === 'DEPENDENCY_BLOCKED');
154
+ const nextOpen = open.find(t => !blocked(t)) || null;
155
+ if (lc.state === 'active') {
156
+ if (inProgress) return cmd(`finish ${inProgress.id}`, `scripts/pincer-ticket.sh verify ${inProgress.id} → done ${inProgress.id}`, { ticket: inProgress.id, rule: 5 });
157
+ if (stale) return cmd(`re-verify ${stale.id} (${stale.readiness.reasons[0].code})`, `scripts/pincer-ticket.sh verify ${stale.id}`, { ticket: stale.id, rule: 5 });
158
+ if (nextOpen) return cmd(`start ${nextOpen.id}`, `scripts/pincer-ticket.sh start ${nextOpen.id}`, { ticket: nextOpen.id, rule: 5 });
159
+ if (open.length) return cmd('unblock the remaining tickets', `${open.map(t => `${t.id}: ${t.readiness.reasons[0] ? t.readiness.reasons[0].detail : 'blocked'}`).join('; ')}`, { ticket: open[0].id, rule: 5 });
160
+ if (!tickets.length) return cmd('write the breakdown', '/pincer-narrow — the change has no tickets', { rule: 5 });
161
+ return cmd('complete the change', change('complete'), { rule: 6 });
162
+ }
163
+ // completed
164
+ if (stale) return cmd(`re-verify ${stale.id} (${stale.readiness.reasons[0].code})`, `${change('reopen')} --reason <text>, then scripts/pincer-ticket.sh verify ${stale.id}`, { ticket: stale.id, rule: 5 });
165
+ const cand = j.candidate || {};
166
+ const newer = (cand.reasons || []).find(r => ['CHECK_FAILED', 'ATTEMPT_RUNNING', 'ATTEMPT_TIMED_OUT', 'ATTEMPT_INTERRUPTED', 'ATTEMPT_ERROR'].includes(r.code));
167
+ if (newer) return cmd(`re-run ${newer.detail.split(':')[0]} and re-evaluate`, `${RUNTIME} check ${newer.detail.split(':')[0]} --candidate ${cand.candidate}${cov ? '' : ' -- <command>'}, then /pincer-evaluate`, { check: newer.detail.split(':')[0], rule: 5 });
168
+ // Rules 7 and 8 (strict coverage): the candidate coverage decides what the evaluation still lacks.
169
+ if (cov && cov.candidate.problems.some(p => CANDIDATE_COVERAGE_CODES.includes(p.code) || (cov.candidate.evaluated && ['CHECK_FAILED', 'ATTEMPT_ERROR'].includes(p.code)))) return { ...cov.next, rule: 7 };
170
+ if (cand.notes !== 'current' || (j.prd && j.prd.status !== 'built')) return cmd('evaluate the candidate', `/pincer-evaluate${j.prd && j.prd.status !== 'built' ? ` (PRD status is '${j.prd.status}', expected 'built')` : cand.reason ? ` (${cand.reason})` : ''}`, { rule: 7 });
171
+ return cmd('read-only release audit', '/pincer-release', { rule: 8 });
172
+ }
173
+
174
+ function render(r) {
175
+ const lines = [];
176
+ const short = s => (typeof s === 'string' ? s.slice(0, 12) : '—');
177
+ lines.push(`PINCER resume · ${r.generated} · ${r.root}`);
178
+ if (!r.change) {
179
+ lines.push(`Selection ${r.selection && r.selection.change ? r.selection.change : 'none'}${r.selection && r.selection.problem ? ` · ${r.selection.problem.code}: ${r.selection.problem.detail}` : ''}`);
180
+ } else {
181
+ const c = r.change, v = c.view;
182
+ lines.push(`Change ${c.id} · ${c.prd} · revision ${short(c.prd_revision)} · base ${c.base.slice(0, 7)} · registered ${c.registered} · sequence ${c.sequence}`);
183
+ lines.push(`Lifecycle ${c.lifecycle.state} since ${c.lifecycle.since}${c.lifecycle.superseded_by ? ` · superseded by ${c.lifecycle.superseded_by}` : ''}`);
184
+ lines.push(`View HEAD ${v.head ? v.head.slice(0, 7) : 'none'} · branch ${v.branch || 'detached'} · base ${v.base_is_ancestor ? 'is an ancestor' : 'is NOT an ancestor'} · dirty ${v.dirty.length} path(s)${v.dirty.length ? `: ${v.dirty.slice(0, 5).join(', ')}${v.dirty.length > 5 ? ` (+${v.dirty.length - 5})` : ''}` : ''}`);
185
+ const a = r.agreement;
186
+ lines.push(`Agreement ${a.current ? short(a.current) : 'unavailable'}${a.authorized ? ` · authorized as ${a.authorized.agreement} by ${a.authorized.id} (${a.authorized.disposition}, ${a.authorized.recorded})` : ' · not authorized'}${a.difference && !a.difference.same ? ` · differs from the authorized agreement: ${agreement.renderDifference(a.difference)}` : ''}`);
187
+ lines.push(`Authorization ${a.verdict}${a.authorized && a.authorized.excerpt ? ` — "${a.authorized.excerpt}" (${a.authorized.reference})${a.authorized.constraints ? ` · constraints: ${a.authorized.constraints}` : ''}` : ''}`);
188
+ lines.push(`Decisions ${a.decisions.open.length || a.decisions.resolved.length ? [...a.decisions.open.map(d => `${d.id} open: ${d.summary}`), ...a.decisions.resolved.map(d => `${d.id} resolved: ${d.summary}`)].join('; ') : 'none'}`);
189
+ lines.push(`References PRD ${r.references.prd.path}${r.references.prd.title ? ` ("${r.references.prd.title}")` : ''}${r.references.snapshot ? ` · authorized snapshot ${r.references.snapshot}` : ''}${r.references.tickets.length ? ` · tickets ${r.references.tickets.map(t => `${t.id} (${t.file})`).join(', ')}` : ' · no tickets'}`);
190
+ for (const t of r.references.tickets) if (t.objective) lines.push(` ${t.id} ${t.objective}`);
191
+ const ticketWord = t => (t.status === 'open' ? (t.readiness.reasons.some(x => x.code === 'DEPENDENCY_BLOCKED') ? ` ${t.readiness.reasons[0].detail}` : '') : t.readiness.ready ? ' ready' : t.readiness.reasons[0] ? ` ${t.readiness.reasons[0].code}` : '');
192
+ lines.push(`Tickets ${r.tickets.length ? r.tickets.map(t => `${t.id} ${t.status}${ticketWord(t)}`).join(' · ') : 'none'}`);
193
+ lines.push(`Attempts ${r.attempts.length ? r.attempts.map(x => `${x.ticket || x.check} ${x.id} ${x.outcome}${x.current ? ' (current)' : ''}`).join('; ') : 'none'}`);
194
+ const cand = r.candidate;
195
+ lines.push(`Candidate ${cand ? `${cand.locator || 'NOTES.md'}: ${cand.notes === 'current' ? `current (${cand.candidate})` : cand.reason}${cand.evidence ? ` · evidence ${cand.evidence.verdict}` : ''}` : 'none'}`);
196
+ const cv = r.coverage;
197
+ lines.push(`Coverage ${cv.label}${cv.strict ? ` · structure ${cv.structure.complete ? 'complete' : `incomplete (${cv.structure.problems[0].code})`} · implementation ${cv.implementation.scenarios.complete}/${cv.implementation.scenarios.total - cv.implementation.scenarios.dispositioned} scenarios · candidate ${cv.candidate.evaluated ? `delivery original ${cv.candidate.delivery.original}, agreed ${cv.candidate.delivery.agreed}, adequacy ${cv.candidate.adequacy}` : 'not evaluated'}` : cv.reason ? ` · ${cv.reason}` : ''}`);
198
+ lines.push(`Handoff (authored) ${r.handoff ? `${r.handoff.kind || 'note'} since ${r.handoff.since}${r.handoff.reason ? ` · reason: ${r.handoff.reason}` : ''}${r.handoff.note ? ` · note: ${r.handoff.note}` : ''}` : 'none'}`);
199
+ }
200
+ lines.push(`Blockers ${r.blockers.length ? r.blockers.map(b => `${b.code} ${b.detail}`).join('\n ') : 'none'}`);
201
+ lines.push(`Next ${r.next.action}: ${r.next.command}`);
202
+ return `${lines.join('\n')}\n`;
203
+ }
204
+
205
+ module.exports = { SCHEMA, build, decide, render };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+ // The head of the next-action precedence (docs/runtime-contracts.md, rules 1-8),
3
+ // in one place. `resume` and `coverage` both render a next action; when each
4
+ // carried its own copy they drifted, and `coverage` ended up recommending
5
+ // commands the gates refuse — `start T-01` on a paused change, execution on a
6
+ // cancelled one. Rules 2 and 3 live here so the two reports cannot disagree
7
+ // again, and so neither can disagree with the gate that will run next.
8
+ const changes = require('./changes.cjs');
9
+
10
+ const RUNTIME = 'node scripts/pincer-runtime.cjs';
11
+ const action = (what, command, extra = {}) => ({ action: what, command, ticket: null, check: null, ...extra });
12
+
13
+ // Rules 2 and 3: what outranks the agreement gap. Returns null when none applies
14
+ // and the caller should go on to rule 4.
15
+ // running — transaction.runningAttempts(root, id)
16
+ // viewProblems — the BASE_MISMATCH reasons for this change
17
+ function preface({ id, lifecycle, base = null, running = [], viewProblems = [] }) {
18
+ if (running.length) {
19
+ const first = running[0];
20
+ return first.alive === false
21
+ ? action('recover the interrupted attempt', `${RUNTIME} recover`, { rule: 2 })
22
+ : action('wait for the running attempt', `wait for attempt ${first.id}, or ${RUNTIME} recover if its owner died`, { rule: 2 });
23
+ }
24
+ if (changes.TERMINAL.includes(lifecycle.state)) {
25
+ return action('inspect the historical change; execution needs a new change',
26
+ `${RUNTIME} change show ${id} — then register a replacement: ${RUNTIME} register --prd <prd> --change <id>`, { rule: 3 });
27
+ }
28
+ if (viewProblems.length) {
29
+ return action('check out the branch that carries the change',
30
+ `git checkout <branch with base ${String(base || '').slice(0, 7)}> — ${viewProblems[0].detail}`, { rule: 3 });
31
+ }
32
+ return null;
33
+ }
34
+
35
+ // Rule 3 again, after rule 4 is satisfied: a planned or paused change has to be
36
+ // brought to active before any ticket command will run.
37
+ function lifecycleAction({ id, lifecycle }) {
38
+ if (lifecycle.state === 'planned') return action('activate the change', `${RUNTIME} change activate ${id}`, { rule: 3 });
39
+ if (lifecycle.state === 'paused') return action('resume the change', `${RUNTIME} change resume ${id}`, { rule: 3 });
40
+ return null;
41
+ }
42
+
43
+ // A ticket command resolves its ticket against the *selected* change, so when a
44
+ // report describes a change this worktree has not selected, the command it prints
45
+ // is refused with WRONG_CHANGE unless the selection is changed first. Commands that
46
+ // name their change explicitly (`change authorize <id>`, `change show <id>`) run
47
+ // as written and are left alone.
48
+ const TICKET_COMMAND = /(^|[^\w])(scripts\/pincer-ticket\.sh|pincer-runtime\.cjs)\s+(start|verify|done)\s+T-/;
49
+ function qualify(next, { id, selected = true }) {
50
+ if (selected || !next || typeof next.command !== 'string' || !TICKET_COMMAND.test(next.command)) return next;
51
+ return { ...next, command: `${RUNTIME} change select ${id}, then ${next.command}` };
52
+ }
53
+
54
+ module.exports = { preface, lifecycleAction, qualify, RUNTIME };
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+ // PINCER runtime — the attempt runner (docs/runtime-contracts.md, "Attempts",
3
+ // "Capture and sanitization"). Persists a `running` record before launch (which
4
+ // supersedes prior readiness for the context immediately), snapshots inputs
5
+ // before and after, executes the check through `bash -eo pipefail` in its own
6
+ // process group with bounded sanitized capture, enforces the timeout with
7
+ // escalation, handles signals, and finalizes the record from what actually
8
+ // happened. Nothing here decides readiness; readiness.cjs reads the records.
9
+ const fs = require('node:fs');
10
+ const os = require('node:os');
11
+ const path = require('node:path');
12
+ const { spawn, execFileSync } = require('node:child_process');
13
+ const parse = require('./parse.cjs');
14
+ const source = require('./source.cjs');
15
+ const state = require('./state.cjs');
16
+ const { sanitizeLine, sanitizeText } = require('./sanitize.cjs');
17
+ const { nowIso } = require('./fsutil.cjs');
18
+
19
+ const CAPTURE_LIMIT = 1024 * 1024;
20
+ const PARTIAL_LINE_LIMIT = 64 * 1024;
21
+ const GRACE_MS = 5000;
22
+ const DRAIN_MS = 2000;
23
+ const RUNNER_ARGS = ['-eo', 'pipefail', '-c'];
24
+
25
+ let bashInfo = null;
26
+ function runnerInfo() {
27
+ if (bashInfo) return bashInfo;
28
+ let shell = 'bash', version = 'unknown';
29
+ try { shell = execFileSync('bash', ['-c', 'command -v bash'], { encoding: 'utf8' }).trim() || 'bash'; } catch { /* keep default */ }
30
+ try { version = execFileSync('bash', ['--version'], { encoding: 'utf8' }).split('\n')[0]; } catch { /* keep default */ }
31
+ bashInfo = { shell, args: RUNNER_ARGS, version };
32
+ return bashInfo;
33
+ }
34
+
35
+ // A bounded, sanitized capture of one stream into a file, echoed to `echo` when given.
36
+ class Capture {
37
+ constructor(file, echo) {
38
+ this.file = file; this.echo = echo;
39
+ this.fd = fs.openSync(file, 'w');
40
+ this.bytes = 0; this.dropped = 0; this.redactions = 0; this.truncated = false;
41
+ this.partial = ''; this.state = {}; this.failed = null;
42
+ }
43
+ write(chunk) {
44
+ this.partial += chunk.toString('utf8');
45
+ let index;
46
+ while ((index = this.partial.indexOf('\n')) !== -1) {
47
+ this.emit(this.partial.slice(0, index), true);
48
+ this.partial = this.partial.slice(index + 1);
49
+ }
50
+ if (this.partial.length > PARTIAL_LINE_LIMIT) { this.emit(this.partial, false); this.partial = ''; }
51
+ }
52
+ emit(line, newline) {
53
+ const { text, redactions } = sanitizeLine(line, this.state);
54
+ this.redactions += redactions;
55
+ const out = newline ? `${text}\n` : text;
56
+ if (this.echo) this.echo.write(out);
57
+ const buffer = Buffer.from(out, 'utf8');
58
+ if (this.bytes + buffer.length <= CAPTURE_LIMIT) {
59
+ try { fs.writeSync(this.fd, buffer); this.bytes += buffer.length; } catch (error) { this.failed = this.failed || error.message; }
60
+ } else {
61
+ if (!this.truncated) {
62
+ const room = CAPTURE_LIMIT - this.bytes;
63
+ if (room > 0) { try { fs.writeSync(this.fd, buffer.subarray(0, room)); this.bytes += room; } catch (error) { this.failed = this.failed || error.message; } }
64
+ this.truncated = true;
65
+ this.dropped += buffer.length - Math.max(room, 0);
66
+ } else this.dropped += buffer.length;
67
+ }
68
+ }
69
+ close() {
70
+ if (this.partial) { this.emit(this.partial, false); this.partial = ''; }
71
+ if (this.truncated) {
72
+ try { fs.writeSync(this.fd, `\n[pincer: truncated, ${this.dropped} more bytes not stored]\n`); } catch (error) { this.failed = this.failed || error.message; }
73
+ }
74
+ fs.closeSync(this.fd);
75
+ return { bytes: this.bytes, truncated: this.truncated, redactions: this.redactions, failed: this.failed };
76
+ }
77
+ }
78
+
79
+ // Run one attempt. `context` is the attempt context (kind, change, prd, prd_revision,
80
+ // base, ticket/ticket_digest or candidate/check); `commands` the block lines;
81
+ // `timeoutSeconds` the effective timeout; `echo` when the output should also reach
82
+ // the terminal. `revalidate`, when given, is called under the worktree lock
83
+ // immediately before the `running` record is written and returns the context to
84
+ // record (docs/runtime-contracts.md, "Command gates"): the gates the caller passed
85
+ // before the lock are evaluated again there, so a transition, revision or
86
+ // authorization committed in between is seen and the attempt is refused (a thrown
87
+ // transaction Refusal becomes { code, problem, refused: true }) or recorded against
88
+ // the current agreement. `announce` is called once the record is registered and
89
+ // before the launch, so a refusal prints nothing. Returns { attempt } or { code,
90
+ // problem } for refusals before launch; nothing is written for a refusal.
91
+ async function runAttempt({ root, context, commands, timeoutSeconds, command = 'verify', echo = true, declared = {}, revalidate = null, announce = null, cwd = null }) {
92
+ const block = commands.length ? `${commands.join('\n')}\n` : '';
93
+ const checkDigest = parse.sha256(`${block}timeout=${timeoutSeconds}\n`);
94
+ const display = sanitizeText(block).text.slice(0, 2000);
95
+ const before = source.snapshot(root);
96
+ if (before.problems.length) return { code: before.problems[0].code, problem: before.problems.map(p => `${p.code}: ${p.detail}`).join('\n'), problems: before.problems };
97
+
98
+ let attempt, logDir, relLogDir;
99
+ try {
100
+ state.withLock(root, () => {
101
+ // Finish any committed transaction before writing: its staged files would
102
+ // otherwise be renamed over this attempt and its index pointer later.
103
+ require('./transaction.cjs').recoverPending(root);
104
+ if (revalidate) context = revalidate();
105
+ const key = state.contextKey(context);
106
+ // Changes mode records (schema 2) carry the agreement digest; `mode` only
107
+ // selects the key format and is not part of the record.
108
+ const { mode, ...persisted } = context;
109
+ const schema = persisted.coverage ? 3 : persisted.agreement ? 2 : 1;
110
+ const read = state.readIndex(root);
111
+ if (read.error) { const e = new Error(read.error); e.code = 'INVALID'; throw e; }
112
+ const index = read.index;
113
+ const sequence = index.sequence + 1;
114
+ const id = state.attemptId(sequence);
115
+ source.storeManifest(root, before);
116
+ relLogDir = `${state.RUNTIME_DIR}/attempts/${id}`;
117
+ logDir = path.join(root, relLogDir);
118
+ fs.mkdirSync(logDir, { recursive: true });
119
+ attempt = {
120
+ schema, runtime: schema, id, sequence, context: persisted,
121
+ check: { digest: checkDigest, display, timeout_seconds: timeoutSeconds },
122
+ outcome: 'running', exit_code: null, signal: null,
123
+ runner: runnerInfo(), cwd: cwd || '.',
124
+ environment: { os: `${os.platform()} ${os.release()}`, node: process.version, declared },
125
+ started: nowIso(), finished: null,
126
+ source: { before: before.digest, after: null, files: before.files.length, limitations: before.limitations },
127
+ artifacts: { stdout: { path: `${relLogDir}/stdout.log`, sha256: null, bytes: 0, truncated: false, redactions: 0 }, stderr: { path: `${relLogDir}/stderr.log`, sha256: null, bytes: 0, truncated: false, redactions: 0 } },
128
+ owner: { pid: process.pid, ppid: process.ppid, host: os.hostname() }, child: null,
129
+ limitations: [],
130
+ };
131
+ state.writeAttempt(root, attempt);
132
+ index.sequence = sequence;
133
+ index.current[key] = id;
134
+ index.running.push(id);
135
+ state.writeIndex(root, index);
136
+ }, { command });
137
+ } catch (error) {
138
+ if (error && error.refusal) return { code: error.code, problem: error.message, refused: true };
139
+ if (error.code === 'STATE_BUSY') return { code: 'STATE_BUSY', problem: error.message };
140
+ if (error.code === 'INVALID') return { code: 'INPUT_INVALID', problem: error.message };
141
+ return { code: 'ATTEMPT_ERROR', problem: `cannot persist the attempt record: ${error.message}` };
142
+ }
143
+
144
+ if (announce) announce(attempt);
145
+ // Execute in a fresh process group so timeouts and signals reach every descendant.
146
+ let child, launchError = null;
147
+ const stdout = new Capture(path.join(logDir, 'stdout.log'), echo ? process.stdout : null);
148
+ const stderr = new Capture(path.join(logDir, 'stderr.log'), echo ? process.stderr : null);
149
+ let timedOut = false, interruptedBy = null, abandoned = false, killTimer = null, graceTimer = null, drainTimer = null, settle = null;
150
+ const sent = [];
151
+ // Signal the whole group, whether or not the shell itself has exited: a
152
+ // background child that inherited the output pipes keeps the run alive and
153
+ // must be terminated the same way. The bare pid is a fallback only while the
154
+ // shell is known to be alive (after it is reaped the pid may be reused). The
155
+ // group id itself can be reused only once every member is gone; the window
156
+ // between the two attempts is a documented limit, not something detected here.
157
+ // `sent` records only signals a kill call delivered.
158
+ const signalGroup = signal => {
159
+ try { process.kill(-child.pid, signal); sent.push(signal); return; } catch { /* no group left */ }
160
+ if (child.exitCode === null && child.signalCode === null) { try { process.kill(child.pid, signal); sent.push(signal); } catch { /* gone */ } }
161
+ };
162
+ let terminating = false;
163
+ const terminate = () => {
164
+ if (!child || !child.pid || terminating) return;
165
+ terminating = true;
166
+ signalGroup('SIGTERM');
167
+ graceTimer = setTimeout(() => {
168
+ signalGroup('SIGKILL');
169
+ // Bound the wait for the pipes to close: a descendant that survives
170
+ // SIGKILL (or was never reachable) must not hold the run open forever.
171
+ drainTimer = setTimeout(() => {
172
+ abandoned = true;
173
+ try { child.stdout.destroy(); child.stderr.destroy(); child.unref(); } catch { /* best effort */ }
174
+ settle({ code: child.exitCode, signal: child.signalCode });
175
+ }, DRAIN_MS);
176
+ }, GRACE_MS);
177
+ };
178
+ const onSignal = signal => { interruptedBy = signal; terminate(); };
179
+ const exit = await new Promise(resolve => {
180
+ let settled = false;
181
+ settle = result => { if (!settled) { settled = true; resolve(result); } };
182
+ try {
183
+ child = spawn(runnerInfo().shell, [...RUNNER_ARGS, block], { cwd: cwd ? path.join(root, cwd) : root, detached: true, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
184
+ } catch (error) { launchError = error.message; return settle({ code: null, signal: null }); }
185
+ child.on('error', error => { launchError = error.message; });
186
+ if (child.pid) {
187
+ attempt.child = { pid: child.pid };
188
+ try { state.withLock(root, () => state.writeAttempt(root, attempt), { command }); } catch { /* the final write records it */ }
189
+ }
190
+ child.stdout.on('data', chunk => stdout.write(chunk));
191
+ child.stderr.on('data', chunk => stderr.write(chunk));
192
+ process.on('SIGINT', onSignal); process.on('SIGTERM', onSignal);
193
+ killTimer = setTimeout(() => { timedOut = true; terminate(); }, timeoutSeconds * 1000);
194
+ child.on('close', (code, signal) => settle({ code, signal }));
195
+ });
196
+ clearTimeout(killTimer); clearTimeout(graceTimer); clearTimeout(drainTimer);
197
+ process.off('SIGINT', onSignal); process.off('SIGTERM', onSignal);
198
+ const outInfo = stdout.close(), errInfo = stderr.close();
199
+ const termination = `${sent.length ? `the child process group was sent ${sent.join(' then ')}` : 'no reachable process remained in the child\'s group (the shell had exited and its descendants left the group)'}${abandoned ? `; output capture was abandoned ${DRAIN_MS / 1000} s after the SIGKILL point and a descendant may still be running` : ''}`;
200
+
201
+ const after = source.snapshot(root);
202
+ const changed = after.digest && before.digest !== after.digest ? source.diffManifests(before, after) : [];
203
+ attempt.finished = nowIso();
204
+ attempt.exit_code = exit.code;
205
+ attempt.signal = exit.signal;
206
+ attempt.source.after = after.digest;
207
+ for (const [name, info] of [['stdout', outInfo], ['stderr', errInfo]]) {
208
+ const file = path.join(logDir, `${name}.log`);
209
+ let sha = null;
210
+ try { sha = parse.sha256(fs.readFileSync(file)); } catch { sha = null; }
211
+ attempt.artifacts[name] = { ...attempt.artifacts[name], sha256: sha, bytes: info.bytes, truncated: info.truncated, redactions: info.redactions };
212
+ }
213
+ const captureFailure = outInfo.failed || errInfo.failed;
214
+ if (launchError) { attempt.outcome = 'error'; attempt.error = `cannot launch the check: ${launchError}`; }
215
+ else if (interruptedBy) { attempt.outcome = 'interrupted'; attempt.limitations.push(`interrupted by ${interruptedBy}; ${termination}`); }
216
+ else if (timedOut) { attempt.outcome = 'timed_out'; attempt.limitations.push(`terminated after ${timeoutSeconds} s; ${termination}`); }
217
+ else if (captureFailure) { attempt.outcome = 'error'; attempt.error = `capture failed: ${captureFailure}`; }
218
+ else if (after.problems.length) { attempt.outcome = 'error'; attempt.error = `source view invalid after the run: ${after.problems[0].code} ${after.problems[0].detail}`; }
219
+ else if (exit.code === 0 && changed.length) { attempt.outcome = 'error'; attempt.error = `SOURCE_CHANGED: the check mutated source: ${changed.slice(0, 5).join(', ')}${changed.length > 5 ? ` (+${changed.length - 5})` : ''}`; }
220
+ else if (exit.code === 0) attempt.outcome = 'passed';
221
+ else attempt.outcome = 'failed';
222
+ if (changed.length && attempt.outcome !== 'error') attempt.limitations.push(`source changed during the run: ${changed.slice(0, 5).join(', ')}`);
223
+ if (after.digest) { try { source.storeManifest(root, after); } catch { /* best effort; the digest is recorded */ } }
224
+
225
+ try {
226
+ state.withLock(root, () => {
227
+ const read = state.readIndex(root);
228
+ if (read.error) { const e = new Error(read.error); e.code = 'INVALID'; throw e; }
229
+ const index = read.index;
230
+ state.writeAttempt(root, attempt);
231
+ index.running = index.running.filter(id => id !== attempt.id);
232
+ state.writeIndex(root, index);
233
+ }, { command });
234
+ } catch (error) {
235
+ return { code: 'ATTEMPT_ERROR', problem: `attempt ${attempt.id} finished ${attempt.outcome} but the record could not be finalized: ${error.message}`, attempt };
236
+ }
237
+ return { attempt, changed };
238
+ }
239
+
240
+ const exitFor = attempt => ({ passed: 0, failed: 1, timed_out: 124, interrupted: 130, error: 4 })[attempt.outcome] ?? 4;
241
+
242
+ module.exports = { runAttempt, exitFor, CAPTURE_LIMIT, GRACE_MS, DRAIN_MS, runnerInfo };
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+ // PINCER runtime — output sanitizer (docs/runtime-contracts.md, "Capture and
3
+ // sanitization"). A safety net applied to every captured line and to command
4
+ // display text before anything is persisted: documented secret patterns are
5
+ // replaced by [redacted] and counted. It is not a guarantee; checks must avoid
6
+ // printing secrets, and no raw log is ever exported.
7
+ const PATTERNS = [
8
+ // Bearer tokens first, so an `Authorization: Bearer <token>` header keeps the
9
+ // token, not the word Bearer, as the redacted value.
10
+ { re: /((?:bearer|basic|token|digest)\s+)[A-Za-z0-9\-._~+/=]+/gi, replace: '$1[redacted]' },
11
+ // key/secret/password/token/authorization assignments and JSON fields; a
12
+ // quoted value is redacted up to its closing quote, an unquoted one to the
13
+ // next whitespace or separator. Bounded quantifiers: an unbounded prefix would
14
+ // backtrack quadratically on long lines.
15
+ { re: /((?:^|[^A-Za-z0-9_.-])[A-Za-z0-9_.-]{0,64}?(?:key|secret|password|passwd|token|authorization)[A-Za-z0-9_.-]{0,64}["']?[ \t]{0,8}[:=][ \t]{0,8})(?:"([^"\n]{0,512})"|'([^'\n]{0,512})'|([^\s"',;]+))/gi, replace: (m, head, dq, sq, bare) => `${head}${dq !== undefined ? '"[redacted]"' : sq !== undefined ? "'[redacted]'" : '[redacted]'}` },
16
+ { re: /AKIA[0-9A-Z]{16}/g, replace: '[redacted]' },
17
+ { re: /gh[pousr]_[A-Za-z0-9]{20,}/g, replace: '[redacted]' },
18
+ ];
19
+ const PEM_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
20
+ const PEM_END = /-----END [A-Z ]*PRIVATE KEY-----/;
21
+
22
+ // Stateful: PEM private key blocks span lines. `state` is a plain object the
23
+ // caller keeps per stream.
24
+ function sanitizeLine(line, state = {}) {
25
+ let redactions = 0;
26
+ if (state.inPem) {
27
+ if (PEM_END.test(line)) state.inPem = false;
28
+ return { text: '[redacted]', redactions: 1 };
29
+ }
30
+ if (PEM_BEGIN.test(line)) {
31
+ state.inPem = !PEM_END.test(line);
32
+ return { text: '[redacted]', redactions: 1 };
33
+ }
34
+ let text = line;
35
+ for (const { re, replace } of PATTERNS) {
36
+ text = text.replace(re, (...args) => {
37
+ redactions++;
38
+ return typeof replace === 'function' ? replace(...args) : replace.replace(/\$(\d)/g, (_, n) => args[Number(n)]);
39
+ });
40
+ }
41
+ return { text, redactions };
42
+ }
43
+
44
+ function sanitizeText(text) {
45
+ const state = {};
46
+ let redactions = 0;
47
+ const lines = String(text).split('\n').map(line => { const r = sanitizeLine(line, state); redactions += r.redactions; return r.text; });
48
+ return { text: lines.join('\n'), redactions };
49
+ }
50
+
51
+ // A Verification block must not carry an inline secret literal: the block is
52
+ // recorded as display text and would persist the value. `$references` and
53
+ // empty values are fine.
54
+ // An assignment anywhere in the line (after a word boundary, so `env TOKEN=x cmd`
55
+ // and `true; TOKEN=x cmd` count); `$VAR`, `"$VAR"`, `$(…)` and backtick
56
+ // references are allowed, literals are not.
57
+ const INLINE_SECRET = /(?:^|[\s;&|(])(?:export\s+)?[A-Za-z0-9_]*(?:KEY|SECRET|PASSWORD|PASSWD|TOKEN)[A-Za-z0-9_]*=(?!["']?\$|["']?`|\s|$)\S/i;
58
+ function inlineSecretLine(commands) {
59
+ const index = commands.findIndex(line => INLINE_SECRET.test(line));
60
+ return index === -1 ? null : index + 1;
61
+ }
62
+
63
+ module.exports = { sanitizeLine, sanitizeText, inlineSecretLine, PATTERNS };