@delorenj/pjangler 1.5.18 → 1.5.22
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/dist/crank/tui.js +447 -0
- package/dist/index.js +5600 -2425
- package/dist/mcp-server.js +4718 -2248
- package/package.json +8 -4
- package/templates/commonproject/template/.github/scripts/check-gateway-routing.sh +100 -0
- package/templates/commonproject/template/.github/workflows/code-review.yml.jinja +53 -7
- package/templates/commonproject/template/.mise/scripts/docs-sync.py +175 -0
- package/templates/commonproject/template/mise.toml.jinja +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delorenj/pjangler",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.22",
|
|
4
4
|
"description": "Project subsystem bootstrapper CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"node": ">=24"
|
|
27
27
|
},
|
|
28
28
|
"scripts": {
|
|
29
|
-
"build": "esbuild src/index.ts src/mcp-server.ts src/prompt.ts src/project-registry-service.ts src/notebook/journal-service-main.ts --bundle --packages=external --platform=node --format=esm --sourcemap --outbase=src --outdir=dist && node scripts/export-project-notebook-skill.mjs",
|
|
29
|
+
"build": "esbuild src/index.ts src/mcp-server.ts src/prompt.ts src/project-registry-service.ts src/notebook/journal-service-main.ts --bundle --packages=external --platform=node --format=esm --sourcemap --outbase=src --outdir=dist && esbuild src/crank/tui.tsx --bundle --packages=external --platform=node --format=esm --sourcemap --outbase=src --outdir=dist && node scripts/export-project-notebook-skill.mjs",
|
|
30
30
|
"check:audit:prod": "npm audit --omit=dev",
|
|
31
31
|
"check:lock": "node scripts/check-package-lock-parity.mjs",
|
|
32
32
|
"check:submodules": "node scripts/check-submodule-contract.mjs",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"mcp": "node dist/mcp-server.js",
|
|
36
36
|
"typecheck": "tsc --noEmit",
|
|
37
37
|
"test:bmad-installer-contract": "node tests/bmad-installer-contract-regressions.mjs",
|
|
38
|
+
"test:bmad-rollback-surface": "node tests/pjan-180-surface-oracle.mjs",
|
|
38
39
|
"test:pjan-77": "node tests/pjan-77-notebook-domain-regressions.mjs && node tests/pjan-77-notebook-adapter-contract.mjs && node tests/pjan-77-notebook-lifecycle-regressions.mjs && node tests/pjan-77-notebook-cli-contract.mjs && node tests/pjan-77-notebook-hooks-capture.mjs && node tests/pjan-77-notebook-security-isolation.mjs && node tests/pjan-77-notebook-release-gates.mjs",
|
|
39
40
|
"test": "node scripts/run-tests.mjs",
|
|
40
41
|
"migrate:up": "node-pg-migrate --migrations-dir migrations up",
|
|
@@ -62,14 +63,17 @@
|
|
|
62
63
|
"@delorenj/skillex": "0.3.0",
|
|
63
64
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
64
65
|
"commander": "^14.0.2",
|
|
66
|
+
"ink": "^8.0.0",
|
|
65
67
|
"pg": "^8.16.0",
|
|
68
|
+
"react": "^19.3.1",
|
|
69
|
+
"smol-toml": "^1.8.0",
|
|
66
70
|
"yaml": "^2.9.0",
|
|
67
|
-
"zod": "^4.4.3"
|
|
68
|
-
"smol-toml": "^1.8.0"
|
|
71
|
+
"zod": "^4.4.3"
|
|
69
72
|
},
|
|
70
73
|
"devDependencies": {
|
|
71
74
|
"@types/node": "^25.9.1",
|
|
72
75
|
"@types/pg": "^8.15.0",
|
|
76
|
+
"@types/react": "^19.3.0",
|
|
73
77
|
"c8": "^10.1.3",
|
|
74
78
|
"esbuild": "^0.25.0",
|
|
75
79
|
"node-pg-migrate": "^8.0.0",
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Regression guard: every workflow that runs Claude goes through the
|
|
3
|
+
# AutomaticAI gateway (https://api.automaticai.io), never api.anthropic.com.
|
|
4
|
+
# The code-review workflow runs it as its first step, so a regression fails the
|
|
5
|
+
# job before any model call is made. Usage:
|
|
6
|
+
# bash .github/scripts/check-gateway-routing.sh [workflows-dir]
|
|
7
|
+
#
|
|
8
|
+
# A workflow fails when it:
|
|
9
|
+
# - uses the subscription OAuth token (claude_code_oauth_token /
|
|
10
|
+
# CLAUDE_CODE_OAUTH_TOKEN), which talks to api.anthropic.com directly;
|
|
11
|
+
# - names api.anthropic.com anywhere outside a comment;
|
|
12
|
+
# - runs claude-code-action without ANTHROPIC_BASE_URL set to the gateway,
|
|
13
|
+
# without anthropic_api_key from secrets.AUTOMATICAI_GATEWAY_TOKEN, or
|
|
14
|
+
# without github_token (the action's OIDC app-token exchange calls
|
|
15
|
+
# api.anthropic.com);
|
|
16
|
+
# - leaves classify_inline_comments on (its post-step POSTs the key to a
|
|
17
|
+
# hard-coded https://api.anthropic.com/v1/messages);
|
|
18
|
+
# - runs claude-code-action without `--model automaticai/...` in claude_args,
|
|
19
|
+
# with any other --model there, or with an OPUS/SONNET/HAIKU default model
|
|
20
|
+
# slot that is not an automaticai/ gateway route.
|
|
21
|
+
# Comment lines are ignored so headers can explain what is forbidden.
|
|
22
|
+
# Pattern: delorenj/heyma-obsidian 68a7810 + 1cd9f06 (AAI-59).
|
|
23
|
+
set -uo pipefail
|
|
24
|
+
|
|
25
|
+
GATEWAY="https://api.automaticai.io"
|
|
26
|
+
dir="${1:-$(dirname "$0")/../workflows}"
|
|
27
|
+
status=0
|
|
28
|
+
count=0
|
|
29
|
+
fail() { echo " $1: $2" >&2; status=1; }
|
|
30
|
+
|
|
31
|
+
# Values of `key:` lines, unquoted, one per line.
|
|
32
|
+
values() {
|
|
33
|
+
grep -oE "$1:[[:space:]]*['\"]?[^[:space:]'\"]+" <<<"$2" |
|
|
34
|
+
sed -E "s/^$1:[[:space:]]*['\"]?//" || true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# claude_args values, inline or as a YAML block scalar (| or >).
|
|
38
|
+
claude_args() {
|
|
39
|
+
awk '
|
|
40
|
+
block {
|
|
41
|
+
if ($0 ~ /^[[:space:]]*$/) next
|
|
42
|
+
match($0, /^[[:space:]]*/)
|
|
43
|
+
if (RLENGTH > indent) { print; next }
|
|
44
|
+
block = 0
|
|
45
|
+
}
|
|
46
|
+
/^[[:space:]]*claude_args:/ {
|
|
47
|
+
match($0, /^[[:space:]]*/); indent = RLENGTH
|
|
48
|
+
value = $0; sub(/^[[:space:]]*claude_args:[[:space:]]*/, "", value)
|
|
49
|
+
if (value ~ /^[|>][+-]?[[:space:]]*$/) block = 1; else print value
|
|
50
|
+
}
|
|
51
|
+
' <<<"$1"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
shopt -s nullglob
|
|
55
|
+
for file in "$dir"/*.yml "$dir"/*.yaml; do
|
|
56
|
+
count=$((count + 1))
|
|
57
|
+
name=$(basename "$file")
|
|
58
|
+
code=$(grep -vE '^[[:space:]]*#' "$file" || true)
|
|
59
|
+
|
|
60
|
+
grep -qiE 'claude_code_oauth_token' <<<"$code" &&
|
|
61
|
+
fail "$name" "uses claude_code_oauth_token / CLAUDE_CODE_OAUTH_TOKEN (direct Anthropic subscription auth)"
|
|
62
|
+
grep -qE 'api\.anthropic\.com' <<<"$code" &&
|
|
63
|
+
fail "$name" "names api.anthropic.com; inference must go through $GATEWAY"
|
|
64
|
+
grep -qE 'anthropics/claude-code(-action)?@' <<<"$code" || continue
|
|
65
|
+
|
|
66
|
+
bases=$(values ANTHROPIC_BASE_URL "$code")
|
|
67
|
+
[ -n "$bases" ] || fail "$name" "claude-code-action needs ANTHROPIC_BASE_URL: $GATEWAY"
|
|
68
|
+
while read -r base; do
|
|
69
|
+
[ -z "$base" ] || [ "${base%/}" = "$GATEWAY" ] || fail "$name" "ANTHROPIC_BASE_URL $base is not $GATEWAY"
|
|
70
|
+
done <<<"$bases"
|
|
71
|
+
grep -qE 'anthropic_api_key:[[:space:]]*\$\{\{[[:space:]]*secrets\.AUTOMATICAI_GATEWAY_TOKEN[[:space:]]*\}\}' <<<"$code" ||
|
|
72
|
+
fail "$name" "claude-code-action must authenticate with secrets.AUTOMATICAI_GATEWAY_TOKEN"
|
|
73
|
+
grep -qE 'github_token:' <<<"$code" ||
|
|
74
|
+
fail "$name" "pass github_token (the action's OIDC app-token exchange calls api.anthropic.com)"
|
|
75
|
+
grep -qE "classify_inline_comments:[[:space:]]*['\"]?false['\"]?" <<<"$code" ||
|
|
76
|
+
fail "$name" "set classify_inline_comments: 'false' (its post-step calls api.anthropic.com directly)"
|
|
77
|
+
|
|
78
|
+
models=$(claude_args "$code" | grep -oE -- "--model(=|[[:space:]]+)['\"]?[^[:space:]'\"]+" |
|
|
79
|
+
sed -E "s/^--model(=|[[:space:]]+)['\"]?//" || true)
|
|
80
|
+
[ -n "$models" ] || fail "$name" "claude_args must pass --model automaticai/<account>/<model>"
|
|
81
|
+
while read -r model; do
|
|
82
|
+
[ -z "$model" ] || [[ "$model" == automaticai/* ]] ||
|
|
83
|
+
fail "$name" "claude_args --model $model must be an automaticai/ gateway route"
|
|
84
|
+
done <<<"$models"
|
|
85
|
+
|
|
86
|
+
for slot in OPUS SONNET HAIKU; do
|
|
87
|
+
slots=$(values "ANTHROPIC_DEFAULT_${slot}_MODEL" "$code")
|
|
88
|
+
[ -n "$slots" ] || fail "$name" "ANTHROPIC_DEFAULT_${slot}_MODEL must be an automaticai/ gateway route"
|
|
89
|
+
while read -r model; do
|
|
90
|
+
[ -z "$model" ] || [[ "$model" == automaticai/* ]] ||
|
|
91
|
+
fail "$name" "ANTHROPIC_DEFAULT_${slot}_MODEL $model must be an automaticai/ gateway route"
|
|
92
|
+
done <<<"$slots"
|
|
93
|
+
done
|
|
94
|
+
done
|
|
95
|
+
|
|
96
|
+
if [ "$status" -ne 0 ]; then
|
|
97
|
+
echo "Gateway routing check failed." >&2
|
|
98
|
+
exit 1
|
|
99
|
+
fi
|
|
100
|
+
echo "Gateway routing check passed ($count workflows)."
|
|
@@ -3,10 +3,35 @@
|
|
|
3
3
|
# Rendered unconditionally by the CommonProject template: every project pjangler
|
|
4
4
|
# creates gets code review on day one, with no opt-in question to forget.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
6
|
+
# Every model call this review makes goes through the AutomaticAI gateway,
|
|
7
|
+
# https://api.automaticai.io, on the operator's personal Claude subscription.
|
|
8
|
+
# Nothing here talks to api.anthropic.com for inference (AAI-59; the pattern is
|
|
9
|
+
# delorenj/heyma-obsidian 68a7810 and 1cd9f06).
|
|
10
|
+
#
|
|
11
|
+
# Auth: the `AUTOMATICAI_GATEWAY_TOKEN` repo secret, a scoped gateway token for
|
|
12
|
+
# consumer `<repo>-code-review` (routes personal claude-opus-5.5 and
|
|
13
|
+
# claude-sonnet-5.5 only). Until it is set the job runs and fails at the action
|
|
14
|
+
# step. Mint it, then set it, from ~/code/client-portal/gateway:
|
|
15
|
+
# python3 ops/gateway-tokens.py mint <repo>-code-review --days 90 \
|
|
16
|
+
# --models automaticai/personal/claude-opus-5.5 automaticai/personal/claude-sonnet-5.5
|
|
17
|
+
# op read 'op://DeLoSecrets/<item-id>/<repo>-code-review' \
|
|
18
|
+
# | gh secret set AUTOMATICAI_GATEWAY_TOKEN -R <owner>/<repo>
|
|
19
|
+
# It expires after 90 days; `gateway-tokens.py rotate` and the same pipe renew
|
|
20
|
+
# it. The action passes it as ANTHROPIC_API_KEY, so Claude Code sends it as
|
|
21
|
+
# `x-api-key` to ANTHROPIC_BASE_URL; the gateway accepts that header.
|
|
22
|
+
#
|
|
23
|
+
# Every model slot is pinned to a personal-account gateway route: the main loop
|
|
24
|
+
# and the plugin's Opus agents to claude-opus-5.5, its Sonnet and Haiku agents
|
|
25
|
+
# (and Claude Code's background helper) to claude-sonnet-5.5. The personal
|
|
26
|
+
# account has no Haiku route, and a helper must not leave the account.
|
|
27
|
+
#
|
|
28
|
+
# Two action defaults would still reach Anthropic directly, so both are off:
|
|
29
|
+
# - classify_inline_comments: its post-step POSTs ANTHROPIC_API_KEY to a
|
|
30
|
+
# hard-coded Anthropic Messages endpoint (Haiku).
|
|
31
|
+
# - the GitHub App token exchange (via OIDC). The job uses the workflow's own
|
|
32
|
+
# GITHUB_TOKEN and needs no `id-token: write`.
|
|
33
|
+
# `.github/scripts/check-gateway-routing.sh` runs first and fails the job if any
|
|
34
|
+
# of this regresses.
|
|
10
35
|
#
|
|
11
36
|
# Runner: self-hosted on big-chungus ONLY. GitHub-hosted minutes are billed and
|
|
12
37
|
# the budget is $0, so a hosted label (`ubuntu-latest`, `macos-*`) just sits
|
|
@@ -16,6 +41,7 @@
|
|
|
16
41
|
#
|
|
17
42
|
# Trigger: `pull_request`, deliberately NOT `pull_request_target`. The latter
|
|
18
43
|
# would hand repo secrets to code from forked PRs; nothing here needs it.
|
|
44
|
+
# `workflow_dispatch` reviews an existing PR on demand (and proves the route).
|
|
19
45
|
#
|
|
20
46
|
# NOTE FOR TEMPLATE MAINTAINERS: this is a Copier `.jinja` file and GitHub
|
|
21
47
|
# Actions expression syntax collides with Jinja's variable delimiters. Every
|
|
@@ -26,6 +52,11 @@ name: Code Review
|
|
|
26
52
|
on:
|
|
27
53
|
pull_request:
|
|
28
54
|
types: [opened, synchronize, ready_for_review, reopened]
|
|
55
|
+
workflow_dispatch:
|
|
56
|
+
inputs:
|
|
57
|
+
pr:
|
|
58
|
+
description: 'Pull request number to review'
|
|
59
|
+
required: true
|
|
29
60
|
|
|
30
61
|
jobs:
|
|
31
62
|
claude-review:
|
|
@@ -34,7 +65,10 @@ jobs:
|
|
|
34
65
|
contents: read
|
|
35
66
|
pull-requests: read
|
|
36
67
|
issues: read
|
|
37
|
-
|
|
68
|
+
env:
|
|
69
|
+
# Telemetry and error reporting are not inference, but they are the last
|
|
70
|
+
# direct Anthropic traffic a CI review would make.
|
|
71
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1'
|
|
38
72
|
|
|
39
73
|
steps:
|
|
40
74
|
- name: Checkout repository
|
|
@@ -42,13 +76,25 @@ jobs:
|
|
|
42
76
|
with:
|
|
43
77
|
fetch-depth: 1
|
|
44
78
|
|
|
79
|
+
- name: Gateway routing guard
|
|
80
|
+
run: bash .github/scripts/check-gateway-routing.sh
|
|
81
|
+
|
|
45
82
|
- name: Run Claude Code Review
|
|
46
83
|
id: claude-review
|
|
47
84
|
uses: anthropics/claude-code-action@v1
|
|
85
|
+
env:
|
|
86
|
+
ANTHROPIC_BASE_URL: https://api.automaticai.io
|
|
87
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: automaticai/personal/claude-opus-5.5
|
|
88
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: automaticai/personal/claude-sonnet-5.5
|
|
89
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: automaticai/personal/claude-sonnet-5.5
|
|
48
90
|
with:
|
|
49
|
-
|
|
91
|
+
anthropic_api_key: {% raw %}${{ secrets.AUTOMATICAI_GATEWAY_TOKEN }}{% endraw %}
|
|
92
|
+
github_token: {% raw %}${{ secrets.GITHUB_TOKEN }}{% endraw %}
|
|
93
|
+
classify_inline_comments: 'false'
|
|
94
|
+
claude_args: '--model automaticai/personal/claude-opus-5.5'
|
|
95
|
+
settings: '{"effortLevel": "xhigh"}'
|
|
50
96
|
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
|
51
97
|
plugins: 'code-review@claude-code-plugins'
|
|
52
|
-
prompt: '/code-review:code-review {% raw %}${{ github.repository }}/pull/${{ github.event.pull_request.number }}{% endraw %}'
|
|
98
|
+
prompt: '/code-review:code-review {% raw %}${{ github.repository }}/pull/${{ github.event.pull_request.number || inputs.pr }}{% endraw %}'
|
|
53
99
|
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
54
100
|
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Copy recent project Markdown into a dated Obsidian snapshot.
|
|
3
|
+
|
|
4
|
+
The vault folder is Projects/<project_id>/<date>, where project_id comes from
|
|
5
|
+
the repository's .project.json manifest (falling back to the repository
|
|
6
|
+
directory name for bare repos). The index heading uses project_name (or the
|
|
7
|
+
directory name) so the display name may differ from the vault folder.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from urllib.parse import quote, urlencode
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Sources are root files or under docs/ and _bmad-output/, so this cannot collide.
|
|
24
|
+
INDEX = "_docs-sync/Index.md"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def positive_hours(value):
|
|
28
|
+
hours = float(value)
|
|
29
|
+
if not math.isfinite(hours) or hours <= 0:
|
|
30
|
+
raise argparse.ArgumentTypeError("hours must be a finite number greater than zero")
|
|
31
|
+
return hours
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def vault_component(value, source):
|
|
35
|
+
"""Reject anything that is not a safe single path component."""
|
|
36
|
+
if (
|
|
37
|
+
not value
|
|
38
|
+
or value in (".", "..")
|
|
39
|
+
or "/" in value
|
|
40
|
+
or "\\" in value
|
|
41
|
+
or "\x00" in value
|
|
42
|
+
):
|
|
43
|
+
raise RuntimeError(f"Unsafe {source} for the vault path: {value!r}")
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def project_identity(root):
|
|
48
|
+
"""Return (vault folder, display name) for the repository.
|
|
49
|
+
|
|
50
|
+
Reads .project.json when present: project_id names the vault folder and
|
|
51
|
+
project_name titles the index. A bare repository without a manifest (or
|
|
52
|
+
with blank identity fields) falls back to the repository directory name
|
|
53
|
+
for both. A manifest that exists but is not valid JSON is an error rather
|
|
54
|
+
than a silent fallback, because it indicates a broken project.
|
|
55
|
+
"""
|
|
56
|
+
identifier, name = None, None
|
|
57
|
+
manifest = root / ".project.json"
|
|
58
|
+
if manifest.is_file():
|
|
59
|
+
try:
|
|
60
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
61
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
62
|
+
raise RuntimeError(f"Cannot parse {manifest}: {error}")
|
|
63
|
+
if isinstance(data, dict):
|
|
64
|
+
identifier = data.get("project_id")
|
|
65
|
+
name = data.get("project_name")
|
|
66
|
+
if not isinstance(identifier, str) or not identifier.strip():
|
|
67
|
+
identifier = root.name
|
|
68
|
+
if not isinstance(name, str) or not name.strip():
|
|
69
|
+
name = root.name
|
|
70
|
+
# The display name goes into a Markdown heading; keep it one line.
|
|
71
|
+
display = " ".join(name.strip().splitlines())
|
|
72
|
+
return vault_component(identifier.strip(), "project_id"), display
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def recent_docs(root, hours):
|
|
76
|
+
result = subprocess.run(
|
|
77
|
+
["git", "-C", str(root), "ls-files", "--cached", "--others",
|
|
78
|
+
"--exclude-standard", "-z"],
|
|
79
|
+
check=True, capture_output=True,
|
|
80
|
+
)
|
|
81
|
+
cutoff = time.time() - hours * 3600
|
|
82
|
+
for name in sorted(set(os.fsdecode(result.stdout).split("\0"))):
|
|
83
|
+
relative = Path(name)
|
|
84
|
+
if not name or relative.suffix.lower() != ".md":
|
|
85
|
+
continue
|
|
86
|
+
if any(part.startswith(".") for part in relative.parts):
|
|
87
|
+
continue
|
|
88
|
+
if len(relative.parts) > 1 and relative.parts[0] not in ("docs", "_bmad-output"):
|
|
89
|
+
continue
|
|
90
|
+
source = root / relative
|
|
91
|
+
if any(path.is_symlink() for path in (source, *source.parents)):
|
|
92
|
+
continue
|
|
93
|
+
if source.is_file() and source.stat().st_mtime >= cutoff:
|
|
94
|
+
yield relative
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def open_note(note):
|
|
98
|
+
uri = "obsidian://open?" + urlencode({"path": str(note)}, quote_via=quote)
|
|
99
|
+
if sys.platform == "win32":
|
|
100
|
+
os.startfile(uri)
|
|
101
|
+
return
|
|
102
|
+
launcher = "open" if sys.platform == "darwin" else "xdg-open"
|
|
103
|
+
executable = shutil.which(launcher)
|
|
104
|
+
if not executable:
|
|
105
|
+
raise RuntimeError(f"Cannot find {launcher}; use --no-open for a headless sync")
|
|
106
|
+
subprocess.run([executable, uri], check=True)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def sync(root, vault, hours, no_open):
|
|
110
|
+
if not vault.is_dir():
|
|
111
|
+
raise RuntimeError(f"Obsidian vault does not exist: {vault}; set VAULT or pass --vault")
|
|
112
|
+
if vault == root or root in vault.parents:
|
|
113
|
+
raise RuntimeError("The vault must be outside the repository")
|
|
114
|
+
project_id, display = project_identity(root)
|
|
115
|
+
files = list(recent_docs(root, hours))
|
|
116
|
+
if not files:
|
|
117
|
+
print(f"No Markdown files modified in the last {hours:g} hours; nothing copied or opened.")
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
121
|
+
destination = vault / "Projects" / project_id / today
|
|
122
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
for relative in files:
|
|
124
|
+
target = destination / relative
|
|
125
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
shutil.copy2(root / relative, target)
|
|
127
|
+
|
|
128
|
+
# Include earlier copies from today too; narrower reruns never delete notes.
|
|
129
|
+
links = []
|
|
130
|
+
for note in sorted(destination.rglob("*")):
|
|
131
|
+
if note.is_file() and note.suffix.lower() == ".md" and note != destination / INDEX:
|
|
132
|
+
relative = note.relative_to(destination).as_posix()
|
|
133
|
+
label = relative.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]").replace("\n", " ")
|
|
134
|
+
links.append(f"- [{label}](<../{quote(relative, safe='/')}>)")
|
|
135
|
+
index = destination / INDEX
|
|
136
|
+
index.parent.mkdir(exist_ok=True)
|
|
137
|
+
index.write_text(
|
|
138
|
+
f"# {display} docs — {today}\n\n"
|
|
139
|
+
f"Copied from `{root}`. Relative repository paths are preserved.\n\n"
|
|
140
|
+
+ "\n".join(links) + "\n",
|
|
141
|
+
encoding="utf-8",
|
|
142
|
+
)
|
|
143
|
+
print(f"Synced {len(files)} Markdown files to {destination}", flush=True)
|
|
144
|
+
print(f"Index: {index}", flush=True)
|
|
145
|
+
if not no_open:
|
|
146
|
+
open_note(index)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main():
|
|
150
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
151
|
+
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2],
|
|
152
|
+
help=argparse.SUPPRESS)
|
|
153
|
+
parser.add_argument("--vault", type=Path,
|
|
154
|
+
default=os.environ.get("VAULT") or os.environ.get("OBSIDIAN_VAULT")
|
|
155
|
+
or str(Path.home() / "code/DeLoDocs"),
|
|
156
|
+
help="existing Obsidian vault (default: VAULT, OBSIDIAN_VAULT, ~/code/DeLoDocs)")
|
|
157
|
+
parser.add_argument("--hours", type=positive_hours, default=24,
|
|
158
|
+
help="copy Markdown modified within this many hours (default: 24)")
|
|
159
|
+
parser.add_argument("--no-open", action="store_true", help="copy without opening Obsidian")
|
|
160
|
+
args = parser.parse_args()
|
|
161
|
+
try:
|
|
162
|
+
sync(args.root.expanduser().resolve(), args.vault.expanduser().resolve(),
|
|
163
|
+
args.hours, args.no_open)
|
|
164
|
+
except (OSError, RuntimeError, subprocess.CalledProcessError) as error:
|
|
165
|
+
detail = ""
|
|
166
|
+
if isinstance(error, subprocess.CalledProcessError) and error.stderr:
|
|
167
|
+
detail = ": " + os.fsdecode(error.stderr).strip()
|
|
168
|
+
print(f"docs:sync: {error}{detail}", file=sys.stderr)
|
|
169
|
+
print("If copying succeeded but Obsidian could not open, retry with --no-open.", file=sys.stderr)
|
|
170
|
+
return 1
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if __name__ == "__main__":
|
|
175
|
+
sys.exit(main())
|
|
@@ -43,6 +43,10 @@ run = "'{% raw %}{{config_root}}{% endraw %}/.agents/hooks/sync.py' --uninstall
|
|
|
43
43
|
[[watch_files]]
|
|
44
44
|
patterns = ["AGENTS.md"]
|
|
45
45
|
task = "link:agentfiles"
|
|
46
|
+
|
|
47
|
+
[[watch_files]]
|
|
48
|
+
patterns = [".env.op"]
|
|
49
|
+
task = "env:materialize"
|
|
46
50
|
{% if agent_hooks_layer %}
|
|
47
51
|
# Re-fan-out the agent hooks whenever the single source of truth changes.
|
|
48
52
|
[[watch_files]]
|
|
@@ -52,6 +56,10 @@ task = "hooks:sync"
|
|
|
52
56
|
[tasks."link:agentfiles"]
|
|
53
57
|
description = "Symlink all agent files to AGENTS.md"
|
|
54
58
|
run = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/link-agentfiles.sh' '{% raw %}{{config_root}}{% endraw %}'"
|
|
59
|
+
|
|
60
|
+
[tasks."env:materialize"]
|
|
61
|
+
description = "Inject .env.op into .env"
|
|
62
|
+
run = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/materialize-env.sh'"
|
|
55
63
|
{% if agent_hooks_layer %}
|
|
56
64
|
# --- Project-scoped agent hooks (see .agents/hooks/README.md) ---
|
|
57
65
|
|
|
@@ -70,3 +78,21 @@ run = "'{% raw %}{{config_root}}{% endraw %}/.agents/hooks/sync.py' --uninstall"
|
|
|
70
78
|
[tasks."hindsight:setup"]
|
|
71
79
|
description = "Provision this dev's shared project Hindsight key from 1Password into .env (op read; upserts those two keys, leaves the rest of .env intact)"
|
|
72
80
|
run = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/hindsight-setup.sh'"
|
|
81
|
+
|
|
82
|
+
# Core task (unconditional): snapshot recent project Markdown into the
|
|
83
|
+
# Obsidian vault under Projects/<project_id>/<date> and open its index.
|
|
84
|
+
# {{config_root}} is raw-escaped like the hooks above so mise, not Jinja,
|
|
85
|
+
# expands it at task runtime (and sh -c keeps the space-safe quoting).
|
|
86
|
+
[tasks."docs:sync"]
|
|
87
|
+
description = "Copy recent Markdown from docs/, _bmad-output/ and the repo root to the dated project vault folder and open its Obsidian index"
|
|
88
|
+
usage = '''
|
|
89
|
+
flag "--hours <hours>" default="24" help="Copy Markdown modified within this many hours"
|
|
90
|
+
flag "--vault <vault>" default="" help="Existing Obsidian vault (defaults to VAULT, OBSIDIAN_VAULT, or ~/code/DeLoDocs)"
|
|
91
|
+
flag "--no-open" help="Copy without opening Obsidian"
|
|
92
|
+
'''
|
|
93
|
+
run = '''
|
|
94
|
+
set -- --hours "$usage_hours"
|
|
95
|
+
if [ -n "$usage_vault" ]; then set -- "$@" --vault "$usage_vault"; fi
|
|
96
|
+
if [ "$usage_no_open" = true ]; then set -- "$@" --no-open; fi
|
|
97
|
+
python3 "{% raw %}{{config_root}}{% endraw %}/.mise/scripts/docs-sync.py" --root "{% raw %}{{config_root}}{% endraw %}" "$@"
|
|
98
|
+
'''
|