crewforth 3.2.0-rc.2 → 3.2.0-rc.3

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 (61) hide show
  1. package/CHANGELOG.md +100 -3
  2. package/README.md +2 -0
  3. package/VERSION +1 -1
  4. package/adopt.sh +10 -1
  5. package/bin/cli.js +3 -2
  6. package/kit/CLAUDE.md +3 -3
  7. package/kit/agents/crew-review-agent.md +2 -0
  8. package/kit/eval/doctor.sh +13 -2
  9. package/kit/eval/gate-report.sh +2 -0
  10. package/kit/eval/lib/crew-map/narrative.mjs +42 -8
  11. package/kit/eval/lib/crew-map/project.mjs +3 -2
  12. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  13. package/kit/eval/lib/crew-map/scan.mjs +56 -4
  14. package/kit/eval/lib/crew-map/stacks.mjs +114 -8
  15. package/kit/eval/lib/crew-map/surface.mjs +62 -1
  16. package/kit/eval/lib/gate-log.sh +52 -0
  17. package/kit/eval/lib/json-string.awk +27 -0
  18. package/kit/eval/lib/preset.sh +109 -0
  19. package/kit/eval/lib/presets/default.json +7 -0
  20. package/kit/eval/lib/presets/solo.json +7 -0
  21. package/kit/eval/lib/presets/strict.json +11 -0
  22. package/kit/eval/lib/preview.sh +150 -0
  23. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  24. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  25. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  26. package/kit/eval/lib/settings-json.awk +4 -0
  27. package/kit/eval/lib/statusline.sh +1 -1
  28. package/kit/eval/lib/verify-cmd.sh +17 -0
  29. package/kit/eval/review-bench.sh +26 -0
  30. package/kit/eval/smoke-test.sh +142 -20
  31. package/kit/hooks/board-sync.sh +2 -23
  32. package/kit/hooks/guard-agent-model.sh +32 -23
  33. package/kit/hooks/guard-bash.sh +36 -21
  34. package/kit/hooks/guard-write.sh +3 -7
  35. package/kit/hooks/precompact-snapshot.sh +75 -0
  36. package/kit/hooks/session-rehydrate.sh +25 -2
  37. package/kit/settings.json +12 -0
  38. package/kit/skills/crew-code-review/SKILL.md +16 -0
  39. package/kit/skills/crew-map/SKILL.md +16 -5
  40. package/kit/skills/crew-review/SKILL.md +2 -1
  41. package/kit/studio/README.md +21 -10
  42. package/kit/studio/server/index.js +74 -22
  43. package/kit/studio/server/lib/graph.js +9 -1
  44. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  45. package/kit/studio/server/lib/lan.js +87 -0
  46. package/kit/studio/server/lib/redact.js +60 -0
  47. package/kit/studio/server/lib/remote.js +104 -18
  48. package/kit/studio/server/lib/session.js +1 -1
  49. package/kit/studio/server/lib/terminal.js +51 -13
  50. package/kit/studio/server/lib/transcript.js +32 -0
  51. package/kit/studio/web/app.js +69 -7
  52. package/kit/studio/web/canvas.js +14 -2
  53. package/kit/studio/web/chat.js +32 -7
  54. package/kit/studio/web/graph-plan.js +3 -2
  55. package/kit/studio/web/map-plan.js +72 -3
  56. package/kit/studio/web/map.js +103 -10
  57. package/kit/studio/web/qr.js +200 -0
  58. package/kit/studio/web/style.css +34 -0
  59. package/kit/studio/web/tokens.css +6 -0
  60. package/package.json +1 -1
  61. package/start.sh +14 -1
@@ -1,5 +1,5 @@
1
1
  // stacks.mjs — modules, their declared dependencies, their imports and their references to each other, for the
2
- // five stacks. Every fact carries the line it was read from; what cannot be read is returned as notMeasured.
2
+ // six stacks. Every fact carries the line it was read from; what cannot be read is returned as notMeasured.
3
3
  //
4
4
  // A module is { id, name, stack, dir, manifest, tech[], evidence[], deps[], refs[], imports[], entry, entryEv,
5
5
  // aliases[], selfNames[], files[] }.
@@ -10,7 +10,7 @@
10
10
  // (a namespace prefix, a Gradle line read by pattern).
11
11
 
12
12
  import path from 'node:path';
13
- import { parseJsonc, parseToml, parseXml, xKids, xKid, xText, xLine, xFind, Unsupported } from './parse.mjs';
13
+ import { parseJsonc, parseToml, parseXml, parseYaml, yGet, xKids, xKid, xText, xLine, xFind, Unsupported } from './parse.mjs';
14
14
  import { ev, evFirst, lineOf, isTestPath } from './project.mjs';
15
15
 
16
16
  const P = path.posix;
