@damian87/omp 0.9.2 → 0.12.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/.github/copilot-instructions.md +16 -0
- package/.github/skills/create-skill/SKILL.md +3 -3
- package/.github/skills/daily-log/SKILL.md +1 -1
- package/.github/skills/jira-ticket/SKILL.md +6 -3
- package/.github/skills/omp-autopilot/SKILL.md +5 -1
- package/.github/skills/ralph/SKILL.md +6 -4
- package/.github/skills/research-codebase/SKILL.md +10 -6
- package/.github/skills/research-codebase/reference/agent-prompts.md +8 -8
- package/.github/skills/schedule/SKILL.md +4 -0
- package/.github/skills/slack/SKILL.md +1 -0
- package/.github/skills/team/SKILL.md +9 -3
- package/.github/skills/team/scripts/team-launch.sh +11 -5
- package/.github/skills/ultraqa/SKILL.md +5 -2
- package/.github/skills/ultrawork/SKILL.md +9 -5
- package/.github/skills/weighted-consensus/SKILL.md +8 -3
- package/README.md +4 -1
- package/dist/src/cli.js +10 -1
- package/dist/src/cli.js.map +1 -1
- package/dist/src/comms/index.d.ts +13 -6
- package/dist/src/comms/index.js +65 -10
- package/dist/src/comms/index.js.map +1 -1
- package/dist/src/copilot/doctor.d.ts +1 -0
- package/dist/src/copilot/doctor.js +242 -8
- package/dist/src/copilot/doctor.js.map +1 -1
- package/dist/src/copilot/env-passthrough.d.ts +16 -0
- package/dist/src/copilot/env-passthrough.js +28 -0
- package/dist/src/copilot/env-passthrough.js.map +1 -0
- package/dist/src/copilot/launch.js +22 -6
- package/dist/src/copilot/launch.js.map +1 -1
- package/dist/src/copilot/setup.js +13 -0
- package/dist/src/copilot/setup.js.map +1 -1
- package/dist/src/copilot/trust.d.ts +22 -0
- package/dist/src/copilot/trust.js +62 -0
- package/dist/src/copilot/trust.js.map +1 -0
- package/dist/src/cost/index.d.ts +3 -0
- package/dist/src/cost/index.js +4 -0
- package/dist/src/cost/index.js.map +1 -0
- package/dist/src/cost/ledger.d.ts +21 -0
- package/dist/src/cost/ledger.js +72 -0
- package/dist/src/cost/ledger.js.map +1 -0
- package/dist/src/cost/summary.d.ts +22 -0
- package/dist/src/cost/summary.js +68 -0
- package/dist/src/cost/summary.js.map +1 -0
- package/dist/src/cost/tokenize.d.ts +7 -0
- package/dist/src/cost/tokenize.js +24 -0
- package/dist/src/cost/tokenize.js.map +1 -0
- package/dist/src/gateway/notify.d.ts +1 -1
- package/dist/src/gateway/notify.js +1 -1
- package/dist/src/gateway/notify.js.map +1 -1
- package/dist/src/instructions-memory.js +7 -6
- package/dist/src/instructions-memory.js.map +1 -1
- package/dist/src/team/tmux.d.ts +1 -1
- package/dist/src/team/tmux.js +41 -10
- package/dist/src/team/tmux.js.map +1 -1
- package/docs/general-skills.md +1 -0
- package/docs/plans/copilot-native-hooks.md +119 -0
- package/docs/plans/verification-plan.md +104 -0
- package/hooks/hooks.json +59 -72
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/agent-stop.mjs +87 -0
- package/scripts/error.mjs +9 -7
- package/scripts/lib/cost-ledger.mjs +91 -0
- package/scripts/lib/hook-input.mjs +51 -0
- package/scripts/lib/hook-output.mjs +77 -6
- package/scripts/lib/loop-driver.mjs +44 -0
- package/scripts/lib/minify.mjs +80 -0
- package/scripts/post-tool-use-failure.mjs +21 -0
- package/scripts/post-tool-use.mjs +71 -8
- package/scripts/pre-tool-use.mjs +8 -6
- package/scripts/prompt-submit.mjs +14 -9
- package/scripts/session-end.mjs +7 -5
- package/scripts/session-start.mjs +8 -11
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Verification Plan — PR #31 (skill audit fixes + Copilot-native hooks)
|
|
2
|
+
|
|
3
|
+
> Purpose: prove every change in PR #31 actually works against a real Copilot CLI
|
|
4
|
+
> session. Split into what is ALREADY verified (no re-run needed) and the DEFERRED
|
|
5
|
+
> live checks that need Copilot model quota (Free tier resets Jul 1; or use Pro).
|
|
6
|
+
|
|
7
|
+
## Legend
|
|
8
|
+
- ✅ done = already executed and green this cycle (do not redo)
|
|
9
|
+
- ⏳ deferred = needs Copilot model quota; run when available
|
|
10
|
+
|
|
11
|
+
## Verification log — 2026-06-13 (live, no model quota needed)
|
|
12
|
+
**B1 (hooks fire) EXECUTED and a real bug was found + fixed.**
|
|
13
|
+
- Copilot CLI 1.0.61 DOES load+run plugin hooks (logged `Invalid hooks config … hooks must be an object` on the old Claude-format file; accepted the new v1 format with no error).
|
|
14
|
+
- First live run: every hook fired but failed — `HookExitCodeError: code 1` — because the command used `${OMP_PLUGIN_ROOT:-$OMC_PLUGIN_ROOT}`, which Copilot does NOT set. Captured the hook runtime env: Copilot exposes **`COPILOT_PLUGIN_ROOT`** (and `PLUGIN_ROOT` / `CLAUDE_PLUGIN_ROOT`).
|
|
15
|
+
- Fix (commit e7658d8): manifest now uses `${COPILOT_PLUGIN_ROOT:-…}`.
|
|
16
|
+
- Re-run after fix: `.omp/state/hooks.log` recorded `SessionStart` (correct `directory`), `UserPromptSubmit`, and `errorOccurred` (caught the quota error); **zero new HookExitCodeError**. → B1 PASS.
|
|
17
|
+
- Still ⏳: `agentStop` firing on a *completed* turn + Copilot honoring `{decision:"block"}` re-prompt — the turn quota-errored (→ errorOccurred) instead of completing, so the loop re-prompt is unexercised until quota.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## A. Already verified (evidence on record)
|
|
22
|
+
|
|
23
|
+
| Check | How | Result |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| Build from clean | `rm -rf dist && npm run build` | ✅ |
|
|
26
|
+
| Unit/integration suite | `npx vitest run` | ✅ 508/508 |
|
|
27
|
+
| Type safety | `npx tsc -p tsconfig.json --noEmit` | ✅ clean |
|
|
28
|
+
| Skill lint | `node dist/src/cli.js lint:skills --root .` | ✅ 0 issues |
|
|
29
|
+
| Catalog | `node dist/src/cli.js catalog validate` | ✅ pass |
|
|
30
|
+
| Every hook script emits valid JSON | pipe Copilot payload to each `scripts/*.mjs` | ✅ 7/7 |
|
|
31
|
+
| `preToolUse` safe (no spurious deny) | pipe payload | ✅ allow |
|
|
32
|
+
| `agent-stop` fails OPEN on bad input | `echo '' \| node scripts/agent-stop.mjs` | ✅ allow |
|
|
33
|
+
| `agent-stop` loop math | stdin pipe: 1/3→2/3→allow+clear on sentinel; unit tests | ✅ |
|
|
34
|
+
| `comms send` submits (C-m→Enter) | live `omp comms send … --json` | ✅ ok=True submitted=True |
|
|
35
|
+
| doctor validates manifest | `omp doctor --json` | ✅ "Copilot v1, 7 events (agentStop present)" |
|
|
36
|
+
| Fixed skills reference omp CLI | grep | ✅ ralph/ultrawork/ultraqa/jira |
|
|
37
|
+
|
|
38
|
+
These do NOT need re-running unless code changes.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## B. Deferred live verification (needs Copilot model quota)
|
|
43
|
+
|
|
44
|
+
### Prerequisites
|
|
45
|
+
1. Quota available (Jul 1 reset, or Copilot Pro). Confirm: a trivial `omp comms ask --text "say OK" --wait` returns a model reply, not `quota_exceeded`.
|
|
46
|
+
2. Plugin synced to this branch: `rsync -a --delete .github/ ~/.copilot/installed-plugins/oh-my-copilot/oh-my-copilot/.github/` and copy `hooks/`, `plugin.json`, `scripts/` (or reinstall the plugin from this branch).
|
|
47
|
+
3. Restart Copilot so it loads the new hooks + skills.
|
|
48
|
+
|
|
49
|
+
### B1 — Hooks actually fire (the claim the old memory said was impossible)
|
|
50
|
+
Install a throwaway diagnostic that writes a file, then start a session.
|
|
51
|
+
- **sessionStart**: file written on boot (past trust prompt). PASS = file exists.
|
|
52
|
+
- **agentStop**: file written when the agent finishes a turn. PASS = file exists.
|
|
53
|
+
- **preToolUse/postToolUse**: file written around a tool call.
|
|
54
|
+
- Acceptance: ≥ sessionStart + agentStop fire. (If none fire, the `${OMP_PLUGIN_ROOT}` env resolution in `hooks/hooks.json` is the suspect — see design doc risk.)
|
|
55
|
+
|
|
56
|
+
### B2 — Native memory injection (sessionStart)
|
|
57
|
+
- `omp goal set "ship v1"`, start a fresh session, ask the model "what is the repo goal?".
|
|
58
|
+
- Acceptance: model reports the goal from `additionalContext` (not only from copilot-instructions.md). Confirm by temporarily blanking the instructions block and re-testing.
|
|
59
|
+
|
|
60
|
+
### B3 — agentStop loop driver (headline feature)
|
|
61
|
+
- `omp ralph start "add a --version flag to the omp CLI" --max-iterations 3`.
|
|
62
|
+
- Drive one short task turn; let the agent stop WITHOUT emitting `RALPH_COMPLETE`.
|
|
63
|
+
- Acceptance:
|
|
64
|
+
- agentStop returns `{decision:"block"}` and Copilot takes another turn with the `[RALPH ITERATION n/3]` prompt.
|
|
65
|
+
- `.omp/state/ralph.json` iteration increments each turn.
|
|
66
|
+
- On emitting `RALPH_COMPLETE` (or hitting 3) → `{decision:"allow"}`, loop stops, state cleared.
|
|
67
|
+
- Repeat for `ultraqa` (cycleCount) and `ultrawork`.
|
|
68
|
+
|
|
69
|
+
### B4 — 23-skill behavioral re-run (audit Gate 2)
|
|
70
|
+
Re-run the existing harness against both models Copilot exposes:
|
|
71
|
+
```bash
|
|
72
|
+
node .review/driver.mjs --session <copilot-tmux> --model gpt5mini --skills all
|
|
73
|
+
node .review/driver.mjs --session <copilot-tmux> --model haiku45 --skills all
|
|
74
|
+
```
|
|
75
|
+
Score each transcript 0–3: (a) skill loaded (`● skill(x)`), (b) followed phase/contract, (c) produced contracted output, (d) avoided its own "do not use" anti-pattern.
|
|
76
|
+
|
|
77
|
+
Targeted acceptance for the previously-broken skills:
|
|
78
|
+
| skill | must observe live |
|
|
79
|
+
|---|---|
|
|
80
|
+
| ralph | runs `omp ralph start` before the work loop |
|
|
81
|
+
| ultrawork | runs `omp ultrawork start` |
|
|
82
|
+
| ultraqa | runs `omp ultraqa start` + `omp ultraqa cycle` per cycle |
|
|
83
|
+
| jira-ticket | runs `omp jira render` (dry-run) — does NOT improvise raw REST/create |
|
|
84
|
+
| research-codebase | uses native glob/grep/read (NO `task`/`view`/`explore` tool calls) |
|
|
85
|
+
| caveman | output is actually compressed (the static gate showed plain prose — confirm fixed or file a bug) |
|
|
86
|
+
| grill-me | asks exactly ONE question |
|
|
87
|
+
|
|
88
|
+
### B5 — `/team` end-to-end
|
|
89
|
+
- `omp team 2:copilot "<small task>" --name vtest`; confirm workers receive prompts (already verified delivery live), DO the task, and the leader aggregates results. Then `omp team shutdown vtest`.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## C. Sign-off checklist
|
|
94
|
+
- [ ] B1 sessionStart + agentStop fire
|
|
95
|
+
- [ ] B2 goal visible via hook injection
|
|
96
|
+
- [ ] B3 ralph/ultraqa/ultrawork loops re-prompt and stop correctly
|
|
97
|
+
- [ ] B4 all 5 previously-broken skills exhibit corrected behavior; 23-skill scores recorded to `.review/`
|
|
98
|
+
- [ ] B5 team completes a task end-to-end
|
|
99
|
+
- [ ] Any new defect filed; report appended to `.review/REPORT.md`
|
|
100
|
+
|
|
101
|
+
## D. Notes
|
|
102
|
+
- Cloud agents only load `.github/hooks/` (not plugin hooks) — B1–B3 target the local CLI.
|
|
103
|
+
- Keep all transcripts under `.review/transcripts/` for reproducibility.
|
|
104
|
+
EOF
|
package/hooks/hooks.json
CHANGED
|
@@ -1,74 +1,61 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
{
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
],
|
|
62
|
-
"Error": [
|
|
63
|
-
{
|
|
64
|
-
"matcher": "*",
|
|
65
|
-
"hooks": [
|
|
66
|
-
{
|
|
67
|
-
"type": "command",
|
|
68
|
-
"command": "node \"${OMP_PLUGIN_ROOT:-$OMC_PLUGIN_ROOT}\"/scripts/error.mjs",
|
|
69
|
-
"timeout": 5
|
|
70
|
-
}
|
|
71
|
-
]
|
|
72
|
-
}
|
|
73
|
-
]
|
|
2
|
+
"version": 1,
|
|
3
|
+
"hooks": {
|
|
4
|
+
"sessionStart": [
|
|
5
|
+
{
|
|
6
|
+
"type": "command",
|
|
7
|
+
"bash": "node \"${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-${OMP_PLUGIN_ROOT:-$OMC_PLUGIN_ROOT}}}}\"/scripts/session-start.mjs",
|
|
8
|
+
"timeoutSec": 5
|
|
9
|
+
}
|
|
10
|
+
],
|
|
11
|
+
"userPromptSubmitted": [
|
|
12
|
+
{
|
|
13
|
+
"type": "command",
|
|
14
|
+
"bash": "node \"${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-${OMP_PLUGIN_ROOT:-$OMC_PLUGIN_ROOT}}}}\"/scripts/prompt-submit.mjs",
|
|
15
|
+
"timeoutSec": 5
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"preToolUse": [
|
|
19
|
+
{
|
|
20
|
+
"type": "command",
|
|
21
|
+
"bash": "node \"${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-${OMP_PLUGIN_ROOT:-$OMC_PLUGIN_ROOT}}}}\"/scripts/pre-tool-use.mjs",
|
|
22
|
+
"timeoutSec": 5
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"postToolUse": [
|
|
26
|
+
{
|
|
27
|
+
"type": "command",
|
|
28
|
+
"bash": "node \"${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-${OMP_PLUGIN_ROOT:-$OMC_PLUGIN_ROOT}}}}\"/scripts/post-tool-use.mjs",
|
|
29
|
+
"timeoutSec": 5
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"postToolUseFailure": [
|
|
33
|
+
{
|
|
34
|
+
"type": "command",
|
|
35
|
+
"bash": "node \"${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-${OMP_PLUGIN_ROOT:-$OMC_PLUGIN_ROOT}}}}\"/scripts/post-tool-use-failure.mjs",
|
|
36
|
+
"timeoutSec": 5
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"sessionEnd": [
|
|
40
|
+
{
|
|
41
|
+
"type": "command",
|
|
42
|
+
"bash": "node \"${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-${OMP_PLUGIN_ROOT:-$OMC_PLUGIN_ROOT}}}}\"/scripts/session-end.mjs",
|
|
43
|
+
"timeoutSec": 5
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"agentStop": [
|
|
47
|
+
{
|
|
48
|
+
"type": "command",
|
|
49
|
+
"bash": "node \"${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-${OMP_PLUGIN_ROOT:-$OMC_PLUGIN_ROOT}}}}\"/scripts/agent-stop.mjs",
|
|
50
|
+
"timeoutSec": 10
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
"errorOccurred": [
|
|
54
|
+
{
|
|
55
|
+
"type": "command",
|
|
56
|
+
"bash": "node \"${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-${OMP_PLUGIN_ROOT:-$OMC_PLUGIN_ROOT}}}}\"/scripts/error.mjs",
|
|
57
|
+
"timeoutSec": 5
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
}
|
|
74
61
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@damian87/omp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Multi-agent orchestration for GitHub Copilot CLI — autonomous loops (Autopilot, Ralph, UltraQA, Ultrawork), parallel tmux agent teams, a weighted-consensus model council, a Slack chat bridge, durable scheduled jobs, and in-session skills + custom agents. Zero learning curve.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oh-my-copilot",
|
|
3
3
|
"description": "Multi-agent orchestration skills for GitHub Copilot CLI — autopilot, ralph, ultrawork, ultraqa, team, council, code-review and more as in-session slash skills + custom agents.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.12.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Damian Borek",
|
|
7
7
|
"email": "borekdamian@yahoo.pl"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copilot CLI `agentStop` hook: drives the omp persistence loops (ralph /
|
|
3
|
+
// ultrawork / ultraqa). When a loop is active and not yet complete, returns
|
|
4
|
+
// {decision:"block", reason:"<next-turn prompt>"} so Copilot takes another turn;
|
|
5
|
+
// otherwise {decision:"allow"}. Fail-OPEN (never traps the user in a loop).
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, statSync, openSync, readSync, closeSync } from "node:fs";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
import { readStdin } from "./lib/stdin.mjs";
|
|
9
|
+
import { hookCwd, printStopDecision, appendHookLog } from "./lib/hook-output.mjs";
|
|
10
|
+
import { decideLoop, LOOP_MODES } from "./lib/loop-driver.mjs";
|
|
11
|
+
|
|
12
|
+
const HOOK_NAME = "agentStop";
|
|
13
|
+
const TRANSCRIPT_TAIL_BYTES = 64 * 1024;
|
|
14
|
+
|
|
15
|
+
// Match the omp CLI's modeStatePath (src/mode-state/paths.ts): literal resolved
|
|
16
|
+
// cwd, NOT ompRoot — so the hook reads/writes the exact files `omp ralph start`
|
|
17
|
+
// wrote, even when invoked from a subdirectory.
|
|
18
|
+
function stateFile(root, mode) {
|
|
19
|
+
return join(root, ".omp", "state", `${mode}.json`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readState(root, mode) {
|
|
23
|
+
const p = stateFile(root, mode);
|
|
24
|
+
if (!existsSync(p)) return undefined;
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Read only the tail of the transcript — it can be large, and a completion
|
|
33
|
+
// sentinel from this turn lives at the end.
|
|
34
|
+
function readTranscriptTail(path) {
|
|
35
|
+
if (!path || !existsSync(path)) return "";
|
|
36
|
+
try {
|
|
37
|
+
const size = statSync(path).size;
|
|
38
|
+
const start = Math.max(0, size - TRANSCRIPT_TAIL_BYTES);
|
|
39
|
+
const len = size - start;
|
|
40
|
+
const fd = openSync(path, "r");
|
|
41
|
+
try {
|
|
42
|
+
const buf = Buffer.alloc(len);
|
|
43
|
+
readSync(fd, buf, 0, len, start);
|
|
44
|
+
return buf.toString("utf8");
|
|
45
|
+
} finally {
|
|
46
|
+
closeSync(fd);
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
(async () => {
|
|
54
|
+
try {
|
|
55
|
+
const raw = await readStdin();
|
|
56
|
+
const data = raw ? JSON.parse(raw) : {};
|
|
57
|
+
const directory = hookCwd(data);
|
|
58
|
+
const root = resolve(directory);
|
|
59
|
+
|
|
60
|
+
const states = {};
|
|
61
|
+
for (const m of LOOP_MODES) states[m.key] = readState(root, m.key);
|
|
62
|
+
|
|
63
|
+
const transcript = readTranscriptTail(data.transcriptPath ?? data.transcript_path);
|
|
64
|
+
const result = decideLoop(states, transcript);
|
|
65
|
+
|
|
66
|
+
// Persist counter increment (block) or clear the loop (allow on complete/cap).
|
|
67
|
+
if (result.patch) {
|
|
68
|
+
const s = states[result.patch.mode];
|
|
69
|
+
if (s) {
|
|
70
|
+
s[result.patch.counter] = result.patch.value;
|
|
71
|
+
try { writeFileSync(stateFile(root, result.patch.mode), JSON.stringify(s, null, 2)); } catch { /* best effort */ }
|
|
72
|
+
}
|
|
73
|
+
} else if (result.clear) {
|
|
74
|
+
const s = states[result.clear];
|
|
75
|
+
if (s) {
|
|
76
|
+
s.active = false;
|
|
77
|
+
try { writeFileSync(stateFile(root, result.clear), JSON.stringify(s, null, 2)); } catch { /* best effort */ }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
appendHookLog(directory, HOOK_NAME, { decision: result.decision, reason: result.reason });
|
|
82
|
+
printStopDecision(result.decision, result.decision === "block" ? result.reason : "");
|
|
83
|
+
} catch (err) {
|
|
84
|
+
console.error(`[hook ${HOOK_NAME}] failed: ${err?.message ?? err}`);
|
|
85
|
+
printStopDecision("allow"); // fail-open: never trap the loop on an error
|
|
86
|
+
}
|
|
87
|
+
})();
|
package/scripts/error.mjs
CHANGED
|
@@ -2,17 +2,19 @@
|
|
|
2
2
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { readStdin } from "./lib/stdin.mjs";
|
|
5
|
+
import { failOpen } from "./lib/hook-output.mjs";
|
|
6
|
+
import { parseHookInput } from "./lib/hook-input.mjs";
|
|
5
7
|
|
|
6
8
|
const HOOK_NAME = "Error";
|
|
7
9
|
|
|
8
10
|
(async () => {
|
|
9
11
|
try {
|
|
10
12
|
const raw = await readStdin();
|
|
11
|
-
const
|
|
12
|
-
const sessionId =
|
|
13
|
-
const directory =
|
|
14
|
-
const toolName =
|
|
15
|
-
const errorMessage =
|
|
13
|
+
const input = parseHookInput(raw);
|
|
14
|
+
const sessionId = input.sessionId;
|
|
15
|
+
const directory = input.cwd;
|
|
16
|
+
const toolName = input.toolName;
|
|
17
|
+
const errorMessage = input.error ?? "unknown";
|
|
16
18
|
const logFile = join(directory, ".omp", "state", "hooks.log");
|
|
17
19
|
try {
|
|
18
20
|
mkdirSync(dirname(logFile), { recursive: true });
|
|
@@ -23,9 +25,9 @@ const HOOK_NAME = "Error";
|
|
|
23
25
|
} catch {
|
|
24
26
|
// best effort
|
|
25
27
|
}
|
|
26
|
-
|
|
28
|
+
failOpen();
|
|
27
29
|
} catch (err) {
|
|
28
30
|
console.error(`[hook ${HOOK_NAME}] failed: ${err?.message ?? err}`);
|
|
29
|
-
|
|
31
|
+
failOpen();
|
|
30
32
|
}
|
|
31
33
|
})();
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { ompRoot } from "./omp-root.mjs";
|
|
4
|
+
|
|
5
|
+
export function normalizeTokenInput(value) {
|
|
6
|
+
if (value == null) return "";
|
|
7
|
+
if (typeof value === "string") return value;
|
|
8
|
+
try {
|
|
9
|
+
return JSON.stringify(value) ?? "";
|
|
10
|
+
} catch {
|
|
11
|
+
return String(value);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function countTokens(value) {
|
|
16
|
+
const text = normalizeTokenInput(value);
|
|
17
|
+
if (text.length === 0) return 0;
|
|
18
|
+
return Math.ceil(text.length / 4);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function costDir(cwd) {
|
|
22
|
+
return join(ompRoot(cwd), ".omp", "state", "cost");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function safeSessionId(sessionId) {
|
|
26
|
+
return (
|
|
27
|
+
String(sessionId || "unknown")
|
|
28
|
+
.trim()
|
|
29
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
30
|
+
.replace(/^-+|-+$/g, "") || "unknown"
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function costLedgerPath(cwd, sessionId) {
|
|
35
|
+
return join(costDir(cwd), `${safeSessionId(sessionId)}.jsonl`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeRecord(record = {}) {
|
|
39
|
+
return {
|
|
40
|
+
ts: record.ts ?? new Date().toISOString(),
|
|
41
|
+
sessionId: record.sessionId || "unknown",
|
|
42
|
+
event: record.event,
|
|
43
|
+
toolName: record.toolName,
|
|
44
|
+
model: record.model,
|
|
45
|
+
inTokens: Number.isFinite(record.inTokens) ? Math.max(0, Number(record.inTokens)) : 0,
|
|
46
|
+
outTokens: Number.isFinite(record.outTokens) ? Math.max(0, Number(record.outTokens)) : 0,
|
|
47
|
+
rawOutTokens: Number.isFinite(record.rawOutTokens) ? Math.max(0, Number(record.rawOutTokens)) : undefined,
|
|
48
|
+
savedTokens: Number.isFinite(record.savedTokens) ? Math.max(0, Number(record.savedTokens)) : undefined,
|
|
49
|
+
rawPath: typeof record.rawPath === "string" ? record.rawPath : undefined,
|
|
50
|
+
estUSD: Number.isFinite(record.estUSD) ? Number(record.estUSD) : undefined,
|
|
51
|
+
note: record.note,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function appendCostRecord(cwd, record) {
|
|
56
|
+
const normalized = normalizeRecord(record);
|
|
57
|
+
const file = costLedgerPath(cwd, normalized.sessionId);
|
|
58
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
59
|
+
appendFileSync(file, `${JSON.stringify(normalized)}\n`, "utf8");
|
|
60
|
+
return file;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readFileRecords(file) {
|
|
64
|
+
if (!existsSync(file)) return [];
|
|
65
|
+
const records = [];
|
|
66
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
67
|
+
if (!line.trim()) continue;
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(line);
|
|
70
|
+
if (parsed && typeof parsed === "object" && typeof parsed.event === "string") records.push(normalizeRecord(parsed));
|
|
71
|
+
} catch {
|
|
72
|
+
// ignore corrupt rows
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return records;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function readCostRecords(cwd, options = {}) {
|
|
79
|
+
const dir = costDir(cwd);
|
|
80
|
+
const todayPrefix = new Date().toISOString().slice(0, 10);
|
|
81
|
+
const files = options.sessionId
|
|
82
|
+
? [costLedgerPath(cwd, options.sessionId)]
|
|
83
|
+
: existsSync(dir)
|
|
84
|
+
? readdirSync(dir)
|
|
85
|
+
.filter((file) => file.endsWith(".jsonl"))
|
|
86
|
+
.sort()
|
|
87
|
+
.map((file) => join(dir, file))
|
|
88
|
+
: [];
|
|
89
|
+
const records = files.flatMap(readFileRecords);
|
|
90
|
+
return options.today ? records.filter((record) => String(record.ts ?? "").startsWith(todayPrefix)) : records;
|
|
91
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
function parseMaybeJson(value) {
|
|
2
|
+
if (typeof value !== "string") return value;
|
|
3
|
+
const trimmed = value.trim();
|
|
4
|
+
if (!trimmed) return value;
|
|
5
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value;
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(trimmed);
|
|
8
|
+
} catch {
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeResult(raw) {
|
|
14
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
15
|
+
const resultType = raw.resultType ?? raw.result_type;
|
|
16
|
+
const textResultForLlm = raw.textResultForLlm ?? raw.text_result_for_llm;
|
|
17
|
+
if (resultType == null && textResultForLlm == null) return undefined;
|
|
18
|
+
return {
|
|
19
|
+
resultType: resultType ?? "success",
|
|
20
|
+
textResultForLlm: textResultForLlm == null ? "" : String(textResultForLlm),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeHookInput(data = {}, options = {}) {
|
|
25
|
+
const payload = data && typeof data === "object" ? data : {};
|
|
26
|
+
const cwd = payload.cwd ?? payload.directory ?? options.cwd ?? process.cwd();
|
|
27
|
+
const toolResult = normalizeResult(payload.toolResult ?? payload.tool_result ?? payload.toolOutput);
|
|
28
|
+
const error = payload.error?.message ?? payload.error ?? payload.message;
|
|
29
|
+
return {
|
|
30
|
+
raw: payload,
|
|
31
|
+
hookEventName: payload.hookEventName ?? payload.hook_event_name,
|
|
32
|
+
sessionId: payload.sessionId ?? payload.session_id ?? "unknown",
|
|
33
|
+
timestamp: payload.timestamp,
|
|
34
|
+
cwd,
|
|
35
|
+
directory: cwd,
|
|
36
|
+
prompt: payload.prompt ?? payload.message?.content ?? "",
|
|
37
|
+
toolName: payload.toolName ?? payload.tool_name ?? "unknown",
|
|
38
|
+
toolArgs: parseMaybeJson(payload.toolArgs ?? payload.tool_input ?? payload.toolInput),
|
|
39
|
+
toolResult,
|
|
40
|
+
error: error == null ? undefined : String(error),
|
|
41
|
+
transcriptPath: payload.transcriptPath ?? payload.transcript_path,
|
|
42
|
+
stopReason: payload.stopReason ?? payload.stop_reason,
|
|
43
|
+
trigger: payload.trigger,
|
|
44
|
+
customInstructions: payload.customInstructions ?? payload.custom_instructions,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseHookInput(raw, options = {}) {
|
|
49
|
+
const data = raw ? JSON.parse(raw) : {};
|
|
50
|
+
return normalizeHookInput(data, options);
|
|
51
|
+
}
|
|
@@ -2,19 +2,90 @@ import { appendFileSync, mkdirSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { ompRoot } from "./omp-root.mjs";
|
|
4
4
|
|
|
5
|
+
// Hook scripts run under BOTH GitHub Copilot CLI (camelCase events, top-level
|
|
6
|
+
// `additionalContext` / `{decision,reason}` / `{permissionDecision}`) and Claude
|
|
7
|
+
// Code (`{continue, hookSpecificOutput}` / `{decision, reason}`). The injection
|
|
8
|
+
// path (`printContinue`) dual-emits: every output object carries both
|
|
9
|
+
// vocabularies, and each host ignores the keys it does not recognize. See
|
|
10
|
+
// docs/plans/copilot-native-hooks.md.
|
|
11
|
+
//
|
|
12
|
+
// The cost/minification path uses the documented Copilot builder shapes
|
|
13
|
+
// (`buildContinueOutput`/`buildAdditionalContextOutput`/`buildModifiedResultOutput`/
|
|
14
|
+
// `buildPermissionDecisionOutput`). An empty `{}` is a no-op "continue" for both
|
|
15
|
+
// hosts, so these builders coexist with the dual-emit injection path.
|
|
16
|
+
|
|
17
|
+
/** Project directory from hook input — Copilot sends `cwd`, Claude sends `directory`. */
|
|
18
|
+
export function hookCwd(data) {
|
|
19
|
+
return data?.cwd ?? data?.directory ?? process.cwd();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildContinueOutput() {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildAdditionalContextOutput(additionalContext = "") {
|
|
27
|
+
return additionalContext ? { additionalContext } : buildContinueOutput();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildModifiedResultOutput(textResultForLlm, additionalContext = "", resultType = "success") {
|
|
31
|
+
return {
|
|
32
|
+
modifiedResult: {
|
|
33
|
+
resultType,
|
|
34
|
+
textResultForLlm,
|
|
35
|
+
},
|
|
36
|
+
...(additionalContext ? { additionalContext } : {}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildPermissionDecisionOutput(permissionDecision, permissionDecisionReason, modifiedArgs) {
|
|
41
|
+
return {
|
|
42
|
+
permissionDecision,
|
|
43
|
+
...(permissionDecisionReason ? { permissionDecisionReason } : {}),
|
|
44
|
+
...(modifiedArgs == null ? {} : { modifiedArgs }),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* sessionStart / userPromptSubmitted injection. When there is context to inject,
|
|
50
|
+
* dual-emit it for both hosts (Copilot top-level `additionalContext` + Claude
|
|
51
|
+
* `continue`/`hookSpecificOutput`). With nothing to inject, emit an empty `{}` —
|
|
52
|
+
* a no-op "continue" understood by both hosts and the zero-cost default.
|
|
53
|
+
*/
|
|
5
54
|
export function printContinue(hookEventName, additionalContext = "") {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
55
|
+
if (!additionalContext) {
|
|
56
|
+
console.log(JSON.stringify(buildContinueOutput()));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
console.log(
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
continue: true,
|
|
62
|
+
additionalContext, // Copilot CLI
|
|
63
|
+
hookSpecificOutput: { hookEventName, additionalContext }, // Claude Code
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** agentStop (Copilot) / Stop (Claude): both honor {decision, reason}. */
|
|
69
|
+
export function printStopDecision(decision, reason = "") {
|
|
70
|
+
const out = { decision }; // "block" forces another turn; "allow" lets it stop
|
|
71
|
+
if (reason) out.reason = reason; // serves as the next-turn prompt when blocked
|
|
72
|
+
console.log(JSON.stringify(out));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** preToolUse (Copilot): allow | deny | ask, with optional reason / modified args. */
|
|
76
|
+
export function printPermission(permissionDecision, reason = "", modifiedArgs) {
|
|
77
|
+
const out = { permissionDecision };
|
|
78
|
+
if (reason) out.permissionDecisionReason = reason;
|
|
79
|
+
if (modifiedArgs) out.modifiedArgs = modifiedArgs;
|
|
80
|
+
console.log(JSON.stringify(out));
|
|
10
81
|
}
|
|
11
82
|
|
|
12
83
|
export function printBlock(reason) {
|
|
13
|
-
console.log(JSON.stringify(
|
|
84
|
+
console.log(JSON.stringify(buildPermissionDecisionOutput("deny", reason)));
|
|
14
85
|
}
|
|
15
86
|
|
|
16
87
|
export function failOpen() {
|
|
17
|
-
console.log(JSON.stringify(
|
|
88
|
+
console.log(JSON.stringify(buildContinueOutput()));
|
|
18
89
|
}
|
|
19
90
|
|
|
20
91
|
export function appendHookLog(directory, hookName, payload) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Pure decision logic for the agentStop loop driver. Given the current loop-mode
|
|
2
|
+
// states and the session transcript text, decide whether Copilot should stop
|
|
3
|
+
// (`allow`) or take another turn (`block` + a next-turn `reason`). Kept side-effect
|
|
4
|
+
// free so it is unit-testable; file I/O lives in scripts/agent-stop.mjs.
|
|
5
|
+
|
|
6
|
+
// Priority order: a single-owner ralph loop wins over a QA cycle over a batch.
|
|
7
|
+
export const LOOP_MODES = [
|
|
8
|
+
{ key: "ralph", sentinel: "RALPH_COMPLETE", counter: "iteration", max: "maxIterations", defMax: 10 },
|
|
9
|
+
{ key: "ultraqa", sentinel: "ULTRAQA_COMPLETE", counter: "cycleCount", max: "maxCycles", defMax: 5 },
|
|
10
|
+
{ key: "ultrawork", sentinel: "ULTRAWORK_COMPLETE", counter: "iteration", max: "maxIterations", defMax: 20 },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {Record<string, any>} states e.g. { ralph: {active, iteration, maxIterations}, ... }
|
|
15
|
+
* @param {string} transcriptText recent transcript text to scan for a completion sentinel
|
|
16
|
+
* @returns {{decision:"allow"|"block", reason?:string, clear?:string, patch?:{mode:string,counter:string,value:number}}}
|
|
17
|
+
*/
|
|
18
|
+
export function decideLoop(states = {}, transcriptText = "") {
|
|
19
|
+
for (const m of LOOP_MODES) {
|
|
20
|
+
const s = states[m.key];
|
|
21
|
+
if (!s || !s.active) continue;
|
|
22
|
+
|
|
23
|
+
// The model signals completion by emitting the sentinel token — let it stop.
|
|
24
|
+
if (transcriptText.includes(m.sentinel)) {
|
|
25
|
+
return { decision: "allow", clear: m.key, reason: `${m.key} complete (sentinel seen)` };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const cur = Number(s[m.counter] ?? 0);
|
|
29
|
+
const max = Number(s[m.max] ?? m.defMax);
|
|
30
|
+
// Safety cap: never loop past the configured maximum, even without a sentinel.
|
|
31
|
+
if (cur + 1 >= max) {
|
|
32
|
+
return { decision: "allow", clear: m.key, reason: `${m.key} reached max (${max})` };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const next = cur + 1;
|
|
36
|
+
const reason =
|
|
37
|
+
`[${m.key.toUpperCase()} ITERATION ${next}/${max}] Not finished. Continue the task. ` +
|
|
38
|
+
`When ALL acceptance criteria pass, output the exact token ${m.sentinel} on its own line.`;
|
|
39
|
+
return { decision: "block", patch: { mode: m.key, counter: m.counter, value: next }, reason };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// No active loop — normal stop.
|
|
43
|
+
return { decision: "allow" };
|
|
44
|
+
}
|