Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 204 additions & 6 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -502,14 +502,60 @@ jobs:
fi
echo "CMP byte-mirror check passed — all pool/example skill pairs are byte-identical."

- name: "MF-3 — skills/*/SKILL.md tools: vocabulary gate"
run: |
# ADR-029 v2.5: Closed vocabulary gate for tools: frontmatter field.
# Allowed tokens: claude-code copilot cursor windsurf (no others at v2.5).
# Runs on skills/*/SKILL.md only; upstream-contribution/ excluded by path-glob shape.
# MF-S1 (security MUST-FIX): multi-line YAML form rejected explicitly.
set -o pipefail
ALLOWED='claude-code copilot cursor windsurf'
BAD_FILES=""
for skill_md in skills/*/SKILL.md; do
# Extract YAML frontmatter (between first two ---) and find tools: line
TOOLS_LINE=$(awk '/^---$/{c++; next} c==1 && /^tools:/' "$skill_md")
if [ -z "$TOOLS_LINE" ]; then
echo "::error::${skill_md} missing tools: frontmatter field"
BAD_FILES="${BAD_FILES} ${skill_md}"
continue
fi
# Parse list: tools: [claude-code, copilot] -> claude-code copilot
TOKENS=$(echo "$TOOLS_LINE" | sed -E 's/^tools:[[:space:]]*\[//; s/\][[:space:]]*$//; s/,/ /g' | tr -d ' ' | tr ',' ' ')
# MF-S1: Reject multi-line YAML form — tools: present but unparseable as inline array
if [ -z "$TOKENS" ] && [ -n "$TOOLS_LINE" ]; then
echo "::error::${skill_md} tools: present but unparsed (multi-line form not supported at v2.5)"
BAD_FILES="${BAD_FILES} ${skill_md}"
continue
fi
for token in $TOKENS; do
# Strip any remaining whitespace
token=$(echo "$token" | tr -d '[:space:]')
if [ -z "$token" ]; then continue; fi
if ! echo "$ALLOWED" | grep -qw "$token"; then
echo "::error::${skill_md} tools: contains invalid token '${token}' (allowed: ${ALLOWED})"
BAD_FILES="${BAD_FILES} ${skill_md}"
fi
done
done
if [ -n "$BAD_FILES" ]; then
echo "::error::MF-3 vocabulary gate failed on:${BAD_FILES}"
exit 1
fi
SKILL_COUNT=$(find skills -name "SKILL.md" | wc -l)
echo "MF-3 tools: vocabulary gate passed (${SKILL_COUNT} skills checked)."

- name: MF-1 — selection-presets.md token vocabulary gate
run: |
# S1 WARNING from v2.4 Phase 2 — MF-1 MUST-FIX.
# F4 v2.5 hardening (CF-v2.4-G / AC-F4-1): set -o pipefail + explicit empty-check
# replaces the '|| true' masking pattern. pipefail catches middle-segment pipeline
# failures; explicit BAD=0 assignment handles the 0-match case from grep -c.
# Per-step pipefail scope — does not affect other steps in the job.
set -o pipefail
# selection-presets.md vocabulary gate (S1 WARNING from v2.4 Phase 2)
if [ -f selection-presets.md ]; then
BAD=$(awk '/^```preset$/,/^```$/' selection-presets.md \
| grep -E '^(match_signals|skill_bundle): ' \
| grep -cE '[^a-z0-9, :_-]' || true)
| grep -cE '[^a-z0-9, :_-]') || BAD=0
if [ "${BAD:-0}" -gt 0 ]; then
echo "::error::selection-presets.md contains invalid token vocabulary (must match [a-z0-9, :_-])"
exit 1
Expand All @@ -522,12 +568,45 @@ jobs:

- name: MF-2 — curated-skills-registry.md goal_tags vocabulary gate
run: |
# S2 WARNING from v2.4 Phase 2 — MF-2 MUST-FIX.
# curated-skills-registry.md goal_tags vocabulary gate (S2 WARNING from v2.4 Phase 2)
# F4 v2.5 hardening: set -o pipefail replaces '|| true' masking (CF-v2.4-G).
# MF-2 awk now uses structural header scan (CF-v2.4-B / MF-S2 MUST-FIX):
# finds goal_tags column by name, not position. If header absent, exits 2 (fail-closed).
# Per-step pipefail scope — does not affect other steps in the job.
set -o pipefail
if [ -f curated-skills-registry.md ]; then
BAD=$(awk -F'|' '/^\| / && NR>2 { print $7 }' curated-skills-registry.md \
BAD=$(awk -F'|' '
{
# Structural header scan: find goal_tags column by name, not position (CF-v2.4-B / MF-S2)
# Scans all pipe-delimited rows for one whose cell exactly equals "goal_tags" (no backticks).
# Skips documentation-table rows where goal_tags appears with backticks (e.g. `goal_tags`).
if (header_seen == 0 && /^\| / && /goal_tags/) {
found_col = 0
for (i=1; i<=NF; i++) {
cell = $i
gsub(/^[[:space:]]+|[[:space:]]+$/, "", cell)
if (cell == "goal_tags") { found_col = i }
}
if (found_col > 0) {
col = found_col
header_seen = 1
}
# If found_col == 0, the row has goal_tags in backticks or inline — skip and keep scanning
next
}
# Data rows: print goal_tags column value (only after header found)
if (header_seen == 1 && /^\| / && !/^\| *---/) {
print $col
}
}
END {
if (header_seen == 0) {
print "HEADER_MISSING_GOAL_TAGS" > "/dev/stderr"
exit 2
}
}
' curated-skills-registry.md \
| grep -vE '^[[:space:]]*(goal_tags|---)' \
| grep -cE '[^a-z0-9, -]' || true)
| grep -cE '[^a-z0-9, -]') || BAD=0
if [ "${BAD:-0}" -gt 0 ]; then
echo "::error::curated-skills-registry.md goal_tags contains invalid token vocabulary in ${BAD} row(s)"
exit 1
Expand All @@ -538,6 +617,45 @@ jobs:
exit 1
fi

- name: MF-2 column reorder regression test (AC-F4-5)
run: |
# Verify MF-2 structural header scan finds goal_tags by name even when column is reordered.
# Fixture: tests/fixtures/registry-column-reorder.md has goal_tags at position 3 (not 6).
# A bad token 'BAD_TOKEN!' is injected in the goal_tags column — gate must fire.
set -o pipefail
if [ ! -f "tests/fixtures/registry-column-reorder.md" ]; then
echo "::error::Regression fixture missing: tests/fixtures/registry-column-reorder.md"
exit 1
fi
BAD=$(awk -F'|' '
{
if (header_seen == 0 && /^\| / && /goal_tags/) {
found_col = 0
for (i=1; i<=NF; i++) {
cell = $i
gsub(/^[[:space:]]+|[[:space:]]+$/, "", cell)
if (cell == "goal_tags") { found_col = i }
}
if (found_col > 0) { col = found_col; header_seen = 1 }
next
}
if (header_seen == 1 && /^\| / && !/^\| *---/) {
print $col
}
}
END {
if (header_seen == 0) { print "HEADER_MISSING_GOAL_TAGS" > "/dev/stderr"; exit 2 }
}
' tests/fixtures/registry-column-reorder.md \
| grep -vE '^[[:space:]]*(goal_tags|---)' \
| grep -cE '[^a-z0-9, -]') || BAD=0
if [ "${BAD:-0}" -lt 1 ]; then
echo "::error::MF-2 column reorder regression test FAILED — BAD_TOKEN not detected when goal_tags column is reordered."
echo "This means MF-2 is using positional column index, not structural header scan."
exit 1
fi
echo "MF-2 column reorder regression test PASSED — goal_tags found by header name in reordered column, BAD=${BAD}."

- name: Advisory notice for unenforced examples
run: |
# S1: All 7 examples are now in ENFORCED_EXAMPLES (v2.4 amendment).
Expand Down Expand Up @@ -836,3 +954,83 @@ jobs:
else
echo "PARTIAL: accumulator contains ${FETCHED_COUNT}/2 entries (network issue — non-blocking)"
fi

lock-content-sha-fault-injection:
name: Lock Content-SHA Fault Injection (AC-F1-3)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Verify integrity mismatch fires on poisoned content_sha256
run: |
set -euo pipefail
# ADR-028 fault-injection test: fixture has DEADBEEF content_sha256 on one entry.
# The verify logic must detect the mismatch and exit non-zero.
# Fixture lives at tests/fixtures/sha-fault-injection.json.
if [ ! -f "tests/fixtures/sha-fault-injection.json" ]; then
echo "::error::Fault-injection fixture missing: tests/fixtures/sha-fault-injection.json"
exit 1
fi
PINNED=$(jq -r '.pinned_commit_sha' tests/fixtures/sha-fault-injection.json)
UPSTREAM=$(jq -r '.upstream' tests/fixtures/sha-fault-injection.json)
MISMATCH=0
while IFS='|' read -r path stored_hash; do
if [ "$stored_hash" = "MISSING" ] || [ -z "$stored_hash" ]; then
continue
fi
TMPFILE=$(mktemp)
curl -sf "https://raw.githubusercontent.com/${UPSTREAM}/${PINNED}/${path}" -o "$TMPFILE" || {
echo "WARNING: Failed to fetch ${path} — skipping"
rm -f "$TMPFILE"
continue
}
ACTUAL=$(sha256sum "$TMPFILE" | awk '{print $1}')
rm -f "$TMPFILE"
if [ "$ACTUAL" != "$stored_hash" ]; then
echo "::error::Integrity mismatch on ${path} — stored content_sha256=${stored_hash} fetched=${ACTUAL}"
MISMATCH=1
fi
done < <(jq -r '.files[] | "\(.path)|\(.content_sha256 // "MISSING")"' tests/fixtures/sha-fault-injection.json)
if [ "$MISMATCH" -eq 0 ]; then
echo "::error::Fault-injection test FAILED — mismatch was NOT detected. Verify logic may be broken."
exit 1
fi
echo "Fault-injection test PASSED — integrity mismatch correctly detected on poisoned content_sha256."

lock-content-sha-cross-check:
name: lock-content-sha-cross-check (C-v2.5-19)
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Cross-environment content_sha256 verification
run: |
set -euo pipefail
# ADR-028 deliberation A1: cross-environment trust anchor.
# Fetches each files[] entry from raw.githubusercontent.com at pinned_commit_sha
# in a clean GitHub-Actions runner. Asserts SHA-256 equality with stored content_sha256.
# Any mismatch fails the PR. Runs on every PR (not only sync-agency invocations).
PINNED=$(jq -r '.pinned_commit_sha' cowork.lock.json)
UPSTREAM=$(jq -r '.upstream' cowork.lock.json)
FAIL=0
CHECKED=0
while IFS='|' read -r path stored_hash; do
if [ "$stored_hash" = "MISSING" ] || [ -z "$stored_hash" ]; then
echo "INFO: ${path} has no content_sha256 (pre-v2.5 grace) — skipping"
continue
fi
curl -sf "https://raw.githubusercontent.com/${UPSTREAM}/${PINNED}/${path}" -o /tmp/x || {
echo "WARNING: Failed to fetch ${path} — skipping"
continue
}
ACTUAL=$(sha256sum /tmp/x | awk '{print $1}')
if [ "$ACTUAL" != "$stored_hash" ]; then
echo "::error::content_sha256 mismatch on ${path}: stored=${stored_hash} actual=${ACTUAL}"
FAIL=1
fi
CHECKED=$((CHECKED + 1))
done < <(jq -r '.files[] | "\(.path)|\(.content_sha256 // "MISSING")"' cowork.lock.json)
if [ "$FAIL" -eq 1 ]; then
echo "::error::lock-content-sha-cross-check FAILED — content integrity cannot be verified."
exit 1
fi
echo "lock-content-sha-cross-check PASSED — ${CHECKED} entries verified in clean GHA runner."
11 changes: 11 additions & 0 deletions .github/workflows/sync-agency.yml
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,17 @@ jobs:
# Compute SHA-256
FILE_SHA256=$(sha256sum "/tmp/fetched-files/${category}/${filename}" | awk '{print $1}')

# ADR-028 v2.5: Verify content_sha256 integrity before accumulator append
# Ordered AFTER SHA-256 compute (line 216) and BEFORE accumulator append (line 237).
# Fail-closed: mismatch exits loop before any partial state reaches the lock rewrite.
OLD_CONTENT_SHA256=$(jq -r --arg p "$file_path" '.files[] | select(.path == $p) | .content_sha256 // "MISSING"' cowork.lock.json)
if [ "$OLD_CONTENT_SHA256" != "MISSING" ] && [ -n "$OLD_CONTENT_SHA256" ]; then
if [ "$FILE_SHA256" != "$OLD_CONTENT_SHA256" ]; then
echo "::error::Integrity mismatch on ${file_path} — stored content_sha256=${OLD_CONTENT_SHA256} fetched=${FILE_SHA256}"
exit 1
fi
fi

# S1 Content scan — run all 8 patterns (bash array iteration — no subshell)
FILE_FLAGGED=false
for pattern in "${SCAN_PATTERNS[@]}"; do
Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@ All notable changes to this project are documented here. This project uses [Sema

---

## [2.5.0] — 2026-05-09

### Added
- ADR-028: `content_sha256` per-file integrity field backfilled across all 110 entries in `cowork.lock.json`. The sync workflow now verifies `content_sha256` on every pull before accumulating changes — mismatches abort with a CI error.
- `tests/fixtures/sha-fault-injection.json` — CI fixture for lock-content-sha fault-injection test (asserts mismatch fires).
- `lock-content-sha-fault-injection` CI job — regression test that the verify logic fires on the DEADBEEF fixture.
- `lock-content-sha-cross-check` CI job — cross-environment trust anchor: recomputes SHA on PR and compares to lock (C-v2.5-19).
- ADR-029: `tools:` SKILL.md frontmatter field — closed vocabulary `[claude-code, copilot, cursor, windsurf]`. Default-when-absent rule (assume `claude-code` at runtime). CI vocab gate (MF-3) enforces all pool skills declare an inline-array `tools:` value.
- `tools: [claude-code]` added to all 20 skills in `skills/*/SKILL.md`. All 21 `examples/*/SKILL.md` byte-mirrored (ADR-018 research-synthesis exemption applied). MF-3 CI gate blocks vocab violations and multi-line YAML form (MF-S1 MUST-FIX).
- ADR-030: Outbound contribution model — `upstream-contribution/` working directory convention, attribution-via-PR-description policy. First outbound submission: meeting-notes skill to `msitarzewski/agency-agents`.
- `upstream-contribution/meeting-notes-upstream.md` — upstream-format version of meeting-notes skill. Writing-profile reference stripped (CF-L1-1). Attribution line in PR description (CF-L4-1).
- Upstream contribution: [PR #521](https://github.com/msitarzewski/agency-agents/pull/521) — meeting-notes skill submitted to `project-management/` category.
- MF-3 vocabulary gate in `quality.yml` — closed allowlist, multi-line YAML form rejected (MF-S1 MUST-FIX).
- MF-1 hardening: `set -o pipefail` per-step scope + `|| BAD=0` pattern replaces `|| true` (CF-v2.4-G / AC-F4-1).
- MF-2 hardening: structural header scan replacing positional `$7` (MF-S2 MUST-FIX / AC-F4-3). awk finds `goal_tags` column by name; skips backtick-wrapped documentation rows.
- `tests/fixtures/registry-column-reorder.md` — regression fixture for MF-2 structural scan (goal_tags at column 3 with BAD_TOKEN).
- `scripts/install-pre-commit.sh` — local markdownlint pre-commit hook installer. Closes the v2.3.0 MD058 gap. Same ruleset as CI `markdown-lint` step.
- `docs/security-review-v2.5.md`, `docs/compliance-review-v2.5.md` — Phase 2 review documents for this cycle.

### Changed
- MF-2 awk now uses structural header scan (goal_tags found by column name, not positional index) — making it resilient to column-reorder in `curated-skills-registry.md`.
- `quality.yml` `skill-depth-check` job: `upstream-contribution/` excluded from depth-check (follows upstream format, not Cowork 9-section template). ADR-016 v2.5 amendment.
- `docs/architecture.md`: ADR-028 ACCEPTED, ADR-029, ADR-030, ADR-007 amendment (v2.5), ADR-016 amendment (v2.5) added.

---

## [2.4.0] — 2026-05-08

### Added
Expand Down
36 changes: 36 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,42 @@ After a pipeline cycle reaches Phase 7 APPROVED, the release branch is ready for

---

## Local Development

### Pre-commit hook (markdownlint)

Install the local markdownlint pre-commit hook to catch `MD058` and other violations before they reach CI:

```bash
bash scripts/install-pre-commit.sh
```

**Requirements:** Node.js + `markdownlint-cli` (`npm install -g markdownlint-cli`).

The hook runs the same ruleset as the CI `markdown-lint` step (`.markdownlint.json` at repo root). If `.markdownlint.json` is absent, markdownlint defaults apply.

**Manual procedure** (if you prefer not to use the script):

```bash
# 1. Install markdownlint-cli
npm install -g markdownlint-cli

# 2. Write the hook
cat > .git/hooks/pre-commit <<'HOOK'
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT=$(git rev-parse --show-toplevel)
markdownlint --config "${REPO_ROOT}/.markdownlint.json" "${REPO_ROOT}/**/*.md" --ignore "${REPO_ROOT}/node_modules"
HOOK

