@0xcraft/powershot 1.1.5 → 1.2.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 CHANGED
@@ -180,8 +180,8 @@ commands.
180
180
  flowchart LR
181
181
  START["Selected files and checks"] --> ACCOUNT{"Required work accounted for?"}
182
182
  ACCOUNT -- "yes" --> DEPTH{"Enriched semantic depth available?"}
183
- DEPTH -- "yes" --> FULL["full coverage"]
184
- DEPTH -- "no · portable policy" --> PORTABLE["portable coverage · gaps named"]
183
+ DEPTH -- "yes" --> FULL["full applicable-oracle coverage"]
184
+ DEPTH -- "no · portable policy" --> PORTABLE["portable oracle coverage · gaps named"]
185
185
  FULL --> FINDINGS{"Findings?"}
186
186
  PORTABLE --> FINDINGS
187
187
  FINDINGS -- "no" --> CLEAN["exit 0 · complete and clean"]
@@ -206,11 +206,11 @@ flowchart LR
206
206
  | `2` | Command or Git input was invalid |
207
207
  | `3` | Review is incomplete; findings may be missing |
208
208
 
209
- A clean report names its effective severity threshold, deterministic/model mode, file
210
- and check counts, and coverage level. A partial or failed review instead says it is not
211
- a verdict and keeps the missing work visible. Use `--format manifest` to inspect file
212
- dispositions, executed and unavailable checks, failures, judge units, and
213
- `notLookedAt`.
209
+ A clean report names its effective severity threshold, deterministic/model mode,
210
+ reviewed/changed file ratio, check count, and oracle coverage level. A partial or
211
+ failed review instead says it is not a verdict and keeps the missing work visible. Use
212
+ `--format manifest` to inspect file dispositions, executed and unavailable checks,
213
+ failures, judge units, and `notLookedAt`.
214
214
 
215
215
  ## CI integration
216
216
 
@@ -298,6 +298,20 @@ psh delegate > /tmp/powershot-brief.md
298
298
  psh review --verify-only --absorb /tmp/powershot-findings.json
