agentic-team 0.0.0-stage → 6.2.0
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/LICENSE +21 -0
- package/README.md +189 -2
- package/bin/build-template.js +208 -0
- package/bin/index.js +1334 -0
- package/bin/platforms.js +510 -0
- package/package.json +41 -4
- package/template/.claude-plugin/plugin.json +10 -0
- package/template/.orchestra/README.md +391 -0
- package/template/.orchestra/blueprints/README.md +87 -0
- package/template/.orchestra/blueprints/api-only.md +46 -0
- package/template/.orchestra/blueprints/component-crud-resource.md +25 -0
- package/template/.orchestra/blueprints/saas-starter.md +69 -0
- package/template/.orchestra/config.yml +293 -0
- package/template/.orchestra/roles/orchestrator.md +105 -0
- package/template/.orchestra/roles/product-manager.md +439 -0
- package/template/.orchestra/scripts/compact-diff.awk +45 -0
- package/template/.orchestra/scripts/context.mjs +373 -0
- package/template/.orchestra/stacks/README.md +81 -0
- package/template/.orchestra/stacks/_lib/common.sh +3095 -0
- package/template/.orchestra/stacks/_repo/setup.sh +1113 -0
- package/template/.orchestra/stacks/backend-hono/claude/commands/endpoint.md +13 -0
- package/template/.orchestra/stacks/backend-hono/claude/skills/api-endpoint/SKILL.md +111 -0
- package/template/.orchestra/stacks/backend-hono/claude/skills/testing/SKILL.md +75 -0
- package/template/.orchestra/stacks/backend-hono/setup.sh +5945 -0
- package/template/.orchestra/stacks/backend-hono/stack.md +195 -0
- package/template/.orchestra/stacks/mobile-android-native/claude/commands/component.md +13 -0
- package/template/.orchestra/stacks/mobile-android-native/claude/skills/design-component/SKILL.md +58 -0
- package/template/.orchestra/stacks/mobile-android-native/setup.sh +607 -0
- package/template/.orchestra/stacks/mobile-android-native/stack.md +138 -0
- package/template/.orchestra/stacks/mobile-expo/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/mobile-expo/claude/skills/design-component/SKILL.md +92 -0
- package/template/.orchestra/stacks/mobile-expo/setup.sh +470 -0
- package/template/.orchestra/stacks/mobile-expo/stack.md +141 -0
- package/template/.orchestra/stacks/mobile-ios-native/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/mobile-ios-native/claude/skills/design-component/SKILL.md +58 -0
- package/template/.orchestra/stacks/mobile-ios-native/setup.sh +468 -0
- package/template/.orchestra/stacks/mobile-ios-native/stack.md +130 -0
- package/template/.orchestra/stacks/web-nextjs/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/web-nextjs/claude/skills/design-component/SKILL.md +100 -0
- package/template/.orchestra/stacks/web-nextjs/claude/skills/design-tokens/SKILL.md +61 -0
- package/template/.orchestra/stacks/web-nextjs/setup.sh +128 -0
- package/template/.orchestra/stacks/web-nextjs/stack.md +170 -0
- package/template/.orchestra/stacks/web-vite-react/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/web-vite-react/claude/skills/design-component/SKILL.md +100 -0
- package/template/.orchestra/stacks/web-vite-react/claude/skills/design-tokens/SKILL.md +61 -0
- package/template/.orchestra/stacks/web-vite-react/setup.sh +185 -0
- package/template/.orchestra/stacks/web-vite-react/stack.md +177 -0
- package/template/.orchestra/trackers/github.sh +467 -0
- package/template/CLAUDE.md +54 -0
- package/template/agents/architect.md +83 -0
- package/template/agents/engineer-high.md +17 -0
- package/template/agents/engineer-low.md +17 -0
- package/template/agents/engineer-max.md +17 -0
- package/template/agents/engineer-medium.md +17 -0
- package/template/agents/engineer-xhigh.md +17 -0
- package/template/agents/lead.md +925 -0
- package/template/agents/milestone-reviewer.md +65 -0
- package/template/agents/orchestrator.md +105 -0
- package/template/agents/product-manager.md +439 -0
- package/template/agents/re-reviewer.md +25 -0
- package/template/agents/reviewer.md +303 -0
- package/template/agents/scaffolder.md +206 -0
- package/template/agents/tracker.md +202 -0
- package/template/commands/blueprint.md +23 -0
- package/template/commands/create-role.md +64 -0
- package/template/commands/export.md +45 -0
- package/template/commands/help.md +56 -0
- package/template/commands/hotfix.md +14 -0
- package/template/commands/orchestrator.md +12 -0
- package/template/commands/pm.md +18 -0
- package/template/commands/rewind.md +63 -0
- package/template/commands/setup.md +15 -0
- package/template/commands/start.md +29 -0
- package/template/commands/status.md +11 -0
- package/template/commands/sync.md +76 -0
- package/template/commands/verifier.md +53 -0
- package/template/hooks/audit-context.sh +166 -0
- package/template/hooks/enforce-role.sh +193 -0
- package/template/hooks/protect-system-files.sh +123 -0
- package/template/hooks/review-rounds.sh +172 -0
- package/template/rules/acceptance-check.orchestra.md +11 -0
- package/template/rules/analytics-kpi.orchestra.md +56 -0
- package/template/rules/code-standards.orchestra.md +35 -0
- package/template/rules/comments.orchestra.md +77 -0
- package/template/rules/commit-format.orchestra.md +20 -0
- package/template/rules/dependency-upgrade.orchestra.md +112 -0
- package/template/rules/design-parity.orchestra.md +181 -0
- package/template/rules/observability.orchestra.md +121 -0
- package/template/rules/performance.orchestra.md +190 -0
- package/template/rules/phase-limits.orchestra.md +3 -0
- package/template/rules/pull-request.orchestra.md +218 -0
- package/template/rules/role-boundaries.orchestra.md +132 -0
- package/template/rules/security.orchestra.md +106 -0
- package/template/rules/stuck-detection.orchestra.md +25 -0
- package/template/rules/testing-standards.orchestra.md +219 -0
- package/template/rules/tracker-authority.orchestra.md +198 -0
- package/template/rules/verification-gate.orchestra.md +56 -0
- package/template/skills/accessibility/SKILL.md +31 -0
- package/template/skills/android-testing/SKILL.md +112 -0
- package/template/skills/architecture/SKILL.md +175 -0
- package/template/skills/auth-setup/SKILL.md +105 -0
- package/template/skills/best-practices/SKILL.md +29 -0
- package/template/skills/code-optimizer/SKILL.md +27 -0
- package/template/skills/core-web-vitals/SKILL.md +32 -0
- package/template/skills/crud-api/SKILL.md +32 -0
- package/template/skills/debug/SKILL.md +28 -0
- package/template/skills/deployment/SKILL.md +104 -0
- package/template/skills/error-handling/SKILL.md +154 -0
- package/template/skills/frontend-design/SKILL.md +30 -0
- package/template/skills/fullstack-infrastructure/SKILL.md +108 -0
- package/template/skills/ios-testing/SKILL.md +100 -0
- package/template/skills/mobile-testing/SKILL.md +102 -0
- package/template/skills/observability/SKILL.md +91 -0
- package/template/skills/project-structure/SKILL.md +114 -0
- package/template/skills/push-notifications/SKILL.md +146 -0
- package/template/skills/react-best-practices/SKILL.md +31 -0
- package/template/skills/realtime/SKILL.md +146 -0
- package/template/skills/review/SKILL.md +82 -0
- package/template/skills/testing/SKILL.md +45 -0
- package/template/skills/web-quality-audit/SKILL.md +47 -0
- package/template/skills/web-testing/SKILL.md +120 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Verify that implemented milestones match their requirements. PM role only.
|
|
2
|
+
|
|
3
|
+
**Usage:**
|
|
4
|
+
- `/orchestra:verifier` — verify all `done` milestones
|
|
5
|
+
- `/orchestra:verifier 1,2,3` — verify only specified milestone numbers
|
|
6
|
+
|
|
7
|
+
1. Read `.orchestra/roles/product-manager.md` to activate PM.
|
|
8
|
+
2. Scan `.orchestra/milestones/` using Glob + Read tools (NOT bash scripts) — collect milestones to verify:
|
|
9
|
+
- No arguments: all milestones with `status: done`
|
|
10
|
+
- With numbers: only milestones matching those numbers (e.g., `1` matches `M1-*`)
|
|
11
|
+
3. For each milestone, read:
|
|
12
|
+
- `prd.md` — product requirements and acceptance criteria
|
|
13
|
+
- `rfc.md` — technical design decisions (if exists)
|
|
14
|
+
- `milestone.md` — summary and acceptance criteria
|
|
15
|
+
- `grooming.md` — scope decisions and phase breakdown
|
|
16
|
+
- All `phases/*.md` — phase acceptance criteria
|
|
17
|
+
4. For each milestone, read execution context:
|
|
18
|
+
- `decisions.md` — why specific approaches were chosen, and which were later closed
|
|
19
|
+
- `journal.md` — rejected alternatives, useful when a requirement shows as "deviated"
|
|
20
|
+
- `context.md` — `## Phases` section (which phases completed, which failed)
|
|
21
|
+
5. For each milestone, read the actual implementation:
|
|
22
|
+
- Run `git log --oneline` filtered to commits from that milestone's phases
|
|
23
|
+
- Run `git diff` for those commits, piped through `.orchestra/scripts/compact-diff.awk`, to see what changed
|
|
24
|
+
- Read the current state of modified files — diff shows changes, but current code shows completeness
|
|
25
|
+
6. Compare requirements vs implementation. For each requirement/acceptance criterion:
|
|
26
|
+
- **met** — implementation satisfies the requirement
|
|
27
|
+
- **partial** — partially implemented, missing aspects noted
|
|
28
|
+
- **missed** — not implemented at all
|
|
29
|
+
- **deviated** — implemented differently than specified
|
|
30
|
+
6. Report:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
## Verification: M1-user-auth
|
|
34
|
+
|
|
35
|
+
### Requirements Coverage
|
|
36
|
+
- ✅ met: JWT authentication endpoint (phase-1, commit abc123)
|
|
37
|
+
- ⚠️ partial: Rate limiting — implemented but no Redis backing (phase-2)
|
|
38
|
+
- ❌ missed: Password reset flow — not in any commit
|
|
39
|
+
- 🔀 deviated: Token refresh — RFC said rotating tokens, implemented static expiry
|
|
40
|
+
|
|
41
|
+
### Summary
|
|
42
|
+
4 requirements: 1 met, 1 partial, 1 missed, 1 deviated
|
|
43
|
+
|
|
44
|
+
### Severity
|
|
45
|
+
- 🔴 critical: Password reset flow missing (core auth feature)
|
|
46
|
+
- 🟡 moderate: Rate limiting without Redis (works but won't scale)
|
|
47
|
+
- 🟡 moderate: Token refresh deviation (security concern)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
8. After reporting all milestones, if there are critical or moderate gaps:
|
|
51
|
+
- List gaps grouped by severity
|
|
52
|
+
- Suggest: "Use `/orchestra:pm` to plan fix milestones for these gaps."
|
|
53
|
+
- Do NOT create milestones directly — PM decides scope and priority
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Two hook registrations share this one file — PreCompact writes, SessionStart
|
|
3
|
+
# reads — because the finding they pass between them has to live somewhere in
|
|
4
|
+
# between, and that somewhere is the only thing the two halves share: audit()
|
|
5
|
+
# itself runs on PreCompact alone. A mechanical check that a milestone's
|
|
6
|
+
# context.md is internally consistent, run right where a human stops watching
|
|
7
|
+
# and a lead session risks losing the thread.
|
|
8
|
+
#
|
|
9
|
+
# PreCompact cannot block compaction — it is not in Claude Code's exit-code-2
|
|
10
|
+
# table — and whether its own additionalContext reaches the model afterward
|
|
11
|
+
# is undocumented. So this hook never tries to stop or steer compaction: on
|
|
12
|
+
# PreCompact it only writes findings to a per-session scratch file.
|
|
13
|
+
# SessionStart's "compact" matcher is documented and fires right after
|
|
14
|
+
# compaction lands, so that is where the findings actually reach the model —
|
|
15
|
+
# this is advisory, never a block, and it never writes to context.md itself:
|
|
16
|
+
# status stays lead's to advance and the human's to move, per
|
|
17
|
+
# tracker-authority.orchestra.md.
|
|
18
|
+
#
|
|
19
|
+
# The scratch file is keyed by session_id, the same way enforce-role.sh keys
|
|
20
|
+
# its role marker: two terminals are two sessions, and a single shared file
|
|
21
|
+
# would let one session's compaction findings surface on the other's
|
|
22
|
+
# SessionStart — a real cross-session leak in the two-terminal model this
|
|
23
|
+
# project runs by design.
|
|
24
|
+
#
|
|
25
|
+
# Reads the hook call on stdin. Always exits 0.
|
|
26
|
+
set -euo pipefail
|
|
27
|
+
|
|
28
|
+
input="$(cat)"
|
|
29
|
+
|
|
30
|
+
printf '%s' "$input" | CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" node -e '
|
|
31
|
+
const fs = require("node:fs");
|
|
32
|
+
const path = require("node:path");
|
|
33
|
+
|
|
34
|
+
// Advisory findings are stale past this; a run that never reached
|
|
35
|
+
// SessionStart (crash, a long gap before the next prompt) should not
|
|
36
|
+
// surface an old result as if it just happened. Not project-configurable
|
|
37
|
+
// like thresholds.stuck_retry_limit: this is a display window for
|
|
38
|
+
// informational text, not a gate anything can fail.
|
|
39
|
+
const STALE_MS = 10 * 60 * 1000;
|
|
40
|
+
|
|
41
|
+
let s = "";
|
|
42
|
+
process.stdin.on("data", (d) => (s += d)).on("end", () => {
|
|
43
|
+
let call;
|
|
44
|
+
try {
|
|
45
|
+
call = JSON.parse(s);
|
|
46
|
+
} catch {
|
|
47
|
+
// Optional by design: an unparseable call is not ours to judge.
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const session = String(call.session_id || "").replace(/[^A-Za-z0-9._-]/g, "");
|
|
52
|
+
if (!session) return;
|
|
53
|
+
|
|
54
|
+
const root = process.env.CLAUDE_PROJECT_DIR;
|
|
55
|
+
const auditPath = path.join(root, ".orchestra", ".sessions", `${session}.context-audit.json`);
|
|
56
|
+
const milestonesDir = path.join(root, ".orchestra", "milestones");
|
|
57
|
+
|
|
58
|
+
function audit() {
|
|
59
|
+
const findings = [];
|
|
60
|
+
const open = [];
|
|
61
|
+
let entries = [];
|
|
62
|
+
try {
|
|
63
|
+
entries = fs.readdirSync(milestonesDir, { withFileTypes: true });
|
|
64
|
+
} catch {
|
|
65
|
+
return { findings, open };
|
|
66
|
+
}
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
if (!entry.isDirectory()) continue;
|
|
69
|
+
let text;
|
|
70
|
+
try {
|
|
71
|
+
text = fs.readFileSync(path.join(milestonesDir, entry.name, "context.md"), "utf8");
|
|
72
|
+
} catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// Anchored to a line start (not a stray "### Phases" or a mention
|
|
76
|
+
// inside prose) via lookaround rather than the "m" flag, which would
|
|
77
|
+
// also redefine "$" to mean end-of-line and cut the capture short at
|
|
78
|
+
// the very first line break instead of the next real section.
|
|
79
|
+
const section = /(?:^|\r?\n)## Phases\r?\n([\s\S]*?)(?=\r?\n## |$)/.exec(text);
|
|
80
|
+
const body = section ? section[1] : "";
|
|
81
|
+
for (const line of body.split(/\r?\n/)) {
|
|
82
|
+
const m = /^-\s*(\S+):\s*(done|in-progress|failed|pending|blocked)\b/i.exec(line.trim());
|
|
83
|
+
if (!m) continue;
|
|
84
|
+
const [, phase, statusRaw] = m;
|
|
85
|
+
const status = statusRaw.toLowerCase();
|
|
86
|
+
if (status === "done" && !/commit:\s*\S+/.test(line)) {
|
|
87
|
+
findings.push(`${entry.name}/${phase}: marked done with no commit hash`);
|
|
88
|
+
} else if (status !== "done") {
|
|
89
|
+
open.push(`${entry.name}/${phase}: ${status}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// decisions.md: one source for what a valid line is — the script that
|
|
94
|
+
// writes it — rather than a second parser here that drifts from it.
|
|
95
|
+
const script = path.join(root, ".orchestra", "scripts", "context.mjs");
|
|
96
|
+
if (fs.existsSync(script)) {
|
|
97
|
+
try {
|
|
98
|
+
require("node:child_process").execFileSync(process.execPath, [script, "check"], {
|
|
99
|
+
cwd: root,
|
|
100
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
101
|
+
});
|
|
102
|
+
} catch (e) {
|
|
103
|
+
for (const f of String(e.stdout || "").split("\n").filter(Boolean)) findings.push(f);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { findings, open };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (call.hook_event_name === "PreCompact") {
|
|
110
|
+
const result = audit();
|
|
111
|
+
result.at = new Date().toISOString();
|
|
112
|
+
try {
|
|
113
|
+
const sessionsDir = path.dirname(auditPath);
|
|
114
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
115
|
+
// The installer writes this once at setup time, but this hook must
|
|
116
|
+
// not depend on that having happened — the directory it just
|
|
117
|
+
// created above needs its own ignore rule, not an assumption that
|
|
118
|
+
// setup already put one there.
|
|
119
|
+
const ignorePath = path.join(sessionsDir, ".gitignore");
|
|
120
|
+
if (!fs.existsSync(ignorePath)) fs.writeFileSync(ignorePath, "*\n!.gitignore\n");
|
|
121
|
+
fs.writeFileSync(auditPath, JSON.stringify(result));
|
|
122
|
+
} catch {
|
|
123
|
+
// Advisory only: a write failure here is not worth surfacing.
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The "compact" filter lives in settings.json'"'"'s matcher for this
|
|
129
|
+
// registration, not here: the exact stdin field naming that trigger is
|
|
130
|
+
// undocumented, so this branch trusts the registration to have already
|
|
131
|
+
// scoped it rather than re-checking a field name that might be wrong.
|
|
132
|
+
if (call.hook_event_name === "SessionStart") {
|
|
133
|
+
let result;
|
|
134
|
+
try {
|
|
135
|
+
result = JSON.parse(fs.readFileSync(auditPath, "utf8"));
|
|
136
|
+
} catch {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// A missing or unparseable timestamp must read as stale, not as
|
|
140
|
+
// "not old enough to discard" — Date.parse(undefined) is NaN, and
|
|
141
|
+
// NaN > STALE_MS is false, which would otherwise treat a corrupted
|
|
142
|
+
// write as always-fresh.
|
|
143
|
+
const at = result.at ? Date.parse(result.at) : Number.NaN;
|
|
144
|
+
if (Number.isNaN(at) || Date.now() - at > STALE_MS) return;
|
|
145
|
+
|
|
146
|
+
const lines = [];
|
|
147
|
+
if (result.findings && result.findings.length) {
|
|
148
|
+
lines.push("Context audit after compaction — the milestone record has unresolved defects:");
|
|
149
|
+
for (const f of result.findings) lines.push(` - ${f}`);
|
|
150
|
+
}
|
|
151
|
+
if (result.open && result.open.length) {
|
|
152
|
+
lines.push("Open phases across milestones:");
|
|
153
|
+
for (const o of result.open) lines.push(` - ${o}`);
|
|
154
|
+
}
|
|
155
|
+
if (!lines.length) return;
|
|
156
|
+
|
|
157
|
+
// Native Claude Code reads SessionStart'"'"'s stdout as plain text, not the
|
|
158
|
+
// hookSpecificOutput JSON form other hosts (Codex, Qoder) need for it —
|
|
159
|
+
// confirmed against a production implementation (ponytail'"'"'s
|
|
160
|
+
// hooks/ponytail-runtime.js), since the official docs never showed a
|
|
161
|
+
// literal example either way.
|
|
162
|
+
process.stdout.write(lines.join("\n"));
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
'
|
|
166
|
+
exit 0
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# PreToolUse guard: a session that declared a role stays inside it.
|
|
3
|
+
#
|
|
4
|
+
# `.claude/rules/role-boundaries.orchestra.md` says the PM writes only under
|
|
5
|
+
# `.orchestra/milestones/` and never opens a branch or a pull request. That
|
|
6
|
+
# was prose, and a PM opened a pull request anyway. A rule nothing checks is
|
|
7
|
+
# advice.
|
|
8
|
+
#
|
|
9
|
+
# The hook could not know the role — a slash command is a markdown prompt and
|
|
10
|
+
# exports nothing. So the role command has the agent run one Bash line,
|
|
11
|
+
# `true # orchestra-role=product-manager`, and this hook, which sees every
|
|
12
|
+
# Bash call together with the session id Claude Code hands it, records the
|
|
13
|
+
# pair under `.orchestra/.sessions/`. Every later call in that session is
|
|
14
|
+
# judged against the recorded role. Two terminals are two sessions, so the
|
|
15
|
+
# PM's marker never applies to lead.
|
|
16
|
+
#
|
|
17
|
+
# Lead is held to one rule: phase work starts inside a milestone agent.
|
|
18
|
+
#
|
|
19
|
+
# No marker means no role — discussion mode — and nothing is blocked.
|
|
20
|
+
#
|
|
21
|
+
# Reads the tool call on stdin. Exit 0 allows, exit 2 blocks.
|
|
22
|
+
set -euo pipefail
|
|
23
|
+
|
|
24
|
+
input="$(cat)"
|
|
25
|
+
|
|
26
|
+
verdict="$(printf '%s' "$input" | CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" node -e '
|
|
27
|
+
const fs = require("node:fs");
|
|
28
|
+
const path = require("node:path");
|
|
29
|
+
const { execFileSync } = require("node:child_process");
|
|
30
|
+
|
|
31
|
+
let s = "";
|
|
32
|
+
process.stdin.on("data", (d) => (s += d)).on("end", () => {
|
|
33
|
+
let call;
|
|
34
|
+
try {
|
|
35
|
+
call = JSON.parse(s);
|
|
36
|
+
} catch {
|
|
37
|
+
// Optional by design: an unparseable call is not ours to judge.
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const session = String(call.session_id || "").replace(/[^A-Za-z0-9._-]/g, "");
|
|
41
|
+
if (!session) return;
|
|
42
|
+
const tool = call.tool_name || "";
|
|
43
|
+
const input = call.tool_input || {};
|
|
44
|
+
|
|
45
|
+
let root;
|
|
46
|
+
try {
|
|
47
|
+
root = execFileSync("git", ["-C", process.env.CLAUDE_PROJECT_DIR, "rev-parse", "--show-toplevel"], {
|
|
48
|
+
encoding: "utf8",
|
|
49
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
50
|
+
}).trim();
|
|
51
|
+
} catch {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!root) return;
|
|
55
|
+
// Both sides through realpath: on macOS a temp dir is /var/… while git
|
|
56
|
+
// answers /private/var/…, and path.relative between them says "outside".
|
|
57
|
+
const real = (p) => { try { return fs.realpathSync(p); } catch { return p; } };
|
|
58
|
+
root = real(root);
|
|
59
|
+
const projectDir = real(process.env.CLAUDE_PROJECT_DIR);
|
|
60
|
+
// A path that does not exist yet — a file about to be written — is
|
|
61
|
+
// resolved through its nearest existing ancestor, so an absolute path
|
|
62
|
+
// spelled with the unresolved prefix still lands inside the repo.
|
|
63
|
+
const relTo = (p) => {
|
|
64
|
+
let abs = path.resolve(projectDir, p);
|
|
65
|
+
let head = abs;
|
|
66
|
+
let tail = "";
|
|
67
|
+
while (!fs.existsSync(head) && path.dirname(head) !== head) {
|
|
68
|
+
tail = path.join(path.basename(head), tail);
|
|
69
|
+
head = path.dirname(head);
|
|
70
|
+
}
|
|
71
|
+
abs = path.join(real(head), tail);
|
|
72
|
+
return path.relative(root, abs);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const dir = path.join(root, ".orchestra", ".sessions");
|
|
76
|
+
const marker = path.join(dir, session);
|
|
77
|
+
|
|
78
|
+
// The declaration. Recorded, then allowed through.
|
|
79
|
+
if (tool === "Bash") {
|
|
80
|
+
const m = /orchestra-role=(product-manager|orchestrator|lead)\b/.exec(input.command || "");
|
|
81
|
+
if (m) {
|
|
82
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
83
|
+
fs.writeFileSync(marker, m[1]);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let role;
|
|
89
|
+
try {
|
|
90
|
+
role = fs.readFileSync(marker, "utf8").trim();
|
|
91
|
+
} catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// ── Lead ─────────────────────────────────────────────────────────────
|
|
95
|
+
// Phase work runs inside a milestone agent, so each milestone'"'"'s context is
|
|
96
|
+
// dropped when it ends. One run kept two milestones in the lead session
|
|
97
|
+
// instead: 738 calls, context up to 925k tokens. A call from a sub-agent
|
|
98
|
+
// carries agent_id; one from the lead session itself does not.
|
|
99
|
+
if (role === "lead") {
|
|
100
|
+
if (tool !== "Agent" || call.agent_id) return;
|
|
101
|
+
const type = input.subagent_type || "";
|
|
102
|
+
if (!/^(engineer-\w+|reviewer|re-reviewer)$/.test(type)) return;
|
|
103
|
+
if (/^engineer-/.test(type) && /^\s*Hotfix:/.test(String(input.prompt || ""))) return;
|
|
104
|
+
process.stdout.write(
|
|
105
|
+
`Lead runs phase work inside a milestone agent, not in its own session — launch a general-purpose agent with the Milestone Agent or Single-Phase Agent prompt in .claude/agents/lead.md, and let it start ${type}. A hotfix engineer'"'"'s prompt starts with a line "Hotfix: <description>".\n`,
|
|
106
|
+
);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (role !== "product-manager") return;
|
|
111
|
+
|
|
112
|
+
// ── The PM ───────────────────────────────────────────────────────────
|
|
113
|
+
// Writes milestones. Commits them to delivery.base_branch and pushes. Nothing else.
|
|
114
|
+
const deny = (why) => {
|
|
115
|
+
process.stdout.write(`Outside the PM'"'"'s scope: ${why}\n`);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (tool === "Write" || tool === "Edit") {
|
|
119
|
+
const file = input.file_path || input.path || "";
|
|
120
|
+
if (!file) return;
|
|
121
|
+
const rel = relTo(file);
|
|
122
|
+
if (rel.startsWith("..")) return deny(`${file} is outside the repository`);
|
|
123
|
+
if (!rel.startsWith(".orchestra/milestones/")) {
|
|
124
|
+
return deny(`the PM writes only under .orchestra/milestones/ — ${rel} is not there. Say what needs changing and which command can do it.`);
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (tool === "Workflow") {
|
|
130
|
+
return deny("the PM never runs a workflow. A code review is lead'"'"'s, on the pull request; a plan review is the milestone-reviewer agent.");
|
|
131
|
+
}
|
|
132
|
+
if (tool === "Agent") {
|
|
133
|
+
const type = input.subagent_type || "";
|
|
134
|
+
if (type !== "milestone-reviewer" && type !== "tracker") {
|
|
135
|
+
return deny(`the PM launches only milestone-reviewer and tracker — not ${type || "a general-purpose agent"}. Reviewing code, building, and scouting are lead'"'"'s.`);
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (/clickup_(create|update)_task$/.test(tool)) {
|
|
140
|
+
const status = String(input.status ?? "").trim();
|
|
141
|
+
if (!status) return;
|
|
142
|
+
// The tracker agent runs inside the PM'"'"'s session and lands a new task
|
|
143
|
+
// in backlog — the one status write the PM side is allowed. Read the
|
|
144
|
+
// board'"'"'s own name for it from config, compared case-insensitively.
|
|
145
|
+
let backlog = "backlog";
|
|
146
|
+
try {
|
|
147
|
+
const cfg = fs.readFileSync(path.join(root, ".orchestra", "config.yml"), "utf8");
|
|
148
|
+
const m = /^\s*backlog:\s*["'"'"']?([^"'"'"'#\n]+)/m.exec(cfg);
|
|
149
|
+
if (m) backlog = m[1].trim();
|
|
150
|
+
} catch {
|
|
151
|
+
// No config: the default name is the only name we know.
|
|
152
|
+
}
|
|
153
|
+
if (status.toLowerCase() === backlog.toLowerCase()) return;
|
|
154
|
+
return deny(`the PM never sets a task'"'"'s status — export lands it in backlog, lead advances it, the human moves it. Drop status: ${JSON.stringify(status)}.`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (tool !== "Bash") return;
|
|
158
|
+
const cmd = input.command || "";
|
|
159
|
+
|
|
160
|
+
if (/\bgh\s+pr\s+(create|merge|edit|close|ready|checkout)\b/.test(cmd)) {
|
|
161
|
+
return deny("the PM never opens, edits or merges a pull request. Milestone files go to delivery.base_branch directly; code is lead'"'"'s.");
|
|
162
|
+
}
|
|
163
|
+
if (/\bgit\s+(checkout|switch)\s+(-b|-c|--create|-B)\b/.test(cmd)) {
|
|
164
|
+
return deny("the PM never creates a branch. Milestone files are committed to delivery.base_branch.");
|
|
165
|
+
}
|
|
166
|
+
if (/\bgit\s+(merge|rebase|cherry-pick|reset\s+--hard)\b/.test(cmd)) {
|
|
167
|
+
return deny("the PM never rewrites history or merges.");
|
|
168
|
+
}
|
|
169
|
+
if (/\bgit\s+push\b.*(\s--force\b|\s-f\b|\s--force-with-lease\b)/.test(cmd)) {
|
|
170
|
+
return deny("the PM never force-pushes.");
|
|
171
|
+
}
|
|
172
|
+
if (/\bgit\s+commit\b[^&|;]*\s(?:-[a-zA-Z]*a[a-zA-Z]*|--all)\b/.test(cmd)) {
|
|
173
|
+
return deny("`git commit -a` stages everything. Add .orchestra/milestones/ explicitly, then commit.");
|
|
174
|
+
}
|
|
175
|
+
const add = /\bgit\s+add\s+(.+?)(?:\s*(?:&&|\|\||;|$))/.exec(cmd);
|
|
176
|
+
if (add) {
|
|
177
|
+
const args = add[1].split(/\s+/).filter((a) => a && !a.startsWith("-"));
|
|
178
|
+
const outside = args.filter((a) => {
|
|
179
|
+
const rel = relTo(a.replace(/^["'"'"']|["'"'"']$/g, ""));
|
|
180
|
+
return !rel.startsWith(".orchestra/milestones");
|
|
181
|
+
});
|
|
182
|
+
if (args.length === 0 || outside.length) {
|
|
183
|
+
return deny(`the PM stages only .orchestra/milestones/ — not ${outside.join(", ") || "-A / ."}.`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
')"
|
|
188
|
+
|
|
189
|
+
if [ -n "$verdict" ]; then
|
|
190
|
+
printf '%s\n' "$verdict" >&2
|
|
191
|
+
exit 2
|
|
192
|
+
fi
|
|
193
|
+
exit 0
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# PreToolUse guard: never edit installed Orchestra system files in place.
|
|
3
|
+
#
|
|
4
|
+
# `.claude/rules/role-boundaries.orchestra.md` lists files no project role
|
|
5
|
+
# owns. The check is not "which role is running": a slash command is a
|
|
6
|
+
# markdown prompt and cannot export anything a hook could read, so any guard
|
|
7
|
+
# built on that premise would never fire. The reliable question is *which repo
|
|
8
|
+
# this is*, and then *did Orchestra put this file here*.
|
|
9
|
+
#
|
|
10
|
+
# The second half used to be a list of globs, which was wrong in both
|
|
11
|
+
# directions. It blocked `.orchestra/config.yml`, which the installer merges
|
|
12
|
+
# rather than overwrites and which `/orchestra:setup` is documented to write.
|
|
13
|
+
# It blocked every `.claude/skills/*/SKILL.md`, including the ones
|
|
14
|
+
# docs/skills.md tells the user to create, and including a stack's own
|
|
15
|
+
# `apps/<name>/.claude/` pack. And a relative file_path matched none of the
|
|
16
|
+
# patterns, so it sailed straight through.
|
|
17
|
+
#
|
|
18
|
+
# `.orchestra/.installed.json` records what was installed, so the question has
|
|
19
|
+
# an answer instead of an approximation.
|
|
20
|
+
#
|
|
21
|
+
# Reads the tool call on stdin. Exit 0 allows, exit 2 blocks.
|
|
22
|
+
set -euo pipefail
|
|
23
|
+
|
|
24
|
+
input="$(cat)"
|
|
25
|
+
|
|
26
|
+
blocked="$(printf '%s' "$input" | CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" node -e '
|
|
27
|
+
const fs = require("node:fs");
|
|
28
|
+
const path = require("node:path");
|
|
29
|
+
const { execFileSync } = require("node:child_process");
|
|
30
|
+
|
|
31
|
+
let s = "";
|
|
32
|
+
process.stdin.on("data", (d) => (s += d)).on("end", () => {
|
|
33
|
+
let file;
|
|
34
|
+
try {
|
|
35
|
+
const i = JSON.parse(s).tool_input || {};
|
|
36
|
+
file = i.file_path || i.path || "";
|
|
37
|
+
} catch {
|
|
38
|
+
// Optional by design: an unparseable call is not ours to judge.
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (!file) return;
|
|
42
|
+
|
|
43
|
+
// A relative path used to match no pattern at all, which made the guard
|
|
44
|
+
// depend on how the caller happened to spell it.
|
|
45
|
+
const abs = path.resolve(process.env.CLAUDE_PROJECT_DIR, file);
|
|
46
|
+
|
|
47
|
+
let root;
|
|
48
|
+
try {
|
|
49
|
+
root = execFileSync("git", ["-C", path.dirname(abs), "rev-parse", "--show-toplevel"], {
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
52
|
+
}).trim();
|
|
53
|
+
} catch {
|
|
54
|
+
// Not in a repository: nothing here is an installed copy.
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!root) return;
|
|
58
|
+
|
|
59
|
+
// The agentic-team repo is where these files are source. That is a fact
|
|
60
|
+
// about the tree, not about who is asking.
|
|
61
|
+
if (fs.existsSync(path.join(root, "bin/build-template.js"))) return;
|
|
62
|
+
|
|
63
|
+
// git reports the real path and path.resolve does not, so on any system
|
|
64
|
+
// where the tree sits under a symlink — /var on macOS, a linked worktree
|
|
65
|
+
// — the two disagree and every comparison below silently misses.
|
|
66
|
+
let existing = path.dirname(abs);
|
|
67
|
+
while (!fs.existsSync(existing) && existing !== path.dirname(existing)) {
|
|
68
|
+
existing = path.dirname(existing);
|
|
69
|
+
}
|
|
70
|
+
const real = path.join(fs.realpathSync(existing), path.relative(existing, abs));
|
|
71
|
+
const rel = path.relative(fs.realpathSync(root), real).split(path.sep).join("/");
|
|
72
|
+
if (rel.startsWith("..")) return;
|
|
73
|
+
|
|
74
|
+
// config.yml is merged on upgrade, so an edit survives; setup writes its
|
|
75
|
+
// verification keys, which .orchestra/README.md documents.
|
|
76
|
+
if (rel === ".orchestra/config.yml") return;
|
|
77
|
+
|
|
78
|
+
const manifestPath = path.join(root, ".orchestra/.installed.json");
|
|
79
|
+
if (fs.existsSync(manifestPath)) {
|
|
80
|
+
let files = null;
|
|
81
|
+
try {
|
|
82
|
+
files = JSON.parse(fs.readFileSync(manifestPath, "utf8")).files;
|
|
83
|
+
} catch {
|
|
84
|
+
// Fall through to the patterns below rather than allowing everything.
|
|
85
|
+
}
|
|
86
|
+
if (Array.isArray(files)) {
|
|
87
|
+
if (files.includes(rel)) process.stdout.write(abs);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Installed before the manifest existed. Approximate, and deliberately
|
|
93
|
+
// narrower than the old list: skills and roles are left out because
|
|
94
|
+
// without a manifest a users own cannot be told from a shipped one, and
|
|
95
|
+
// blocking theirs is the worse mistake.
|
|
96
|
+
const legacy = [
|
|
97
|
+
/^\.orchestra\/stacks\//,
|
|
98
|
+
/^\.orchestra\/README\.md$/,
|
|
99
|
+
/^\.claude\/agents\//,
|
|
100
|
+
/^\.claude\/rules\/.*\.orchestra\.md$/,
|
|
101
|
+
/^\.claude\/commands\/orchestra\//,
|
|
102
|
+
];
|
|
103
|
+
if (legacy.some((re) => re.test(rel))) process.stdout.write(abs);
|
|
104
|
+
});
|
|
105
|
+
' 2>/dev/null || true)"
|
|
106
|
+
|
|
107
|
+
[ -n "$blocked" ] || exit 0
|
|
108
|
+
|
|
109
|
+
cat >&2 <<MSG
|
|
110
|
+
Blocked: ${blocked}
|
|
111
|
+
|
|
112
|
+
This is an installed Orchestra system file — .orchestra/.installed.json says
|
|
113
|
+
Orchestra put it there. Editing it here is lost work: the next upgrade
|
|
114
|
+
overwrites it, and the next project meets the same problem you were about to
|
|
115
|
+
fix.
|
|
116
|
+
|
|
117
|
+
Change it in the agentic-team repo and reinstall. If it is genuinely
|
|
118
|
+
project-specific, it belongs in the app's own CLAUDE.md, a project skill of
|
|
119
|
+
your own, or .orchestra/config.yml — none of which is overwritten.
|
|
120
|
+
|
|
121
|
+
See .claude/rules/role-boundaries.orchestra.md.
|
|
122
|
+
MSG
|
|
123
|
+
exit 2
|