crewforth 3.2.0 → 3.3.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.
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: crew-allow-verify
3
+ description: Allow a verify command the hook was refused for want of a rule, or list the ones refused.
4
+ argument-hint: "[<command>] (nothing: list the refused ones)"
5
+ disable-model-invocation: true
6
+ metadata:
7
+ kind: command
8
+ ---
9
+ # /crew-allow-verify
10
+ Argument: $ARGUMENTS
11
+
12
+ The user typed this command. Typing it is the decision: `hooks/prompt-approval.sh` read the message itself. With a
13
+ command after it, it added `verify <command>` to `.claude/crew-model-rules`, the line that lets the hook run that
14
+ command when a card names it as its verify. With nothing after it, it listed the verify commands refused for want of
15
+ such a line, each as the whole message that allows it. Nothing in this text adds anything, and you cannot do this
16
+ for the user: the rules file is closed to you, and a prompt you schedule or hand to another session is refused.
17
+
18
+ Read the line Crewforth added to this turn and say what it says:
19
+
20
+ - **"verify command allowed"**: tell the user which command the hook may now run, from the next agent call.
21
+ - **"verify commands refused for want of a rule"**: show the lines it lists, one per line, as they stand. Each is a
22
+ message only the user can send; do not send one for them.
23
+ - **"verify command NOT allowed"**: tell the user the reason it gives (more than one command, a character it does not
24
+ write, a git commit or push, a rule that would let the hook run any command — `bash`, `node -e`, `npm exec` with
25
+ nothing that names the script or the test — a message too long, a rules file that is a link, the first message of
26
+ a session, a headless session).
27
+ - **No such line**: the message was not the command alone. Say that it has to be the whole message.
28
+
29
+ What this does not touch: a deny rule of the settings still stops the command, the hook still runs it alone with no
30
+ chain, and `/crew-doctor` lists the same refused commands.
@@ -22,7 +22,9 @@ Verify Crewforth is actually *active* in this project (not just present on disk)
22
22
  drifted behind the code. Report the gaps as suggestions, not as failures, and never "fix" them unasked — adding
23
23
  a devcontainer or an MCP server is the user's call.
24
24
  5. Summarise: **healthy**, or the precise fixes applied/needed, plus the readiness score. If it's not a git repo,
25
- note that the commit-time gates need `git init` + `git config core.hooksPath .claude/hooks`.
25
+ note that the commit-time gates need `git init` + the update (`npx crewforth update`), which arms them.
26
+ Where it lists verify commands never run, each `/crew-allow-verify <command>` line is a message only the user can
27
+ send: show them as they stand, and do not send one.
26
28
  If the output contains a line starting with ⭐, pass it to the user verbatim as the LAST line of your summary —
27
29
  it prints once per Crewforth version here (the installer has its own once), and it is meant for the user, not for you.
28
30
 
@@ -43,8 +43,9 @@ that never landed and has to be written down explicitly this time. Missing scrip
43
43
 
44
44
  ## Holding a board item? The handover is not finished until the TEAM has it
45
45
  `docs/SESSION_STATE.md` is gitignored — it survives your `/clear`, and nobody else can read it. So when a
46
- `teamboard` claim is open, **the handover is not complete until it is on the board**. This is not an optional
47
- extra step:
46
+ `teamboard` claim is open, **the handover is not complete until it is on the board**. Whether one is open is
47
+ read from `bash .claude/hooks/board.sh status` (Bash tool, not PowerShell; a row marked `<- you`), not guessed;
48
+ no board or no such row → the handover says nothing about the board. This is not an optional extra step:
48
49
  - Still on it → `board.sh note <id> "<where it stands, and why the rejected path was rejected>"`.
49
50
  - Not coming back → `board.sh drop <id> "<same>"`, which puts it back in circulation with your context attached.
50
51
  - Anything settled here that changes how OTHER items must be done → `board.sh decide "<what>" "<why + what it
@@ -31,10 +31,11 @@ git diff --cached --unified=0 | grep -E '^\+' | grep -Ev '^\+\+\+' \
31
31
  ```
32
32
 
33
33
  ## Hook setup (full install only — a plugin cannot set `core.hooksPath`)
34
- `start.sh` sets `git config core.hooksPath .claude/hooks` (if there is a git repo). The hooks live
35
- under `.claude` → they are in gitignore and stay local (§4.3). To do it later for a repo:
34
+ `start.sh` sets `core.hooksPath` to the absolute path of `.claude/hooks` (if there is a git repo). The hooks live
35
+ under `.claude` → they are in gitignore and stay local (§4.3), so a relative path would leave a linked worktree with
36
+ no hook at all. To do it later for a repo, from its root:
36
37
  ```bash
37
- git config core.hooksPath .claude/hooks
38
+ git config core.hooksPath "$(git rev-parse --show-toplevel)/.claude/hooks"
38
39
  chmod +x .claude/hooks/pre-commit .claude/hooks/commit-msg
39
40
  ```
40
41
 
