@delorenj/pjangler 1.5.18 → 1.5.21

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": "@delorenj/pjangler",
3
- "version": "1.5.18",
3
+ "version": "1.5.21",
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
- # Auth: the `CLAUDE_CODE_OAUTH_TOKEN` repo secret (or an org-level secret this
7
- # repo inherits). It is a long-lived OAuth/session token, NOT a metered API key.
8
- # Until it is set the job runs and fails at the action step — see the "Code
9
- # Review" section of the CommonProject README.
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
- id-token: write
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
- claude_code_oauth_token: {% raw %}${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}{% endraw %}
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
+ '''