@azure-id/orc 1.5.0 → 1.6.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/CHANGELOG.md +75 -0
- package/README.md +62 -49
- package/bin/cli.js +81 -0
- package/bin/verify-contracts.js +60 -42
- package/bin/verify-package.js +3 -0
- package/bin/webui/i18n/en/overview.json +1 -0
- package/bin/webui/i18n/id/overview.json +1 -0
- package/bin/webui/js/panels/overview.js +8 -0
- package/package.json +1 -1
- package/templates/hooks/README.md +110 -1
- package/templates/hooks/orc-read-gate.js +279 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,81 @@ Format: `### v<version> — <title> _(<date>)_`.
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
+
### v1.6.0 - the rule that can finally say no _(2026-09-07)_
|
|
14
|
+
|
|
15
|
+
**Still on the unscoped `orc` package?** Do this once first - your `orc upgrade`
|
|
16
|
+
is the pre-v0.56.0 one and cannot install itself. Full detail in the CAUTION at
|
|
17
|
+
the top of this file.
|
|
18
|
+
|
|
19
|
+
- **Step 1 - release the command from the old package:** `npm uninstall -g orc`
|
|
20
|
+
- **Step 2 - install the current package:** `npm i -g @azure-id/orc`
|
|
21
|
+
- **Step 3 - re-apply it to your project:** `orc update`
|
|
22
|
+
|
|
23
|
+
**Do not use `npm i -g -f`.** Full detail in v0.56.0 below.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
ORC's read discipline has always said the same thing in three places: the main
|
|
27
|
+
session reads to **find**, and dispatches an agent to **understand**. It was
|
|
28
|
+
prose in `_shared/read-ladder.md`, in `/orc-doc` hard rule 0, and on line 21 of
|
|
29
|
+
`/orc-quick` - and **nothing checked any of it**.
|
|
30
|
+
|
|
31
|
+
`orc-read-gate.js` is a `PreToolUse` hook on `Read` that can refuse. It ships
|
|
32
|
+
**off**, and `off` is byte-identical to not having it - asserted by a test, not
|
|
33
|
+
by intention.
|
|
34
|
+
|
|
35
|
+
**The threshold is ORC's own number, measured.** The pattern this came from uses
|
|
36
|
+
350 lines, derived from a 10-30 second delegation round trip. ORC's round trip
|
|
37
|
+
is nothing like that: a dispatch measured **p50 76s, p90 188s** (n=125), and one
|
|
38
|
+
real read-only dispatch cost **13,276 tokens** to read a four-line file. At the
|
|
39
|
+
measured 55.2 chars/line across 316 sampled reads, break-even is **~1000 lines**.
|
|
40
|
+
Copying 350 would delegate work whose overhead exceeds its saving.
|
|
41
|
+
|
|
42
|
+
**`agent_id` is the only discriminator, and that was MEASURED, not assumed.**
|
|
43
|
+
`PreToolUse` **does** fire inside a dispatched subagent - the assumption that
|
|
44
|
+
hooks are session-level was wrong. `session_id` and `transcript_path` are
|
|
45
|
+
**identical** in both contexts, so a gate written against either would block the
|
|
46
|
+
full read an executor must perform before an `Edit`, and a reconstructed
|
|
47
|
+
`old_string` corrupts files. The gate tests for the **presence** of `agent_id`,
|
|
48
|
+
never for the absence of some other key, which asserts nothing.
|
|
49
|
+
|
|
50
|
+
**Everything it stays silent on, and each one is deliberate:** any read by a
|
|
51
|
+
subagent - outside an open ORC run - a targeted `offset`/`limit` read - a file
|
|
52
|
+
under the threshold - build logs, test results and `.jsonl` that a gate parses
|
|
53
|
+
whole, because a truncated red build reads **green** - and any error at all,
|
|
54
|
+
because **a read gate that throws and blocks a read has broken the tool.**
|
|
55
|
+
|
|
56
|
+
**A block always names the cheaper path.** A gate that only refuses is a gate
|
|
57
|
+
people switch off. It names the targeted read, the agent dispatch, and the
|
|
58
|
+
config key that turns it down.
|
|
59
|
+
|
|
60
|
+
**Every `warn` and `block` writes one trace line** (`READ-GATE`), an allow
|
|
61
|
+
writes none. That is affordable here for a structural reason: the gate only acts
|
|
62
|
+
while a run is open, so a trace always exists. `orc doctor` gains
|
|
63
|
+
`read-gate-unwired` and `read-gate-fallback`, both reported **only while the
|
|
64
|
+
feature is armed** - a doctor that warns about the default is one people learn
|
|
65
|
+
to ignore.
|
|
66
|
+
|
|
67
|
+
**Two config keys**, both on the `SEED_EMPTY` allowlist with an empty `lanes[]`,
|
|
68
|
+
because a hook has no lane and cannot resolve config: `read_gate`
|
|
69
|
+
(`off`|`warn`|`block`, default `off`) and `read_gate_max_lines` (default 1000).
|
|
70
|
+
|
|
71
|
+
**What this release deliberately does NOT do**, with the reasons recorded so
|
|
72
|
+
nobody re-proposes them: no gate on `Bash` reads (`cat`/`head`/`tail`) - it is
|
|
73
|
+
several times the false-positive surface, and the measurement says the `Read`
|
|
74
|
+
tool is only about 7.5% of the actual read surface here - and no
|
|
75
|
+
`context-reader` slot in `EXTRA_SLOTS`, because at a 1000-line threshold the
|
|
76
|
+
addressable population is five reads across 239 sampled sessions, which is
|
|
77
|
+
infrastructure for nothing.
|
|
78
|
+
|
|
79
|
+
**An honest note on the measurement.** The audit behind this release found the
|
|
80
|
+
existing prose is largely *working*: median full read is 85 lines, p90 is 288,
|
|
81
|
+
and 46% of reads already use `offset`/`limit` without being told. Oversized
|
|
82
|
+
reads are about **1% of price-weighted main-session ingest** at a generous upper
|
|
83
|
+
bound. This hook is a guardrail on a road most runs already stay on - it is off
|
|
84
|
+
by default for exactly that reason.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
13
88
|
### v1.5.0 - the lane that runs the test _(2026-09-07)_
|
|
14
89
|
|
|
15
90
|
**Still on the unscoped `orc` package?** Do this once first - your `orc upgrade`
|
package/README.md
CHANGED
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
*Intake → analyze → plan → score → parallel subagents → review → verify → ship.*
|
|
8
8
|
|
|
9
9
|

|
|
10
|
-

|
|
11
11
|

|
|
12
12
|

|
|
13
13
|

|
|
14
14
|

|
|
15
15
|