299
299
  ```
300
300
 
301
+ Delegation uses the same `SelectionPlan` and target snapshot as a real review. The
302
+ brief names every changed file as selected, policy-waived, or failed, so unsupported
303
+ or oversized files cannot disappear between task creation and absorption. For an
304
+ agent that consumes structured input, request the versioned task directly:
305
+
306
+ ```bash
307
+ psh delegate --format json > /tmp/powershot-task.json
308
+ ```
309
+
310
+ `powershot.delegate/v1` contains the resolved file dispositions, applicable judges,
311
+ bounded review units, changed-line excerpts, intent, and the exact output contract.
312
+ Creating either form is LLM-free and does not create a session. Deterministic checks
313
+ run when the returned finding array is absorbed by `psh review --verify-only`.
314
+
301
315
  ## Language coverage
302
316
 
303
317
  | Language | Available oracles |
package/dist/cli/args.js CHANGED
@@ -6,7 +6,8 @@ psh — code review for machine-written code
6
6
  psh review --from main --to feat/x branch range
7
7
  psh review --commit <hash> a single commit
8
8
  psh scan <path> audit existing files, no git history needed
9
- psh delegate emit the judging work for an agent you already pay for
9
+ psh delegate [--format text|markdown|json]
10
+ emit a selection-accounted task for your existing agent
10
11
  psh session list | view <id> runs that can be resumed, or replayed as a page
11
12
  psh session diff <a> <b> what one review found that the other did not
12
13
  psh dismiss <id> [--reason "..."] record that a finding is correct, and stop showing it
@@ -1,8 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readFileSync } from 'node:fs';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
3
  import { Budget, parseLimits } from '#app/budget.js';
4
4
  import { loadConfig, policyChanged } from '#app/config.js';
5
- import { absorbDelegated, delegateBrief } from '#app/delegate.js';
5
+ import { absorbDelegated, buildDelegateTask, delegateBrief, delegateJson } from '#app/delegate.js';
6
6
  import { baseRefOf, checkRange, headSha, repoRoot, shaOf } from '#app/git.js';
7
7
  import { JUDGES } from '#app/judges/prompts.js';
8
8
  import { RunManifest, coverageProblems, hashOf, writeManifest } from '#app/manifest.js';
@@ -13,6 +13,7 @@ import { progress, stage } from '#app/report/terminal.js';
13
13
  import { summarizeRun } from '#app/report/summary.js';
14
14
  import { review } from '#app/review.js';
15
15
  import { scanPaths } from '#app/scan.js';
16
+ import { SelectionPlan } from '#app/plan.js';
16
17
  import { Session } from '#app/session.js';
17
18
  import { withTargetTree } from '#app/snapshot.js';
18
19
  import { SEVERITIES } from '#app/types.js';
@@ -33,6 +34,10 @@ export async function runReviewCommand(command, values, positionals) {
33
34
  process.stderr.write('--format must be one of: ' + REPORT_FORMATS.join(', ') + '\n');
34
35
  return 2;
35
36
  }
37
+ if (command === 'delegate' && !['text', 'markdown', 'json'].includes(values.format)) {
38
+ process.stderr.write('delegate --format must be one of: text, markdown, json\n');
39
+ return 2;
40
+ }
36
41
  if ((values.from && !values.to) || (values.to && !values.from)) {
37
42
  process.stderr.write('--from and --to must be given together.\n');
38
43
  return 2;
@@ -142,19 +147,32 @@ export async function runReviewCommand(command, values, positionals) {
142
147
  if (command === 'delegate') {
143
148
  const { buildGround } = await import('#app/ground.js');
144
149
  const { collectChanges, statedIntent } = await import('#app/git.js');
145
- const { matchesAny } = await import('#app/config.js');
146
- const changes = collectChanges(root, range).filter((change) => !matchesAny(change.path, config.ignore));
147
- if (changes.length === 0) {
150
+ const all = collectChanges(root, range);
151
+ if (all.length === 0) {
148
152
  process.stderr.write('Nothing to review.\n');
149
153
  return 0;
150
154
  }
151
- const ground = await withTargetTree(root, range, (tree) => buildGround(tree, changes));
152
- process.stdout.write(delegateBrief(ground, config, {
153
- intent: statedIntent(root, range),
154
- maxBundleLines: maxBundle,
155
- checks: requestedChecks,
156
- }));
157
- return 0;
155
+ const task = await withTargetTree(root, range, async (tree) => {
156
+ const plan = SelectionPlan.build(tree, all, config);
157
+ const selected = plan.keep(all);
158
+ const ground = await buildGround(tree, selected, undefined, all);
159
+ plan.accountForGround(selected, ground);
160
+ return buildDelegateTask(ground, config, plan.items(), {
161
+ intent: statedIntent(root, range),
162
+ maxBundleLines: maxBundle,
163
+ checks: requestedChecks,
164
+ target: { from: values.from, to: values.to, commit: values.commit },
165
+ });
166
+ });
167
+ const rendered = values.format === 'json' ? delegateJson(task) : delegateBrief(task);
168
+ if (values.output !== undefined) {
169
+ writeFileSync(values.output, rendered);
170
+ process.stderr.write(dim(' written to ' + values.output) + '\n');
171
+ }
172
+ else {
173
+ process.stdout.write(rendered);
174
+ }
175
+ return task.state === 'failed' ? 3 : 0;
158
176
  }
159
177
  const canceller = new AbortController();
160
178
  let interrupted = false;
@@ -46,6 +46,7 @@ export function runSessionCommand(positionals) {
46
46
  verifyOnly: session.report.verifyOnly,
47
47
  minSeverity: session.report.minSeverity,
48
48
  filesReviewed: session.report.filesReviewed,
49
+ filesChanged: session.report.filesChanged,
49
50
  deterministicChecks: session.report.deterministicChecks,
50
51
  scopeDetails: session.report.scopeDetails,
51
52
  }));
package/dist/delegate.js CHANGED
@@ -2,44 +2,120 @@ import { bundle, bundleName } from './bundle.js';
2
2
  import { renderChanges } from './judges/judge.js';
3
3
  import { COMMON, JUDGES } from './judges/prompts.js';
4
4
  import { enabled } from './config.js';
5
- export function delegateBrief(g, cfg, opts) {
5
+ import { stripPath } from './text.js';
6
+ export const DELEGATE_SCHEMA = 'powershot.delegate/v1';
7
+ export function buildDelegateTask(g, cfg, files, opts) {
6
8
  const units = bundle(g, opts.maxBundleLines ?? 1200);
7
9
  const judges = JUDGES.filter((j) => opts.checks ? opts.checks.includes(j.name) : enabled(cfg.judges, j.name));
10
+ return {
11
+ schema: DELEGATE_SCHEMA,
12
+ state: files.some((file) => file.disposition === 'failed') ? 'failed' : 'complete',
13
+ target: opts.target ?? {},
14
+ intent: opts.intent,
15
+ files: files.map(({ path, disposition, reason, bytes, addedLines, language }) => ({
16
+ path,
17
+ disposition,
18
+ reason,
19
+ bytes,
20
+ addedLines,
21
+ language,
22
+ })),
23
+ judges: judges.map((judge) => {
24
+ const usesIntent = judge.needsIntent === true;
25
+ const applicable = !usesIntent || Boolean(opts.intent);
26
+ return {
27
+ name: judge.name,
28
+ brief: judge.brief,
29
+ applicable,
30
+ usesIntent,
31
+ reason: applicable ? undefined : 'no stated intent available',
32
+ };
33
+ }),
34
+ instructions: {
35
+ summary: 'Perform only the judgement work in this task. PowerShot runs its deterministic checks when these findings are absorbed.',
36
+ groundRules: COMMON,
37
+ output: {
38
+ type: 'json-array',
39
+ empty: [],
40
+ example: [{
41
+ file: 'src/a.ts',
42
+ line: 12,
43
+ severity: 'high',
44
+ confidence: 'firm',
45
+ check: 'plausible-logic',
46
+ title: '…',
47
+ why: '…',
48
+ fix: '…',
49
+ }],
50
+ },
51
+ },
52
+ units: units.map((unit, index) => ({
53
+ id: index + 1,
54
+ name: bundleName(unit, g.root),
55
+ files: unit.files.map((file) => file.path),
56
+ changes: renderChanges(unit.files),
57
+ })),
58
+ };
59
+ }
60
+ const cell = (value) => stripPath(value).replace(/\|/g, '\\|');
61
+ export function delegateBrief(task) {
62
+ const scope = task.files.length === 0
63
+ ? ['_No changed files._']
64
+ : [
65
+ '| File | Disposition | Language | Added | Reason |',
66
+ '|---|---|---|---:|---|',
67
+ ...task.files.map((file) => '| ' + cell(file.path) + ' | ' + file.disposition + ' | ' + file.language + ' | ' + file.addedLines +
68
+ ' | ' + cell(file.reason ?? '') + ' |'),
69
+ ];
8
70
  const out = [
9
71
  '# PowerShot review brief',
10
72
  '',
11
- 'The deterministic checks have already run; what follows is the judgement work.',
73
+ task.instructions.summary,
12
74
  'Act as each judge below over each review unit, then reply with a single JSON array',
13
75
  'combining every finding. Add nothing outside the array.',
14
76
  '',
77
+ '## Scope',
78
+ '',
79
+ 'Task state: **' + task.state + '**.',
80
+ '',
81
+ ...scope,
82
+ '',
15
83
  '## Output contract',
16
84
  '',
17
85
  '```json',