# 3. Make it executable
chmod +x .git/hooks/pre-commit
```

To uninstall: `rm .git/hooks/pre-commit`. A backup of any overwritten hook is saved to `.git/hooks/pre-commit.bak`.

---

## Running CI checks locally

```bash
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

[![CI](https://github.com/jmlozano1990/cowork-starter-kit/actions/workflows/quality.yml/badge.svg)](https://github.com/jmlozano1990/cowork-starter-kit/actions/workflows/quality.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-2.4.0-green.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-2.5.0-green.svg)](CHANGELOG.md)

---

Expand Down Expand Up @@ -147,11 +147,11 @@ v2.0 ships a supply-chain lock file (`cowork.lock.json`) that SHA-pins all upstr

> **Trust boundary:** The `cowork.lock.json` file is the integrity anchor for upstream content. If you cloned this repo from a fork or modified the lock file locally, the supply-chain guarantees do not apply. Always install from a trusted clone of cowork-starter-kit's main repository.

## Next up — v2.5: First External Skill Import + ADR-028 Implementation
## What's new in v2.5

v2.4.0 ships the Dynamic Workspace Architect: open-ended goal discovery (F3 keyword matcher), dynamic skill-bundle composition from a consolidated `skills/` pool (20 SKILL.md files), Q&A bundle customization (F4), dynamic install with ADR-024 attribution (F5), byte-identical starter file Q1 blocks across all 7 presets, byte-identical deprecation stubs for per-preset `skills-as-prompts.md`, and CI vocabulary gates (MF-1, MF-2).
v2.5.0 ships: ADR-028 `content_sha256` integrity field (all 110 lock entries backfilled + CI cross-check), `tools:` SKILL.md frontmatter with MF-3 vocab gate, the first outbound skill contribution ([meeting-notes → agency-agents#521](https://github.com/msitarzewski/agency-agents/pull/521)), MF-1/MF-2 CI hardening (`set -o pipefail` + structural awk header scan replacing positional `$7`), and local markdownlint pre-commit installer.

**Next up (v2.5):** ADR-028 `content_sha256` lock-schema implementation, first external skill import via `/sync-agency`, local markdownlint pre-commit (closes the v2.3.0 MD058 gap).
**Next up (v2.6):** Multi-tool skill authoring (v3.0 routing intent) — individual skills validated for Copilot/Cursor/Windsurf and widened beyond `claude-code`.

---

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.4.0
2.5.0
Loading
Loading