@@ -20,7 +20,7 @@ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
20
20
  const isComment = (l) => /^\s*(\/\/|#|\*|\/\*|<!--)/.test(l);
21
21
 
22
22
  export const SOURCE_EXT = {
23
- node: /\.(m?[jt]sx?|cjs|cts|mts)$/, python: /\.py$/, go: /\.go$/, dotnet: /\.cs$/, java: /\.(java|kt)$/,
23
+ node: /\.(m?[jt]sx?|cjs|cts|mts)$/, python: /\.py$/, go: /\.go$/, dotnet: /\.cs$/, java: /\.(java|kt)$/, dart: /\.dart$/,
24
24
  };
25
25
 
26
26
  function mod(stack, name, manifest, dir) {
@@ -28,6 +28,22 @@ function mod(stack, name, manifest, dir) {
28
28
  imports: [], entry: false, entryEv: null, aliases: [], selfNames: [name], files: [] };
29
29
  }
30
30
 
31
+ /**
32
+ * What each module is built with, from rules/tech.json: a package its manifest declares, or a file at its root.
33
+ * Labels are words a newcomer reads (Hono, Cloudflare Workers, Flutter), not versions; frameworks before tools, in
34
+ * the order of the rules. Before, only nine npm frameworks were named and a Node module read as "node".
35
+ */
36
+ export function applyTech(st, pj, rules) {
37
+ const has = new Set(pj.files);
38
+ for (const m of st.modules) {
39
+ const deps = new Set(m.deps.map((d) => `${d.eco}\t${d.name}`));
40
+ for (const [eco, list] of Object.entries(rules?.packages ?? {})) for (const [name, label] of list) if (deps.has(`${eco}\t${name}`)) m.tech.add(label);
41
+ const base = m.dir === '.' ? '' : `${m.dir}/`;
42
+ for (const [file, label] of rules?.files ?? []) if (has.has(`${base}${file}`)) m.tech.add(label);
43
+ if (m.stack === 'node' && !m.tech.has('TypeScript') && m.files.some((f) => /\.(ts|tsx|mts|cts)$/.test(f))) m.tech.add('TypeScript');
44
+ }
45
+ }
46
+
31
47
  // A version the map may show. A dependency can be declared by URL (git+https://token@host/repo.git#v1), and a URL
32
48
  // is never copied out: it reads as 'url', which no detector compares.
33
49
  export function cleanVersion(v) {
@@ -48,7 +64,7 @@ function runnable(m) {
48
64
 
49
65
  // Hand each source file to the deepest module of its stack whose directory holds it.
50
66
  // A file under a sample project's own manifest (left out of discovery) belongs to that sample, so to nobody here.
51
- const MANIFEST = /(^|\/)(package\.json|pyproject\.toml|setup\.cfg|requirements[^/]*\.txt|go\.mod|[^/]+\.(cs|fs|vb)proj|pom\.xml|build\.gradle(\.kts)?|settings\.gradle(\.kts)?)$/;
67
+ const MANIFEST = /(^|\/)(package\.json|pyproject\.toml|setup\.cfg|requirements[^/]*\.txt|go\.mod|[^/]+\.(cs|fs|vb)proj|pom\.xml|build\.gradle(\.kts)?|settings\.gradle(\.kts)?|pubspec\.yaml)$/;
52
68
  function own(modules, files, stack) {
53
69
  const mine = modules.filter((m) => m.stack === stack).sort((a, b) => b.dir.length - a.dir.length);
54
70
  const samples = files.filter((f) => MANIFEST.test(f) && isTestPath(f)).map(dirOf);
@@ -76,9 +92,6 @@ function nodeStack(pj, out) {
76
92
  if (!v[sect] || typeof v[sect] !== 'object') continue;
77
93
  for (const [dn, ver] of Object.entries(v[sect])) m.deps.push({ eco: 'npm', name: dn, version: cleanVersion(ver), dev, peer: sect === 'peerDependencies', ev: ev(f, L([sect, dn]), dn) });
78
94
  }
79
- for (const fw of ['next', 'react', 'vue', 'svelte', '@angular/core', 'express', 'fastify', '@nestjs/core', 'vite']) {
80
- const d = m.deps.find((x) => x.name === fw && x.version !== 'url'); if (d) m.tech.add(`${fw}@${d.version.replace(/^[\^~>=<\s]+/, '')}`);
81
- }
82
95
  if (v.bin) { m.entry = true; m.entryEv = ev(f, L(['bin']), 'bin'); }
83
96
  else if (v.scripts && (v.scripts.start || v.scripts.dev)) { const k = v.scripts.start ? 'start' : 'dev'; m.entry = true; m.entryEv = ev(f, L(['scripts', k]), k); }
84
97
  else if (v.workspaces) { m.entry = true; m.entryEv = ev(f, L(['workspaces']), 'workspaces'); }
@@ -265,6 +278,98 @@ function goStack(pj, out) {
265
278
  }
266
279
  }
267
280
 
281
+ // ---- Dart / Flutter -------------------------------------------------------------------------------------------
282
+ // One module per pubspec.yaml. A `path:` dependency names another module of this repository (a reference); a
283
+ // `sdk: flutter` dependency makes it a Flutter module. Its .dart files import `package:<name>/…`: another module's
284
+ // name is a reference, anything else a package it uses; `dart:` is the SDK. lib/main.dart or bin/ is an entry, and
285
+ // so is a tool/ script with a main() (`dart run tool/x.dart` is how a package's own command is run).
286
+ // Before this reader, a Flutter repository's app and its packages were not on the map at all (measured on a real
287
+ // project: 5 Dart modules, 949 .dart files, none drawn).
288
+ function dartStack(pj, out) {
289
+ const made = [];
290
+ for (const f of pj.manifests.filter((x) => P.basename(x) === 'pubspec.yaml')) {
291
+ const src = pj.read(f); if (!src) continue;
292
+ let y; try { y = parseYaml(src.text); } catch (e) { out.notMeasured.push({ what: f, reason: `pubspec.yaml outside the subset this scanner reads: ${e.message}` }); continue; }
293
+ const nm = yGet(y, 'name');
294
+ if (!nm || nm.v.t !== 'str' || !nm.v.v) { out.notMeasured.push({ what: f, reason: 'pubspec.yaml has no name' }); continue; }
295
+ const m = mod('dart', nm.v.v, f, dirOf(f));
296
+ m.evidence.push(ev(f, nm.line, nm.v.v));
297
+ const paths = [];
298
+ for (const [key, dev] of [['dependencies', false], ['dev_dependencies', true]]) {
299
+ const block = yGet(y, key); if (!block || block.v.t !== 'map') continue;
300
+ for (const d of block.v.e) {
301
+ const e = ev(f, d.line, d.k);
302
+ if (d.v.t === 'map') {
303
+ const sdk = yGet(d.v, 'sdk'); const pth = yGet(d.v, 'path');
304
+ if (sdk && sdk.v.t === 'str') { if (sdk.v.v === 'flutter' && d.k === 'flutter') m.tech.add('Flutter'); continue; }
305
+ if (pth && pth.v.t === 'str') { paths.push({ to: P.normalize(P.join(m.dir === '.' ? '' : m.dir, pth.v.v)), name: d.k, ev: e }); continue; }
306
+ const v = yGet(d.v, 'version'); m.deps.push({ eco: 'pub', name: d.k, version: v && v.v.t === 'str' ? cleanVersion(v.v.v) : '', dev, ev: e });
307
+ } else {
308
+ m.deps.push({ eco: 'pub', name: d.k, version: d.v.t === 'str' ? cleanVersion(d.v.v) : '', dev, ev: e });
309
+ }
310
+ }
311
+ }
312
+ if (yGet(y, 'flutter')) m.tech.add('Flutter');
313
+ m._paths = paths;
314
+ out.modules.push(m); made.push(m);
315
+ }
316
+ if (!made.length) return;
317
+ own(out.modules, pj.files, 'dart');
318
+ const byName = new Map(made.map((m) => [m.name, m]));
319
+ for (const m of made) {
320
+ for (const p of m._paths) {
321
+ const t = made.find((o) => o.dir === p.to) ?? byName.get(p.name);
322
+ if (t && t !== m) m.refs.push({ to: t.id, toMod: t, ev: p.ev, confidence: 'evidence' });
323
+ }
324
+ delete m._paths;
325
+ for (const f of m.files) {
326
+ if (/(^|\/)(lib\/main\.dart|bin\/[^/]+\.dart)$/.test(f.slice(m.dir === '.' ? 0 : m.dir.length + 1)) && !m.entry) {
327
+ m.entry = true; m.entryEv = evFirst(f, pj.read(f)?.lines ?? []);
328
+ }
329
+ if (!m.entry && /^tool\/[^/]+\.dart$/.test(f.slice(m.dir === '.' ? 0 : m.dir.length + 1))) {
330
+ const i = (pj.read(f)?.lines ?? []).findIndex((l) => /^\s*(?:Future<void>|void)\s+main\s*\(/.test(l));
331
+ if (i >= 0) { m.entry = true; m.entryEv = ev(f, i + 1, 'main'); }
332
+ }
333
+ const src = pj.read(f); if (!src) continue;
334
+ src.lines.forEach((l, i) => {
335
+ const x = /^\s*(?:import|export)\s+['"]package:([A-Za-z0-9_]+)\/[^'"]*['"]/.exec(l);
336
+ if (!x) return;
337
+ const t = byName.get(x[1]);
338
+ if (t) { if (t !== m && !m.refs.some((r) => r.toMod === t)) m.refs.push({ to: t.id, toMod: t, ev: ev(f, i + 1, x[1]), confidence: 'evidence' }); return; }
339
+ if (x[1] === m.name) return;
340
+ m.imports.push({ spec: x[1], root: x[1], ev: ev(f, i + 1, x[1]), file: f });
341
+ });
342
+ }
343
+ }
344
+ // Flutter declares packages nothing imports (cupertino_icons is a font, flutter_lints a lint set, build_runner a
345
+ // command), so an unused-package rule would mostly be wrong here: it is not run, and that is said.
346
+ if (out.modules.some((m) => m.stack === 'dart')) out.notMeasured.push({ what: 'Dart package usage (dep-unused, dep-undeclared)', reason: 'Flutter declares packages that no file imports (fonts, lint sets, code generators), so a declared package is not proven unused' });
347
+ }
348
+
349
+ // A Flutter app holds its platforms as folders of their own: android/ is a Gradle project, ios/ an Xcode one. They
350
+ // are how the app is built for each platform, not modules of the repository, so they are folded into the app: its
351
+ // tech says Android / iOS, and the Gradle modules under it are not drawn on their own (measured on a real project:
352
+ // two "unplaced" Gradle modules beside a Flutter app the map did not show).
353
+ function foldPlatforms(pj, out) {
354
+ const apps = out.modules.filter((m) => m.stack === 'dart' && m.tech.has('Flutter'));
355
+ if (!apps.length) return;
356
+ const drop = new Set();
357
+ for (const a of apps) {
358
+ const base = a.dir === '.' ? '' : `${a.dir}/`;
359
+ for (const m of out.modules) {
360
+ if (m === a || m.stack !== 'java') continue;
361
+ if (m.dir === `${base}android` || m.dir.startsWith(`${base}android/`)) { drop.add(m); a.tech.add('Android'); }
362
+ }
363
+ if (pj.files.some((f) => f.startsWith(`${base}ios/`))) a.tech.add('iOS');
364
+ }
365
+ if (!drop.size) return;
366
+ out.modules = out.modules.filter((m) => !drop.has(m));
367
+ for (const m of out.modules) m.refs = m.refs.filter((r) => !drop.has(r.toMod));
368
+ // The Gradle notes were about those folders; with no Java module left on the map they speak of nothing.
369
+ if (!out.modules.some((m) => m.stack === 'java')) out.notMeasured = out.notMeasured.filter((x) => !JAVA_NOTES.has(x.what));
370
+ }
371
+ const JAVA_NOTES = new Set(['Gradle build logic', 'Java/Kotlin package usage (dep-unused, dep-undeclared)']);
372
+
268
373
  // ---- .NET ---------------------------------------------------------------------------------------------------
269
374
  function dotnetStack(pj, out) {
270
375
  const central = new Map();
@@ -425,12 +530,13 @@ export function scanStacks(pj0) {
425
530
  // Its source files still belong to the module that holds them (a test imports that module's dependencies).
426
531
  const pj = { ...pj0, manifests: pj0.files.filter((f) => !isTestPath(f)) };
427
532
  const out = { modules: [], notMeasured: [], globalAliases: [] };
428
- for (const fn of [nodeStack, pythonStack, goStack, dotnetStack, javaStack]) {
533
+ for (const fn of [nodeStack, pythonStack, goStack, dartStack, dotnetStack, javaStack]) {
429
534
  try { fn(pj, out); } catch (e) {
430
535
  if (!(e instanceof Unsupported)) throw e;
431
536
  out.notMeasured.push({ what: fn.name, reason: e.message });
432
537
  }
433
538
  }
539
+ foldPlatforms(pj, out);
434
540
  // Two manifests with one name (a copy, a fork in a subdirectory) would share an id: the directory tells them apart.
435
541
  const count = new Map();
436
542
  for (const m of out.modules) count.set(m.id, (count.get(m.id) || 0) + 1);
@@ -117,7 +117,7 @@ export function configuration(pj, st, rules, hostOf, ex, out) {
117
117
  src.lines.forEach((l, i) => {
118
118
  const m = /^\s*(ENV|ARG)\s+([A-Za-z_][A-Za-z0-9_]*)/i.exec(l); if (m) def(m[2], f, i + 1, 'dockerfile');
119
119
  const from = /^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)/i.exec(l);
120
- if (from) { const o = ownerOf(st.modules, f); if (o) o.tech.add(`image:${from[1]}`); }
120
+ if (from) { const o = ownerOf(st.modules, f); if (o) o.tech.add('Docker'); } // the image itself is the line's evidence
121
121
  });
122
122
  }
123
123
  }
@@ -186,6 +186,52 @@ export function codeUrls(pj, st, hostOf, ex, out) {
186
186
  }
187
187
  }
188
188
 
189
+ // ---- one module calling another at the address it is served on -----------------------------------------------
190
+ // A module says where it is served in its own deployment file: wrangler routes and custom domains (Cloudflare
191
+ // Workers), the app of fly.toml (<app>.fly.dev). Another module that writes that host, quoted on its own or as the
192
+ // host of a URL, calls it: an app that builds Uri(host: 'api.x.app') never spells a URL, and before this a call
193
+ // to the project's own server was drawn as an external API, or not at all.
194
+ const SERVED_FILES = /(^|\/)(wrangler\.toml|wrangler\.jsonc?|fly\.toml)$/;
195
+ export function servedCalls(pj, st, ex, out) {
196
+ const served = new Map(); // host -> module
197
+ for (const m of st.modules) {
198
+ for (const f of pj.files.filter((x) => SERVED_FILES.test(x) && dirOf(x) === m.dir)) {
199
+ const src = pj.read(f); if (!src) continue;
200
+ src.lines.forEach((l, i) => {
201
+ if (isComment(l)) return;
202
+ const hosts = [];
203
+ for (const x of l.matchAll(/["']?(?:pattern|route)["']?\s*[=:]\s*["']([^"']+)["']/g)) hosts.push(x[1]);
204
+ if (/fly\.toml$/.test(f)) { const a = /^\s*app\s*=\s*["']([a-z0-9-]+)["']/.exec(l); if (a) hosts.push(`${a[1]}.fly.dev`); }
205
+ for (const raw of hosts) {
206
+ const h = raw.replace(/^https?:\/\//, '').replace(/^\*\.?/, '').split('/')[0].toLowerCase();
207
+ if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(h) || isLocalHost(h) || served.has(h)) continue;
208
+ served.set(h, m); m.evidence.push(ev(f, i + 1, raw));
209
+ }
210
+ });
211
+ }
212
+ }
213
+ out.served = served;
214
+ if (!served.size) return;
215
+ for (const [h, to] of served) {
216
+ const q = h.replace(/[.]/g, '\\.');
217
+ const re = new RegExp(`(["'\`])${q}\\1|://${q}(?=[/:"'\`]|$)`);
218
+ for (const m of st.modules) {
219
+ if (m === to) continue;
220
+ for (const f of m.files) {
221
+ if (isTestPath(f)) continue;
222
+ const src = pj.read(f); if (!src) continue;
223
+ src.lines.forEach((l, i) => { if (!isComment(l) && re.test(l)) out.edges.push({ from: m.id, to: to.id, kind: 'calls', confidence: 'evidence', via: 'code', evidence: [ev(f, i + 1, h)] }); });
224
+ }
225
+ }
226
+ // The host was read as an external API: it is the project's own module, so its edges go there and the node goes.
227
+ const id = `ext:http-api:${h}`;
228
+ if (!ex.ext.has(id)) continue;
229
+ for (const e of out.edges) if (e.to === id) { e.to = to.id; e.kind = 'calls'; }
230
+ out.edges = out.edges.filter((e) => e.from !== e.to);
231
+ ex.ext.delete(id);
232
+ }
233
+ }
234
+
189
235
  // ---- endpoints ----------------------------------------------------------------------------------------------
190
236
  // A path is compared in one spelling: lower case, one leading slash, no trailing slash, every parameter ({id},
191
237
  // :id, ${id}, <int:id>, {id:int}) as {}. A path whose start was built at run time (a variable before the first
@@ -322,6 +368,21 @@ export function endpoints(pj, st, out) {
322
368
  if (axa && axa.s && !/^https?:/.test(axa.s)) add(consumes, m, ax[2], axa.s, f, axa.at, axa.s);
323
369
  });
324
370
  }
371
+ // Dart consumers: a Dio client's relative path, and a full URL (http.get(Uri.parse(…)), Uri.https(host, path))
372
+ // only when its host is one this project serves; any other host is someone else's API. `$id` is a parameter.
373
+ if (/\.dart$/.test(f)) {
374
+ const ours = (h) => out.served?.has(h.toLowerCase());
375
+ const dp = (s) => s.replace(/\$\{[^}]*\}|\$\w+/g, '{x}');
376
+ L.forEach((l, i) => {
377
+ if (isComment(l)) return;
378
+ const dio = /\b(\w*[dD]io\w*|_?client|_?api)\.(get|post|put|patch|delete)(?:<[^()]*?>)?\(\s*(['"])(\/[^'"]*)\3/.exec(l);
379
+ if (dio) add(consumes, m, dio[2], dp(dio[4]), f, i, dio[4]);
380
+ const hp = /\.(get|post|put|patch|delete)\(\s*Uri\.parse\(\s*(['"])https?:\/\/([^/'"$]+)(\/[^'"]*)\2/.exec(l);
381
+ if (hp && ours(hp[3])) add(consumes, m, hp[1], dp(hp[4]), f, i, hp[4]);
382
+ const uh = /\bUri\.https?\(\s*(['"])([^'"$]+)\1\s*,\s*(['"])(\/[^'"]*)\3/.exec(l);
383
+ if (uh && ours(uh[2])) add(consumes, m, 'GET', dp(uh[4]), f, i, uh[4]);
384
+ });
385
+ }
325
386
  // Python: FastAPI / Flask / Django providers; requests / httpx consumers
326
387
  if (/\.py$/.test(f)) {
327
388
  const pre = new Map();
@@ -0,0 +1,52 @@
1
+ # Sourced, not run. The ONE rule for where the gate log is, for the gates that write it and everything that reads it
2
+ # (the status line, gate-report, doctor). Studio's reader (server/lib/kit-telemetry.js) follows the same order.
3
+ #
4
+ # 1. CREW_GATE_LOG, when set: used as given (/dev/null turns recording off).
5
+ # 2. .claude/gate-log.tsv, outside a git repository or where git ignores it (a full install ignores .claude/).
6
+ # 3. Otherwise <git dir>/crewforth-gate-log.tsv: inside the repository's own .git, which is never committed.
7
+ # Before 3.2.0 the log was simply not written there, so a project that tracks .claude/ had no record of a
8
+ # refusal, no 🛡 on its status line and no note of a model the gate lowered (seen on a real project).
9
+ #
10
+ # Builtins only on the reading side, which the status line runs every few seconds. The writing side asks git
11
+ # (one or two processes), and only when a decision is being logged.
12
+
13
+ # The repository's git directory, from the project root up, without a process: _CREW_GD, or rc 1.
14
+ crew_git_dir(){
15
+ local d="${1:-.}" n=0 l
16
+ _CREW_GD=""
17
+ while [ "$n" -lt 12 ]; do
18
+ if [ -d "$d/.git" ]; then _CREW_GD="$d/.git"; return 0; fi
19
+ if [ -f "$d/.git" ]; then
20
+ IFS= read -r l < "$d/.git" 2>/dev/null || l=""
21
+ case "$l" in "gitdir: "*) l="${l#gitdir: }"; l="${l%$'\r'}"
22
+ case "$l" in /*|[A-Za-z]:*) ;; *) l="$d/$l" ;; esac
23
+ [ -d "$l" ] && { _CREW_GD="$l"; return 0; } ;; esac
24
+ return 1
25
+ fi
26
+ [ "$d" = / ] && return 1
27
+ d="$d/.."; n=$((n+1))
28
+ done
29
+ return 1
30
+ }
31
+
32
+ # Where a gate writes: _CREW_GL ("" = nowhere). Run from the project root.
33
+ crew_gate_log_write(){
34
+ _CREW_GL=""
35
+ if [ -n "${CREW_GATE_LOG:-}" ]; then _CREW_GL="$CREW_GATE_LOG"; return 0; fi
36
+ [ -d .claude ] || return 0
37
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1 || git check-ignore -q .claude/gate-log.tsv 2>/dev/null; then
38
+ _CREW_GL=.claude/gate-log.tsv; return 0
39
+ fi
40
+ local g; g="$(git rev-parse --absolute-git-dir 2>/dev/null)" || g=""
41
+ [ -n "$g" ] && [ -d "$g" ] && _CREW_GL="$g/crewforth-gate-log.tsv"
42
+ return 0
43
+ }
44
+
45
+ # Where a reader finds it: _CREW_GL ("" = none yet). No process.
46
+ crew_gate_log_read(){
47
+ _CREW_GL=""
48
+ if [ -n "${CREW_GATE_LOG:-}" ]; then _CREW_GL="$CREW_GATE_LOG"; return 0; fi
49
+ if [ -f .claude/gate-log.tsv ]; then _CREW_GL=.claude/gate-log.tsv; return 0; fi
50
+ crew_git_dir . && [ -f "$_CREW_GD/crewforth-gate-log.tsv" ] && _CREW_GL="$_CREW_GD/crewforth-gate-log.tsv"
51
+ return 0
52
+ }
@@ -0,0 +1,27 @@
1
+ # json-string.awk — the text on stdin as the inside of a JSON string, printed with no quotes around it and no newline
2
+ # after it. One escaper for every hook that puts dynamic text into its JSON (board-sync.sh, session-rehydrate.sh).
3
+ #
4
+ # printf '%s\n' "$MSG" | LC_ALL=C awk -f json-string.awk
5
+ #
6
+ # jq-identical, measured on 15 inputs (quote, backslash, tab, CR, C0 controls, DEL, UTF-8, leading/trailing
7
+ # newlines, 5000 chars): 15/15 equal to `jq -cn --arg m`. The one it replaced escaped only the quote, the backslash
8
+ # and the newline, so a tab in a board item produced JSON the CLI cannot parse (jq rc=5) and the board silently
9
+ # vanished from the session. The trailing newline the caller prints closes the last record, so a value ending in a
10
+ # newline keeps it. A CR that ENDS a line is dropped on purpose: it is a CRLF line ending, not content, and MSYS gawk
11
+ # already drops it on read (text mode) while BSD awk keeps it, measured on Windows; stripping it here makes every OS
12
+ # emit the same bytes. A CR inside a line is still escaped as \r.
13
+ BEGIN { ORS = ""
14
+ for (i = 1; i < 32; i++) ctl[i] = sprintf("%c", i)
15
+ nm[8] = "\\b"; nm[9] = "\\t"; nm[12] = "\\f"; nm[13] = "\\r" }
16
+ NR > 1 { print "\\n" }
17
+ { s = $0; sub(/\r$/, "", s)
18
+ gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s)
19
+ for (i = 1; i < 32; i++) {
20
+ if (i == 10 || !index(s, ctl[i])) continue
21
+ r = (i in nm) ? nm[i] : sprintf("\\u%04x", i)
22
+ out = ""; while ((p = index(s, ctl[i])) > 0) { out = out substr(s, 1, p - 1) r; s = substr(s, p + 1) }
23
+ s = out s
24
+ }
25
+ d = sprintf("%c", 127)
26
+ if (index(s, d)) { out = ""; while ((p = index(s, d)) > 0) { out = out substr(s, 1, p - 1) "\\u007f"; s = substr(s, p + 1) }; s = out s }
27
+ print s }
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env bash
2
+ # preset.sh — an organisation's settings for Crewforth in one file, applied to a project.
3
+ #
4
+ # bash preset.sh --apply <name|file.json> from the project root; a name is one of eval/lib/presets/
5
+ # bash preset.sh --check <name|file.json> is it a valid preset? Nothing is written (the installers ask first)
6
+ # bash preset.sh --show the preset in force here, or "none"
7
+ # bash preset.sh --list the presets that ship
8
+ #
9
+ # A preset is plain JSON (see presets/default.json for every key):
10
+ # critical.add / critical.remove paths made critical, or not (globs, as `+ glob` / `- glob`)
11
+ # floors [agent, change, risk, model]: a model a class of work runs on at least
12
+ # verify commands the gate may run as a verify command
13
+ # lang en | tr, the language of what Crewforth says (only when the project has none)
14
+ #
15
+ # Where it goes: .claude/crew-model-rules, the file every reader of these rules already reads (the model gate,
16
+ # agent-outcome's scan, doctor, Studio), as one marked block at the top. The project's own lines stay below it, so
17
+ # the rule is the file's own: for a critical path the last line that matches wins, so a project's `- glob` undoes a
18
+ # preset's `+ glob`; a floor only ever raises, so a preset's floor is a minimum a project can raise and not lower.
19
+ # Applying again replaces the block and nothing else; the same preset twice gives the same bytes.
20
+ #
21
+ # rc 0 = applied (or shown) · 1 = the preset is not valid, or names nothing; nothing was written · 2 = usage
22
+ # Every JSON read is Crewforth's own reader (settings-json.awk): no jq, no python.
23
+ set -u
24
+ _here="${BASH_SOURCE%[/\\]*}"; [ "$_here" = "${BASH_SOURCE}" ] && _here=.
25
+ SJ="$_here/settings-json.awk"; DIR="$_here/presets"
26
+ RULES=.claude/crew-model-rules; CONF=.claude/kit.conf
27
+ BEGIN_MARK='# ---- preset:'; END_MARK='# ---- end of preset (lines below are this project'"'"'s own and win over it, see preset.sh) ----'
28
+
29
+ die(){ echo "preset: $*" >&2; exit 1; }
30
+ jget(){ awk -v op=get -v path="$1" -f "$SJ" "$2" 2>/dev/null; }
31
+ jstrings(){ awk -v op=strings -v path="$1" -f "$SJ" "$2" 2>/dev/null; }
32
+ jlen(){ local n; n="$(awk -v op=len -v path="$1" -f "$SJ" "$2" 2>/dev/null)"; printf '%s' "${n:-0}"; }
33
+
34
+ case "${1:-}" in
35
+ --list) for f in "$DIR"/*.json; do [ -f "$f" ] || continue; n="${f##*/}"; printf '%s\n' "${n%.json}"; done; exit 0 ;;
36
+ --show)
37
+ [ -f "$RULES" ] || { echo none; exit 0; }
38
+ while IFS= read -r l || [ -n "$l" ]; do case "$l" in "$BEGIN_MARK "*) l="${l#"$BEGIN_MARK "}"; echo "${l%% *}"; exit 0 ;; esac; done < "$RULES"
39
+ echo none; exit 0 ;;
40
+ --apply|--check) [ -n "${2:-}" ] || { echo "usage: preset.sh --apply|--check <name|file.json> | --show | --list" >&2; exit 2; } ;;
41
+ *) echo "usage: preset.sh --apply|--check <name|file.json> | --show | --list" >&2; exit 2 ;;
42
+ esac
43
+ MODE="$1"
44
+ [ "$MODE" = --check ] || [ -d .claude ] || die "no .claude/ here: run it from the root of a project Crewforth is installed in"
45
+
46
+ # ---- which file ----
47
+ P="$2"
48
+ case "$P" in *.json|*/*) ;; *) P="$DIR/$P.json" ;; esac
49
+ [ -f "$P" ] || die "no preset at $P (the ones that ship: $(bash "$0" --list 2>/dev/null | tr '\n' ' '))"
50
+ awk -v op=validate -f "$SJ" "$P" 2>/dev/null || die "$P is not a JSON object"
51
+ NAME="$(jget name "$P")"; NAME="${NAME#\"}"; NAME="${NAME%\"}"
52
+ case "$NAME" in ''|*[!a-z0-9-]*) die "$P: name must be lower case letters, digits and -" ;; esac
53
+
54
+ # ---- what it says, checked before anything is written ----
55
+ LINES=()
56
+ for k in add remove; do
57
+ sign='+'; [ "$k" = remove ] && sign='-'
58
+ while IFS= read -r g; do
59
+ [ -n "$g" ] || continue
60
+ case "$g" in *[[:space:]\#]*) die "$P: critical.$k holds '$g', which has a space or a #" ;; esac
61
+ LINES+=("$sign $g")
62
+ done < <(jstrings "critical.$k" "$P")
63
+ done
64
+ nf="$(jlen floors "$P")"; i=0
65
+ while [ "$i" -lt "$nf" ]; do
66
+ f=(); while IFS= read -r w; do f+=("$w"); done < <(jstrings "floors.$i" "$P")
67
+ [ "${#f[@]}" = 4 ] || die "$P: floors[$i] is not [agent, change, risk, model]"
68
+ case "${f[3]}" in haiku|sonnet|opus) ;; *) die "$P: floors[$i] names the model '${f[3]}', not haiku, sonnet or opus" ;; esac
69
+ for w in "${f[@]}"; do case "$w" in ''|*[[:space:]\#]*) die "$P: floors[$i] has an empty word, a space or a #" ;; esac; done
70
+ LINES+=("floor ${f[0]} ${f[1]} ${f[2]} ${f[3]}"); i=$((i+1))
71
+ done
72
+ while IFS= read -r c; do
73
+ [ -n "$c" ] || continue
74
+ case "$c" in *[\;\&\|\<\>\`\$\#]*) die "$P: verify '$c' has a shell operator or a #; the gate runs a verify command as one command" ;; esac
75
+ LINES+=("verify $c")
76
+ done < <(jstrings verify "$P")
77
+ LANGV="$(jget lang "$P")"; LANGV="${LANGV#\"}"; LANGV="${LANGV%\"}"
78
+ case "$LANGV" in ''|null|en|tr) ;; *) die "$P: lang is '$LANGV', not en or tr" ;; esac
79
+ for k in $(awk -v op=keys -f "$SJ" "$P" 2>/dev/null); do
80
+ case "$k" in about|name|critical|floors|verify|lang) ;; *) die "$P: unknown key '$k' (about, name, critical, floors, verify, lang)" ;; esac
81
+ done
82
+
83
+ [ "$MODE" = --check ] && { echo "preset: $NAME is valid — ${#LINES[@]} line(s)"; exit 0; }
84
+
85
+ # ---- the block, at the top of the rules file; the project's own lines kept below it ----
86
+ OWN=""
87
+ if [ -f "$RULES" ]; then
88
+ skip=0
89
+ while IFS= read -r l || [ -n "$l" ]; do
90
+ case "$l" in "$BEGIN_MARK "*) skip=1; continue ;; "$END_MARK") skip=0; continue ;; esac
91
+ [ "$skip" = 1 ] || OWN+="$l"$'\n'
92
+ done < "$RULES"
93
+ fi
94
+ T="$RULES.preset.$$"
95
+ {
96
+ printf '%s %s (written by preset.sh; apply again to change it, edit your own lines below) ----\n' "$BEGIN_MARK" "$NAME"
97
+ for l in ${LINES[@]+"${LINES[@]}"}; do printf '%s\n' "$l"; done
98
+ printf '%s\n' "$END_MARK"
99
+ printf '%s' "$OWN"
100
+ } > "$T" && mv -f "$T" "$RULES" || { rm -f "$T"; die "could not write $RULES"; }
101
+
102
+ # ---- kit.conf: the preset's language, only where the project has none. Which preset is in force is said once, by
103
+ # the block's own first line (--show reads it): kit.conf is rewritten by every update, the rules file is not.
104
+ if [ -f "$CONF" ] && case "$LANGV" in en|tr) true ;; *) false ;; esac; then
105
+ hadlang=0; while IFS= read -r l || [ -n "$l" ]; do case "$l" in lang=*) hadlang=1 ;; esac; done < "$CONF"
106
+ [ "$hadlang" = 0 ] && printf 'lang=%s\n' "$LANGV" >> "$CONF"
107
+ fi
108
+ echo "preset: $NAME applied — ${#LINES[@]} line(s) in $RULES; your own lines below the block were kept"
109
+ exit 0
@@ -0,0 +1,7 @@
1
+ {
2
+ "about": "Crewforth as it ships: no line of its own. Every key a preset may hold is here, empty. critical.add and critical.remove are globs made critical or not (a critical path runs on opus and only an agent on opus writes there); floors are [agent, change, risk, model] with * for any, a model a class of work runs on at least; verify are commands the model gate may run as a card's verify command; lang (en or tr) is used only where the project has no language yet.",
3
+ "name": "default",
4
+ "critical": { "add": [], "remove": [] },
5
+ "floors": [],
6
+ "verify": []
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "about": "For one person on their own project: the common test commands may run as a card's verify command, so cheap work can be checked and run on haiku; no path is added.",
3
+ "name": "solo",
4
+ "critical": { "add": [], "remove": [] },
5
+ "floors": [],
6
+ "verify": ["npm test", "pnpm test", "yarn test", "pytest", "go test ./...", "cargo test", "dotnet test", "flutter test"]
7
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "about": "For a team that ships to production from this repository: what deploys it is critical too, and the referees of the work run on opus whatever the risk.",
3
+ "name": "strict",
4
+ "critical": { "add": ["infra", "deploy", "terraform", "k8s", "helm", ".github/workflows"], "remove": [] },
5
+ "floors": [
6
+ ["crew-review-agent", "*", "*", "opus"],
7
+ ["*", "test-write", "*", "opus"],
8
+ ["*", "audit", "*", "opus"]
9
+ ],
10
+ "verify": []
11
+ }
@@ -0,0 +1,150 @@
1
+ # Sourced, not run. What a risky command would have touched, in one line, for the gate that stops or asks about it:
2
+ # the user reads "it would delete 7 uncommitted changes", not only "git reset --hard".
3
+ #
4
+ # crew_preview <kind> <command> -> _CREW_PV ("" when there is nothing to say)
5
+ # reset git reset --hard: the uncommitted changes to tracked files it discards, and the commits it drops
6
+ # from the branch when it names a target (git reset --hard HEAD~3)
7
+ # clean git clean -f…: the same command as a dry run (-n, with its -d / -x / -X), and how many paths it lists
8
+ # push git push --force: the commits on the remote branch the local one does not have, as of the last fetch
9
+ # rm rm -r…: the files under each named target inside the project; a target outside it, or a pattern, is
10
+ # not counted and the line says so
11
+ # commit an approved commit: the files and lines it takes (the staged ones, the tracked changes too for -a)
12
+ # push-ask an approved push: the commits it sends, the first three by subject
13
+ #
14
+ # READ ONLY. Every command here only reads (status, diff --stat, rev-list, log, clean -n, find), nothing fetches,
15
+ # and a count of files stops at 10 000. The caller runs this in a subshell and prints what comes back: a preview
16
+ # that fails, or ends the shell, leaves the gate's decision as it was. It never decides anything.
17
+
18
+ _PV_MAX=10000
19
+
20
+ # Is this a repository, without a process where the gate log's helper is loaded (it is, in the gates).
21
+ _pv_repo(){ if declare -F crew_git_dir >/dev/null 2>&1; then crew_git_dir .; else git rev-parse --is-inside-work-tree >/dev/null 2>&1; fi; }
22
+
23
+ # The current branch's upstream as remote/branch, read from HEAD and the config with builtins: _PV_UP ("" = none).
24
+ # A push is asked about on the hot path of a gate whose cost is pinned per call, so the common case (no upstream
25
+ # yet) opens no process at all. A linked worktree keeps HEAD in its own git dir and the config in the common one.
26
+ _pv_upstream(){
27
+ _PV_UP=""; local gd="" cd l br="" sec="" rem="" mer=""
28
+ declare -F crew_git_dir >/dev/null 2>&1 && crew_git_dir . && gd="$_CREW_GD"
29
+ [ -n "$gd" ] && [ -f "$gd/HEAD" ] || return 1
30
+ IFS= read -r l < "$gd/HEAD" || return 1; l="${l%$'\r'}"
31
+ case "$l" in "ref: refs/heads/"*) br="${l#ref: refs/heads/}" ;; *) return 1 ;; esac
32
+ cd="$gd"; if [ -f "$gd/commondir" ]; then IFS= read -r l < "$gd/commondir"; l="${l%$'\r'}"; case "$l" in /*|[A-Za-z]:*) cd="$l" ;; *) cd="$gd/$l" ;; esac; fi
33
+ [ -f "$cd/config" ] || return 1
34
+ while IFS= read -r l || [ -n "$l" ]; do
35
+ l="${l%$'\r'}"; l="${l#"${l%%[![:space:]]*}"}"
36
+ case "$l" in
37
+ "[branch \"$br\"]") sec=1 ;;
38
+ "["*) sec="" ;;
39
+ remote*=*) [ -n "$sec" ] && { rem="${l#*=}"; rem="${rem#"${rem%%[![:space:]]*}"}"; } ;;
40
+ merge*=*) [ -n "$sec" ] && { mer="${l#*=}"; mer="${mer#"${mer%%[![:space:]]*}"}"; } ;;
41
+ esac
42
+ done < "$cd/config"
43
+ [ -n "$rem" ] && [ -n "$mer" ] && [ "$rem" != . ] && _PV_UP="$rem/${mer#refs/heads/}"
44
+ return 0
45
+ }
46
+
47
+ _pv_words(){ # $1 = command -> _PV_W (the words after the git subcommand $2, quotes dropped)
48
+ local s="$1" sub="$2"
49
+ s="${s#*git}"; s="${s#*"$sub"}"; s="${s%%[;&|]*}"; s="${s//\"/}"; s="${s//\'/}"
50
+ _PV_W="$s"
51
+ }
52
+
53
+ # With pathname expansion off for the whole preview: the words of the command are split, never expanded here, so
54
+ # `rm -rf build/*` is said as a pattern instead of each file the hook's own shell would find (measured on Windows).
55
+ crew_preview(){
56
+ local _pvf=0; case "$-" in *f*) _pvf=1 ;; esac
57
+ set -f; _crew_preview "$@"; [ "$_pvf" = 1 ] || set +f
58
+ return 0
59
+ }
60
+ _crew_preview(){
61
+ _CREW_PV=""
62
+ local kind="$1" cmd="$2" n t w a flags line
63
+ case "$kind" in
64
+ reset|clean|push|commit|push-ask)
65
+ _pv_repo || { _CREW_PV="Preview: not a git work tree here, nothing counted."; return 0; } ;;
66
+ esac
67
+ case "$kind" in
68
+ reset)
69
+ n="$(git status --porcelain --untracked-files=no 2>/dev/null | wc -l)"; n="${n//[!0-9]/}"
70
+ _pv_words "$cmd" reset; t=""
71
+ for w in $_PV_W; do case "$w" in -*) ;; *) t="$w"; break ;; esac; done
72
+ a=""
73
+ if [ -n "$t" ] && git rev-parse -q --verify "$t^{commit}" >/dev/null 2>&1; then
74
+ a="$(git rev-list --count "$t..HEAD" 2>/dev/null)"; a="${a//[!0-9]/}"
75
+ fi
76
+ _CREW_PV="Preview: it would discard ${n:-0} uncommitted change(s) to tracked files${a:+ and drop $a commit(s) from the branch (back to $t)}."
77
+ ;;
78
+ clean)
79
+ _pv_words "$cmd" clean; flags="-n"
80
+ for w in $_PV_W; do case "$w" in
81
+ --) break ;;
82
+ -*) case "$w" in --*) ;; *) case "$w" in *d*) flags="$flags -d" ;; esac; case "$w" in *x*) flags="$flags -x" ;; *X*) flags="$flags -X" ;; esac ;; esac ;;
83
+ esac; done
84
+ # shellcheck disable=SC2086 # the flags are words of our own
85
+ n="$(git clean $flags 2>/dev/null | wc -l)"; n="${n//[!0-9]/}"
86
+ _CREW_PV="Preview: a dry run (git clean $flags) lists ${n:-0} path(s) it would delete."
87
+ ;;
88
+ push)
89
+ _pv_words "$cmd" push; local rem="" ref="" up=""
90
+ for w in $_PV_W; do case "$w" in -*) ;; *) if [ -z "$rem" ]; then rem="$w"; elif [ -z "$ref" ]; then ref="$w"; fi ;; esac; done
91
+ ref="${ref#+}"; ref="${ref##*:}"
92
+ if [ -n "$rem" ] && [ -n "$ref" ]; then up="refs/remotes/$rem/$ref"
93
+ else up="$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null)"; fi
94
+ if [ -n "$up" ] && git rev-parse -q --verify "$up^{commit}" >/dev/null 2>&1; then
95
+ n="$(git rev-list --count "HEAD..$up" 2>/dev/null)"; n="${n//[!0-9]/}"; up="${up#refs/remotes/}"
96
+ if [ "${n:-0}" -gt 0 ]; then _CREW_PV="Preview: $up has $n commit(s) this branch does not, as of the last fetch; a forced push would overwrite them."
97
+ else _CREW_PV="Preview: as of the last fetch, $up has no commit this branch lacks; a fetch now could show otherwise."; fi
98
+ else
99
+ _CREW_PV="Preview: no remote branch known for it here (nothing fetched), so what it would overwrite is not counted."
100
+ fi
101
+ ;;
102
+ rm)
103
+ local root out="" one c
104
+ root="$(pwd -P 2>/dev/null)"
105
+ _pv_words "x $cmd" rm
106
+ for w in $_PV_W; do
107
+ case "$w" in -*|'') continue ;; *'*'*|*'?'*|'~'*|'$'*) out="${out:+$out; }$w: a pattern, not counted"; continue ;; esac
108
+ # A drive-letter path (D:/x, as Git Bash passes it) is absolute too: measured on Windows, it read as relative
109
+ # and the line said "not there" for a folder that was.
110
+ one="$w"; case "$one" in /*|[A-Za-z]:[/\\]*) ;; *) one="$root/$one" ;; esac
111
+ if [ ! -e "$one" ]; then out="${out:+$out; }$w: not there"; continue; fi
112
+ c="$(cd "$one" 2>/dev/null && pwd -P)" || c="$(cd "$(dirname "$one")" 2>/dev/null && pwd -P)/${one##*/}"
113
+ case "$c/" in "$root"/*) ;; *) out="${out:+$out; }$w: outside the project, not counted"; continue ;; esac
114
+ n="$(find "$one" -type f 2>/dev/null | head -n $((_PV_MAX + 1)) | wc -l)"; n="${n//[!0-9]/}"
115
+ [ "${n:-0}" -gt "$_PV_MAX" ] && n="more than $_PV_MAX"
116
+ out="${out:+$out; }$w: ${n:-0} file(s)"
117
+ done
118
+ [ -n "$out" ] && _CREW_PV="Preview: $out."
119
+ ;;
120
+ commit)
121
+ case " $cmd " in *' -a'*|*' --all'*|*' -[A-Za-z]*a'*) line="$(git diff HEAD --shortstat 2>/dev/null)" ;; *) line="$(git diff --cached --shortstat 2>/dev/null)" ;; esac
122
+ line="${line# }"
123
+ _CREW_PV="Preview: ${line:-nothing staged}."
124
+ ;;
125
+ push-ask)
126
+ local subj="" k=0 rem="" ref="" src="" tip=HEAD
127
+ # A push that names its remote and branch (git push origin feat/x) sends to that branch, not the upstream; and
128
+ # what it sends is the named LOCAL branch's commits (src of src:dst, or the name itself), not the current one's.
129
+ _pv_words "$cmd" push
130
+ for w in $_PV_W; do case "$w" in -*) ;; *) if [ -z "$rem" ]; then rem="$w"; elif [ -z "$ref" ]; then ref="$w"; fi ;; esac; done
131
+ ref="${ref#+}"; src="${ref%%:*}"; ref="${ref##*:}"
132
+ if [ -n "$src" ] && [ "$src" != HEAD ]; then
133
+ if [ -f "${_CREW_GD:-.git}/refs/heads/$src" ] || git rev-parse -q --verify "refs/heads/$src" >/dev/null 2>&1; then tip="refs/heads/$src"; fi
134
+ fi
135
+ if [ -n "$rem" ] && [ -n "$ref" ] && [ "$ref" != HEAD ]; then
136
+ if [ -f "${_CREW_GD:-.git}/refs/remotes/$rem/$ref" ] || git rev-parse -q --verify "refs/remotes/$rem/$ref" >/dev/null 2>&1; then _PV_UP="$rem/$ref"
137
+ else _CREW_PV="Preview: $rem/$ref is not known here; the push would create it on the remote."; return 0; fi
138
+ else _pv_upstream; fi
139
+ if [ -n "$_PV_UP" ]; then
140
+ # One process: the subjects of what it sends, counted here and the first three named.
141
+ while IFS= read -r line; do k=$((k+1)); [ "$k" -le 3 ] && subj="${subj:+$subj; }${line:0:72}"; done < <(git log --format=%s "$_PV_UP..$tip" 2>/dev/null)
142
+ [ "$k" -gt 3 ] && subj="$subj …"
143
+ _CREW_PV="Preview: it sends $k commit(s) to $_PV_UP${subj:+ — $subj}."
144
+ else
145
+ _CREW_PV="Preview: the branch has no upstream set here; what it sends is not counted."
146
+ fi
147
+ ;;
148
+ esac
149
+ return 0
150
+ }