@@ -29,7 +29,7 @@ _crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
29
29
  FLOOR=18
30
30
  DIST=https://nodejs.org/dist
31
31
  # Used only when nodejs.org cannot be reached to ask which release is current.
32
- FALLBACK_VERSION=v24.21.0
32
+ FALLBACK_VERSION=v24.22.1 # carries the fixes of eval/lib/node-advisory.tsv; raise it with that table
33
33
 
34
34
  # Windows caps a full file path at 260 characters and PowerShell's Expand-Archive enforces it.
35
35
  # Measured: a 141-character target failed with PathTooLongException at __generated__\google\api\…
@@ -43,6 +43,7 @@ import { addViewer, listViewers, viewerByToken, revokeViewer, noteRequest, noteR
43
43
  import { redact, transcriptOnly } from './lib/redact.js';
44
44
  import { lanListener } from './lib/lan.js';
45
45
  import { randomUUID } from 'node:crypto';
46
+ import { nodeAdvisoryNote } from './lib/node-advisory.js';
46
47
  import { spawn } from 'node:child_process';
47
48
 
48
49
  const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -1104,6 +1105,8 @@ async function main() {
1104
1105
  process.stdout.write(TOKEN_GENERATED
1105
1106
  ? ' (loopback only; token generated for this run)\n'
1106
1107
  : ' (loopback only; token from CREW_STUDIO_TOKEN)\n');
1108
+ const nodeNote = nodeAdvisoryNote();
1109
+ if (nodeNote) process.stdout.write(` warning: ${nodeNote}\n`);
1107
1110
 
1108
1111
  // Recorded only after listen() succeeds, so the file never claims a port this process did not get. Failure
1109
1112
  // to write is not fatal: the panel works, the next session simply cannot find it, which is where we started.
@@ -145,8 +145,11 @@ const firstTry = (r) => r.verify === 'pass' && r.fixes === 0;
145
145
  */
146
146
  export function floorOfRow(r, ruleFloors = [], floors = []) {
147
147
  let f = rankOf(AGENT_FLOORS[r.agent]);
148
- if (r.verify === 'none') f = Math.max(f, 2);
148
+ const commitAgent = r.agent === 'crew-commit-agent';
149
+ if (r.verify === 'none' && r.change !== 'text' && !commitAgent) f = Math.max(f, 2);
149
150
  if (r.change === 'test-write' || r.change === 'audit') f = Math.max(f, 2);
151
+ // The table's sonnet row, as the gate holds it: feature, fix-known and refactor, except for the commit agent.
152
+ if (!commitAgent && (r.change === 'feature' || r.change === 'fix-known' || r.change === 'refactor')) f = Math.max(f, 2);
150
153
  if (r.risk === 'critical' && r.change !== 'test-run') f = Math.max(f, 3);
151
154
  const any = (want, v) => want === '*' || want === v;
152
155
  for (const x of ruleFloors) if (any(x.agent, r.agent) && any(x.change, r.change) && any(x.risk, r.risk)) f = Math.max(f, rankOf(x.model));
@@ -250,6 +253,9 @@ export function suggestionsOf(rows, floors = [], loosened = [], need = LOOSEN_AF
250
253
  const k = keyOf(f);
251
254
  // Once for each raise: a floor whose lowering stands is not offered again (the same rule the gate reads it by).
252
255
  if (loweringOf(f, loosened)) continue;
256
+ // Not below the class's own floor: /crew-loosen refuses that, since the gate would refuse the lowered model anyway
257
+ // (a feature class raised to sonnet by the record of haiku runs from before its own floor was sonnet).
258
+ if (rankOf(TIERS[tier - 1]) < floorOfRow({ agent: f.agent, change: f.change, risk: f.risk, verify: 'pass' })) continue;
253
259
  const passed = passedLatest(rows, k, f.model, Date.parse(f.when ?? '') || 0, need);
254
260
  if (!passed) continue;
255
261
  out.push({ key: k, agent: f.agent, change: f.change, risk: f.risk, from: f.model, to: TIERS[tier - 1], passed, heldSince: f.when ?? null });
@@ -0,0 +1,46 @@
1
+ // Is the Node running this panel missing a published security fix? The table is eval/lib/node-advisory.tsv, the
2
+ // same file eval/doctor.sh reads, so the two cannot disagree. A warning only: the panel still starts.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const TABLE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'eval', 'lib', 'node-advisory.tsv');
8
+
9
+ /** The table's text as { url, lines: Map(major -> 'x.y.z') }; null when it holds no release line. */
10
+ export function parseAdvisory(text) {
11
+ let url = '';
12
+ const lines = new Map();
13
+ for (const raw of String(text ?? '').split(/\r?\n/)) {
14
+ if (!raw || raw.startsWith('#')) continue;
15
+ const [k, v] = raw.split('\t');
16
+ if (k === 'advisory') url = v ?? '';
17
+ else if (/^\d+$/.test(k) && /^\d+\.\d+\.\d+$/.test(v ?? '')) lines.set(Number(k), v);
18
+ }
19
+ return lines.size ? { url, lines } : null;
20
+ }
21
+
22
+ const parts = (v) => String(v).replace(/^v/, '').split('.').map((n) => Number.parseInt(n, 10) || 0);
23
+ const below = (a, b) => { const x = parts(a), y = parts(b); for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i]; return false; };
24
+
25
+ /**
26
+ * The sentence to print for `version` (process.versions.node), or null when there is nothing to say: it carries
27
+ * its line's fixes, its line is newer than the table, or the table could not be read.
28
+ */
29
+ export function judgeNode(version, advisory) {
30
+ if (!advisory || !/^\d+\.\d+\.\d+/.test(String(version ?? ''))) return null;
31
+ const major = parts(version)[0];
32
+ const newest = Math.max(...advisory.lines.keys());
33
+ if (major > newest) return null;
34
+ const fixed = advisory.lines.get(major);
35
+ if (fixed && !below(version, fixed)) return null;
36
+ return fixed
37
+ ? `Node ${version} is below ${fixed}, the release of its line with the fixes of ${advisory.url} — update Node`
38
+ : `Node ${version} is on a line that no longer gets security fixes (${advisory.url} fixes ${[...advisory.lines.values()].join(', ')}) — update Node`;
39
+ }
40
+
41
+ /** judgeNode for the running process, reading the table from disk; null when the table is not there. */
42
+ export function nodeAdvisoryNote(version = process.versions.node) {
43
+ let text;
44
+ try { text = fs.readFileSync(TABLE, 'utf8'); } catch { return null; }
45
+ return judgeNode(version, parseAdvisory(text));
46
+ }
@@ -65,12 +65,12 @@ export function workSteps(row) {
65
65
  }
66
66
 
67
67
  /**
68
- * What is said about an agent the model gate does not look at: one whose type is not crew-* (hooks/guard-agent-model.sh
69
- * leaves it alone), so there is no card to read and no line in the record. Null for a crew agent or an unknown type.
68
+ * What is said about an agent that is not crew-*: it has no floor and no line in the record, but the model gate still
69
+ * holds its expensive end, except for a card naming critical work (_am_outside in hooks/guard-agent-model.sh). Null for a crew agent or an unknown type.
70
70
  */
71
71
  export function outsideRouting(agentType) {
72
72
  if (typeof agentType !== 'string' || !agentType || agentType.startsWith('crew-')) return null;
73
- return 'Outside model routing: not a crew agent, so the gate chose nothing for it and Crewforth kept no record of it. It ran on the model its call asked for, or else the session\u2019s.';
73
+ return 'Outside model routing: not a crew agent, so it has no floor and Crewforth keeps no outcome line for it. The gate only holds the expensive end: a call that asks for opus or fable, or names no model and would take the session\u2019s, is held at sonnet unless its task carries an above: <why> line or opens with a card naming critical work. An agent whose own definition names a model keeps it.';
74
74
  }
75
75
 
76
76
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewforth",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "Crewforth \u2014 your engineering crew for Claude Code: agents, skills and tool-level gates, in any stack.",
5
5
  "bin": {
6
6
  "crewforth": "bin/cli.js"
package/start.sh CHANGED
@@ -760,10 +760,14 @@ fi
760
760
  # `--show-prefix` is the question itself: empty at the work-tree root, `sub/dir/` below it, non-zero exit
761
761
  # outside a repo — and it carries no path spelling to disagree about. Verified here at a normal root, a
762
762
  # worktree root, a subdirectory and a non-repo.
763
- if PFX="$(git rev-parse --show-prefix 2>/dev/null)" && [ -z "$PFX" ] && git config core.hooksPath .claude/hooks 2>/dev/null; then
763
+ # ABSOLUTE, from git's own answer for the toplevel. A relative value resolves against the work tree a commit runs
764
+ # in, and a linked worktree (one Claude Code opens included) has no .claude/ when it is gitignored: measured, the
765
+ # trace scan blocked a commit at the root and let the same commit through in a worktree, with no hook run at all.
766
+ if PFX="$(git rev-parse --show-prefix 2>/dev/null)" && [ -z "$PFX" ] && TOP="$(git rev-parse --show-toplevel 2>/dev/null)" \
767
+ && [ -n "$TOP" ] && git config core.hooksPath "$TOP/.claude/hooks" 2>/dev/null; then
764
768
  { _mt 'trace scan: core.hooksPath -> .claude/hooks (§4.1/§4.2 commit gate active)'; echo " ${_M}"; }
765
769
  else
766
- { _mt 'NOTE: no git repository at this level; after %s run: %s' "'git init'" 'git config core.hooksPath .claude/hooks'; echo " ${_M}"; }
770
+ { _mt 'NOTE: no git repository at this level; after %s run: %s' "'git init'" "git config core.hooksPath \"\$PWD/.claude/hooks\""; echo " ${_M}"; }
767
771
  fi
768
772
  rm -rf "$SRC"
769
773
  echo