|
|
16
16
|
|
|
17
|
-
**Latest: v1.
|
|
17
|
+
**Latest: v1.6.0** · updated 2026-09-07 · [full changelog](CHANGELOG.md)
|
|
18
18
|
|
|
19
19
|
**On npm: [`@azure-id/orc`](https://www.npmjs.com/package/@azure-id/orc)** — `npm i -g @azure-id/orc`
|
|
20
20
|
|
|
@@ -576,7 +576,7 @@ a current audit: [EVAL-REPORT.md](EVAL-REPORT.md).
|
|
|
576
576
|
**Full history: [CHANGELOG.md](CHANGELOG.md)** — or `orc changelog`, which prints
|
|
577
577
|
only what is newer than the version you have.
|
|
578
578
|
|
|
579
|
-
### v1.
|
|
579
|
+
### v1.6.0 - the rule that can finally say no _(2026-09-07)_
|
|
580
580
|
|
|
581
581
|
**Still on the unscoped `orc` package?** Do this once first - your `orc upgrade`
|
|
582
582
|
is the pre-v0.56.0 one and cannot install itself. Full detail in the CAUTION at
|
|
@@ -588,53 +588,66 @@ the top of this file.
|
|
|
588
588
|
|
|
589
589
|
**Do not use `npm i -g -f`.** Full detail in v0.56.0 below.
|
|
590
590
|
|
|
591
|
-
Every other testing surface in ORC **writes** tests. `/orc-test` **runs** them.
|
|
592
591
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
592
|
+
ORC's read discipline has always said the same thing in three places: the main
|
|
593
|
+
session reads to **find**, and dispatches an agent to **understand**. It was
|
|
594
|
+
prose in `_shared/read-ladder.md`, in `/orc-doc` hard rule 0, and on line 21 of
|
|
595
|
+
`/orc-quick` - and **nothing checked any of it**.
|
|
596
|
+
|
|
597
|
+
`orc-read-gate.js` is a `PreToolUse` hook on `Read` that can refuse. It ships
|
|
598
|
+
**off**, and `off` is byte-identical to not having it - asserted by a test, not
|
|
599
|
+
by intention.
|
|
600
|
+
|
|
601
|
+
**The threshold is ORC's own number, measured.** The pattern this came from uses
|
|
602
|
+
350 lines, derived from a 10-30 second delegation round trip. ORC's round trip
|
|
603
|
+
is nothing like that: a dispatch measured **p50 76s, p90 188s** (n=125), and one
|
|
604
|
+
real read-only dispatch cost **13,276 tokens** to read a four-line file. At the
|
|
605
|
+
measured 55.2 chars/line across 316 sampled reads, break-even is **~1000 lines**.
|
|
606
|
+
Copying 350 would delegate work whose overhead exceeds its saving.
|
|
607
|
+
|
|
608
|
+
**`agent_id` is the only discriminator, and that was MEASURED, not assumed.**
|
|
609
|
+
`PreToolUse` **does** fire inside a dispatched subagent - the assumption that
|
|
610
|
+
hooks are session-level was wrong. `session_id` and `transcript_path` are
|
|
611
|
+
**identical** in both contexts, so a gate written against either would block the
|
|
612
|
+
full read an executor must perform before an `Edit`, and a reconstructed
|
|
613
|
+
`old_string` corrupts files. The gate tests for the **presence** of `agent_id`,
|
|
614
|
+
never for the absence of some other key, which asserts nothing.
|
|
615
|
+
|
|
616
|
+
**Everything it stays silent on, and each one is deliberate:** any read by a
|
|
617
|
+
subagent - outside an open ORC run - a targeted `offset`/`limit` read - a file
|
|
618
|
+
under the threshold - build logs, test results and `.jsonl` that a gate parses
|
|
619
|
+
whole, because a truncated red build reads **green** - and any error at all,
|
|
620
|
+
because **a read gate that throws and blocks a read has broken the tool.**
|
|
621
|
+
|
|
622
|
+
**A block always names the cheaper path.** A gate that only refuses is a gate
|
|
623
|
+
people switch off. It names the targeted read, the agent dispatch, and the
|
|
624
|
+
config key that turns it down.
|
|
625
|
+
|
|
626
|
+
**Every `warn` and `block` writes one trace line** (`READ-GATE`), an allow
|
|
627
|
+
writes none. That is affordable here for a structural reason: the gate only acts
|
|
628
|
+
while a run is open, so a trace always exists. `orc doctor` gains
|
|
629
|
+
`read-gate-unwired` and `read-gate-fallback`, both reported **only while the
|
|
630
|
+
feature is armed** - a doctor that warns about the default is one people learn
|
|
631
|
+
to ignore.
|
|
632
|
+
|
|
633
|
+
**Two config keys**, both on the `SEED_EMPTY` allowlist with an empty `lanes[]`,
|
|
634
|
+
because a hook has no lane and cannot resolve config: `read_gate`
|
|
635
|
+
(`off`|`warn`|`block`, default `off`) and `read_gate_max_lines` (default 1000).
|
|
636
|
+
|
|
637
|
+
**What this release deliberately does NOT do**, with the reasons recorded so
|
|
638
|
+
nobody re-proposes them: no gate on `Bash` reads (`cat`/`head`/`tail`) - it is
|
|
639
|
+
several times the false-positive surface, and the measurement says the `Read`
|
|
640
|
+
tool is only about 7.5% of the actual read surface here - and no
|
|
641
|
+
`context-reader` slot in `EXTRA_SLOTS`, because at a 1000-line threshold the
|
|
642
|
+
addressable population is five reads across 239 sampled sessions, which is
|
|
643
|
+
infrastructure for nothing.
|
|
644
|
+
|
|
645
|
+
**An honest note on the measurement.** The audit behind this release found the
|
|
646
|
+
existing prose is largely *working*: median full read is 85 lines, p90 is 288,
|
|
647
|
+
and 46% of reads already use `offset`/`limit` without being told. Oversized
|
|
648
|
+
reads are about **1% of price-weighted main-session ingest** at a generous upper
|
|
649
|
+
bound. This hook is a guardrail on a road most runs already stay on - it is off
|
|
650
|
+
by default for exactly that reason.
|
|
638
651
|
|
|
639
652
|
## Requirements
|
|
640
653
|
|
package/bin/cli.js
CHANGED
|
@@ -364,6 +364,31 @@ function installGuards(claudeDir) {
|
|
|
364
364
|
wireTrace("PreToolUse", "Task|Agent");
|
|
365
365
|
wireTrace("SubagentStop", null);
|
|
366
366
|
|
|
367
|
+
// 5) PreToolUse read gate (v1.6.0) — add once, or refresh its path on update.
|
|
368
|
+
// Wired even though `read_gate` defaults to OFF: the hook's first act is to
|
|
369
|
+
// read that key and exit 0, so an unarmed gate is byte-identical to not
|
|
370
|
+
// having it (asserted by a test). Wiring it here means arming the feature is
|
|
371
|
+
// a config edit, never an install step the user has to discover.
|
|
372
|
+
const readGateCmd = nodeCmd(path.join(hooksDest, "orc-read-gate.js"));
|
|
373
|
+
let readGated = false;
|
|
374
|
+
for (const entry of settings.hooks.PreToolUse) {
|
|
375
|
+
for (const h of entry.hooks || []) {
|
|
376
|
+
if (typeof h.command === "string" && h.command.includes("orc-read-gate")) {
|
|
377
|
+
h.command = readGateCmd; // keep the path current
|
|
378
|
+
readGated = true;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (!readGated) {
|
|
383
|
+
settings.hooks.PreToolUse.push({
|
|
384
|
+
matcher: "Read",
|
|
385
|
+
hooks: [{ type: "command", command: readGateCmd }],
|
|
386
|
+
});
|
|
387
|
+
console.log(" add settings.json → PreToolUse read gate (off by default)");
|
|
388
|
+
} else {
|
|
389
|
+
console.log(" upd settings.json → PreToolUse read gate path");
|
|
390
|
+
}
|
|
391
|
+
|
|
367
392
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
368
393
|
}
|
|
369
394
|
|
|
@@ -1234,6 +1259,10 @@ const CONFIG_FAMILIES = {
|
|
|
1234
1259
|
// reason, because a config that once said yes is on for the run you needed it
|
|
1235
1260
|
// off.
|
|
1236
1261
|
test: { contested: false, question: "how far a live test run goes, and how hard it pushes" },
|
|
1262
|
+
// v1.6.0 — the read gate. UNCONTESTED: no forcing mode reaches it, because
|
|
1263
|
+
// the gate acts on the MAIN session's reads and `opus5_only` / `extra_*`
|
|
1264
|
+
// decide which MODEL runs a dispatched role. Nothing shadows these two.
|
|
1265
|
+
read: { contested: false, question: "whether an oversized full read by the main session is refused" },
|
|
1237
1266
|
};
|
|
1238
1267
|
|
|
1239
1268
|
// Ordered, tiered metadata. Common first, then advanced.
|
|
@@ -1290,6 +1319,8 @@ const CONFIG_META = [
|
|
|
1290
1319
|
// it is an operating key of a hook. Off by default, and off means Claude
|
|
1291
1320
|
// Code's own agent-panel row, unchanged.
|
|
1292
1321
|
{ key: "subagent_line_custom", def: "off", tier: "common", answers: [{ family: "statusline", prio: "P2", mode: "replace" }], lanes: [], validate: vEnum("off", "on"), options: ["off", "on"], desc: "Whether the agent panel renders YOUR composed row for each subagent instead of Claude Code's own. Off is Claude Code's row, unchanged. Compose it in `orc ui` > CLI Hook Interface, on the subagent board. INDEPENDENT of the per-agent token record: ORC writes what the agent panel reports whether this is on or off, because that measurement is handed over either way and throwing it out because a display setting is off would be the wrong trade." },
|
|
1322
|
+
{ key: "read_gate", def: "off", tier: "common", answers: [{ family: "read", prio: "P2", mode: "replace" }], lanes: [], validate: vEnum("off", "warn", "block"), options: ["off", "warn", "block"], desc: "Whether the PreToolUse read gate acts on an oversized full read by the MAIN session. Off is byte-identical to not having the hook. `warn` allows and says so; `block` refuses and names the cheaper path. It is SILENT on every read a subagent makes (so an executor's read-before-edit is never touched), outside an open ORC run, on a targeted offset/limit read, on output a gate parses, and under `read_gate_max_lines`." },
|
|
1323
|
+
{ key: "read_gate_max_lines", def: 1000, tier: "advanced", answers: [{ family: "read", prio: "P2", mode: "replace" }], lanes: [], validate: vInt(1), options: [500, 1000, 2000], desc: "Line count at or above which `read_gate` acts. The default 1000 is MEASURED, not borrowed: a read-only dispatch costs ~13k tokens and p50 76s, which at 55.2 chars/line across 316 sampled reads puts break-even near 1000 lines. A lower number delegates work whose overhead exceeds its saving." },
|
|
1293
1324
|
{ key: "statusline_custom", def: "off", tier: "common", answers: [{ family: "statusline", prio: "P2", mode: "replace" }], lanes: [], validate: vEnum("off", "on"), options: ["off", "on"], desc: "Whether the status line renders YOUR composed layout instead of the shipped two lines. Off is byte-identical to what ships. Compose the layout in `orc ui` > CLI Hook Interface (the CLI half exists so the panel has something to shell). Turning this on with an invalid or missing layout is refused, naming the reason; a layout that later becomes unreadable falls back to the shipped lines silently and is reported by `orc doctor`." },
|
|
1294
1325
|
{ key: "wait_hop_minutes", def: 30, tier: "advanced", answers: [{ family: "wait", prio: "P2", mode: "replace" }], lanes: [], validate: vInt(1), options: [5, 10, 15, 30], desc: "How long ONE detached hop waits before ORC re-reads the window. Short on purpose: each wake-up is session activity, and session activity is the only thing that makes the statusline write a fresh reading. One long sleep wakes into a reading as stale as the sleep was long." },
|
|
1295
1326
|
{ key: "wait_max_hops", def: 5, tier: "advanced", answers: [{ family: "wait", prio: "P2", mode: "replace" }], lanes: [], validate: vInt(1), options: [1, 2, 3, 5, 8, 12], desc: "How many hops before ORC gives up and stops with the hand-back. A wrong reset time must cost you a bounded wait, never a session that never comes back." },
|
|
@@ -32462,6 +32493,56 @@ function doctor() {
|
|
|
32462
32493
|
}
|
|
32463
32494
|
} catch (_) {}
|
|
32464
32495
|
|
|
32496
|
+
// 5a-bis) the read gate (v1.6.0). Same two rules as the status line above,
|
|
32497
|
+
// for the same reasons. ONLY WHILE ARMED: `read_gate: off` is the default and
|
|
32498
|
+
// the overwhelmingly common state, and a doctor that warns about the default
|
|
32499
|
+
// is a doctor people learn to scroll past. And the hook FAILS OPEN — it must,
|
|
32500
|
+
// because a read gate that throws and blocks a read has broken the tool — so
|
|
32501
|
+
// it records WHY, and this is where that recording becomes a sentence. A gate
|
|
32502
|
+
// that quietly stopped gating is exactly the bug a user cannot report.
|
|
32503
|
+
try {
|
|
32504
|
+
const cfg = resolvedConfig(claudeDir);
|
|
32505
|
+
const mode = String(cfg.read_gate || "off").toLowerCase();
|
|
32506
|
+
if (mode === "off") {
|
|
32507
|
+
ok("read gate off (fine — the default; the read ladder stays advisory)");
|
|
32508
|
+
} else {
|
|
32509
|
+
// Armed but never wired: the hook exists and the key is on, yet nothing
|
|
32510
|
+
// in settings.json matches `Read`, so the gate has never once fired.
|
|
32511
|
+
const wired = (() => {
|
|
32512
|
+
try {
|
|
32513
|
+
const s = JSON.parse(fs.readFileSync(path.join(claudeDir, "settings.json"), "utf8"));
|
|
32514
|
+
return (s.hooks && s.hooks.PreToolUse ? s.hooks.PreToolUse : []).some((e) =>
|
|
32515
|
+
(e.hooks || []).some((h) => String(h.command || "").includes("orc-read-gate"))
|
|
32516
|
+
);
|
|
32517
|
+
} catch (_) {
|
|
32518
|
+
return false;
|
|
32519
|
+
}
|
|
32520
|
+
})();
|
|
32521
|
+
const st = (() => {
|
|
32522
|
+
try {
|
|
32523
|
+
return JSON.parse(
|
|
32524
|
+
fs.readFileSync(path.join(claudeDir, "orc", "read-gate-fallback.json"), "utf8")
|
|
32525
|
+
);
|
|
32526
|
+
} catch (_) {
|
|
32527
|
+
return null;
|
|
32528
|
+
}
|
|
32529
|
+
})();
|
|
32530
|
+
if (!wired)
|
|
32531
|
+
warn(
|
|
32532
|
+
"read-gate-unwired",
|
|
32533
|
+
`read_gate is '${mode}' but .claude/settings.json has no PreToolUse hook on Read — the gate has never fired`,
|
|
32534
|
+
{ fix: "orc update", fix_command: "orc update" }
|
|
32535
|
+
);
|
|
32536
|
+
else if (st && st.rung && Date.now() - (st.at || 0) < 24 * 60 * 60 * 1000)
|
|
32537
|
+
warn(
|
|
32538
|
+
"read-gate-fallback",
|
|
32539
|
+
`read gate: it allowed a read it could not judge (${st.rung}${st.detail ? ": " + st.detail : ""}) — fail-open worked, but the gate was blind for that read`,
|
|
32540
|
+
{ fix: "orc doctor (the record clears itself after 24h)" }
|
|
32541
|
+
);
|
|
32542
|
+
else ok(`read gate armed (${mode}) and wired`);
|
|
32543
|
+
}
|
|
32544
|
+
} catch (_) {}
|
|
32545
|
+
|
|
32465
32546
|
// 5b) the wiki (v0.49.1). Exactly TWO findings, and the restraint is the
|
|
32466
32547
|
// design: a doctor that warns about normal states teaches people to ignore
|
|
32467
32548
|
// doctor. Both route to the Knowledge panel via FINDING_ROUTE — a caution
|
package/bin/verify-contracts.js
CHANGED
|
@@ -869,6 +869,10 @@ const CONTRACTS = [
|
|
|
869
869
|
"skills/orc-test/SKILL.md",
|
|
870
870
|
"agents/orc-trace-writer-haiku-4-5.md",
|
|
871
871
|
"hooks/orc-trace.js",
|
|
872
|
+
// v1.6.0: the read gate READS the pointer to decide whether an ORC run
|
|
873
|
+
// is open at all. Outside a run it refuses nothing — the gate constrains
|
|
874
|
+
// ORC's own reading, never the user's session.
|
|
875
|
+
"hooks/orc-read-gate.js",
|
|
872
876
|
// v1.2.1: the statusline READS the pointer to decide which run `status:`
|
|
873
877
|
// is about. "The newest file" would be a different, wrong answer during
|
|
874
878
|
// a lane suspend, when two traces are live and only one is the run.
|
|
@@ -2616,6 +2620,12 @@ const CONTRACTS = [
|
|
|
2616
2620
|
name: "read ladder (escalating read discipline for read-heavy roles)",
|
|
2617
2621
|
token: "read-ladder.md",
|
|
2618
2622
|
files: [
|
|
2623
|
+
// v1.6.0 — the ENFORCEMENT half. The ladder was advisory prose in three
|
|
2624
|
+
// places and enforced in none; this hook is the layer that can refuse.
|
|
2625
|
+
// It cites the ladder by name because its two carve-outs ARE the
|
|
2626
|
+
// ladder's two exceptions — rename the file and the gate's reason text
|
|
2627
|
+
// stops naming anything real.
|
|
2628
|
+
"hooks/orc-read-gate.js",
|
|
2619
2629
|
"agents/orc-executor-haiku-4-5.md",
|
|
2620
2630
|
"agents/orc-executor-opus-4-7-high.md",
|
|
2621
2631
|
"agents/orc-executor-opus-4-7-med.md",
|
|
@@ -3293,48 +3303,48 @@ const CONTRACTS = [
|
|
|
3293
3303
|
files: ["skills/_shared/config-precedence.md", "skills/orc/config.md"],
|
|
3294
3304
|
binFiles: ["bin/cli.js"],
|
|
3295
3305
|
},
|
|
3296
|
-
// ── v1.5.0 — /orc-test, the lane that RUNS the test ───────────────────────
|
|
3297
|
-
// THE TRIPLE. Registered together because they fail together: a lane that
|
|
3298
|
-
// will edit the system under test has already stopped needing permission to
|
|
3299
|
-
// reach it, and one that reports what it did not observe has nothing left
|
|
3300
|
-
// that a target's authorization was protecting.
|
|
3301
|
-
{
|
|
3302
|
-
name: "three verdicts, and `unknown` is the honest one (v1.5.0 — /orc-test)",
|
|
3303
|
-
token: "a lane that reports a result it did not observe",
|
|
3304
|
-
files: ["skills/_shared/live-target.md", "skills/orc-test/SKILL.md"],
|
|
3305
|
-
binFiles: ["bin/cli.js"],
|
|
3306
|
-
},
|
|
3307
|
-
{
|
|
3308
|
-
name: "the target is FROZEN and authorized (v1.5.0 — /orc-test)",
|
|
3309
|
-
token: "a lane that sends traffic to a target nobody authorized",
|
|
3310
|
-
files: ["skills/_shared/live-target.md", "skills/orc-test/SKILL.md"],
|
|
3311
|
-
binFiles: ["bin/cli.js"],
|
|
3312
|
-
},
|
|
3313
|
-
{
|
|
3314
|
-
name: "it measures and hands back — it never repairs (v1.5.0 — /orc-test)",
|
|
3315
|
-
token: "a lane that fixes the system under test",
|
|
3316
|
-
files: ["skills/_shared/live-target.md", "skills/orc-test/SKILL.md"],
|
|
3317
|
-
binFiles: ["bin/cli.js"],
|
|
3318
|
-
},
|
|
3319
|
-
// Two single-token CLI mirrors. Both are PATHS the CLI composes and the
|
|
3320
|
-
// payload names, so a rename on either side is exactly the drift a
|
|
3321
|
-
// presence-only assertion catches (the v0.29.0 `binFiles` shape).
|
|
3322
|
-
{
|
|
3323
|
-
name: "orc-test run folder (v1.5.0 — project root, never .claude/, never staged)",
|
|
3324
|
-
token: "orc/orc-test/",
|
|
3325
|
-
files: [
|
|
3326
|
-
"agents/orc-test-interpreter-opus-5-low.md",
|
|
3327
|
-
"commands/orc-test.md",
|
|
3328
|
-
"skills/orc-test/SKILL.md",
|
|
3329
|
-
],
|
|
3330
|
-
binFiles: ["bin/cli.js"],
|
|
3331
|
-
},
|
|
3332
|
-
{
|
|
3333
|
-
name: "the evidence folder every finding cites (v1.5.0 — redacted before it reaches disk)",
|
|
3334
|
-
token: "evidence/",
|
|
3335
|
-
files: ["agents/orc-test-interpreter-opus-5-low.md", "skills/_shared/live-target.md"],
|
|
3336
|
-
binFiles: ["bin/cli.js"],
|
|
3337
|
-
},
|
|
3306
|
+
// ── v1.5.0 — /orc-test, the lane that RUNS the test ───────────────────────
|
|
3307
|
+
// THE TRIPLE. Registered together because they fail together: a lane that
|
|
3308
|
+
// will edit the system under test has already stopped needing permission to
|
|
3309
|
+
// reach it, and one that reports what it did not observe has nothing left
|
|
3310
|
+
// that a target's authorization was protecting.
|
|
3311
|
+
{
|
|
3312
|
+
name: "three verdicts, and `unknown` is the honest one (v1.5.0 — /orc-test)",
|
|
3313
|
+
token: "a lane that reports a result it did not observe",
|
|
3314
|
+
files: ["skills/_shared/live-target.md", "skills/orc-test/SKILL.md"],
|
|
3315
|
+
binFiles: ["bin/cli.js"],
|
|
3316
|
+
},
|
|
3317
|
+
{
|
|
3318
|
+
name: "the target is FROZEN and authorized (v1.5.0 — /orc-test)",
|
|
3319
|
+
token: "a lane that sends traffic to a target nobody authorized",
|
|
3320
|
+
files: ["skills/_shared/live-target.md", "skills/orc-test/SKILL.md"],
|
|
3321
|
+
binFiles: ["bin/cli.js"],
|
|
3322
|
+
},
|
|
3323
|
+
{
|
|
3324
|
+
name: "it measures and hands back — it never repairs (v1.5.0 — /orc-test)",
|
|
3325
|
+
token: "a lane that fixes the system under test",
|
|
3326
|
+
files: ["skills/_shared/live-target.md", "skills/orc-test/SKILL.md"],
|
|
3327
|
+
binFiles: ["bin/cli.js"],
|
|
3328
|
+
},
|
|
3329
|
+
// Two single-token CLI mirrors. Both are PATHS the CLI composes and the
|
|
3330
|
+
// payload names, so a rename on either side is exactly the drift a
|
|
3331
|
+
// presence-only assertion catches (the v0.29.0 `binFiles` shape).
|
|
3332
|
+
{
|
|
3333
|
+
name: "orc-test run folder (v1.5.0 — project root, never .claude/, never staged)",
|
|
3334
|
+
token: "orc/orc-test/",
|
|
3335
|
+
files: [
|
|
3336
|
+
"agents/orc-test-interpreter-opus-5-low.md",
|
|
3337
|
+
"commands/orc-test.md",
|
|
3338
|
+
"skills/orc-test/SKILL.md",
|
|
3339
|
+
],
|
|
3340
|
+
binFiles: ["bin/cli.js"],
|
|
3341
|
+
},
|
|
3342
|
+
{
|
|
3343
|
+
name: "the evidence folder every finding cites (v1.5.0 — redacted before it reaches disk)",
|
|
3344
|
+
token: "evidence/",
|
|
3345
|
+
files: ["agents/orc-test-interpreter-opus-5-low.md", "skills/_shared/live-target.md"],
|
|
3346
|
+
binFiles: ["bin/cli.js"],
|
|
3347
|
+
},
|
|
3338
3348
|
];
|
|
3339
3349
|
|
|
3340
3350
|
// Spine size budgets (v0.19.0). These SKILL.md files are ALWAYS loaded when
|
|
@@ -3741,6 +3751,14 @@ for (const b of BUDGETS) {
|
|
|
3741
3751
|
// exactly as no spine reads `extra_timeout_s`. An empty lanes[] here is an
|
|
3742
3752
|
// ANSWER.
|
|
3743
3753
|
"test_max_rps",
|
|
3754
|
+
// v1.6.0 — operating keys of the READ GATE HOOK, and the same answer as
|
|
3755
|
+
// `statusline_custom` for the same reason: a hook has no lane and cannot
|
|
3756
|
+
// resolve config, so it reads the raw key off the file exactly as it
|
|
3757
|
+
// already does for `log_dir`. No spine reads either of these — the gate
|
|
3758
|
+
// acts on the MAIN session's reads, which no lane is in a position to
|
|
3759
|
+
// mediate. An empty lanes[] here is an ANSWER, not a to-do.
|
|
3760
|
+
"read_gate",
|
|
3761
|
+
"read_gate_max_lines",
|
|
3744
3762
|
]);
|
|
3745
3763
|
for (const e of metaEntries) {
|
|
3746
3764
|
if (!e.lanes) {
|
package/bin/verify-package.js
CHANGED
|
@@ -480,6 +480,9 @@ const required = [
|
|
|
480
480
|
// it. Wired even with the board off, because the measurement is handed over
|
|
481
481
|
// either way.
|
|
482
482
|
"templates/hooks/orc-subagent-line.js",
|
|
483
|
+
// v1.6.0 — the read gate. The one layer that can refuse an oversized read by
|
|
484
|
+
// the ORCHESTRATOR. Ships wired but OFF; `read_gate` arms it.
|
|
485
|
+
"templates/hooks/orc-read-gate.js",
|
|
483
486
|
"templates/hooks/orc-trace.js",
|
|
484
487
|
];
|
|
485
488
|
|
|
@@ -87,6 +87,7 @@
|
|
|
87
87
|
"overview.item.legacyGlobalPackage.cta": "Open Maintenance",
|
|
88
88
|
"overview.item.extraDemotedRun.cta": "Open Extra",
|
|
89
89
|
"overview.item.laneKeysDrifted.cta": "Open Maintenance",
|
|
90
|
+
"overview.item.readGateUnwired.cta": "Open Maintenance",
|
|
90
91
|
"overview.extraOrphan.title": "A dispatch did not report back",
|
|
91
92
|
"overview.extraOrphan.note": "ORC sent work to a non-Claude worker and never heard the result. Its changes may still be on disk.",
|
|
92
93
|
"overview.extraOrphan.chip": "{n} dispatch",
|
|
@@ -87,6 +87,7 @@
|
|
|
87
87
|
"overview.item.legacyGlobalPackage.cta": "Buka Pemeliharaan",
|
|
88
88
|
"overview.item.extraDemotedRun.cta": "Buka Extra",
|
|
89
89
|
"overview.item.laneKeysDrifted.cta": "Buka Pemeliharaan",
|
|
90
|
+
"overview.item.readGateUnwired.cta": "Buka Pemeliharaan",
|
|
90
91
|
"overview.extraOrphan.title": "Sebuah pengiriman tidak melapor kembali",
|
|
91
92
|
"overview.extraOrphan.note": "ORC mengirim pekerjaan ke pekerja non-Claude dan tidak pernah menerima hasilnya. Perubahannya mungkin masih ada di disk.",
|
|
92
93
|
"overview.extraOrphan.chip": "{n} pengiriman",
|
|
@@ -77,6 +77,14 @@ const FINDING_ROUTE = {
|
|
|
77
77
|
// `test-evidence-unstaged` goes there too even though the fix is a line in
|
|
78
78
|
// .gitignore that ORC will not write: Test is where the sentence explaining
|
|
79
79
|
// WHY that folder must never be staged already lives.
|
|
80
|
+
// v1.6.0. An INSTALL-FOOTPRINT finding takes the documented default:
|
|
81
|
+
// `read-gate-unwired` is cleared by `orc update`, which is an action row on
|
|
82
|
+
// MAINTENANCE. `read-gate-fallback` is NOT a button anywhere — the record
|
|
83
|
+
// ages out on its own and there is nothing to press — so it gets
|
|
84
|
+
// `panel: null` rather than a button that would do nothing, the same call
|
|
85
|
+
// `trace-pointer-dangling` made.
|
|
86
|
+
"read-gate-unwired": { panel: "maintenance", cta: "overview.item.readGateUnwired.cta" },
|
|
87
|
+
"read-gate-fallback": { panel: null },
|
|
80
88
|
"test-env-unhealthy": { panel: "test", cta: "overview.item.testEnvUnhealthy.cta" },
|
|
81
89
|
"test-run-red": { panel: "test", cta: "overview.item.testRunRed.cta" },
|
|
82
90
|
"test-unchecked-owasp": { panel: "test", cta: "overview.item.testUncheckedOwasp.cta" },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@azure-id/orc",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "ORC — an orchestrator skill constellation for Claude Code: intake, planning, scored parallel subagents, code-pattern matching, review, verify, ship, plus a project knowledge-base wiki.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"orc": "bin/cli.js"
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
# The ORC
|
|
1
|
+
# The ORC hooks
|
|
2
2
|
|
|
3
3
|
> This page is written in Simplified Technical English. Short sentences, one
|
|
4
4
|
> idea each, plain words. See `bin/webui/i18n/TERMS.md` for the term list.
|
|
5
5
|
|
|
6
|
+
This page covers the two hooks you can see. The STATUS LINE, which shows what
|
|
7
|
+
is happening, and the READ GATE, which can stop an oversized read.
|
|
8
|
+
|
|
6
9
|
ORC shows two lines at the bottom of your terminal. Claude Code draws them.
|
|
7
10
|
ORC writes them.
|
|
8
11
|
|
|
@@ -258,6 +261,91 @@ it did not see.
|
|
|
258
261
|
|
|
259
262
|
---
|
|
260
263
|
|
|
264
|
+
## The read gate
|
|
265
|
+
|
|
266
|
+
This is a different hook. It is off when you install ORC.
|
|
267
|
+
|
|
268
|
+
ORC has a rule about reading. The main session reads a file to FIND things.
|
|
269
|
+
To UNDERSTAND a file, ORC sends an agent to read it and report back. The agent
|
|
270
|
+
uses its own context, not yours. The rule was written in three guide files and
|
|
271
|
+
nothing checked it. The read gate is the part that can say no.
|
|
272
|
+
|
|
273
|
+
Turn it on like this:
|
|
274
|
+
|
|
275
|
+
```
|
|
276
|
+
orc config set read_gate warn # it tells you, and still reads the file
|
|
277
|
+
orc config set read_gate block # it stops the read
|
|
278
|
+
orc config set read_gate off # the default
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### What it does
|
|
282
|
+
|
|
283
|
+
It looks at one thing: a `Read` of a whole file, by the main session, during an
|
|
284
|
+
ORC run, when the file is 1000 lines or more.
|
|
285
|
+
|
|
286
|
+
In `warn` it shows you a note and reads the file anyway. In `block` it stops the
|
|
287
|
+
read and tells you three other ways to get what you need:
|
|
288
|
+
|
|
289
|
+
- Read the file with `offset` and `limit`. This is never stopped.
|
|
290
|
+
- Search the file first, then read that part.
|
|
291
|
+
- Send an agent to read it. An agent's reads are never stopped.
|
|
292
|
+
|
|
293
|
+
**A block always names another way.** A gate that only says no is a gate people
|
|
294
|
+
turn off.
|
|
295
|
+
|
|
296
|
+
### When it says nothing
|
|
297
|
+
|
|
298
|
+
This list is the important part. The gate is quiet in all of these states, and
|
|
299
|
+
each one is on purpose:
|
|
300
|
+
|
|
301
|
+
| State | Why |
|
|
302
|
+
|---|---|
|
|
303
|
+
| An agent is reading | An agent must read a file in full before it edits it. If it could not, it would guess the old text and damage the file. |
|
|
304
|
+
| `read_gate` is `off` | This is the default. With `off`, the hook does nothing at all. |
|
|
305
|
+
| No ORC run is open | The gate is about ORC's own reading. It is not a rule for your session. Outside a run it never stops anything. |
|
|
306
|
+
| You used `offset` or `limit` | This is the behaviour the gate wants. It can never stop it. |
|
|
307
|
+
| The file is under 1000 lines | See the next part. |
|
|
308
|
+
| The file is a build log, a test result, or `.jsonl` | ORC reads these to decide pass or fail. A cut-short failing build looks like a passing build. That is worse than any saving. |
|
|
309
|
+
| The gate hit an error | It always lets the read through. Then it writes down what went wrong. |
|
|
310
|
+
|
|
311
|
+
**The gate cannot see a file you read with a shell command** such as `cat` or
|
|
312
|
+
`head`. It only sees the `Read` tool.
|
|
313
|
+
|
|
314
|
+
### Why 1000 lines
|
|
315
|
+
|
|
316
|
+
Sending an agent is not free. One real agent read cost about 13,000 tokens and
|
|
317
|
+
about 76 seconds. A line in this project is about 55 characters. So a file must
|
|
318
|
+
be near 1000 lines before sending an agent costs less than reading it yourself.
|
|
319
|
+
|
|
320
|
+
Below that number, sending an agent costs more than it saves.
|
|
321
|
+
|
|
322
|
+
You can change it:
|
|
323
|
+
|
|
324
|
+
```
|
|
325
|
+
orc config set read_gate_max_lines 500
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### What it never does
|
|
329
|
+
|
|
330
|
+
- It never stops an agent's read.
|
|
331
|
+
- It never stops a read outside an ORC run.
|
|
332
|
+
- It never stops a `Bash` command.
|
|
333
|
+
- It never reads the file to you. It only counts the lines.
|
|
334
|
+
- It never fails closed. If the hook breaks, your read still happens.
|
|
335
|
+
|
|
336
|
+
### Where to look when it acts
|
|
337
|
+
|
|
338
|
+
Every `warn` and every `block` writes one line in the run trace:
|
|
339
|
+
|
|
340
|
+
```
|
|
341
|
+
[070926 14:22:01.220] hook READ-GATE block :: lines=2400 max=1000 file=big-plan.md
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
An allowed read writes nothing. `orc doctor` tells you if the gate is on but not
|
|
345
|
+
wired, and if it ever had to let a read through because it could not judge it.
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
261
349
|
## For maintainers
|
|
262
350
|
|
|
263
351
|
- The hook is `orc-statusline.js`. `orc init` installs it and wires it into
|
|
@@ -285,3 +373,24 @@ it did not see.
|
|
|
285
373
|
- The cache file is `.claude/orc/usage-session.json`. The hook reads it once and
|
|
286
374
|
writes it once, after the text is ready. It stores raw numbers only — never a
|
|
287
375
|
word like `fresh` or `STALE`, which is computed each time it is shown.
|
|
376
|
+
|
|
377
|
+
### The read gate
|
|
378
|
+
|
|
379
|
+
- The hook is `orc-read-gate.js`, on `PreToolUse` with matcher `Read`. `orc
|
|
380
|
+
init` wires it even though `read_gate` defaults to `off`, so arming it is a
|
|
381
|
+
config edit and never an install step somebody has to find.
|
|
382
|
+
- **`off` is byte-identical to not having the hook**, and a test asserts it.
|
|
383
|
+
- **`agent_id` is the only way to tell a subagent's read from the main
|
|
384
|
+
session's, and this was MEASURED, not assumed.** `PreToolUse` does fire
|
|
385
|
+
inside a dispatched subagent, and `session_id` and `transcript_path` are
|
|
386
|
+
identical in both. A gate written against either would block the full read an
|
|
387
|
+
executor must do before it edits, and a reconstructed `old_string` corrupts
|
|
388
|
+
files. Test for the PRESENCE of `agent_id`. Never test for the absence of
|
|
389
|
+
another key — that is not a positive statement about anything.
|
|
390
|
+
- The threshold is measured, not borrowed. See `read_gate_max_lines`.
|
|
391
|
+
- It fails open on every path, and each failure it can name writes
|
|
392
|
+
`.claude/orc/read-gate-fallback.json` for `orc doctor` to turn into a
|
|
393
|
+
sentence — only while the feature is armed.
|
|
394
|
+
- It writes one trace line per `warn` and per `block`, never on an allow. That
|
|
395
|
+
is affordable here for a structural reason: the gate only acts while a run is
|
|
396
|
+
open, so a trace always exists.
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ORC read gate — a Claude Code PreToolUse hook on `Read`.
|
|
6
|
+
*
|
|
7
|
+
* ORC's read discipline (`skills/_shared/read-ladder.md`, orc-doc hard rule 0,
|
|
8
|
+
* orc-quick line 21) says the ORCHESTRATOR reads to LOCATE and dispatches to
|
|
9
|
+
* UNDERSTAND. That rule was advisory prose in three places and enforced in
|
|
10
|
+
* none. This hook is the one layer that can refuse.
|
|
11
|
+
*
|
|
12
|
+
* It is DELIBERATELY the narrowest useful gate. Everything below is a state in
|
|
13
|
+
* which it says nothing at all, and every one of them is intentional:
|
|
14
|
+
*
|
|
15
|
+
* - a SUBAGENT is reading (measured: W1 — see `agent_id` below)
|
|
16
|
+
* - `read_gate` is `off` (the default)
|
|
17
|
+
* - no ORC run is open
|
|
18
|
+
* - the read is already targeted (`offset` / `limit` — ladder step 3)
|
|
19
|
+
* - the file is under the threshold
|
|
20
|
+
* - the file is output a gate parses (ladder exception 2)
|
|
21
|
+
* - `read_gate` is `warn` (it says so, and still allows)
|
|
22
|
+
*
|
|
23
|
+
* THE SUBAGENT RULE IS LOAD-BEARING, AND IT IS MEASURED, NOT ASSUMED.
|
|
24
|
+
* `PreToolUse` DOES fire inside a dispatched subagent. `session_id` and
|
|
25
|
+
* `transcript_path` are IDENTICAL in both contexts — a gate written against
|
|
26
|
+
* either would treat an executor's read as the orchestrator's and block the
|
|
27
|
+
* full read that must precede an `Edit`, whose `old_string` cannot be
|
|
28
|
+
* reconstructed from an outline. That is a file-corruption path. The ONLY
|
|
29
|
+
* discriminator is `agent_id`, present in a subagent payload and absent in a
|
|
30
|
+
* main-session one. Test for its PRESENCE — never for the absence of some
|
|
31
|
+
* other key, which is not a positive assertion.
|
|
32
|
+
*
|
|
33
|
+
* Wiring (installed by `orc init` into .claude/settings.json):
|
|
34
|
+
* hooks.PreToolUse[] { matcher: "Read", hooks:[{ type:"command",
|
|
35
|
+
* command: 'node "<.claude>/hooks/orc-read-gate.js"' }] }
|
|
36
|
+
*
|
|
37
|
+
* Contract: read PreToolUse JSON from stdin. Exit 0 = allow. Exit 2 = block
|
|
38
|
+
* the tool call and show stderr to Claude, which relays it to the user.
|
|
39
|
+
*
|
|
40
|
+
* FAIL-OPEN, ALWAYS. A read gate that throws and blocks a read has broken the
|
|
41
|
+
* tool. Every failure path here exits 0, and the ones we can name RECORD
|
|
42
|
+
* themselves so `orc doctor` can turn them into a sentence — but only while
|
|
43
|
+
* the feature is armed. Precedent: orc-statusline.js is fail-silent, and the
|
|
44
|
+
* v1.3.0 statusline gate ladder is all fallbacks, each of which records itself.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
const fs = require("fs");
|
|
48
|
+
const path = require("path");
|
|
49
|
+
|
|
50
|
+
// .claude/hooks/orc-read-gate.js → CLAUDE_DIR = .. → PROJECT_ROOT = ../..
|
|
51
|
+
const CLAUDE_DIR = path.join(__dirname, "..");
|
|
52
|
+
const PROJECT_ROOT = path.join(CLAUDE_DIR, "..");
|
|
53
|
+
|
|
54
|
+
// A run whose trace has been idle this long has ENDED. Same window as
|
|
55
|
+
// orc-trace.js — one idea of "a run is open", not two.
|
|
56
|
+
const STALE_MS = 6 * 60 * 60 * 1000;
|
|
57
|
+
|
|
58
|
+
// Default line threshold. MEASURED, not copied.
|
|
59
|
+
//
|
|
60
|
+
// The number in the source material this pattern came from is 350, derived
|
|
61
|
+
// from a 10-30s delegation round trip. ORC's is nothing like that: a Task
|
|
62
|
+
// dispatch measured p50 76s / p90 188s (n=125), and one real read-only
|
|
63
|
+
// dispatch cost 13,276 tokens to read a four-line file and return three words.
|
|
64
|
+
// At the measured 55.2 chars/line across 316 main-session full reads, that
|
|
65
|
+
// floor is ~962 lines; at the median file's 51.9 chars/line it is ~1024.
|
|
66
|
+
// Break-even is therefore ~1000 lines, and BELOW it delegating a read costs
|
|
67
|
+
// more than the read does. Copying 350 would delegate work whose overhead
|
|
68
|
+
// exceeds its saving.
|
|
69
|
+
const DEFAULT_THRESHOLD = 1000;
|
|
70
|
+
|
|
71
|
+
// Output a gate PARSES is read whole — read-ladder exception 2. The smoke
|
|
72
|
+
// gate, the TDD gate, the verifier and /orc-quick's build loop all decide red
|
|
73
|
+
// vs green from these exact bytes, and a truncated red build reads GREEN.
|
|
74
|
+
// That is worse than any token saving, so this list is deliberately generous:
|
|
75
|
+
// a false ALLOW costs tokens, a false BLOCK costs correctness.
|
|
76
|
+
const GATE_PARSED =
|
|
77
|
+
/(\.(log|tap|xml|lcov|junit)$|(^|[\\/])(coverage|test-results|npm-debug|yarn-error)([\\/]|$)|\.jsonl$)/i;
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Config — tolerant top-level YAML scalar read, matching orc-trace.js exactly.
|
|
81
|
+
// The repo is zero-dep; there is no YAML parser.
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
function readConfigScalar(key) {
|
|
84
|
+
try {
|
|
85
|
+
const text = fs.readFileSync(path.join(CLAUDE_DIR, "orc.config.yaml"), "utf8");
|
|
86
|
+
const re = new RegExp("^" + key + "\\s*:\\s*(.+?)\\s*(?:#.*)?$", "m");
|
|
87
|
+
const m = text.match(re);
|
|
88
|
+
return m ? m[1].replace(/^['"]|['"]$/g, "").trim() : null;
|
|
89
|
+
} catch (_) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function logDir() {
|
|
95
|
+
const rel = readConfigScalar("log_dir") || ".claude/orc/logs";
|
|
96
|
+
return path.isAbsolute(rel) ? rel : path.join(PROJECT_ROOT, rel);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Is an ORC run open? `.current` names the run; the trace file it names must
|
|
100
|
+
// exist and be fresh. A pointer naming a file that does not exist is
|
|
101
|
+
// indistinguishable from a dangling one (v0.34.2), so honour a pointer whose
|
|
102
|
+
// OWN mtime is fresh too — neither half depends on the other.
|
|
103
|
+
function runIsOpen() {
|
|
104
|
+
const dir = logDir();
|
|
105
|
+
let name;
|
|
106
|
+
try {
|
|
107
|
+
name = fs.readFileSync(path.join(dir, ".current"), "utf8").trim();
|
|
108
|
+
} catch (_) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
if (!name) return false;
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
try {
|
|
114
|
+
if (now - fs.statSync(path.join(dir, name)).mtimeMs < STALE_MS) return true;
|
|
115
|
+
} catch (_) {
|
|
116
|
+
/* trace file not created yet — fall through to the pointer's own mtime */
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return now - fs.statSync(path.join(dir, ".current")).mtimeMs < STALE_MS;
|
|
120
|
+
} catch (_) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// D10 — A GATE DECISION THAT LEAVES NO LINE CANNOT BE COUNTED. Same rule as a
|
|
126
|
+
// resume and a demotion, and it is CHEAP HERE FOR A STRUCTURAL REASON: the gate
|
|
127
|
+
// only ever acts while a run is open (rung 2), so a trace to write into is
|
|
128
|
+
// guaranteed to exist by the time we get here. There is no "a read happened
|
|
129
|
+
// with no run" case to handle, because that read was already allowed.
|
|
130
|
+
//
|
|
131
|
+
// Hook-composed, like every other line orc-trace.js writes. Best effort: a line
|
|
132
|
+
// that cannot be written must never take the gate down with it.
|
|
133
|
+
function traceLine(verb, tail) {
|
|
134
|
+
try {
|
|
135
|
+
const dir = logDir();
|
|
136
|
+
const name = fs.readFileSync(path.join(dir, ".current"), "utf8").trim();
|
|
137
|
+
if (!name) return;
|
|
138
|
+
const d = new Date();
|
|
139
|
+
const p = (n, w) => String(n).padStart(w || 2, "0");
|
|
140
|
+
const stamp =
|
|
141
|
+
p(d.getDate()) + p(d.getMonth() + 1) + p(d.getFullYear() % 100) + " " +
|
|
142
|
+
p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds()) +
|
|
143
|
+
"." + p(d.getMilliseconds(), 3);
|
|
144
|
+
fs.appendFileSync(
|
|
145
|
+
path.join(dir, name),
|
|
146
|
+
`[${stamp}] ${"hook".padEnd(8)} ${verb}` + (tail ? ` :: ${tail}` : "") + "\n"
|
|
147
|
+
);
|
|
148
|
+
} catch (_) {}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// A fallback RECORDS ITSELF (v1.3.0's rule). Best effort by construction: a
|
|
152
|
+
// record that cannot be written must never take the gate down with it.
|
|
153
|
+
function recordFallback(rung, detail) {
|
|
154
|
+
try {
|
|
155
|
+
const dir = path.join(CLAUDE_DIR, "orc");
|
|
156
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
157
|
+
fs.writeFileSync(
|
|
158
|
+
path.join(dir, "read-gate-fallback.json"),
|
|
159
|
+
JSON.stringify({ rung, detail: String(detail || "").slice(0, 300), at: Date.now() })
|
|
160
|
+
);
|
|
161
|
+
} catch (_) {}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Count lines without holding the whole file in memory twice. Returns null when
|
|
165
|
+
// the file cannot be measured — and null NEVER blocks (unknown is not "big").
|
|
166
|
+
function countLines(file) {
|
|
167
|
+
try {
|
|
168
|
+
const st = fs.statSync(file);
|
|
169
|
+
if (!st.isFile()) return null;
|
|
170
|
+
// A file that cannot possibly reach the threshold is not worth reading.
|
|
171
|
+
// 1 byte/line is the floor, so bytes < threshold ⇒ lines < threshold.
|
|
172
|
+
const buf = fs.readFileSync(file);
|
|
173
|
+
let n = 1;
|
|
174
|
+
for (let i = 0; i < buf.length; i++) if (buf[i] === 10) n++;
|
|
175
|
+
return n;
|
|
176
|
+
} catch (_) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let raw = "";
|
|
182
|
+
process.stdin.on("data", (c) => (raw += c));
|
|
183
|
+
process.stdin.on("end", () => {
|
|
184
|
+
// RUNG 0 — anything at all goes wrong: ALLOW. This wrapper is the whole
|
|
185
|
+
// fail-open guarantee; every `return` below it is an allow.
|
|
186
|
+
try {
|
|
187
|
+
gate();
|
|
188
|
+
} catch (e) {
|
|
189
|
+
recordFallback("threw", e && e.message);
|
|
190
|
+
process.exit(0);
|
|
191
|
+
}
|
|
192
|
+
process.exit(0);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
function gate() {
|
|
196
|
+
let data;
|
|
197
|
+
try {
|
|
198
|
+
data = JSON.parse(raw || "{}");
|
|
199
|
+
} catch (e) {
|
|
200
|
+
recordFallback("unparseable-payload", e && e.message);
|
|
201
|
+
return; // allow
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (data.tool_name !== "Read") return;
|
|
205
|
+
|
|
206
|
+
// RUNG 0.5 — a SUBAGENT is reading. ALLOW, always, and this check comes
|
|
207
|
+
// before every other one. See the header: this is what keeps the gate off
|
|
208
|
+
// the pre-`Edit` full read that an executor must perform, and it is why the
|
|
209
|
+
// gate never needs to know a slice's declared_files.
|
|
210
|
+
if (data.agent_id != null) return;
|
|
211
|
+
|
|
212
|
+
const mode = String(readConfigScalar("read_gate") || "off").toLowerCase();
|
|
213
|
+
if (mode !== "warn" && mode !== "block") return; // `off` (the default), or garbage
|
|
214
|
+
|
|
215
|
+
// RUNG 2 — no ORC run is open. The gate constrains ORC's own reading; it is
|
|
216
|
+
// not a policy on the user's session. A read outside a run is not ORC's to
|
|
217
|
+
// refuse, and the gate says nothing. This is an HONEST LIMIT, not an
|
|
218
|
+
// oversight: it is stated in templates/hooks/README.md and in `orc doctor`.
|
|
219
|
+
if (!runIsOpen()) return;
|
|
220
|
+
|
|
221
|
+
const input = data.tool_input || {};
|
|
222
|
+
const file = String(input.file_path || "");
|
|
223
|
+
if (!file) return;
|
|
224
|
+
|
|
225
|
+
// RUNG 3 — already a targeted read. This IS ladder step 3; it is the
|
|
226
|
+
// behaviour the gate exists to encourage, so it can never be refused.
|
|
227
|
+
if (input.offset != null || input.limit != null) return;
|
|
228
|
+
|
|
229
|
+
// RUNG 5 — output a gate parses. Exception 2 is not a preference.
|
|
230
|
+
if (GATE_PARSED.test(file)) return;
|
|
231
|
+
|
|
232
|
+
let threshold = parseInt(readConfigScalar("read_gate_max_lines"), 10);
|
|
233
|
+
if (!Number.isFinite(threshold) || threshold < 1) threshold = DEFAULT_THRESHOLD;
|
|
234
|
+
|
|
235
|
+
const lines = countLines(file);
|
|
236
|
+
if (lines == null) {
|
|
237
|
+
// Unmeasurable — a directory, a missing file, a permissions error, a binary
|
|
238
|
+
// the Read tool will handle its own way. UNKNOWN IS NOT BIG.
|
|
239
|
+
recordFallback("unmeasurable", file);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (lines < threshold) return; // RUNG 4 — under the threshold
|
|
243
|
+
|
|
244
|
+
// RUNG 7 — `warn`: allow, and say so. `systemMessage` is shown to the user
|
|
245
|
+
// and is NOT added to model context, so the warning itself costs no tokens.
|
|
246
|
+
if (mode === "warn") {
|
|
247
|
+
traceLine("READ-GATE warn", `lines=${lines} max=${threshold} file=${path.basename(file)}`);
|
|
248
|
+
try {
|
|
249
|
+
process.stdout.write(
|
|
250
|
+
JSON.stringify({
|
|
251
|
+
systemMessage:
|
|
252
|
+
`📖 ORC read gate — ${path.basename(file)} is ${lines} lines ` +
|
|
253
|
+
`(threshold ${threshold}). Allowed: read_gate is 'warn'.\n` +
|
|
254
|
+
` Cheaper: Read with offset/limit, or dispatch an agent to read it and report back.`,
|
|
255
|
+
})
|
|
256
|
+
);
|
|
257
|
+
} catch (_) {}
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// RUNG 8 — block, and NAME THE ALTERNATIVE. A block that only refuses
|
|
262
|
+
// teaches people to switch it off.
|
|
263
|
+
traceLine("READ-GATE block", `lines=${lines} max=${threshold} file=${path.basename(file)}`);
|
|
264
|
+
process.stderr.write(
|
|
265
|
+
`\n⛔ ORC read gate — ${path.basename(file)} is ${lines} lines, over the ` +
|
|
266
|
+
`${threshold}-line threshold.\n` +
|
|
267
|
+
` Reading it whole spends the orchestrator's context on material a ` +
|
|
268
|
+
`worker could summarise.\n\n` +
|
|
269
|
+
` Do one of these instead:\n` +
|
|
270
|
+
` • Read with offset/limit — the ladder's targeted read, and always allowed.\n` +
|
|
271
|
+
` • Grep for what you actually need, then read that range.\n` +
|
|
272
|
+
` • Dispatch an agent to read it and report back (its reads are never gated).\n\n` +
|
|
273
|
+
` If you genuinely need the whole file here: set read_gate to warn or off\n` +
|
|
274
|
+
` orc config set read_gate warn\n` +
|
|
275
|
+
` The gate is silent outside an ORC run, on targeted reads, on files a\n` +
|
|
276
|
+
` gate parses, and on every read a subagent makes.\n`
|
|
277
|
+
);
|
|
278
|
+
process.exit(2);
|
|
279
|
+
}
|