diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml
index 072eb4439..bb7dea8a8 100644
--- a/.github/workflows/cli-ci.yml
+++ b/.github/workflows/cli-ci.yml
@@ -520,6 +520,21 @@ jobs:
- name: Plugin suite
run: node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/**/*.test.js'
+ # A Windows commit's hooks (#948), less the three cli jobs that need cli/'s dependencies.
+ - name: Hooks - a commit's hooks pass on Windows
+ run: |
+ echo "# lefthook subject" > "$RUNNER_TEMP/msg"
+ status=0
+ LEFTHOOK_EXCLUDE=cli-biome,cli-architecture,cli-typecheck \
+ pnpm exec lefthook run pre-commit --all-files --no-auto-install --no-tty > "$RUNNER_TEMP/hooks.log" 2>&1 || status=1
+ pnpm exec lefthook run prepare-commit-msg "$RUNNER_TEMP/msg" message --no-auto-install --no-tty >> "$RUNNER_TEMP/hooks.log" 2>&1 || status=1
+ cat "$RUNNER_TEMP/hooks.log"
+ if grep -n "syntax error" "$RUNNER_TEMP/hooks.log"; then
+ echo "::error::lefthook handed sh a broken command line (#948)"
+ exit 1
+ fi
+ exit $status
+
- run: cd cli && pnpm install --frozen-lockfile
# A real global install, not `node dist/cli.js` by path: nothing else proves `aidd`
# resolves on this platform's PATH. Built here, ahead of the suites below, so this is
diff --git a/lefthook.yml b/lefthook.yml
index 00d136edb..df11272be 100644
--- a/lefthook.yml
+++ b/lefthook.yml
@@ -7,7 +7,7 @@ pre-commit:
- "cli/tests/fixtures/**"
run: |
if ! command -v node >/dev/null 2>&1; then
- echo "❌ node not available; cannot validate JSON"
+ echo '❌ node not available; cannot validate JSON'
exit 1
fi
node scripts/validate-json.mjs {files}
@@ -17,7 +17,7 @@ pre-commit:
- "plugins/*/skills/*/assets/**"
run: |
if ! command -v node >/dev/null 2>&1; then
- echo "❌ node not available; cannot validate YAML"
+ echo '❌ node not available; cannot validate YAML'
exit 1
fi
node scripts/validate-yaml.mjs {files}
@@ -25,14 +25,14 @@ pre-commit:
glob: "{scripts,plugins,cli/src}/**"
run: |
if ! command -v node >/dev/null 2>&1; then
- echo "ℹ️ node not available; skipping scripts-tests"
+ echo 'ℹ️ node not available; skipping scripts-tests'
exit 0
fi
# `node --test
` fails on a directory, so a glob is required — and a glob
# matching nothing exits 0 in silence, so a renamed folder must be caught by a count.
found=$(ls scripts/__tests__/*.test.js 2>/dev/null | wc -l | tr -d ' ')
- if [ "$found" -eq 0 ]; then
- echo "❌ no test files under scripts/__tests__/; the runner is not wired"
+ if [ $found -eq 0 ]; then
+ echo '❌ no test files under scripts/__tests__/; the runner is not wired'
exit 1
fi
# Wrapped: a suite writing into this repository's own .git/hooks passes every
@@ -43,13 +43,13 @@ pre-commit:
glob: "plugins/*/skills/*/SKILL.md"
run: |
for f in {files}; do
- [ -f "$f" ] || continue
- if ! head -10 "$f" | grep -q "^name:"; then
- echo "❌ missing 'name:' frontmatter: $f"
+ [ -f $f ] || continue
+ if ! head -10 $f | grep -q '^name:'; then
+ echo '❌ missing name: frontmatter:' $f
exit 1
fi
- if ! head -10 "$f" | grep -q "^description:"; then
- echo "❌ missing 'description:' frontmatter: $f"
+ if ! head -10 $f | grep -q '^description:'; then
+ echo '❌ missing description: frontmatter:' $f
exit 1
fi
done
@@ -57,7 +57,7 @@ pre-commit:
glob: "plugins/*/skills/**"
run: |
if ! command -v node >/dev/null 2>&1; then
- echo "ℹ️ node not available; skipping check-skill-argument-hints"
+ echo 'ℹ️ node not available; skipping check-skill-argument-hints'
exit 0
fi
node scripts/check-skill-argument-hints.mjs
@@ -65,7 +65,7 @@ pre-commit:
glob: "plugins/*/{skills,agents}/**/*.md"
run: |
if ! command -v node >/dev/null 2>&1; then
- echo "ℹ️ node not available; skipping architecture-rules"
+ echo 'ℹ️ node not available; skipping architecture-rules'
exit 0
fi
node scripts/check-architecture-rules.js {files}
@@ -76,7 +76,7 @@ pre-commit:
- "**/{CLAUDE,AGENTS,copilot-instructions}.md"
run: |
if ! command -v node >/dev/null 2>&1; then
- echo "ℹ️ node not available; skipping context-imports"
+ echo 'ℹ️ node not available; skipping context-imports'
exit 0
fi
node scripts/check-context-imports.js
@@ -89,7 +89,7 @@ pre-commit:
- "plugins/aidd-context/hooks/update_memory.js"
run: |
if ! command -v node >/dev/null 2>&1; then
- echo "ℹ️ node not available; skipping context-reference-form"
+ echo 'ℹ️ node not available; skipping context-reference-form'
exit 0
fi
node scripts/check-context-reference-form.js
@@ -97,7 +97,7 @@ pre-commit:
glob: "{*.md,docs/**,aidd_docs/memory/**}"
run: |
if ! command -v node >/dev/null 2>&1; then
- echo "ℹ️ node not available; skipping referenced-paths"
+ echo 'ℹ️ node not available; skipping referenced-paths'
exit 0
fi
node scripts/check-referenced-paths.js
@@ -106,7 +106,7 @@ pre-commit:
glob: "**/*.md"
run: |
if ! command -v node >/dev/null 2>&1; then
- echo "ℹ️ node not available; skipping doc-duplication"
+ echo 'ℹ️ node not available; skipping doc-duplication'
exit 0
fi
node scripts/check-doc-duplication.js
@@ -115,33 +115,18 @@ pre-commit:
glob: "**/*.md"
run: |
if ! command -v node >/dev/null 2>&1; then
- echo "ℹ️ node not available; skipping markdown-links"
+ echo 'ℹ️ node not available; skipping markdown-links'
exit 0
fi
# Both ignored trees hold deliberately unresolvable targets — fixture paths and
# `@src/foo.ts` agent mentions — not links a reader is meant to follow.
node scripts/check-markdown-links.js --ignore cli/tests/fixtures --ignore cli/aidd_docs/tasks
summarize-plugin-catalogs:
- run: |
- [ -d plugins ] || exit 0
- if ! command -v node >/dev/null 2>&1; then
- echo "ℹ️ node not available; skipping summarize-plugin-catalogs"
- exit 0
- fi
- for plugin in plugins/*/; do
- name=$(basename "$plugin")
- node scripts/summarize-markdown.js "$plugin" "${plugin}CATALOG.md" \
- --depth=4 \
- --fields=description \
- --title="$name catalog" \
- --tagline="Auto-generated index of skills, agents, references and assets shipped by the \`$name\` plugin." \
- >/dev/null
- git add "${plugin}CATALOG.md" 2>/dev/null || true
- done
+ run: sh scripts/summarize-plugin-catalogs.sh
summarize-telemetry-prompts-doc:
run: |
if ! command -v node >/dev/null 2>&1; then
- echo "ℹ️ node not available; skipping summarize-telemetry-prompts-doc"
+ echo 'ℹ️ node not available; skipping summarize-telemetry-prompts-doc'
exit 0
fi
node scripts/summarize-markdown.js plugins/aidd-telemetry docs/prompts-documentation.md \
@@ -152,7 +137,7 @@ pre-commit:
sync-readme-counts:
run: |
if ! command -v node >/dev/null 2>&1; then
- echo "ℹ️ node not available; skipping sync-readme-counts"
+ echo 'ℹ️ node not available; skipping sync-readme-counts'
exit 0
fi
node scripts/sync-readme-counts.mjs >/dev/null
@@ -174,7 +159,7 @@ pre-commit:
glob: "{cli/src/**,cli/tests/**}"
run: |
if ! command -v node >/dev/null 2>&1; then
- echo "ℹ️ node not available; skipping cli-type-honesty"
+ echo 'ℹ️ node not available; skipping cli-type-honesty'
exit 0
fi
node scripts/check-cli-type-honesty.mjs
@@ -195,9 +180,8 @@ commit-msg:
# lefthook owns prepare-commit-msg here and regenerates it on install, so `aidd telemetry on`'s
# delegate is called from a job instead. Absent delegate, nothing runs — opt-in per machine.
+# `aidd telemetry` finds this job by the delegate name in its run line.
prepare-commit-msg:
commands:
aidd-session-trailer:
- run: |
- delegate="$(git rev-parse --git-common-dir)/hooks/aidd-session-trailer.sh"
- if [ -f "$delegate" ]; then sh "$delegate" {1} {2}; fi
+ run: sh scripts/session-trailer-delegate.sh aidd-session-trailer.sh {1} {2}
diff --git a/scripts/__tests__/lefthook-runs-hold-no-double-quote.test.js b/scripts/__tests__/lefthook-runs-hold-no-double-quote.test.js
new file mode 100644
index 000000000..4717bcf30
--- /dev/null
+++ b/scripts/__tests__/lefthook-runs-hold-no-double-quote.test.js
@@ -0,0 +1,24 @@
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const test = require("node:test");
+const yaml = require("js-yaml");
+
+const root = path.resolve(__dirname, "../..");
+
+// Lefthook on Windows passes a run to `sh -c` unescaped, so a `"` truncates it (#948).
+test("no run in lefthook.yml holds a double quote", () => {
+ const config = yaml.load(fs.readFileSync(path.join(root, "lefthook.yml"), "utf8"));
+ const offenders = [];
+ let scanned = 0;
+
+ for (const [hook, def] of Object.entries(config)) {
+ for (const [name, command] of Object.entries(def?.commands ?? {})) {
+ scanned++;
+ if (String(command.run ?? "").includes('"')) offenders.push(`${hook}/${name}`);
+ }
+ }
+
+ assert.ok(scanned > 0, "no `commands:` run found in lefthook.yml");
+ assert.deepEqual(offenders, []);
+});
diff --git a/scripts/__tests__/skill-argument-hints.test.js b/scripts/__tests__/skill-argument-hints.test.js
index 90902e92f..fd394701b 100644
--- a/scripts/__tests__/skill-argument-hints.test.js
+++ b/scripts/__tests__/skill-argument-hints.test.js
@@ -64,3 +64,12 @@ test("a skill whose actions take no input needs no argument-hint", () => {
assert.equal(clean.status, 0, clean.output);
});
+
+test("a skill checked out with CRLF line endings still reads its argument-hint", () => {
+ const clean = runOn({
+ [SKILL]: frontmatter(["argument-hint: a file path or a ticket"]).replaceAll("\n", "\r\n"),
+ "plugins/aidd-probe/skills/01-thing/actions/01-do.md": "## Input\r\n\r\nA path.",
+ });
+
+ assert.equal(clean.status, 0, clean.output);
+});
diff --git a/scripts/__tests__/sync-readme-counts.test.js b/scripts/__tests__/sync-readme-counts.test.js
new file mode 100644
index 000000000..a7068127e
--- /dev/null
+++ b/scripts/__tests__/sync-readme-counts.test.js
@@ -0,0 +1,12 @@
+const assert = require("node:assert/strict");
+const { spawnSync } = require("node:child_process");
+const path = require("node:path");
+const test = require("node:test");
+
+const SCRIPT = path.resolve(__dirname, "..", "sync-readme-counts.mjs");
+
+test("the README counts check finds the README and agrees with the tree", () => {
+ const result = spawnSync(process.execPath, [SCRIPT, "--check"], { encoding: "utf8" });
+
+ assert.equal(result.status, 0, `${result.stdout}${result.stderr}`);
+});
diff --git a/scripts/check-skill-argument-hints.mjs b/scripts/check-skill-argument-hints.mjs
index 258f1b136..cfc498c02 100644
--- a/scripts/check-skill-argument-hints.mjs
+++ b/scripts/check-skill-argument-hints.mjs
@@ -67,7 +67,7 @@ function argumentHint(content) {
async function takesInput(actionFiles) {
for (const file of actionFiles) {
const body = await readFile(file, "utf8");
- if (/^## Input$/m.test(body)) return true;
+ if (/^## Input\r?$/m.test(body)) return true;
}
return actionFiles.length === 0;
}
@@ -75,8 +75,8 @@ async function takesInput(actionFiles) {
const breaches = [];
for (const dir of await skillDirs()) {
const skillPath = path.join(dir, "SKILL.md");
- const content = await readFile(skillPath, "utf8").catch(() => null);
- if (content === null) continue;
+ const content = (await readFile(skillPath, "utf8").catch(() => null))?.replaceAll("\r\n", "\n");
+ if (content == null) continue;
// Named the way the contract and every other guard name a path, whatever the host separator.
const relative = path.relative(ROOT, skillPath).split(path.sep).join("/");
diff --git a/scripts/session-trailer-delegate.sh b/scripts/session-trailer-delegate.sh
new file mode 100755
index 000000000..dd23394a7
--- /dev/null
+++ b/scripts/session-trailer-delegate.sh
@@ -0,0 +1,4 @@
+#!/bin/sh
+delegate="$(git rev-parse --git-common-dir)/hooks/$1"
+shift
+if [ -f "$delegate" ]; then sh "$delegate" "$@"; fi
diff --git a/scripts/summarize-plugin-catalogs.sh b/scripts/summarize-plugin-catalogs.sh
new file mode 100755
index 000000000..0863bbb89
--- /dev/null
+++ b/scripts/summarize-plugin-catalogs.sh
@@ -0,0 +1,16 @@
+#!/bin/sh
+[ -d plugins ] || exit 0
+if ! command -v node >/dev/null 2>&1; then
+ echo "ℹ️ node not available; skipping summarize-plugin-catalogs"
+ exit 0
+fi
+for plugin in plugins/*/; do
+ name=$(basename "$plugin")
+ node scripts/summarize-markdown.js "$plugin" "${plugin}CATALOG.md" \
+ --depth=4 \
+ --fields=description \
+ --title="$name catalog" \
+ --tagline="Auto-generated index of skills, agents, references and assets shipped by the \`$name\` plugin." \
+ >/dev/null
+ git add "${plugin}CATALOG.md" 2>/dev/null || true
+done
diff --git a/scripts/sync-readme-counts.mjs b/scripts/sync-readme-counts.mjs
index d3165acf4..6c47ae300 100644
--- a/scripts/sync-readme-counts.mjs
+++ b/scripts/sync-readme-counts.mjs
@@ -4,8 +4,9 @@
// `N skill(s)` code-span after that plugin's heading. `--check` reports instead of writing.
import { readFileSync, writeFileSync, readdirSync, existsSync } from "node:fs";
+import { fileURLToPath } from "node:url";
-const ROOT = new URL("..", import.meta.url).pathname;
+const ROOT = fileURLToPath(new URL("..", import.meta.url));
const README = ROOT + "README.md";
const PLUGINS = ROOT + "plugins";