18
- '[{"file":"src/a.ts","line":12,"severity":"high","confidence":"firm",',
19
- ' "check":"plausible-logic","title":"…","why":"…","fix":"…"}]',
86
+ JSON.stringify(task.instructions.output.example, null, 2),
20
87
  '```',
21
88
  '',
22
- 'Return `[]` when a judge finds nothing. An empty array is a success, not a failure.',
89
+ 'Return `' + JSON.stringify(task.instructions.output.empty) +
90
+ '` when a judge finds nothing. An empty array is a success, not a failure.',
23
91
  '',
24
92
  '## Ground rules',
25
93
  '',
26
- COMMON,
94
+ task.instructions.groundRules,
27
95
  '',
28
96
  ];
29
- for (const judge of judges) {
97
+ for (const judge of task.judges) {
30
98
  out.push('## Judge: ' + judge.name, '', judge.brief, '');
31
- if (judge.needsIntent) {
32
- out.push(opts.intent
33
- ? 'This change states that it does the following:\n\n> ' + opts.intent.split('\n').join('\n> ')
99
+ if (judge.usesIntent) {
100
+ out.push(task.intent
101
+ ? 'This change states that it does the following:\n\n> ' + task.intent.split('\n').join('\n> ')
34
102
  : '_No stated intent available (no commit in range) — skip this judge._', '');
35
103
  }
36
104
  }
37
105
  out.push('## Review units', '');
38
- units.forEach((unit, i) => {
39
- out.push('### Unit ' + (i + 1) + ' ' + bundleName(unit, g.root), '', '```', renderChanges(unit.files), '```', '');
40
- });
106
+ if (task.units.length === 0) {
107
+ out.push('_No judgement units. Every changed file was excluded or failed preparation; inspect Scope._', '');
108
+ }
109
+ else {
110
+ task.units.forEach((unit) => {
111
+ out.push('### Unit ' + unit.id + ' — ' + cell(unit.name), '', '```', unit.changes, '```', '');
112
+ });
113
+ }
41
114
  return out.join('\n');
42
115
  }
116
+ export function delegateJson(task) {
117
+ return JSON.stringify(task, null, 2) + '\n';
118
+ }
43
119
  export function absorbDelegated(json) {
44
120
  let parsed;
45
121
  try {
package/dist/plan.js CHANGED
@@ -92,6 +92,31 @@ export class SelectionPlan {
92
92
  keep(changed) {
93
93
  return changed.filter((c) => this.rows.get(c.path)?.disposition === 'selected');
94
94
  }
95
+ /**
96
+ * Finish the file-level selection after parsers have had one chance to load it.
97
+ *
98
+ * Review and delegation both promise to describe the same change. Keeping this
99
+ * transition on the plan prevents either caller from silently inventing its own
100
+ * meaning for a deleted, unsupported, or unavailable source file.
101
+ */
102
+ accountForGround(changed, ground) {
103
+ const grounded = new Set([
104
+ ...ground.files.map((file) => file.changed.path),
105
+ ...ground.foreign.map((file) => file.path),
106
+ ]);
107
+ for (const file of changed) {
108
+ if (file.deleted) {
109
+ this.waive(file.path, 'deleted file has no current source to review');
110
+ continue;
111
+ }
112
+ if (grounded.has(file.path))
113
+ continue;
114
+ if (packFor(file.path))
115
+ this.fail(file.path, 'declared language parser unavailable');
116
+ else
117
+ this.waive(file.path, 'no parser for this language');
118
+ }
119
+ }
95
120
  items() {
96
121
  return [...this.rows.values()];
97
122
  }
@@ -73,6 +73,7 @@ export function summarizeRun(record) {
73
73
  filesReviewed: hasFiles
74
74
  ? record.files.filter((file) => file.disposition === 'selected').length
75
75
  : undefined,
76
+ filesChanged: hasFiles ? record.files.length : undefined,
76
77
  deterministicChecks: hasChecks ? new Set(record.checks.ran).size : undefined,
77
78
  scopeDetails: scopeDetails(record),
78
79
  };
@@ -88,13 +89,20 @@ export function noFindingsLabel(summary) {
88
89
  }
89
90
  export function scopeLine(summary) {
90
91
  const parts = [];
91
- if (summary.filesReviewed !== undefined)
92
- parts.push(plural(summary.filesReviewed, 'file') + ' reviewed');
92
+ if (summary.filesReviewed !== undefined) {
93
+ parts.push(summary.filesChanged === undefined
94
+ ? plural(summary.filesReviewed, 'file') + ' reviewed'
95
+ : summary.filesReviewed + '/' + summary.filesChanged + ' changed ' +
96
+ (summary.filesChanged === 1 ? 'file' : 'files') + ' reviewed');
97
+ }
93
98
  if (summary.deterministicChecks !== undefined) {
94
99
  parts.push(plural(summary.deterministicChecks, 'deterministic check'));
95
100
  }
96
- if (summary.coverage !== undefined)
97
- parts.push(summary.coverage + ' coverage');
101
+ if (summary.coverage !== undefined) {
102
+ parts.push(summary.coverage === 'full'
103
+ ? 'full applicable-oracle coverage'
104
+ : 'portable oracle coverage');
105
+ }
98
106
  return parts.length > 0 ? parts.join(' · ') : undefined;
99
107
  }
100
108
  export function modeNote(summary, verifyOnly = 'verify-only') {
package/dist/review.js CHANGED
@@ -12,7 +12,6 @@ import { apiKey } from './judges/llm.js';
12
12
  import { enabled } from './config.js';
13
13
  import { SelectionPlan, capabilitiesOf } from './plan.js';
14
14
  import { Budget } from './budget.js';
15
- import { packFor } from './lang/packs.js';
16
15
  import { SEVERITIES } from './types.js';
17
16
  import { stripControl, stripPath } from './text.js';
18
17
  export function atLeast(severity, min) {
@@ -174,21 +173,10 @@ export async function review(opts) {
174
173
  // Naming a check explicitly is a request for that oracle, even under the portable
175
174
  // default. Strict policy makes the same promise for every configured verifier.
176
175
  const requireEnrichedOracles = config.coverage === 'strict' || opts.checks !== undefined;
177
- // a file the change touched that no parser produced a tree for was not reviewed,
178
- // whatever the summary says about the ones that were
179
- const grounded = new Set([...g.files.map((f) => f.changed.path), ...g.foreign.map((f) => f.path)]);
180
- for (const c of changed) {
181
- if (c.deleted) {
182
- plan.waive(c.path, 'deleted file has no current source to review');
183
- continue;
184
- }
185
- if (grounded.has(c.path))
186
- continue;
187
- if (packFor(c.path))
188
- plan.fail(c.path, 'declared language parser unavailable');
189
- else
190
- plan.waive(c.path, 'no parser for this language');
191
- }
176
+ // A file the change touched that no parser produced a tree for was not reviewed,
177
+ // whatever the summary says about the ones that were. Delegation uses this exact
178
+ // transition too, so its task cannot disagree with the review it will feed.
179
+ plan.accountForGround(changed, g);
192
180
  // Capabilities belong to files, not runs. A typed file beside one excluded from
193
181
  // tsconfig must not make the latter look checked, and an old Ruby file must not
194
182
  // make a new Python file eligible for a before/after oracle.
package/dist/selftest.js CHANGED
@@ -35,7 +35,7 @@ import { dirname, join, sep } from 'node:path';
35
35
  import { runTool } from './judges/tools.js';
36
36
  import { codeQuality } from './report/codequality.js';
37
37
  import { viewer } from './report/viewer.js';
38
- import { absorbDelegated, delegateBrief } from './delegate.js';
38
+ import { absorbDelegated, buildDelegateTask, delegateBrief } from './delegate.js';
39
39
  import { TARGETS, findTarget } from './agents.js';
40
40
  import { PACKS, packFor, parseIsolated } from './lang/packs.js';
41
41
  import { isPhantom, pythonManifest, localModules } from './lang/python-deps.js';
@@ -1175,9 +1175,14 @@ check('portable coverage is a verdict, but never masquerades as full semantic co
1175
1175
  filesReviewed: 1, deterministicChecks: 1, scopeDetails: unavailable,
1176
1176
  });
1177
1177
  assert.match(out, /No medium-or-higher deterministic findings\./);
1178
- assert.match(out, /1 file reviewed · 1 deterministic check · portable coverage/);
1178
+ assert.match(out, /1 file reviewed · 1 deterministic check · portable oracle coverage/);
1179
1179
  assert.match(out, /1 reviewed file lacked type information and a reference graph/);
1180
1180
  assert.doesNotMatch(out, /not a verdict/);
1181
+ const full = terminal([], {
1182
+ subtitle: 'workspace', verified: 0, judged: 0, state: 'complete', notLookedAt: [],
1183
+ coverage: 'full', filesReviewed: 2, filesChanged: 2, deterministicChecks: 3,
1184
+ });
1185
+ assert.match(full, /2\/2 changed files reviewed · 3 deterministic checks · full applicable-oracle coverage/);
1181
1186
  const selected = Array.from({ length: 20 }, (_, index) => ({
1182
1187
  path: 'web/file-' + index + '.ts',
1183
1188
  disposition: 'selected',
@@ -1202,7 +1207,7 @@ check('portable coverage is a verdict, but never masquerades as full semantic co
1202
1207
  },
1203
1208
  }));
1204
1209
  assert.match(md, /✅ \*\*No medium-or-higher deterministic findings\*\*/);
1205
- assert.match(md, /20 files reviewed · 19 deterministic checks · portable coverage/);
1210
+ assert.match(md, /20\/36 changed files reviewed · 19 deterministic checks · portable oracle coverage/);
1206
1211
  assert.match(md, /Model review was disabled \(`verify-only`\)\./);
1207
1212
  assert.match(md, /<summary>Coverage details<\/summary>/);
1208
1213
  const rendered = md.replace(/\\/g, '');
@@ -1958,6 +1963,16 @@ check('the public action persists judge answers and publishes only a verdict', (
1958
1963
  assert.match(action, /m\.coverage === "full" \|\| m\.coverage === "portable" \? m\.coverage : "unknown"/);
1959
1964
  assert.match(action, /Approve a clean review[\s\S]+steps\.review\.outputs\.coverage == 'full'/);
1960
1965
  });
1966
+ check('the public action keeps every pull request write inside the base repository', () => {
1967
+ const action = readFileSync(join(process.cwd(), 'action.yml'), 'utf8');
1968
+ for (const name of ['Post inline comments', 'Comment on the pull request', 'Approve a clean review']) {
1969
+ const start = action.indexOf(' - name: ' + name);
1970
+ const end = action.indexOf('\n - name: ', start + 1);
1971
+ const step = action.slice(start, end === -1 ? undefined : end);
1972
+ assert.ok(start >= 0, name + ' step is missing');
1973
+ assert.match(step, /github\.event\.pull_request\.head\.repo\.full_name == github\.repository/, name);
1974
+ }
1975
+ });
1961
1976
  check('published CI examples preserve one verdict and its exit status', () => {
1962
1977
  const action = readFileSync(join(process.cwd(), 'examples', 'github-actions', 'action.yml'), 'utf8');
1963
1978
  const github = readFileSync(join(process.cwd(), 'examples', 'github-actions', 'cli.yml'), 'utf8');
@@ -1997,10 +2012,11 @@ check('the viewer labels a complete portable session', () => {
1997
2012
  const html = viewer([], {
1998
2013
  id: 'portable', target: 'workspace', started: '2026-01-01T10:00:00Z',
1999
2014
  state: 'complete', notLookedAt: [], coverage: 'portable',
2000
- verifyOnly: true, minSeverity: 'medium', filesReviewed: 1, deterministicChecks: 2,
2015
+ verifyOnly: true, minSeverity: 'medium', filesReviewed: 1, filesChanged: 3,
2016
+ deterministicChecks: 2,
2001
2017
  scopeDetails: ['1 reviewed file lacked type information'],
2002
2018
  });
2003
- assert.match(html, /1 file reviewed · 2 deterministic checks · portable coverage/);
2019
+ assert.match(html, /1\/3 changed files reviewed · 2 deterministic checks · portable oracle coverage/);
2004
2020
  assert.match(html, /No medium-or-higher deterministic findings\./);
2005
2021
  assert.match(html, /<summary>Coverage details<\/summary>/);
2006
2022
  });
@@ -2033,9 +2049,11 @@ check('delegate --checks selects only the requested judging brief', () => {
2033
2049
  provider: 'anthropic', model: 'm', verifiers: ['*'], judges: ['*'],
2034
2050
  minSeverity: 'low', ignore: [], coverage: 'portable', promptCache: true,
2035
2051
  };
2036
- const brief = delegateBrief(ground([{ path: 'a.ts', after: 'export const a = 1\n' }]), cfg, {
2037
- checks: ['intent'], intent: 'add a',
2038
- });
2052
+ const task = buildDelegateTask(ground([{ path: 'a.ts', after: 'export const a = 1\n' }]), cfg, [{
2053
+ path: 'a.ts', disposition: 'selected', bytes: 19, addedLines: 1,
2054
+ language: 'typescript', checks: [],
2055
+ }], { checks: ['intent'], intent: 'add a' });
2056
+ const brief = delegateBrief(task);
2039
2057
  assert.match(brief, /## Judge: intent/);
2040
2058
  assert.doesNotMatch(brief, /## Judge: plausible-logic/);
2041
2059
  });
@@ -2190,6 +2208,17 @@ check('a finding is matched past a line that moved under it', () => {
2190
2208
  assert.equal(Session.compare(at(12), at(22)).remaining.length, 1);
2191
2209
  rmSync(dir, { recursive: true, force: true });
2192
2210
  });
2211
+ check('a finished session preserves the reviewed and changed file counts', () => {
2212
+ const dir = mkdtempSync(join(tmpdir(), 'psh-scope-session-'));
2213
+ const session = Session.create(dir, 'workspace');
2214
+ session.saveReport([], {
2215
+ state: 'complete', notLookedAt: [], filesReviewed: 2, filesChanged: 5,
2216
+ });
2217
+ const reopened = Session.open(dir, session.id);
2218
+ assert.equal(reopened?.report?.filesReviewed, 2);
2219
+ assert.equal(reopened?.report?.filesChanged, 5);
2220
+ rmSync(dir, { recursive: true, force: true });
2221
+ });
2193
2222
  check('partial sessions cannot be compared or rendered as clean', () => {
2194
2223
  const dir = mkdtempSync(join(tmpdir(), 'psh-partial-session-'));
2195
2224
  const partial = Session.create(dir, 'workspace');
@@ -3475,6 +3504,7 @@ check('CLI rejects selections that would otherwise run nothing and report clean'
3475
3504
  assert.equal(status(['scan', 'example.rb', '--verify-only', '--checks', 'plausible-logic', '--format', 'manifest']), 2);
3476
3505
  assert.equal(status(['delegate', '--checks', 'phantom-api']), 2);
3477
3506
  assert.equal(status(['delegate']), 0);
3507
+ assert.equal(status(['delegate', '--format', 'manifest']), 2);
3478
3508
  assert.equal(existsSync(join(dir, '.powershot', 'sessions')), false, 'delegate must not create an unused session');
3479
3509
  assert.equal(status(['scan', 'example.rb', '--format', 'unknown']), 2);
3480
3510
  assert.equal(status(['scan', 'example.rb', '--report', 'unknown=report.txt']), 2);
@@ -3489,6 +3519,58 @@ check('CLI rejects selections that would otherwise run nothing and report clean'
3489
3519
  rmSync(dir, { recursive: true, force: true });
3490
3520
  }
3491
3521
  });
3522
+ check('delegate JSON uses the review selection contract and names excluded files', () => {
3523
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-delegate-plan-')));
3524
+ const cli = join(process.cwd(), 'dist', 'cli.js');
3525
+ try {
3526
+ execFileSync('git', ['init', '-q', '.'], { cwd: dir });
3527
+ writeFileSync(join(dir, 'source.rs'), 'pub fn value() -> i32 { 1 }\n');
3528
+ writeFileSync(join(dir, 'notes.md'), '# Notes\n');
3529
+ writeFileSync(join(dir, 'large.ts'), 'x'.repeat(512 * 1024 + 1));
3530
+ const raw = execFileSync(process.execPath, [cli, 'delegate', '--format', 'json'], {
3531
+ cwd: dir,
3532
+ env: { ...process.env, CI: 'true' },
3533
+ encoding: 'utf8',
3534
+ maxBuffer: 2 * 1024 * 1024,
3535
+ });
3536
+ const task = JSON.parse(raw);
3537
+ const files = new Map(task.files.map((file) => [file.path, file]));
3538
+ assert.equal(task.schema, 'powershot.delegate/v1');
3539
+ assert.equal(task.state, 'complete');
3540
+ assert.equal(files.get('source.rs')?.disposition, 'selected');
3541
+ assert.equal(files.get('notes.md')?.disposition, 'waived');
3542
+ assert.match(files.get('notes.md')?.reason ?? '', /no parser/);
3543
+ assert.equal(files.get('large.ts')?.disposition, 'waived');
3544
+ assert.match(files.get('large.ts')?.reason ?? '', /over 512KB/);
3545
+ assert.ok(task.judges.some((judge) => judge.name === 'plausible-logic' && judge.applicable));
3546
+ assert.deepEqual([...new Set(task.units.flatMap((unit) => unit.files))], ['source.rs']);
3547
+ assert.match(task.units[0]?.changes ?? '', /pub fn value/);
3548
+ const markdown = execFileSync(process.execPath, [cli, 'delegate'], {
3549
+ cwd: dir,
3550
+ env: { ...process.env, CI: 'true' },
3551
+ encoding: 'utf8',
3552
+ maxBuffer: 2 * 1024 * 1024,
3553
+ });
3554
+ assert.match(markdown, /## Scope/);
3555
+ assert.match(markdown, /source\.rs.*selected/);
3556
+ assert.match(markdown, /notes\.md.*no parser/);
3557
+ assert.match(markdown, /large\.ts.*over 512KB/);
3558
+ assert.ok(markdown.length < 100_000, 'an oversized source leaked into the delegate brief');
3559
+ assert.equal(existsSync(join(dir, '.powershot', 'sessions')), false, 'delegate must stay LLM-free');
3560
+ rmSync(join(dir, 'source.rs'));
3561
+ rmSync(join(dir, 'large.ts'));
3562
+ const waivedOnly = JSON.parse(execFileSync(process.execPath, [cli, 'delegate', '--format', 'json'], { cwd: dir, env: { ...process.env, CI: 'true' }, encoding: 'utf8' }));
3563
+ assert.equal(waivedOnly.state, 'complete');
3564
+ assert.deepEqual(waivedOnly.files, [{
3565
+ path: 'notes.md', disposition: 'waived', bytes: 8, addedLines: 2, language: 'other',
3566
+ reason: 'no parser for this language',
3567
+ }]);
3568
+ assert.deepEqual(waivedOnly.units, []);
3569
+ }
3570
+ finally {
3571
+ rmSync(dir, { recursive: true, force: true });
3572
+ }
3573
+ });
3492
3574
  check('a budget stop is partial and the manifest names what was not reviewed', () => {
3493
3575
  const b = new Budget({ requests: 1 }, 0);
3494
3576
  b.spend({ requests: 1 });
package/dist/session.js CHANGED
@@ -110,6 +110,7 @@ export class Session {
110
110
  verifyOnly: verdict.verifyOnly,
111
111
  minSeverity: verdict.minSeverity,
112
112
  filesReviewed: verdict.filesReviewed,
113
+ filesChanged: verdict.filesChanged,
113
114
  deterministicChecks: verdict.deterministicChecks,
114
115
  scopeDetails: verdict.scopeDetails ? [...verdict.scopeDetails] : undefined,
115
116
  };
@@ -121,6 +121,14 @@ A branch or commit review reads source from the target revision, not from whatev
121
121
  currently present in the working directory. Grounding, verification, bundling, and
122
122
  positioning all receive the same tree.
123
123
 
124
+ ### Delegation shares file selection
125
+
126
+ `psh delegate` builds the same target snapshot, `SelectionPlan`, and parser ground as
127
+ `psh review`, then stops before deterministic verification, model calls, sessions, or
128
+ manifests. Its Markdown brief and `powershot.delegate/v1` JSON are renderings of one
129
+ task object. Both therefore expose the same selected, waived, and failed files and the
130
+ same bounded judge units; an output adapter cannot silently choose a different scope.
131
+
124
132
  ### Capabilities belong to files
125
133
 
126
134
  A run can contain a typed TypeScript file beside a Python file or a TypeScript file
package/docs/ci.md CHANGED
@@ -90,10 +90,15 @@ newest unmarked legacy `## PowerShot` summary from v1.1.2 or older without claim
90
90
  ambiguous legacy comment through `PATCH`.
91
91
 
92
92
  The comment leads with the verdict, effective severity threshold, review mode, and
93
- aggregate file/check counts. Portable gaps and files outside parser coverage stay
94
- visible under a collapsed coverage section without filling the timeline with paths.
95
- The generated `powershot.manifest.json` keeps the per-file accounting for workflows
96
- that want to persist it as an artifact.
93
+ reviewed/changed file ratio and check count. Portable gaps and files outside parser
94
+ coverage stay visible under a collapsed coverage section without filling the timeline
95
+ with paths. The generated `powershot.manifest.json` keeps the per-file accounting for
96
+ workflows that want to persist it as an artifact.
97
+
98
+ For a `pull_request` from a fork, GitHub gives the workflow token read-only pull-request
99
+ permissions. PowerShot still runs and writes the complete report to the job summary,
100
+ but skips summary comments, inline comments, and approval because those operations
101
+ require a write-capable token.
97
102
 
98
103
  PowerShot checks the target head throughout reconciliation and removes its own
99
104
  just-created candidate if it observes a changed head. Simultaneous same-head runs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xcraft/powershot",
3
- "version": "1.1.5",
3
+ "version": "1.2.0",
4
4
  "description": "Oracle-first code review for machine-written code, with deterministic verification and CI-ready reports.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "aglumova <alina.glumova@gmail.com>",