@whitehatd/crag 0.2.8 → 0.2.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whitehatd/crag",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "The bedrock layer for AI coding agents. One governance.md. Any project. Never stale.",
5
5
  "bin": {
6
6
  "crag": "bin/crag.js"
@@ -145,8 +145,18 @@ function detectStack(dir, result, options = {}) {
145
145
  */
146
146
  function detectNestedStacks(dir, result) {
147
147
  const containerDirs = [
148
+ // Classic containers (monorepos, microservice demos, VSCode-style editors)
148
149
  'src', 'services', 'packages', 'apps', 'cmd', 'projects', 'microservices',
149
150
  'sdk', 'sdks', 'web', 'ui', 'editors', 'extensions', 'clients',
151
+ // Monolith tier-naming (backend/frontend/mobile layouts are extremely
152
+ // common in single-repo full-stack projects — e.g. Leyoda, Metabase,
153
+ // many Y Combinator startups). Without these, `crag analyze` on a
154
+ // top-level monolith root reports `Stack: unknown` because no root
155
+ // manifest exists and none of the classic containers are present.
156
+ 'backend', 'frontend', 'api', 'client', 'server', 'worker', 'workers',
157
+ 'mobile', 'ios', 'android', 'desktop', 'cli', 'lib', 'libs', 'shared',
158
+ // Common AI / data pipeline layouts
159
+ 'signal-engine', 'pipelines', 'ml', 'models', 'agents', 'notebooks',
150
160
  ];
151
161
 
152
162
  const rootHadStacks = result.stack.length > 0;
@@ -136,9 +136,24 @@ function filterFixtureMembers(members) {
136
136
  });
137
137
  }
138
138
 
139
+ /**
140
+ * Sanitize a directory basename for use as a project name.
141
+ *
142
+ * Drops leading non-alphanumerics (`- Leyoda` → `Leyoda`), trims surrounding
143
+ * whitespace, and collapses internal whitespace runs. Leaves interior dashes
144
+ * and underscores intact (`my-cool-project` stays as-is). If the result is
145
+ * empty (pathological input like `---` or ` `), falls back to the literal
146
+ * basename so `crag analyze` never produces an empty `- Project:` line.
147
+ */
148
+ function sanitizeProjectName(basename) {
149
+ if (typeof basename !== 'string') return String(basename || 'unnamed');
150
+ const trimmed = basename.trim().replace(/^[^A-Za-z0-9]+/, '').replace(/\s+/g, ' ').trim();
151
+ return trimmed.length > 0 ? trimmed : basename;
152
+ }
153
+
139
154
  function analyzeProject(dir) {
140
155
  const result = {
141
- name: path.basename(dir),
156
+ name: sanitizeProjectName(path.basename(dir)),
142
157
  description: '',
143
158
  stack: [],
144
159
  linters: [],
@@ -450,4 +465,4 @@ function mergeWithExisting(existing, generated) {
450
465
  return existing;
451
466
  }
452
467
 
453
- module.exports = { analyze, analyzeProject, isGateCommand, mergeWithExisting };
468
+ module.exports = { analyze, analyzeProject, sanitizeProjectName, isGateCommand, mergeWithExisting };
@@ -5,6 +5,7 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { parseGovernance, flattenGates } = require('../governance/parse');
7
7
  const { extractRunCommands, isGateCommand } = require('../governance/yaml-run');
8
+ const { detectBranchStrategy, classifyGitBranchStrategy } = require('../governance/drift-utils');
8
9
  const { cliError, EXIT_USER, EXIT_INTERNAL, readFileOrExit } = require('../cli-errors');
9
10
 
10
11
  /**
@@ -102,17 +103,12 @@ function checkGateReality(cwd, cmd) {
102
103
  }
103
104
 
104
105
  function checkBranchStrategy(cwd, content, results) {
105
- const govStrategy = content.includes('Feature branches') || content.includes('feature branches')
106
- ? 'feature-branches' : content.includes('Trunk-based') || content.includes('trunk-based')
107
- ? 'trunk-based' : null;
108
-
106
+ const govStrategy = detectBranchStrategy(content);
109
107
  if (!govStrategy) return;
110
108
 
111
109
  try {
112
110
  const branches = execSync('git branch -a --format="%(refname:short)"', { cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
113
- const list = branches.trim().split('\n');
114
- const featureBranches = list.filter(b => /^(feat|fix|docs|chore|feature|hotfix)\//.test(b));
115
- const actual = featureBranches.length > 2 ? 'feature-branches' : 'trunk-based';
111
+ const actual = classifyGitBranchStrategy(branches);
116
112
 
117
113
  if (actual !== govStrategy) {
118
114
  console.log(` \x1b[33mDRIFT\x1b[0m Branch strategy: governance says ${govStrategy}, git shows ${actual}`);
@@ -21,8 +21,11 @@
21
21
  const fs = require('fs');
22
22
  const path = require('path');
23
23
  const { execSync } = require('child_process');
24
- const { parseGovernance, flattenGates, extractSection } = require('../governance/parse');
24
+ const { parseGovernance, flattenGates } = require('../governance/parse');
25
+ const { detectBranchStrategy, countFeatureBranches } = require('../governance/drift-utils');
25
26
  const { isModified, readFrontmatter } = require('../update/integrity');
27
+ const { detectWorkspace } = require('../workspace/detect');
28
+ const { enumerateMembers } = require('../workspace/enumerate');
26
29
  const { EXIT_USER, EXIT_INTERNAL } = require('../cli-errors');
27
30
 
28
31
  const GREEN = '\x1b[32m';
@@ -45,15 +48,36 @@ const ICON_FAIL = `${RED}✗${RESET}`;
45
48
  * hooks, file presence). Useful in CI where .claude/ is
46
49
  * either absent or freshly generated via `crag analyze`.
47
50
  * --strict Treat warnings as failures (exit 1 on any warn).
51
+ * --workspace Run doctor on every workspace member that has `.claude/`,
52
+ * plus the root. Aggregates pass/warn/fail counts across
53
+ * members and exits non-zero if ANY member failed.
48
54
  */
49
55
  function doctor(args) {
50
56
  const cwd = process.cwd();
51
57
  const jsonOutput = args.includes('--json');
52
58
  const ciMode = args.includes('--ci');
53
59
  const strict = args.includes('--strict');
60
+ const workspace = args.includes('--workspace');
54
61
 
55
- const report = runDiagnostics(cwd, { ciMode });
62
+ // Workspace mode: run diagnostics on root + every member. Emits one
63
+ // combined report (JSON) or prints per-member sections (human).
64
+ if (workspace) {
65
+ const { reports, combined } = runWorkspaceDiagnostics(cwd, { ciMode });
66
+ const exitCode = computeExitCode(combined, strict);
67
+
68
+ if (jsonOutput) {
69
+ console.log(JSON.stringify({ mode: 'workspace', reports, combined }, null, 2));
70
+ process.exit(exitCode);
71
+ }
72
+
73
+ for (const r of reports) {
74
+ printReport(r, { ciMode, strict });
75
+ }
76
+ printWorkspaceSummary(combined);
77
+ process.exit(exitCode);
78
+ }
56
79
 
80
+ const report = runDiagnostics(cwd, { ciMode });
57
81
  const exitCode = computeExitCode(report, strict);
58
82
 
59
83
  if (jsonOutput) {
@@ -65,6 +89,45 @@ function doctor(args) {
65
89
  process.exit(exitCode);
66
90
  }
67
91
 
92
+ /**
93
+ * Run doctor across a workspace.
94
+ *
95
+ * Always includes the root (cwd). Then, if the root is a workspace, runs
96
+ * against each enumerated member. Members without a `.claude/` directory
97
+ * are skipped — there's nothing for doctor to check in them.
98
+ *
99
+ * Returns { reports: Report[], combined: CombinedCounts }.
100
+ */
101
+ function runWorkspaceDiagnostics(cwd, options = {}) {
102
+ const reports = [];
103
+ const rootReport = runDiagnostics(cwd, options);
104
+ reports.push(rootReport);
105
+
106
+ const ws = detectWorkspace(cwd);
107
+ if (ws.type !== 'none') {
108
+ const members = enumerateMembers(ws);
109
+ for (const m of members) {
110
+ if (!m.hasClaude) continue; // nothing for doctor to see in this member
111
+ const memberReport = runDiagnostics(m.path, options);
112
+ reports.push(memberReport);
113
+ }
114
+ }
115
+
116
+ const combined = reports.reduce(
117
+ (acc, r) => ({
118
+ cwd,
119
+ pass: acc.pass + r.pass,
120
+ warn: acc.warn + r.warn,
121
+ fail: acc.fail + r.fail,
122
+ memberCount: acc.memberCount + 1,
123
+ ciMode: r.ciMode,
124
+ }),
125
+ { cwd, pass: 0, warn: 0, fail: 0, memberCount: 0, ciMode: options.ciMode || false }
126
+ );
127
+
128
+ return { reports, combined };
129
+ }
130
+
68
131
  function computeExitCode(report, strict) {
69
132
  if (report.fail > 0) return 1;
70
133
  if (strict && report.warn > 0) return 1;
@@ -381,68 +444,6 @@ function diagnoseHooks(cwd) {
381
444
  // Section: Drift (reuses crag diff logic)
382
445
  // ============================================================================
383
446
 
384
- /**
385
- * Detect the branch strategy declared in a governance.md document.
386
- *
387
- * Scopes the text scan to the `## Branch Strategy` section (avoiding false
388
- * matches against unrelated prose). Within that section, the FIRST keyword
389
- * to appear wins — this matches human reading order where the opening
390
- * statement is the rule and later lines are qualifications.
391
- *
392
- * Returns 'feature-branches', 'trunk-based', or null.
393
- *
394
- * Exported for unit testing.
395
- */
396
- function detectBranchStrategy(content) {
397
- if (typeof content !== 'string' || content.length === 0) return null;
398
- const section = extractSection(content, 'Branch Strategy');
399
- const scope = section || content; // fall back to whole file if section absent
400
- const featureIdx = scope.search(/[Ff]eature branches/);
401
- const trunkIdx = scope.search(/[Tt]runk-based/);
402
- if (featureIdx === -1 && trunkIdx === -1) return null;
403
- if (featureIdx === -1) return 'trunk-based';
404
- if (trunkIdx === -1) return 'feature-branches';
405
- return featureIdx < trunkIdx ? 'feature-branches' : 'trunk-based';
406
- }
407
-
408
- /**
409
- * Given the raw output of `git branch -a --format="%(refname:short)"`, return
410
- * the list of unique feature branches (by short name, after stripping any
411
- * remote prefix).
412
- *
413
- * This is the piece that decides whether a repo is practicing feature-branch
414
- * development. A repo whose LOCAL branches have all been merged+deleted but
415
- * whose REMOTE still has open feat/* branches is absolutely still practicing
416
- * feature-branch development — so we count remote-tracking refs as equivalent
417
- * to local branches, and we dedupe.
418
- *
419
- * Exported for unit testing — avoids the need to set up a real git fixture.
420
- */
421
- const FEATURE_PREFIXES = ['feat', 'fix', 'docs', 'chore', 'feature', 'hotfix'];
422
-
423
- function countFeatureBranches(gitBranchOutput) {
424
- if (typeof gitBranchOutput !== 'string' || gitBranchOutput.length === 0) {
425
- return [];
426
- }
427
- const rawList = gitBranchOutput
428
- .split('\n')
429
- .map(b => b.trim())
430
- .filter(Boolean)
431
- // Skip symbolic refs like "origin/HEAD" (no payload branch underneath).
432
- .filter(b => !b.endsWith('/HEAD'));
433
-
434
- const prefixGroup = FEATURE_PREFIXES.join('|');
435
- const remoteStripRe = new RegExp(`^[A-Za-z0-9_.-]+/((?:${prefixGroup})/.+)$`);
436
- const featureRe = new RegExp(`^(${prefixGroup})/`);
437
-
438
- const normalized = rawList.map(b => {
439
- const m = b.match(remoteStripRe);
440
- return m ? m[1] : b;
441
- });
442
- const unique = [...new Set(normalized)];
443
- return unique.filter(b => featureRe.test(b));
444
- }
445
-
446
447
  function diagnoseDrift(cwd) {
447
448
  const checks = [];
448
449
  const govPath = path.join(cwd, '.claude', 'governance.md');
@@ -644,4 +645,25 @@ function printReport(report, { ciMode = false, strict = false } = {}) {
644
645
  }
645
646
  }
646
647
 
647
- module.exports = { doctor, runDiagnostics, countFeatureBranches, detectBranchStrategy };
648
+ /**
649
+ * Print a per-workspace summary after all individual reports have been
650
+ * printed. Shows the combined pass/warn/fail counts across the root and
651
+ * every member that was inspected.
652
+ */
653
+ function printWorkspaceSummary(combined) {
654
+ const total = combined.pass + combined.warn + combined.fail;
655
+ const msg = ` Workspace total — ${combined.pass}/${total} pass, ${combined.warn} warn, ${combined.fail} fail (${combined.memberCount} target${combined.memberCount === 1 ? '' : 's'})`;
656
+ if (combined.fail > 0) console.log(`${RED}${BOLD}${msg}${RESET}\n`);
657
+ else if (combined.warn > 0) console.log(`${YELLOW}${BOLD}${msg}${RESET}\n`);
658
+ else console.log(`${GREEN}${BOLD}${msg}${RESET}\n`);
659
+ }
660
+
661
+ module.exports = {
662
+ doctor,
663
+ runDiagnostics,
664
+ runWorkspaceDiagnostics,
665
+ // Re-exported from drift-utils for backward compatibility with existing
666
+ // tests that import from './commands/doctor'.
667
+ countFeatureBranches,
668
+ detectBranchStrategy,
669
+ };
package/src/crag-agent.md CHANGED
@@ -155,7 +155,7 @@ Create `.claude/hooks/`:
155
155
 
156
156
  **auto-post-start.sh** — always generate. Gate enforcement safety net. Reads tool input from stdin, checks if the command is a `git commit`, warns if `.claude/.gates-passed` sentinel doesn't exist. Non-blocking (warns, doesn't prevent). Same for all projects.
157
157
 
158
- **sandbox-guard.sh** — always generate. Security hardening. Reads tool input from stdin (PreToolUse on Bash), hard-blocks destructive system commands (rm -rf /, dd, mkfs, DROP TABLE, docker system prune, kubectl delete namespace, curl|bash, force-push to main). Warns on file operations targeting paths outside the project root. Same for all projects.
158
+ **sandbox-guard.sh** — always generate. Security hardening. Reads tool input from stdin (PreToolUse on Bash), hard-blocks destructive system commands (rm -rf /, dd, mkfs, DROP TABLE, docker system prune, kubectl delete namespace, curl|bash, force-push to main). Warns on file operations targeting paths outside the project root. Same for all projects. **MUST include `# rtk-hook-version: 3` as line 2 (right after the shebang)** — without this marker in the first 5 lines, RTK prints "Hook outdated" warnings on every invocation and `crag doctor` flags the hook as warn. Also **strip single-quoted strings before the destructive-pattern grep** (`CHECK=$(echo "$COMMAND" | sed "s/'[^']*'/''/g")`) so data payloads that legitimately mention destructive verbs inside quoted strings (e.g. `python script.py '{"content":"rm -rf / is blocked"}'`) don't false-trip the guard.
159
159
 
160
160
  **pre-compact-snapshot.sh** — only if MemStack enabled. Use correct project name.
161
161
 
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Drift-detection helpers shared between `crag doctor` and `crag diff`.
5
+ *
6
+ * Both commands need to answer the same two questions:
7
+ *
8
+ * 1. What branch strategy does the governance claim? (text scan of the
9
+ * `## Branch Strategy` section — first keyword mention wins, so a
10
+ * workspace wrapper that says "trunk-based at root, feature branches
11
+ * in sub-repos" is classified as trunk-based, which matches its own
12
+ * git reality even when sub-repos use feature branches.)
13
+ *
14
+ * 2. What does git actually show? (count unique feature branches across
15
+ * local + remote refs, stripping `origin/` so a repo whose local
16
+ * branches are merged+deleted but whose remote still has open
17
+ * `feat/*` refs is correctly classified as feature-branches.)
18
+ *
19
+ * Before this module existed, doctor.js and diff.js each had their own
20
+ * (slightly different, slightly wrong) implementations. The duplication
21
+ * meant a fix landed in doctor went unapplied in diff and vice-versa —
22
+ * real bug, caught by stress testing against real repos.
23
+ *
24
+ * This module is the single source of truth. Import from here; do not
25
+ * reimplement.
26
+ */
27
+
28
+ const { extractSection } = require('./parse');
29
+
30
+ const FEATURE_PREFIXES = ['feat', 'fix', 'docs', 'chore', 'feature', 'hotfix'];
31
+
32
+ /**
33
+ * Detect the branch strategy declared in a governance.md document.
34
+ *
35
+ * Scopes the text scan to the `## Branch Strategy` section (avoiding false
36
+ * matches against unrelated prose). Within that section, the FIRST keyword
37
+ * to appear wins — this matches human reading order where the opening
38
+ * statement is the rule and later lines are qualifications.
39
+ *
40
+ * Returns 'feature-branches', 'trunk-based', or null.
41
+ */
42
+ function detectBranchStrategy(content) {
43
+ if (typeof content !== 'string' || content.length === 0) return null;
44
+ const section = extractSection(content, 'Branch Strategy');
45
+ const scope = section || content; // fall back to whole file if section absent
46
+ const featureIdx = scope.search(/[Ff]eature branches/);
47
+ const trunkIdx = scope.search(/[Tt]runk-based/);
48
+ if (featureIdx === -1 && trunkIdx === -1) return null;
49
+ if (featureIdx === -1) return 'trunk-based';
50
+ if (trunkIdx === -1) return 'feature-branches';
51
+ return featureIdx < trunkIdx ? 'feature-branches' : 'trunk-based';
52
+ }
53
+
54
+ /**
55
+ * Given the raw output of `git branch -a --format="%(refname:short)"`, return
56
+ * the list of unique feature branches (by short name, after stripping any
57
+ * remote prefix).
58
+ *
59
+ * Normalizes remote-tracking refs so `origin/feat/foo` and `feat/foo`
60
+ * both count as the same feature branch. A repo whose local branches
61
+ * have been merged-and-deleted but whose remote still has feat/*
62
+ * branches IS still practicing feature-branch development — callers
63
+ * MUST NOT misread that as "trunk-based".
64
+ *
65
+ * Also skips symbolic refs like `origin/HEAD`.
66
+ */
67
+ function countFeatureBranches(gitBranchOutput) {
68
+ if (typeof gitBranchOutput !== 'string' || gitBranchOutput.length === 0) {
69
+ return [];
70
+ }
71
+ const rawList = gitBranchOutput
72
+ .split('\n')
73
+ .map(b => b.trim())
74
+ .filter(Boolean)
75
+ .filter(b => !b.endsWith('/HEAD')); // skip symbolic refs
76
+
77
+ const prefixGroup = FEATURE_PREFIXES.join('|');
78
+ const remoteStripRe = new RegExp(`^[A-Za-z0-9_.-]+/((?:${prefixGroup})/.+)$`);
79
+ const featureRe = new RegExp(`^(${prefixGroup})/`);
80
+
81
+ const normalized = rawList.map(b => {
82
+ const m = b.match(remoteStripRe);
83
+ return m ? m[1] : b;
84
+ });
85
+ const unique = [...new Set(normalized)];
86
+ return unique.filter(b => featureRe.test(b));
87
+ }
88
+
89
+ /**
90
+ * Classify a repo as 'feature-branches' or 'trunk-based' based on the
91
+ * current `git branch -a --format=%(refname:short)` output.
92
+ *
93
+ * Threshold: 3+ unique feature branches indicates active feature-branch
94
+ * workflow. Fewer (or 0) indicates trunk-based — even if history shows
95
+ * some merge commits from old feat/* branches that have been deleted.
96
+ */
97
+ function classifyGitBranchStrategy(gitBranchOutput) {
98
+ const features = countFeatureBranches(gitBranchOutput);
99
+ return features.length > 2 ? 'feature-branches' : 'trunk-based';
100
+ }
101
+
102
+ module.exports = {
103
+ FEATURE_PREFIXES,
104
+ detectBranchStrategy,
105
+ countFeatureBranches,
106
+ classifyGitBranchStrategy,
107
+ };
@@ -71,6 +71,47 @@ function extractRunCommands(content) {
71
71
  * (extra gates) are easier to spot than false negatives (missing gates).
72
72
  */
73
73
  function isGateCommand(cmd) {
74
+ if (typeof cmd !== 'string' || cmd.length === 0) return false;
75
+
76
+ // ---------------------------------------------------------------------
77
+ // Early excludes — NEVER gates, regardless of keyword substring matches.
78
+ //
79
+ // A workflow step like `echo "Install: npm install -g foo"` contains
80
+ // the substring "npm install" even though it's shell plumbing (emitting
81
+ // a string to stdout). The old isGateCommand relied entirely on positive
82
+ // regex matches and so flagged these as gates, which `crag diff` then
83
+ // reported as EXTRA — noisy false positives.
84
+ //
85
+ // This first pass rejects obvious non-gates: variable assignments,
86
+ // echoes, git plumbing, release-specific scripts, and GitHub Actions
87
+ // output-writing. If ANY of these match, the command is definitively
88
+ // not a gate regardless of what it mentions downstream.
89
+ // ---------------------------------------------------------------------
90
+ const excludePatterns = [
91
+ // Shell I/O plumbing
92
+ /^\s*echo(\s|$)/,
93
+ /^\s*printf(\s|$)/,
94
+ // Shell variable assignment: NAME=value or NAME=$(subshell)
95
+ /^\s*[A-Z_][A-Z0-9_]*=/,
96
+ // Git mutations and introspection (deploy/release, not gates)
97
+ /^\s*git\s+(config|push|pull|fetch|add|commit|tag|merge|rebase|reset|checkout|stash|clone|init|remote|log|status)\b/,
98
+ // npm release/distribution verbs — not gates
99
+ /^\s*npm\s+(publish|pack|view|audit|login|logout|adduser|deprecate|owner|team|whoami|access)\b/,
100
+ // Release scripts live under scripts/ and are NOT gates themselves
101
+ /^\s*node\s+scripts\/(bump-version|release|publish|sync-)/,
102
+ /^\s*npm\s+run\s+(release|publish|sync-|prepublish|postpublish|prepare)\b/,
103
+ // GitHub Actions output streams — these are CI plumbing, not gates
104
+ /\$GITHUB_(STEP_SUMMARY|OUTPUT|ENV|PATH)/,
105
+ // Conditional / control flow keywords on their own line
106
+ /^\s*(if|then|else|elif|fi|while|do|done|for|case|esac|break|continue|return)\s*$/,
107
+ /^\s*(if|for|while|case)\s+/,
108
+ // Filesystem setup that is not a gate
109
+ /^\s*(mkdir|rmdir|touch|ln|cp|mv|chmod|chown)\s/,
110
+ ];
111
+ for (const rx of excludePatterns) {
112
+ if (rx.test(cmd)) return false;
113
+ }
114
+
74
115
  const patterns = [
75
116
  // Node ecosystem
76
117
  /\bnpm (run |ci|test|install)/,
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: post-start-validation
3
- version: 0.2.2
3
+ version: 0.2.10
4
4
  source_hash: 5a64dfe68b13577dff818fa63ddb6185be360c80b100f205bc586aac39e19e80
5
5
  description: Universal validation and knowledge capture. Detects what changed, runs governance gates, captures knowledge, verifies deployment. Works for any project.
6
6
  ---
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: pre-start-context
3
- version: 0.2.2
4
- source_hash: b7be8434b99d5b189c904263e783d573c82109218725cc31fbd4fa1bf81538b6
3
+ version: 0.2.10
4
+ source_hash: 4ace3388804f528a7e7a446466a506ca5f0da4c23e42b540b9ae8923eaf35e4e
5
5
  description: Universal context loader. Discovers any project's stack, architecture, and state at runtime. Reads governance.md for project-specific rules. Works for any language, framework, or deployment target.
6
6
  ---
7
7
 
@@ -54,17 +54,20 @@ Detect OS and shell. Use appropriate syntax (Unix forward slashes if Git Bash on
54
54
 
55
55
  ## 0.05. Skill Currency Check
56
56
 
57
- Check installed skill version:
57
+ Check whether installed skills are behind the crag CLI:
58
58
 
59
+ // turbo
59
60
  ```
60
- Read .claude/skills/pre-start-context/SKILL.md
61
+ crag upgrade --check
61
62
  ```
62
63
 
63
- If the file has a `version:` frontmatter field, compare it to the expected version (0.2.0). If outdated:
64
- - Report: `"Pre-start skill vX.Y.Z is outdated (v0.2.0 available). Run: crag upgrade"`
65
- - Continue with current version — never block startup.
64
+ If the output lists any skill as needing an upgrade (e.g. `0.2.x 0.2.y`):
65
+ - Report: `"Skills outdated run: crag upgrade"`
66
+ - Continue with the current version — never block startup.
66
67
 
67
- > This check costs one Read call. If skills are current, no action needed.
68
+ > This check uses `crag upgrade --check` so the skill never has to hard-code
69
+ > its own version number; it asks the CLI, which is always in lockstep with
70
+ > the package. If `crag` is not on PATH (bare CI runner), skip silently.
68
71
 
69
72
  ---
70
73