@hybridlabor-api/aos 4.15.0 → 4.17.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.
Files changed (104) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude/hooks/env-file-protection.mjs +19 -1
  3. package/.claude/hooks/go-gate.mjs +787 -68
  4. package/.claude/hooks/go-grant.mjs +88 -0
  5. package/.claude/settings.json +8 -0
  6. package/.codex-plugin/plugin.json +36 -6
  7. package/.opencode/commands/bdb-aos-brainstorm.md +5 -0
  8. package/.opencode/commands/bdb-aos-doctor.md +5 -0
  9. package/.opencode/commands/bdb-aos-graph.md +5 -0
  10. package/.opencode/commands/bdb-aos-init.md +5 -0
  11. package/.opencode/commands/bdb-aos-loop.md +5 -0
  12. package/.opencode/commands/bdb-aos-mastersession.md +5 -0
  13. package/.opencode/commands/bdb-aos-memb.md +5 -0
  14. package/.opencode/commands/bdb-aos-orchestrator.md +5 -0
  15. package/.opencode/commands/bdb-aos-plan.md +5 -0
  16. package/.opencode/commands/bdb-aos-playbooks.md +5 -0
  17. package/.opencode/commands/bdb-aos-setup.md +5 -0
  18. package/.opencode/commands/bdb-aos-shipping.md +5 -0
  19. package/.opencode/commands/bdb-aos-startproject.md +5 -0
  20. package/.opencode/commands/bdb-aos-store.md +5 -0
  21. package/.opencode/plugins/bdb-aos.js +54 -10
  22. package/README.md +4 -4
  23. package/agy-commands/loop.md +5 -0
  24. package/bin/aos-acp.mjs +7 -3
  25. package/bin/aos-doctor.mjs +26 -9
  26. package/bin/aos-uninstall.mjs +25 -1
  27. package/commands/brainstorm.md +5 -0
  28. package/commands/doctor.md +5 -0
  29. package/commands/graph.md +5 -0
  30. package/commands/init.md +5 -0
  31. package/commands/loop.md +5 -0
  32. package/commands/mastersession.md +5 -0
  33. package/commands/memb.md +5 -0
  34. package/commands/orchestrator.md +5 -0
  35. package/commands/plan.md +5 -0
  36. package/commands/playbooks.md +5 -0
  37. package/commands/setup.md +5 -0
  38. package/commands/shipping.md +5 -0
  39. package/commands/startproject.md +5 -0
  40. package/commands/store.md +5 -0
  41. package/docs/codex-agy-setup.md +16 -0
  42. package/docs/opencode-setup.md +36 -0
  43. package/docs/plugin-migration.md +61 -0
  44. package/installer.js +564 -215
  45. package/lib/plugin-migration.js +462 -0
  46. package/lib/store-ui/index.html +9 -1
  47. package/lib/store-ui/server.mjs +2 -0
  48. package/package.json +7 -2
  49. package/plugin-commands.json +144 -0
  50. package/plugin.json +302 -0
  51. package/plugins/bdb-aos-codex/.codex-plugin/plugin.json +38 -0
  52. package/plugins/bdb-aos-codex/skills/brainstorm/SKILL.md +6 -0
  53. package/plugins/bdb-aos-codex/skills/doctor/SKILL.md +6 -0
  54. package/plugins/bdb-aos-codex/skills/graph/SKILL.md +6 -0
  55. package/plugins/bdb-aos-codex/skills/init/SKILL.md +6 -0
  56. package/plugins/bdb-aos-codex/skills/loop/SKILL.md +6 -0
  57. package/plugins/bdb-aos-codex/skills/mastersession/SKILL.md +6 -0
  58. package/plugins/bdb-aos-codex/skills/memb/SKILL.md +6 -0
  59. package/plugins/bdb-aos-codex/skills/orchestrator/SKILL.md +6 -0
  60. package/plugins/bdb-aos-codex/skills/plan/SKILL.md +6 -0
  61. package/plugins/bdb-aos-codex/skills/playbooks/SKILL.md +6 -0
  62. package/plugins/bdb-aos-codex/skills/setup/SKILL.md +6 -0
  63. package/plugins/bdb-aos-codex/skills/shipping/SKILL.md +6 -0
  64. package/plugins/bdb-aos-codex/skills/startproject/SKILL.md +6 -0
  65. package/plugins/bdb-aos-codex/skills/store/SKILL.md +6 -0
  66. package/scripts/build-plugin-manifest.mjs +202 -3
  67. package/skills/basic/master-session/SKILL.md +1 -1
  68. package/skills/bdb-aos/scripts/list-playbooks.mjs +72 -0
  69. package/skills/global_config/gogate/SKILL.md +123 -0
  70. package/skills/global_config/loop-templates/SKILL.md +27 -0
  71. package/skills/global_config/loop-templates/references/ci-until-green.md +27 -0
  72. package/skills/global_config/loop-templates/references/daily-summary.md +27 -0
  73. package/skills/global_config/loop-templates/references/pr-to-merge.md +27 -0
  74. package/skills/global_config/loop-templates/references/review-rounds.md +27 -0
  75. package/skills/playbooks/pb-bug-fix/SKILL.md +48 -0
  76. package/skills/playbooks/pb-clip-from-moodboard/SKILL.md +55 -0
  77. package/skills/playbooks/pb-crew-call-sheet/SKILL.md +40 -0
  78. package/skills/playbooks/pb-deploy-saas/SKILL.md +44 -0
  79. package/skills/playbooks/pb-docs-site/SKILL.md +44 -0
  80. package/skills/playbooks/pb-focus-chunks/SKILL.md +40 -0
  81. package/skills/playbooks/pb-handover/SKILL.md +41 -0
  82. package/skills/playbooks/pb-harness-work/SKILL.md +64 -0
  83. package/skills/playbooks/pb-health-weekly/SKILL.md +43 -0
  84. package/skills/playbooks/pb-idea-to-launch/SKILL.md +51 -0
  85. package/skills/playbooks/pb-image-to-3d/SKILL.md +46 -0
  86. package/skills/playbooks/pb-inbox-zero/SKILL.md +39 -0
  87. package/skills/playbooks/pb-invoice-check/SKILL.md +41 -0
  88. package/skills/playbooks/pb-landing-page/SKILL.md +46 -0
  89. package/skills/playbooks/pb-launch-video/SKILL.md +49 -0
  90. package/skills/playbooks/pb-machine-setup/SKILL.md +44 -0
  91. package/skills/playbooks/pb-master/SKILL.md +53 -0
  92. package/skills/playbooks/pb-newsletter/SKILL.md +43 -0
  93. package/skills/playbooks/pb-offer/SKILL.md +39 -0
  94. package/skills/playbooks/pb-open-source/SKILL.md +50 -0
  95. package/skills/playbooks/pb-pcb-to-case/SKILL.md +46 -0
  96. package/skills/playbooks/pb-redesign-app/SKILL.md +47 -0
  97. package/skills/playbooks/pb-release-aos/SKILL.md +50 -0
  98. package/skills/playbooks/pb-security-sweep/SKILL.md +48 -0
  99. package/skills/playbooks/pb-ship/SKILL.md +49 -0
  100. package/skills/playbooks/pb-show-build/SKILL.md +48 -0
  101. package/skills/playbooks/pb-social-pack/SKILL.md +47 -0
  102. package/skills/playbooks/pb-todo/SKILL.md +41 -0
  103. package/skills/playbooks/pb-worktrees-land/SKILL.md +52 -0
  104. package/.codex-plugin/marketplace.json +0 -11
@@ -12,9 +12,12 @@
12
12
  // 6. git reset --hard
13
13
  // 7. git clean -f (force clean untracked files)
14
14
  // 8. rm -r / rm -rf (recursive deletions)
15
+ // 9. git branch -D, git worktree remove, gh pr/issue/release/repo writes, gh api writes
16
+ // (GUARDED_PATTERNS is the authoritative list; each maps to a grant scope in classify; a catch-all covers what the parser cannot own.)
15
17
  //
16
- // Gate validity: open ONLY if the LAST human-typed user message in the transcript
17
- // is the literal word "GO" (case-insensitive, trimmed). Any other message closes it.
18
+ // Gate validity: open ONLY if the LAST user message in the transcript is
19
+ // human-typed (isHumanEntry) and is the literal word "GO" (case-insensitive,
20
+ // trimmed), or `GO <PR numbers>` (parseGoText). Any other message closes it.
18
21
  //
19
22
  // Token path (master-session): a `GO <session>` typed by the human in a master
20
23
  // session is turned into ~/.aos/go/<session>.token by go-token.mjs. This gate
@@ -36,29 +39,356 @@
36
39
  // the default XDG / ~/.local/share data dir is read, and bun:sqlite is an
37
40
  // unverified fallback. Any miss fails closed; the human types a literal GO.
38
41
 
39
- import { readFileSync, writeFileSync, existsSync, unlinkSync, realpathSync } from "node:fs";
42
+ //
43
+ // Modes and grants (typed `gogate ...`, or /bdb-aos:gogate; set only by go-grant.mjs):
44
+ // per session, state in ~/.aos/gate/<session-key>.json. `hard` = literal GO
45
+ // each time. `soft` (default) = hard plus active grants (scope + expiry) for
46
+ // matching commands. `off` = log only, this session only, 24 h at most. The
47
+ // store is a pointer list: every mode and grant is re-derived at use time from
48
+ // the human transcript entry it names (origin, text hash, timestamp), so a
49
+ // hand-written or widened grant fails closed to hard.
50
+ //
51
+ // Origin: only human-typed entries count, as GO and as gogate commands
52
+ // (isHumanEntry). Loops, peers, task notifications, SDK and system prompts never.
53
+
54
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, realpathSync, mkdirSync, renameSync, appendFileSync, readdirSync, openSync, readSync, closeSync, fstatSync, statSync } from "node:fs";
40
55
  import { createRequire } from "node:module";
56
+ import { spawnSync } from "node:child_process";
57
+ import { createHash } from "node:crypto";
41
58
  import { homedir } from "node:os";
42
59
  import { join } from "node:path";
43
60
  import { fileURLToPath } from "node:url";
44
61
 
62
+ // Command start: line/separator/subshell start, then optional wrappers (sudo/env/command/
63
+ // builtin/exec/nohup/time/xargs/eval with options, `bash -c`, VAR=x), an optional opening
64
+ // quote (bash -c 'git push') and an optional directory (/usr/bin/git). The regexes are the
65
+ // fast first line; the token classifier below (classify + the catch-all) is the authority.
66
+ const WRAP = String.raw`(?:(?:sudo|env|command|builtin|exec|nohup|time|xargs|eval|then|do|else)(?:\s+-{1,2}[\w-]+(?:=\S*)?(?:\s+(?![-'"])(?!(?:\S*/)?(?:git|gh|npm|rm|opencode|curl|wget)\b)[^\s;&|'"]+)?)*\s+|(?:bash|sh|zsh|dash|ksh)(?:\s+-\w+)*\s+-c\s+|\w+=\S*\s+)*`;
67
+ const A = String.raw`(?:^|[;&|\n({\x60])\s*${WRAP}['"]?(?:[^\s;&|()'"]*/)?`;
68
+ // git / gh with global options before the subcommand (plain `git <sub>` is spelled out too).
69
+ const G = String.raw`git(?:\s+(?:-[cC]\s*(?:"[^"]*"|'[^']*'|\S+)|--[\w-]+(?:=(?:"[^"]*"|'[^']*'|\S+))?))+\s+`;
70
+ const GH = String.raw`gh(?:\s+(?:-R|--repo)(?:\s+|=)\S+)+\s+`;
71
+ const S = String.raw`[^;&|\n]*`;
72
+ const re = (s, f = "i") => new RegExp(A + s, f);
73
+
45
74
  export const GUARDED_PATTERNS = [
46
- /(?:^|[;&|]\s*)git\s+push\b/i,
47
- /(?:^|[;&|]\s*)npm\s+publish\b/i,
48
- /(?:^|[;&|]\s*)npm\s+version\b/i,
49
- /(?:^|[;&|]\s*)gh\s+pr\s+merge\b/i,
50
- /(?:^|[;&|]\s*)gh\s+release\s+create\b/i,
51
- /(?:^|[;&|]\s*)git\s+reset\s+--hard\b/i,
52
- /(?:^|[;&|]\s*)git\s+clean\s+-[a-zA-Z]*f\b/i,
53
- /(?:^|[;&|]\s*)rm\s+(?:-\w*[rR]\w*|--recursive)\b/i,
75
+ re(String.raw`(?:git\s+push|${G}push)\b`),
76
+ re(String.raw`npm\s+publish\b`),
77
+ re(String.raw`npm\s+version\b`),
78
+ re(String.raw`(?:gh\s+pr\s+merge|${GH}pr\s+merge)\b`),
79
+ re(String.raw`(?:gh\s+release\s+create|${GH}release\s+create)\b`),
80
+ re(String.raw`(?:git\s+reset\s+--hard|(?:git\s+|${G})reset\b${S}\s--hard)\b`),
81
+ re(String.raw`(?:git\s+clean|${G}clean)\b${S}\s(?:-[a-zA-Z]*f[a-zA-Z]*|--force)\b`),
82
+ re(String.raw`rm\s+(?:${S}\s)?['"]?(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)\b`),
83
+ // case-sensitive: -d (safe delete) must stay unguarded, -D not
84
+ re(String.raw`(?:git\s+branch|${G}branch)\b${S}\s(?:-[a-zA-Z]*D[a-zA-Z]*|-[a-zA-Z]*(?:df|fd)[a-zA-Z]*)(?=\s|$)`, ""),
85
+ re(String.raw`(?:git\s+branch|${G}branch)\b(?=${S}\s(?:-d|--delete)\b)(?=${S}\s(?:-f|--force)\b)`, ""),
86
+ re(String.raw`(?:git\s+worktree|${G}worktree)\s+remove\b`),
87
+ re(String.raw`(?:gh\s+|${GH})pr\s+(?:create|comment|edit|review|close)\b`),
88
+ re(String.raw`(?:gh\s+|${GH})issue\s+(?:create|comment)\b`),
89
+ re(String.raw`(?:gh\s+|${GH})release\s+(?:edit|delete)\b`),
90
+ re(String.raw`(?:gh\s+|${GH})repo\s+(?:create|edit|delete)\b`),
91
+ re(String.raw`(?:gh\s+|${GH})api\b${S}\s(?:-X\s*|--method[\s=]+)(?:POST|PATCH|PUT|DELETE)\b`),
92
+ re(String.raw`(?:gh\s+|${GH})api\b${S}\s(?:-[fF]|--field|--raw-field|--input)`, ""),
93
+ // `git -c alias.x=push x`: a one-off alias hides the subcommand (a persisted `git config alias` cannot be seen here).
94
+ re(String.raw`git\s+(?:-[cC]\s*\S+\s+)*-c\s*['"]?alias\.`),
95
+ // Never grantable (no scope), always a plain GO: driving another OpenCode session
96
+ // (resume/attach, or the server's HTTP API) can type text that looks human there.
97
+ // The HTTP paths are matched anywhere in the command, independent of host spelling
98
+ // (127.1, [::ffff:...], $(hostname), unix socket, fetch() inside node -e).
99
+ // Residual risk: keystrokes into a resumed TUI (tmux send-keys, osascript) look exactly
100
+ // like a human typing and cannot be told apart by any hook.
101
+ re(String.raw`opencode\s+run\b${S}\s(?:-[a-zA-Z]*[sc]\S*|--session|--continue)(?=[\s=]|$)`, ""),
102
+ re(String.raw`claude\b${S}\s(?:-[a-zA-Z]*[cr]\S*|--resume|--continue)(?=[\s=]|$)`, ""),
103
+ re(String.raw`opencode\s+attach\b`),
104
+ /\/session\/[^\s/'"]+\/(?:message|prompt_async|command|shell)\b/i,
105
+ /\/\/[^\s'"]*\/tui\/|\/tui\/(?:append|submit|clear|execute|open|show|publish|control)/i,
54
106
  ];
55
107
 
56
- function readStdin() {
57
- try {
58
- return readFileSync(0, "utf8");
59
- } catch {
60
- return "";
108
+ // ---------------------------------------------------------------------------
109
+ // Scopes: each guarded command segment maps to the grant scope(s) it needs.
110
+ // ---------------------------------------------------------------------------
111
+ export const SCOPES = ["push-feature", "push-main", "merge", "publish", "destructive", "github-write"];
112
+
113
+ // Output redirections (`2>&1`, `>/dev/null`, `&>log`) are removed before splitting, so the
114
+ // `&` in `2>&1` is not a separator and `git push origin a:a 2>&1` reads as a plain push.
115
+ // Process substitutions `>(`/`<(` are kept (the `(` splits them into their own segment).
116
+ const REDIR = /\d*>&\d+|&>>?\s*[^\s(;&|]+|\d*>>?\s*[^\s(;&|]+/g;
117
+ const segments = (cmd) => String(cmd).replace(REDIR, " ").split(/&&|\|\||\$\(|[;&|\n(){}\x60]/).map((s) => s.trim()).filter(Boolean);
118
+ // Tokens keep quoted parts glued to their word; unq() gives the shell value (quotes and
119
+ // backslashes removed: `g\it`, `'git'`, `"push"` are git and push to the shell too).
120
+ const tokens = (s) => s.match(/(?:"[^"]*"|'[^']*'|[^\s"']+)+/g) || [];
121
+ const unq = (t) => String(t ?? "").replace(/["'\\]/g, "");
122
+ const base = (t) => unq(t).replace(/^.*\//, "");
123
+
124
+ // Transparent prefixes: the command after them is what runs. `v` = options taking a value,
125
+ // `pos` = positional arguments of the wrapper itself (timeout DURATION, script FILE).
126
+ const WRAPPERS = {
127
+ sudo: { v: /^-[ugCDhpRrTUt]$/ }, doas: { v: /^-[uC]$/ }, env: { v: /^-[uCS]$/ }, command: {}, builtin: {}, exec: { v: /^-a$/ },
128
+ nohup: {}, time: {}, nice: { v: /^-n$/ }, timeout: { v: /^-[sk]$/, pos: 1 }, caffeinate: { v: /^-[tw]$/ }, stdbuf: { v: /^-[ioe]$/ },
129
+ xargs: { v: /^-[IPndLsEa]$/ }, script: { v: /^-[Ft]$/, pos: 1 }, then: {}, do: {}, else: {},
130
+ };
131
+ const SHELLS = /^(?:bash|sh|zsh|dash|ksh|eval|watch|parallel)$/;
132
+
133
+ // Strips `!`, VAR=x and wrappers. `opaque`: the real command text is hidden (sh -c, eval,
134
+ // watch, env -S, linux `script -c`, xargs input); `gitEnv`: a GIT_* variable is set.
135
+ function stripPrefix(tok) {
136
+ let i = 0, opaque = false, gitEnv = false;
137
+ for (;;) {
138
+ const t = unq(tok[i] ?? "");
139
+ if (t === "!") { i++; continue; }
140
+ if (/^\w+=/.test(t)) { gitEnv ||= /^GIT_/.test(t); i++; continue; }
141
+ const w = WRAPPERS[base(tok[i] ?? "")];
142
+ if (!w) break;
143
+ const name = base(tok[i]);
144
+ i++;
145
+ let pos = w.pos || 0;
146
+ while (i < tok.length) {
147
+ const o = unq(tok[i]);
148
+ if (name === "env" && /^\w+=/.test(o)) { gitEnv ||= /^GIT_/.test(o); i++; continue; }
149
+ if (o.startsWith("-") && o !== "-") {
150
+ if ((name === "env" && /^-S/.test(o)) || (name === "script" && o === "-c")) opaque = true;
151
+ i += w.v?.test(o) ? 2 : 1;
152
+ continue;
153
+ }
154
+ if (pos > 0) { pos--; i++; continue; }
155
+ break;
156
+ }
157
+ if (name === "xargs") opaque = true;
158
+ }
159
+ const head = base(tok[i] ?? "");
160
+ if (SHELLS.test(head)) opaque = true;
161
+ return { rest: tok.slice(i), opaque, gitEnv };
162
+ }
163
+
164
+ // Branches a push-feature grant never covers. Default: main/master plus the usual long-lived
165
+ // names; at gate time the remote's HEAD (git symbolic-ref) replaces the long list when it
166
+ // resolves. AOS_GATE_PROTECTED_BRANCHES adds a comma list. `release-*` style globs allowed.
167
+ export const FALLBACK_PROTECTED = ["main", "master", "develop", "trunk", "production", "release", "release-*", "release/*"];
168
+ const envProtected = () => String(process.env.AOS_GATE_PROTECTED_BRANCHES || "").split(",").map((s) => s.trim()).filter(Boolean);
169
+ const isProtected = (branch, list) => list.some((p) => new RegExp(`^${p.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`, "i").test(branch));
170
+
171
+ // Injectable resolver: (remote) => string[] of protected branch names. Never throws; any
172
+ // failure falls back to FALLBACK_PROTECTED, so an unresolvable remote is protected widely.
173
+ export function makeBranchResolver(cwd, run) {
174
+ const cache = new Map();
175
+ return (remote) => {
176
+ if (cache.has(remote)) return cache.get(remote);
177
+ let list = [...FALLBACK_PROTECTED];
178
+ try {
179
+ if (/^[A-Za-z0-9._-]+$/.test(remote) && run) {
180
+ const r = run("git", ["symbolic-ref", "--short", `refs/remotes/${remote}/HEAD`], { cwd, encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] });
181
+ const head = r && r.status === 0 ? String(r.stdout).trim().replace(new RegExp(`^${remote}/`), "") : "";
182
+ if (/^[A-Za-z0-9._/-]+$/.test(head)) list.push(head);
183
+ }
184
+ } catch { /* fall back */ }
185
+ list.push(...envProtected());
186
+ cache.set(remote, list);
187
+ return list;
188
+ };
189
+ }
190
+ const staticResolver = () => [...FALLBACK_PROTECTED, ...envProtected()];
191
+
192
+ const SAFE_NAME = /^[A-Za-z0-9._/-]+$/;
193
+ const SAFE_PUSH_OPT = /^(?:-u|--set-upstream|-q|--quiet|-v|--verbose|--no-verify|-n|--dry-run|--progress|--no-progress|--porcelain|--atomic|--no-atomic|--signed|--no-signed)$/;
194
+
195
+ // push-feature is an allowlist: every token plain, every refspec EXPLICIT `src:dst` (an
196
+ // explicit destination is never remapped by remote.<name>.push or push.default), the
197
+ // destination not protected. Anything it cannot read (quotes, $, globs, @, +, refs/ outside
198
+ // heads/tags, config overrides, stdin-fed xargs, no `:dst`) needs push-main.
199
+ function pushScopes(args, unsafe, protectedFor) {
200
+ let main = unsafe, publish = false;
201
+ const pos = [];
202
+ for (let i = 0; i < args.length; i++) {
203
+ const t = args[i];
204
+ if (t === "--tags" || t === "--follow-tags") publish = true;
205
+ else if (t === "-o" || t === "--push-option") i++;
206
+ else if (/^--push-option=/.test(t)) continue;
207
+ else if (t.startsWith("-")) { if (!SAFE_PUSH_OPT.test(t)) main = true; }
208
+ else pos.push(t);
209
+ }
210
+ const [remote, ...refs] = pos;
211
+ if (!remote || !SAFE_NAME.test(remote) || !refs.length) main = true; // bare push: current branch and refmap unknown
212
+ const prot = protectedFor(remote || "");
213
+ for (const r of refs) {
214
+ const m = /^([A-Za-z0-9._/-]+):([A-Za-z0-9._/-]+)$/.exec(r);
215
+ if (/^(?:refs\/tags\/|v?\d+(?:\.\d+)+)/.test(r.split(":").pop())) { publish = true; continue; }
216
+ if (!m) { main = true; continue; } // no explicit destination
217
+ const d = m[2].replace(/^(?:refs\/)?heads\//, "");
218
+ if (/^refs\/tags\//.test(m[1])) { publish = true; continue; }
219
+ if (/^head$/i.test(d) || d.startsWith("refs/") || d.startsWith("-") || d.startsWith(".") || isProtected(d, prot)) main = true;
220
+ }
221
+ const out = [];
222
+ if (publish) out.push("publish");
223
+ if (main) out.push("push-main");
224
+ return out.length ? out : ["push-feature"];
225
+ }
226
+
227
+ function ghParts(tok) {
228
+ let i = 1;
229
+ for (;;) {
230
+ const t = unq(tok[i] ?? "");
231
+ if (/^(?:-R|--repo)$/.test(t)) i += 2;
232
+ else if (/^--repo=/.test(t)) i++;
233
+ else break;
234
+ }
235
+ return { a: unq(tok[i]), b: unq(tok[i + 1]), rest: tok.slice(i + 2).map(unq) };
236
+ }
237
+
238
+ const GIT_VALUE_OPTS = /^(?:-[cC]|--git-dir|--work-tree|--namespace|--super-prefix|--exec-path|--config-env|--list-cmds|--attr-source)$/;
239
+ const GIT_FLAG_OPTS = /^(?:-[pP]|--paginate|--no-pager|--bare|--no-replace-objects|--literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs|--no-optional-locks|--no-lazy-fetch|--no-advice|--html-path|--man-path|--info-path|--version|--help)$/;
240
+ const PUSHY_CONFIG = /push|remote\.|url\.|insteadof|alias\./i;
241
+ const PM = /^(?:npm|pnpm|yarn|bun)$/;
242
+ const NPM_VALUE_OPTS = /^(?:--prefix|-w|--workspace|-C|--dir|--registry|--userconfig|--cache|--tag|--otp|--access|--filter|--cwd)$/;
243
+
244
+ // Precise classification of one segment: array of scopes, [] = not guarded,
245
+ // null = guarded but not classifiable (never grantable).
246
+ export function classify(seg, protectedFor = staticResolver) {
247
+ const { rest: tok, opaque, gitEnv } = stripPrefix(tokens(seg));
248
+ const cmd = base(tok[0] ?? "");
249
+ if (opaque) return catchAll(tok, seg, true) ? null : [];
250
+ if (cmd === "git" || cmd === "hub") {
251
+ let i = 1, config = false;
252
+ for (;;) {
253
+ const t = unq(tok[i] ?? "");
254
+ if (GIT_VALUE_OPTS.test(t)) { config ||= /^(?:-c|--config-env)$/.test(t); i += 2; }
255
+ else if (/^(?:-[cC].|--[\w-]+=)/.test(t)) { config ||= /^(?:-c.|--config-env=)/.test(t); i++; }
256
+ else if (GIT_FLAG_OPTS.test(t)) i++;
257
+ else if (t.startsWith("-")) return catchAll(tok, seg) ? null : []; // unknown global option
258
+ else break;
259
+ }
260
+ const sub = unq(tok[i]), r = tok.slice(i + 1).map(unq);
261
+ if (sub === "push") {
262
+ const unsafe = config || gitEnv || /[<'"$\x60\\*?{}[\]~^]/.test(tok.slice(i).join(" "));
263
+ return pushScopes(r, unsafe, protectedFor);
264
+ }
265
+ if (sub === "reset" && r.includes("--hard")) return ["destructive"];
266
+ if (sub === "clean" && r.some((t) => /^-[a-zA-Z]*f/.test(t) || t === "--force")) return ["destructive"];
267
+ if (sub === "branch") {
268
+ const del = r.some((t) => /^-[a-zA-Z]*D/.test(t) || /^-[a-zA-Z]*(?:df|fd)/.test(t)) ||
269
+ (r.some((t) => t === "--delete" || /^-[a-ce-zA-Z]*d[a-zA-Z]*$/.test(t)) && r.some((t) => t === "--force" || /^-[a-eg-zA-Z]*f[a-zA-Z]*$/.test(t)));
270
+ return del ? ["destructive"] : [];
271
+ }
272
+ if (sub === "worktree" && r[0] === "remove") return ["destructive"];
273
+ if (sub === "config" && r.some((t) => PUSHY_CONFIG.test(t))) {
274
+ const args = r.filter((t) => !t.startsWith("-"));
275
+ const read = r.some((t) => /^(?:--get\S*|--list|-l)$/.test(t)) || (args.length === 1 && !r.some((t) => /^--(?:add|unset\S*|replace-all|edit|rename-section|remove-section)$|^-e$/.test(t)));
276
+ return read ? [] : null;
277
+ }
278
+ return [];
279
+ }
280
+ if (PM.test(cmd) || cmd === "npx") {
281
+ if (cmd === "npx") return catchAll(tok, seg) ? null : [];
282
+ let i = 1;
283
+ while (unq(tok[i] ?? "").startsWith("-")) i += NPM_VALUE_OPTS.test(unq(tok[i])) ? 2 : 1;
284
+ const sub = unq(tok[i]), next = unq(tok[i + 1]);
285
+ if (/^(?:publish|version)$/.test(sub) || (cmd === "yarn" && sub === "npm" && next === "publish")) return ["publish"];
286
+ return /^(?:run|run-script|exec|x|dlx)$/.test(sub) && catchAll(tok, seg) ? null : [];
287
+ }
288
+ if (cmd === "rm") {
289
+ const r = tok.slice(1).map(unq);
290
+ const end = r.indexOf("--");
291
+ return r.slice(0, end === -1 ? undefined : end).some((t) => /^-[a-zA-Z]*[rR]/.test(t) || t === "--recursive") ? ["destructive"] : [];
292
+ }
293
+ if (cmd === "gh") {
294
+ const { a, b, rest } = ghParts(tok);
295
+ if (a === "pr" && b === "merge") return ["merge"];
296
+ if (a === "pr" && /^(?:create|comment|edit|review|close)$/.test(b)) return ["github-write"];
297
+ if (a === "release" && b === "create") return ["publish"];
298
+ if (a === "release" && /^(?:edit|delete)$/.test(b)) return ["github-write"];
299
+ if (a === "issue" && /^(?:create|comment)$/.test(b)) return ["github-write"];
300
+ if (a === "repo" && /^(?:create|edit|delete)$/.test(b)) return ["github-write"];
301
+ if (a === "api") {
302
+ const s = [b, ...rest].join(" ");
303
+ if (b === "graphql" && !/mutation/i.test(s) && !/(?:-X|--method)/i.test(s)) return [];
304
+ if (/(?:^|\s)(?:-X\s*|--method[\s=]+)(?:POST|PATCH|PUT|DELETE)\b/i.test(s) || /(?:^|\s)(?:-[fF]|--field|--raw-field|--input)/.test(s)) return ["github-write"];
305
+ }
306
+ return [];
307
+ }
308
+ if (/^(?:push|publish)$/.test(tok[0] ?? "")) return null; // the program name was computed: `$(echo git) push`
309
+ return catchAll(tok, seg) ? null : [];
310
+ }
311
+
312
+ // Catch-all for segments the precise parser does not own (unknown wrappers, `watch`, `sh -c`,
313
+ // unknown git options): a word whose basename is git followed later by a guarded git verb,
314
+ // a package manager followed later by publish/version, gh followed by a write verb, or rm
315
+ // with a recursive flag makes the segment guarded with no scope.
316
+ const GIT_VERBS = /^(?:push|reset|clean|branch|worktree|config)$/;
317
+ function catchAll(tok, seg, opaque = false) {
318
+ // inside sh -c / eval / watch the quoted text is the command, so split it into words
319
+ const w = (opaque ? tok.flatMap((t) => unq(t).split(/\s+/)) : tok).map(base);
320
+ const after = (i, set) => w.slice(i + 1).some((x) => set.test(x));
321
+ for (let i = 0; i < w.length; i++) {
322
+ if ((w[i] === "git" || w[i] === "hub") && after(i, GIT_VERBS)) return true;
323
+ if ((PM.test(w[i]) || w[i] === "npx") && after(i, /^(?:publish|version)$/)) return true;
324
+ if (w[i] === "gh" && after(i, /^(?:merge|create|delete|edit|comment|close|review|api)$/)) return true;
325
+ if (/^(?:np|release-it|semantic-release)$/.test(w[i]) || (w[i] === "changeset" && after(i, /^publish$/))) return true;
326
+ if (w[i] === "rm" && w.slice(i + 1).some((x) => /^-[a-zA-Z]*[rR]/.test(x) || x === "--recursive")) return true;
327
+ }
328
+ return false;
329
+ }
330
+
331
+ const segGuarded = (seg) => { const c = classify(seg); return c === null || c.length > 0 || GUARDED_PATTERNS.some((r) => r.test(seg)); };
332
+
333
+ // A compound command that also rewrites git's push configuration (GIT_CONFIG_*, an exported
334
+ // GIT_ variable, git config of push/remote/url keys, git remote add/set-url) can redirect the
335
+ // push that follows; the whole command then gets no scope.
336
+ const UNSAFE_COMPOUND = /GIT_CONFIG|\bexport\s+GIT_|\bgit\b[^;&|\n]*\bconfig\b[^;&|\n]*(?:push|remote\.|url\.|insteadof)|\bgit\b[^;&|\n]*\bremote\s+(?:add|set-url|rename|set-branches)\b/i;
337
+
338
+ // null = guarded but at least one guarded part maps to no scope (never covered by a grant).
339
+ // A shell reading commands from stdin (`... | sh`, `bash -s`, `source /dev/stdin`): the text it
340
+ // runs is whatever the other segments print, so their quoted words are the command.
341
+ function feedsShell(segs) {
342
+ return segs.some((seg) => {
343
+ const { rest } = stripPrefix(tokens(seg));
344
+ const head = base(rest[0] ?? ""), args = rest.slice(1).map(unq);
345
+ if (/^(?:sh|bash|zsh|dash|ksh|fish)$/.test(head)) return !args.some((a) => !a.startsWith("-")) || args.includes("-s") || args.includes("/dev/stdin");
346
+ return /^(?:source|\.)$/.test(head) && args.some((a) => a === "/dev/stdin" || a === "-");
347
+ });
348
+ }
349
+
350
+ export function commandScopes(command, protectedFor = staticResolver) {
351
+ const out = new Set();
352
+ const segs = segments(command);
353
+ if (feedsShell(segs) && catchAll(tokens(String(command)), "", true)) return null;
354
+ for (const seg of segs) {
355
+ const s = classify(seg, protectedFor);
356
+ if (s === null) return null;
357
+ if (!s.length && GUARDED_PATTERNS.some((r) => r.test(seg))) return null;
358
+ s.forEach((x) => out.add(x));
61
359
  }
360
+ if (!out.size && GUARDED_PATTERNS.some((r) => r.test(String(command)))) return null;
361
+ if (out.size && UNSAFE_COMPOUND.test(String(command))) return null;
362
+ return [...out];
363
+ }
364
+
365
+ export const isGuardedCommand = (command) =>
366
+ typeof command === "string" && (GUARDED_PATTERNS.some((r) => r.test(command)) || (commandScopes(command) ?? [1]).length > 0);
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Grant store protection (Bash side; Write/Edit side is env-file-protection).
370
+ // Limit, said plainly: this is a string match on a normalised command (quotes and
371
+ // backslashes removed, `//` and `/./` folded, case-insensitive for APFS). It can
372
+ // NEVER catch: `cd ~/.aos && cp x gate/k`, a path built in a variable or by an
373
+ // interpreter from pieces that do not sit next to each other, a symlink or script
374
+ // created in an earlier command, here-docs, or any code the agent writes to a file
375
+ // and runs. The real protection is the transcript verification in effectiveGate:
376
+ // a forged store entry without a matching human entry is ignored.
377
+ // ---------------------------------------------------------------------------
378
+ const STORE_REF = /\.aos[/,\s+]+(?:\.[/]+)*(?:gate|go)(?![\w.-])|\.aos\/+[^\s/]*[$*?[{]/i;
379
+ export function gateStoreReason(command) {
380
+ if (typeof command !== "string") return null;
381
+ const norm = command.replace(/["'\\]/g, "").replace(/\/(?:\.?\/)+/g, "/");
382
+ if (!STORE_REF.test(norm)) return null;
383
+ // `>` covers redirects and `>(`; `<(` and `$(`/backticks run commands of their own.
384
+ const readOnly = !/[>\x60]|\$\(|<\(/.test(norm) &&
385
+ norm.split(/&&|\|\||[;|&\n]/).every((s) => /^\s*(?:cat|ls|grep|head|tail)\b/.test(s));
386
+ return readOnly ? null : "Blocked by go-gate: the GO/grant store (~/.aos/gate, ~/.aos/go) is written only by the AOS hooks. Grants come from the human typing `gogate grant ...`; read-only cat/ls/grep/head/tail is allowed.";
387
+ }
388
+
389
+ // Unreadable stdin throws: safeMain denies (exit 2) instead of allowing with empty input.
390
+ function readStdin() {
391
+ return readFileSync(0, "utf8");
62
392
  }
63
393
 
64
394
  function respond(isAgy, allowed, reason = "", command = "") {
@@ -108,18 +438,330 @@ function extractAgyText(rawContent) {
108
438
  }
109
439
 
110
440
  export const slug = (s) => s.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
441
+
442
+ // ---------------------------------------------------------------------------
443
+ // Origin. Claude Code user entries carry origin.kind (human, peer,
444
+ // task-notification, coordinator, auto-continuation), promptSource (typed,
445
+ // queued, suggestion_accepted, sdk, system), turnOrigin, isMeta, and
446
+ // sourceToolUseID for model-invoked skills. Typed prompts have origin.kind
447
+ // "human" + promptSource "typed"; older entries and slash-command wrapper
448
+ // entries (<command-name>) usually carry neither origin nor promptSource.
449
+ //
450
+ // isHumanEntry (enough for a plain GO): origin present -> kind exactly "human"
451
+ // and promptSource absent/typed/queued; origin absent -> promptSource absent or
452
+ // typed. Always: turnOrigin absent or "human", not meta/sidechain/compact
453
+ // summary/tool result/skill injection, no scheduler/wakeup/cron/loop keys.
454
+ // suggestion_accepted (model-written text, one keypress) and sdk never count.
455
+ //
456
+ // isStrictHumanEntry (required to SET a mode or grant): additionally needs
457
+ // positive evidence, origin.kind "human" or promptSource "typed". The asymmetry
458
+ // is deliberate: a plain GO unlocks one command once and a later entry closes it
459
+ // again; a mode or grant lasts up to 24h, so an entry with no origin data at all
460
+ // (legacy, or a scheduled prompt recorded without fields) must not create one.
461
+ // ---------------------------------------------------------------------------
462
+ // Exact key names (not substrings: `isLoopback` is harmless). The positive allowlist below
463
+ // (origin.kind human, promptSource typed/queued) remains the real rule; this only adds
464
+ // explicit rejections for shapes a scheduler, cron, wakeup or loop is known/expected to use.
465
+ const SCHEDULED_KEYS = new Set(["scheduledTaskId", "scheduledFireId", "wakeupSource", "scheduleId", "cronId", "loopId", "scheduled", "scheduledTask", "cronTask", "loopTask"]);
466
+ const SCHEDULED_VALUE = /schedul|wakeup|cron|loop/i;
467
+ const hasSchedulerMark = (o, depth = 0) => {
468
+ if (!o || typeof o !== "object" || depth > 4) return false;
469
+ return Object.entries(o).some(([k, v]) => SCHEDULED_KEYS.has(k) || (depth > 0 && typeof v === "string" && SCHEDULED_VALUE.test(v)) ||
470
+ ((k === "origin" || depth > 0) && hasSchedulerMark(v, depth + 1)));
471
+ };
472
+ export function isHumanEntry(e) {
473
+ if (!e || typeof e !== "object") return false;
474
+ if (e.type === "USER_INPUT" || e.source === "USER_EXPLICIT") return true; // Antigravity: no origin info exists
475
+ if (e.type !== "user" && e.role !== "user") return false;
476
+ if (e.isSidechain === true || e.isMeta === true || e.isCompactSummary === true || e.isVisibleInTranscriptOnly === true) return false;
477
+ if (e.sourceToolUseID || e.toolUseResult !== undefined) return false;
478
+ if (hasSchedulerMark(e)) return false;
479
+ if (e.origin !== undefined && (!e.origin || typeof e.origin !== "object")) return false;
480
+ if (e.turnOrigin !== undefined && e.turnOrigin !== "human") return false;
481
+ if (e.origin !== undefined) return e.origin.kind === "human" && [undefined, "typed", "queued"].includes(e.promptSource);
482
+ return e.promptSource === undefined || e.promptSource === "typed";
483
+ }
484
+ export const isStrictHumanEntry = (e) =>
485
+ isHumanEntry(e) && e.type === "user" && (e.origin?.kind === "human" || (e.origin === undefined && e.promptSource === "typed"));
486
+
487
+ // OpenCode parts: synthetic = model-only, ignored = display-only, aos_bus / aos_loop
488
+ // (any loop|aos_ metadata key) = plugin-injected. None of them is a human turn.
489
+ export const isHumanPart = (p) => !!p && p.type === "text" && typeof p.text === "string" && !p.synthetic && !p.ignored &&
490
+ !(p.metadata && typeof p.metadata === "object" && Object.keys(p.metadata).some((k) => /loop|aos_/i.test(k)));
491
+
492
+ // ---------------------------------------------------------------------------
493
+ // GO and GO <PR references>
494
+ // Strict grammar, nothing else counts: `GO` alone (any case), or `GO` followed
495
+ // only by PR references `#117` / `PR 117` / `PR #117` (PR in capitals), separated
496
+ // by spaces, commas or exactly the connectors und/and, with an optional für/for
497
+ // right after GO. Decision: the connectors are allowed because Tim's real
498
+ // message was "GO für #117 und #118"; every other word makes it prose. Spaces
499
+ // and tabs only, so no line break of any kind (CR, LF, U+0085, U+2028) fits.
500
+ // A PR-only text is never a session name, so no known-session lookup applies.
501
+ // ---------------------------------------------------------------------------
502
+ const REF = String.raw`(?:#\d+|PR[ \t]*#[ \t]*\d+|PR[ \t]+\d+)`;
503
+ const GO_PRS = new RegExp(String.raw`^[Gg][Oo](?:[ \t]+(?:für|for))?[ \t]+${REF}(?:(?:[ \t]*,[ \t]*|[ \t]+(?:(?:und|and)[ \t]+)?)${REF})*$`);
504
+
505
+ // `GO` -> { ok, prs: null }; `GO <refs>` -> { ok, prs }.
506
+ export function parseGoText(text) {
507
+ const t = String(text ?? "").trim();
508
+ if (/^GO$/i.test(t)) return { ok: true, prs: null };
509
+ if (GO_PRS.test(t)) return { ok: true, prs: [...t.matchAll(/(\d+)/g)].map((m) => Number(m[1])) };
510
+ return { ok: false, reason: `last user message is not a GO (GO alone, or GO #117 #118): ${JSON.stringify(t.slice(0, 120))}` };
511
+ }
512
+
513
+ const ghPrTarget = (seg) => {
514
+ const { rest: tok, opaque } = stripPrefix(tokens(seg));
515
+ if (opaque || base(tok[0]) !== "gh") return null;
516
+ const { a, b, rest } = ghParts(tok);
517
+ if (a !== "pr") return null;
518
+ const nums = rest.flatMap((x) => { const m = /^#?(\d+)$/.exec(x) || /\/pull\/(\d+)/.exec(x); return m ? [Number(m[1])] : []; });
519
+ return { sub: b, nums };
520
+ };
521
+
522
+ // A plain GO covers the command. A PR GO covers ONLY gh pr merge|edit|close|review|comment
523
+ // whose PR numbers are all in its list; every other guarded part of the same command needs
524
+ // a plain GO. `--repo`/`-R` is not compared: which repo a number belongs to cannot be known,
525
+ // so only the number has to match.
526
+ export function goAllows(go, command) {
527
+ if (!go?.ok) return false;
528
+ if (!go.prs) return true;
529
+ if (commandScopes(command) === null) return false; // something in it cannot be classified
530
+ let guardedSeen = false;
531
+ for (const seg of segments(command)) {
532
+ if (!segGuarded(seg)) continue;
533
+ guardedSeen = true;
534
+ const pr = ghPrTarget(seg);
535
+ if (!pr || !/^(?:merge|edit|close|review|comment)$/.test(pr.sub) || !pr.nums.length || pr.nums.some((n) => !go.prs.includes(n))) return false;
536
+ }
537
+ return guardedSeen || !isGuardedCommand(command);
538
+ }
539
+
540
+ // ---------------------------------------------------------------------------
541
+ // Transcript reading: line by line in 1 MB chunks (forward or from the end), so a
542
+ // huge transcript never becomes one giant string. Non-object lines are skipped.
543
+ // ---------------------------------------------------------------------------
544
+ const CHUNK = 1 << 20;
545
+ function* fileLines(path) {
546
+ const fd = openSync(path, "r");
547
+ try {
548
+ const buf = Buffer.alloc(CHUNK);
549
+ let rest = Buffer.alloc(0);
550
+ for (let n; (n = readSync(fd, buf, 0, CHUNK, null)) > 0;) {
551
+ const chunk = Buffer.concat([rest, buf.subarray(0, n)]);
552
+ let start = 0;
553
+ for (let i; (i = chunk.indexOf(10, start)) !== -1; start = i + 1) if (i > start) yield chunk.toString("utf8", start, i);
554
+ rest = chunk.subarray(start);
555
+ }
556
+ if (rest.length) yield rest.toString("utf8");
557
+ } finally { closeSync(fd); }
558
+ }
559
+ function* fileLinesReverse(path) {
560
+ const fd = openSync(path, "r");
561
+ try {
562
+ let pos = fstatSync(fd).size;
563
+ let rest = Buffer.alloc(0);
564
+ while (pos > 0) {
565
+ const len = Math.min(CHUNK, pos);
566
+ pos -= len;
567
+ const b = Buffer.alloc(len);
568
+ readSync(fd, b, 0, len, pos);
569
+ const chunk = Buffer.concat([b, rest]);
570
+ let end = chunk.length;
571
+ for (let i; end > 0 && (i = chunk.lastIndexOf(10, end - 1)) !== -1; end = i) if (end > i + 1) yield chunk.toString("utf8", i + 1, end);
572
+ rest = chunk.subarray(0, end);
573
+ }
574
+ if (rest.length) yield rest.toString("utf8");
575
+ } finally { closeSync(fd); }
576
+ }
577
+ const parseEntry = (line) => { try { const e = JSON.parse(line); return e && typeof e === "object" && !Array.isArray(e) ? e : null; } catch { return null; } };
578
+
579
+ // ---------------------------------------------------------------------------
580
+ // Gogate commands and the per-session state store
581
+ // ---------------------------------------------------------------------------
582
+ export const MAX_GRANT_MS = 24 * 60 * 60 * 1000;
583
+ const SKEW_MS = 10 * 60 * 1000; // hook write time vs transcript entry timestamp (queued prompts land later); still bound by the typed duration
584
+ const UNIT = { m: 60e3, h: 3600e3, d: 86400e3 };
585
+
586
+ // Slash-command entries are stored as <command-name>/x</command-name>...<command-args>a</command-args>;
587
+ // only a text made of nothing but those tags is unwrapped.
588
+ const WRAPPER = /^(?:\s*<(command-(?:name|message|args))>[^<]*<\/\1>)+\s*$/;
589
+ export function normalizeCommandText(text) {
590
+ let t = String(text ?? "").trim();
591
+ if (WRAPPER.test(t)) {
592
+ const name = /<command-name>([^<]*)<\/command-name>/.exec(t)?.[1]?.trim() || "";
593
+ const args = /<command-args>([^<]*)<\/command-args>/.exec(t)?.[1] || "";
594
+ t = `${name && !name.startsWith("/") ? "/" : ""}${name} ${args}`;
595
+ }
596
+ return t.replace(/\s+/g, " ").trim();
597
+ }
598
+ export const textHash = (text) => createHash("sha256").update(normalizeCommandText(text)).digest("hex");
599
+
600
+ // Two input forms. PLAIN (primary): the whole message is `gogate <hard|soft|off|status|grant ...>`,
601
+ // one line, nothing else; a typed prompt carries origin human + promptSource typed, so the
602
+ // strict check works. SLASH (`/bdb-aos:gogate`, `/bdb-aos-gogate`, `/gogate`): parsed too,
603
+ // but Claude Code mostly stores slash commands without origin, so they rarely verify.
604
+ // null = not a gogate command. Else { cmd, form } or { cmd: "grant", scopes, ms, session, form } or { error, form }.
605
+ const SLASH_FORM = /^\/(?:bdb-aos[:-])?gogate(?:\s+(.*))?$/i;
606
+ const PLAIN_FORM = /^gogate\s+((?:hard|soft|off|status|grant)\b.*)$/i;
607
+ export function parseGogate(text) {
608
+ const norm = normalizeCommandText(text);
609
+ let m = SLASH_FORM.exec(norm), form = "slash";
610
+ if (!m) {
611
+ m = PLAIN_FORM.exec(norm);
612
+ form = "plain";
613
+ if (!m || /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(String(text ?? "").trim())) return null;
614
+ }
615
+ const r = parseGogateArgs((m[1] || "").split(" ").filter(Boolean));
616
+ return { ...r, form };
617
+ }
618
+
619
+ function parseGogateArgs(args) {
620
+ const sub = (args[0] || "status").toLowerCase();
621
+ if (["hard", "soft", "off", "status"].includes(sub)) return args.length <= 1 ? { cmd: sub } : { error: `${sub} takes no arguments` };
622
+ if (sub !== "grant") return { error: `unknown subcommand "${sub}" (hard|soft|off|status|grant)` };
623
+ if (args.length !== 3) return { error: "usage: grant <scope[,scope...]> <15m|2h|1d|session>" };
624
+ const scopes = [...new Set(args[1].toLowerCase().split(",").filter(Boolean))];
625
+ const bad = scopes.filter((s) => !SCOPES.includes(s));
626
+ if (!scopes.length || bad.length) return { error: `unknown scope ${bad.join(",") || "(none)"}; scopes: ${SCOPES.join(", ")}` };
627
+ if (/^session$/i.test(args[2])) return { cmd: "grant", scopes, ms: MAX_GRANT_MS, session: true };
628
+ const d = /^(\d+)([mhd])$/i.exec(args[2]);
629
+ const ms = d ? Number(d[1]) * UNIT[d[2].toLowerCase()] : NaN;
630
+ if (!(ms > 0)) return { error: `bad duration "${args[2]}" (15m, 2h, 1d or session)` };
631
+ if (ms > MAX_GRANT_MS) return { error: `duration ${args[2]} exceeds the 24h maximum` };
632
+ return { cmd: "grant", scopes, ms, session: false };
633
+ }
634
+
635
+ export const gateDir = () => join(homedir(), ".aos", "gate");
636
+ // Plain ids (Claude uuids) stay readable; anything that would be changed by slugging
637
+ // gets a hash suffix, so two different ids never share a key ("Sess-A" vs "sess-A").
638
+ export const sessionKey = (id) => {
639
+ const raw = String(id ?? "");
640
+ if (!raw) return "";
641
+ return /^[a-z0-9][a-z0-9._-]{0,127}$/.test(raw) ? raw : `${slug(raw).replace(/^\.+/, "").slice(0, 64) || "s"}-${createHash("sha256").update(raw).digest("hex").slice(0, 12)}`;
642
+ };
643
+ export const statePath = (key) => join(gateDir(), `${key}.json`);
644
+
645
+ export function readState(key) {
646
+ try { return JSON.parse(readFileSync(statePath(key), "utf8")); } catch { return null; }
647
+ }
648
+ export function writeState(key, state) {
649
+ mkdirSync(gateDir(), { recursive: true, mode: 0o700 });
650
+ const tmp = `${statePath(key)}.${process.pid}.tmp`;
651
+ writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
652
+ renameSync(tmp, statePath(key));
653
+ }
654
+ const LOG_MAX = 256 * 1024, LOG_KEEP = 500;
655
+ export function gateLog(key, line) {
656
+ try {
657
+ mkdirSync(gateDir(), { recursive: true, mode: 0o700 });
658
+ const f = join(gateDir(), `${key}.log`);
659
+ appendFileSync(f, `${new Date().toISOString()} ${String(line).slice(0, 400).replace(/[\r\n]+/g, " ")}\n`, { mode: 0o600 });
660
+ if (statSync(f).size > LOG_MAX) writeFileSync(f, readFileSync(f, "utf8").split("\n").filter(Boolean).slice(-LOG_KEEP).join("\n") + "\n");
661
+ } catch { /* logging never blocks */ }
662
+ }
663
+
664
+ // Gogate commands from human entries of a Claude transcript, oldest first. `strict`
665
+ // marks entries with positive human evidence; only those can set a mode or grant,
666
+ // but every human-looking one counts for revoking (hard) and for "latest mode".
667
+ export function claudeGogateCommands(transcriptPath) {
668
+ const out = [];
669
+ try {
670
+ for (const line of fileLines(transcriptPath)) {
671
+ if (!/gogate/i.test(line) || !line.includes('"user"')) continue;
672
+ const e = parseEntry(line);
673
+ if (!e || e.type !== "user" || !isHumanEntry(e)) continue;
674
+ const text = extractClaudeText(e.message?.content ?? e.content).trim();
675
+ const parsed = parseGogate(text);
676
+ if (parsed && !parsed.error) out.push({ id: e.uuid ?? null, ts: Date.parse(e.timestamp), hash: textHash(text), parsed, strict: isStrictHumanEntry(e) });
677
+ }
678
+ } catch { return []; }
679
+ return out;
680
+ }
681
+
682
+ const WEAK_HINT = "it came from a slash command, which Claude Code stores without human-origin data; type the plain form as the whole message instead, e.g. `gogate grant merge 2h` or `gogate off`";
683
+ const OC_HINT = "OpenCode accepts only the plain form (`gogate grant merge 2h`) typed as the whole message";
684
+ export const OPENCODE_MAX_GRANT_MS = 2 * 3600e3;
685
+
686
+ // Resolves mode and active grants for a session, verified against `cmds` (gogate commands
687
+ // of the session, oldest first, each marked `strict` when it has human-origin evidence).
688
+ // `source` is the transcript path (Claude) or "opencode:<sessionID>"; store entries for
689
+ // another source are rejected. Rules:
690
+ // - The mode is the latest usable mode command of the TRANSCRIPT: any `hard` (tightening
691
+ // needs no proof), or a strict `soft`/`off`. A non-strict soft/off is ignored and leaves
692
+ // the previous mode. soft/off also need their own store entry (written by go-grant), else hard.
693
+ // - OpenCode has no human-origin signal at all (a prompt posted through the HTTP API is a
694
+ // plain text part), so there `off` is never honoured and grants last at most 2h.
695
+ // - Grants need a strict entry after the last hard; `until` is clamped to the typed duration.
696
+ export function effectiveGate(key, { source, cmds, opencode = false }, now = Date.now()) {
697
+ const st = readState(key);
698
+ const state = st && typeof st === "object" ? st : {};
699
+ const rejected = [];
700
+ const usable = (c) => c.parsed.cmd === "hard" || (c.strict && !(opencode && c.parsed.cmd === "off"));
701
+ for (const c of cmds) {
702
+ if (["soft", "off"].includes(c.parsed.cmd) && !usable(c)) {
703
+ rejected.push(opencode && c.strict ? "mode off is never honoured on OpenCode" : `mode ${c.parsed.cmd} ignored: ${opencode ? OC_HINT : WEAK_HINT}`);
704
+ }
705
+ }
706
+ const lastMode = [...cmds].reverse().find((c) => ["hard", "soft", "off"].includes(c.parsed.cmd) && usable(c));
707
+ const match = (src, ok) => [...cmds].reverse().find((c) =>
708
+ src && typeof src === "object" && src.transcript === source && c.hash === src.text_hash && (!src.uuid || c.id === src.uuid) &&
709
+ Number.isFinite(c.ts) && Math.abs(c.ts - Date.parse(src.issued_at)) <= SKEW_MS && ok(c.parsed));
710
+ const modes = [...(Array.isArray(state.modes) ? state.modes : []), ...(state.mode ? [{ mode: state.mode, source: state.mode_source }] : [])];
711
+ let mode = "soft";
712
+ if (lastMode?.parsed.cmd === "hard") mode = "hard";
713
+ else if (lastMode) {
714
+ const want = lastMode.parsed.cmd;
715
+ if (!modes.some((m) => m && m.mode === want && match(m.source, (p) => p.cmd === want) === lastMode)) {
716
+ mode = "hard";
717
+ rejected.push(`mode "${want}" has no store entry for its transcript prompt`);
718
+ } else if (want === "off" && now - lastMode.ts >= MAX_GRANT_MS) {
719
+ mode = "hard";
720
+ rejected.push("off expired after 24h");
721
+ } else mode = want;
722
+ }
723
+ const lastHard = cmds.findLastIndex((c) => c.parsed.cmd === "hard");
724
+ const grants = [];
725
+ for (const g of Array.isArray(state.grants) ? state.grants : []) {
726
+ const c = match(g?.source, (p) => p.cmd === "grant" && p.scopes.includes(g?.scope));
727
+ if (!c) { rejected.push(`grant ${g?.scope} has no matching human gogate entry`); continue; }
728
+ if (!c.strict) { rejected.push(`grant ${g?.scope} ignored: ${opencode ? OC_HINT : WEAK_HINT}`); continue; }
729
+ if (cmds.indexOf(c) < lastHard) { rejected.push(`grant ${g.scope} revoked by a later hard`); continue; }
730
+ if (c.parsed.session && g.session_key !== key) { rejected.push(`session grant ${g.scope} belongs to another session`); continue; }
731
+ const until = Math.min(Date.parse(g.until), c.ts + (opencode ? Math.min(c.parsed.ms, OPENCODE_MAX_GRANT_MS) : c.parsed.ms));
732
+ if (!(now < until)) continue; // expired
733
+ grants.push({ scope: g.scope, until, session: !!c.parsed.session });
734
+ }
735
+ return { mode, grants, rejected };
736
+ }
737
+
738
+ export const grantsCover = (command, grants, protectedFor) => {
739
+ const need = commandScopes(command, protectedFor);
740
+ return !!need && need.length > 0 && need.every((s) => grants.some((g) => g.scope === s));
741
+ };
742
+
743
+ export function statusText(key, eff, now = Date.now()) {
744
+ const lines = [`AOS go-gate, session ${key}: mode ${eff.mode}`];
745
+ if (!eff.grants.length) lines.push(" no active grants");
746
+ for (const g of eff.grants) lines.push(` grant ${g.scope} until ${new Date(g.until).toISOString()} (${Math.ceil((g.until - now) / 60000)} min left${g.session ? "; session = this session id, max 24h, survives --resume" : ""})`);
747
+ for (const r of [...new Set(eff.rejected)].slice(0, 5)) lines.push(` ignored: ${r}`);
748
+ return lines.join("\n");
749
+ }
750
+
751
+ export const listStateKeys = () => {
752
+ try { return readdirSync(gateDir()).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)); } catch { return []; }
753
+ };
111
754
  const TOKEN_TTL_MS = 10 * 60 * 1000;
112
755
 
113
756
  function ownSessionName(transcriptPath) {
114
757
  let name = "";
115
758
  try {
116
- for (const line of readFileSync(transcriptPath, "utf8").split("\n")) {
759
+ for (const line of fileLines(transcriptPath)) {
117
760
  if (!line.includes('"agent-name"') && !line.includes('"custom-title"')) continue;
118
- try {
119
- const e = JSON.parse(line);
120
- if (e.type === "agent-name" && e.agentName) name = e.agentName;
121
- else if (e.type === "custom-title" && e.customTitle && !name) name = e.customTitle;
122
- } catch { /* partial line */ }
761
+ const e = parseEntry(line);
762
+ if (!e) continue;
763
+ if (e.type === "agent-name" && typeof e.agentName === "string" && e.agentName) name = e.agentName;
764
+ else if (e.type === "custom-title" && typeof e.customTitle === "string" && e.customTitle && !name) name = e.customTitle;
123
765
  }
124
766
  } catch { /* unreadable */ }
125
767
  return name || process.env.AOS_SESSION_NAME || "";
@@ -151,7 +793,7 @@ function opencodeLastUser(sessionId) {
151
793
  const text = db.prepare("SELECT data FROM part WHERE message_id = ? ORDER BY id").all(msg.id)
152
794
  .map((r) => JSON.parse(r.data))
153
795
  // synthetic = model-only, ignored = display-only, aos_bus = bus delivery: none is a human turn.
154
- .filter((p) => p?.type === "text" && !p.synthetic && !p.ignored && !p.metadata?.aos_bus && typeof p.text === "string")
796
+ .filter(isHumanPart)
155
797
  .map((p) => p.text)
156
798
  .join("\n")
157
799
  .trim();
@@ -163,6 +805,40 @@ function opencodeLastUser(sessionId) {
163
805
  }
164
806
  }
165
807
 
808
+ // Human gogate commands of a ROOT OpenCode session, oldest first (read-only db).
809
+ export function opencodeGogateCommands(sessionId) {
810
+ let db;
811
+ try {
812
+ const dbPath = join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "opencode", "opencode.db");
813
+ if (typeof sessionId !== "string" || !existsSync(dbPath)) return [];
814
+ const require = createRequire(import.meta.url);
815
+ try {
816
+ db = new (require("node:sqlite").DatabaseSync)(dbPath, { readOnly: true });
817
+ } catch {
818
+ db = new (require("bun:sqlite").Database)(dbPath, { readonly: true });
819
+ }
820
+ const ses = db.prepare("SELECT parent_id FROM session WHERE id = ?").get(sessionId);
821
+ if (!ses || ses.parent_id != null) return [];
822
+ const out = [];
823
+ const msgs = db.prepare("SELECT id, time_created FROM message WHERE session_id = ? AND json_extract(data, '$.role') = 'user' ORDER BY time_created, id").all(sessionId);
824
+ for (const m of msgs) {
825
+ const text = db.prepare("SELECT data FROM part WHERE message_id = ? ORDER BY id").all(m.id)
826
+ .map((r) => JSON.parse(r.data)).filter(isHumanPart).map((p) => p.text).join("\n").trim();
827
+ const parsed = parseGogate(text);
828
+ if (parsed && !parsed.error) out.push({ id: m.id, ts: Number(m.time_created), hash: textHash(text), parsed, strict: parsed.form === "plain" });
829
+ }
830
+ return out;
831
+ } catch {
832
+ return [];
833
+ } finally {
834
+ try { db?.close(); } catch { /* already closed */ }
835
+ }
836
+ }
837
+
838
+ // Commands for whichever source a store entry names.
839
+ export const gogateCommandsFor = (source) =>
840
+ String(source).startsWith("opencode:") ? opencodeGogateCommands(String(source).slice(9)) : claudeGogateCommands(source);
841
+
166
842
  // Shared with bin/aos-acp.mjs (ACP permission requests). `consume: false`
167
843
  // verifies without deleting the token, for a caller whose inner gate owns it.
168
844
  export function tokenGrantsGo(own, { consume = true } = {}) {
@@ -175,6 +851,7 @@ export function tokenGrantsGo(own, { consume = true } = {}) {
175
851
  } catch {
176
852
  return { ok: false, reason: "GO token unreadable" };
177
853
  }
854
+ if (!tok || typeof tok !== "object") return { ok: false, reason: "GO token unreadable" };
178
855
  const age = Date.now() - Date.parse(tok?.issued_at);
179
856
  if (!(age >= 0 && age < TOKEN_TTL_MS)) {
180
857
  try { unlinkSync(file); } catch { /* already gone */ }
@@ -193,7 +870,7 @@ export function tokenGrantsGo(own, { consume = true } = {}) {
193
870
  key = last.id;
194
871
  } else if (typeof tok.master_transcript === "string") {
195
872
  const last = lastUserText(tok.master_transcript);
196
- if (!last.text || last.text.trim().toLowerCase() !== want) {
873
+ if (!last.text || !last.human || last.text.trim().toLowerCase() !== want) {
197
874
  return { ok: false, reason: "master transcript no longer ends with this GO" };
198
875
  }
199
876
  if (!last.uuid) return { ok: false, reason: "master GO entry has no uuid" };
@@ -214,47 +891,54 @@ export function tokenGrantsGo(own, { consume = true } = {}) {
214
891
  return { ok: true };
215
892
  }
216
893
 
217
- function lastUserMessageIsGo(transcriptPath) {
894
+ export function lastUserMessageIsGo(transcriptPath) {
218
895
  const last = lastUserText(transcriptPath);
219
896
  if (last.error) return { ok: false, reason: last.error };
220
897
  if (last.text === null) return { ok: false, reason: "no user message found in transcript" };
221
- return { ok: /^GO$/i.test(last.text.trim()), reason: `last user message was: ${JSON.stringify(last.text)}` };
898
+ if (!last.human) return { ok: false, reason: "last user message is not human-typed (loop, peer, task notification, SDK or system)" };
899
+ return parseGoText(last.text);
222
900
  }
223
901
 
224
- export function lastUserText(transcriptPath) {
225
- let raw;
226
- try {
227
- raw = readFileSync(transcriptPath, "utf8");
228
- } catch (e) {
229
- return { text: null, error: `could not read transcript (${e.message})` };
902
+ function anyOrigin(transcriptPath) {
903
+ for (const line of fileLinesReverse(transcriptPath)) {
904
+ if (!line.includes('"origin"')) continue;
905
+ const e = parseEntry(line);
906
+ if (e && e.origin !== undefined) return true;
230
907
  }
908
+ return false;
909
+ }
231
910
 
232
- const lines = raw.split("\n").filter(Boolean);
233
- for (let i = lines.length - 1; i >= 0; i--) {
234
- let entry;
235
- try {
236
- entry = JSON.parse(lines[i]);
237
- } catch {
238
- continue; // tolerate partial/trailing lines
239
- }
911
+ export function lastUserText(transcriptPath) {
912
+ if (typeof transcriptPath !== "string" || !transcriptPath) return { text: null, error: "transcript path missing or not a string" };
913
+ try {
914
+ for (const line of fileLinesReverse(transcriptPath)) {
915
+ const entry = parseEntry(line);
916
+ if (!entry) continue; // partial, trailing or non-object lines
240
917
 
241
- // 1. Antigravity transcript entry format
242
- if (entry.type === "USER_INPUT" || entry.source === "USER_EXPLICIT") {
243
- const text = extractAgyText(entry.content);
244
- if (!text) continue;
245
- return { text };
246
- }
918
+ // 1. Antigravity transcript entry format
919
+ if (entry.type === "USER_INPUT" || entry.source === "USER_EXPLICIT") {
920
+ const text = extractAgyText(entry.content);
921
+ if (!text) continue;
922
+ return { text, human: true };
923
+ }
247
924
 
248
- // 2. Claude Code transcript entry format
249
- if (entry.type === "user" || entry.role === "user") {
250
- if (entry.isSidechain === true) continue;
251
- const content = entry.message?.content ?? entry.content;
252
- const text = extractClaudeText(content).trim();
253
- if (!text) continue;
254
- return { text, uuid: entry.uuid ?? null };
925
+ // 2. Claude Code transcript entry format
926
+ if (entry.type === "user" || entry.role === "user") {
927
+ if (entry.isSidechain === true) continue;
928
+ const content = entry.message?.content ?? entry.content;
929
+ const text = extractClaudeText(content).trim();
930
+ if (!text) continue;
931
+ // Adaptive: once any entry of this transcript carries `origin`, Claude Code records
932
+ // origin for typed prompts, so the last entry needs strict human evidence. The
933
+ // lenient legacy rule (no origin, no promptSource) stays only for transcripts
934
+ // where no entry has origin at all.
935
+ const strictNeeded = entry.origin !== undefined || anyOrigin(transcriptPath);
936
+ return { text, uuid: entry.uuid ?? null, human: strictNeeded ? isStrictHumanEntry(entry) : isHumanEntry(entry) };
937
+ }
255
938
  }
939
+ } catch (e) {
940
+ return { text: null, error: `could not read transcript (${e.message})` };
256
941
  }
257
-
258
942
  return { text: null };
259
943
  }
260
944
 
@@ -272,6 +956,7 @@ function main() {
272
956
  process.exit(2);
273
957
  }
274
958
  }
959
+ if (!input || typeof input !== "object") input = {};
275
960
 
276
961
  // Detect harness
277
962
  const isAgy = !!(input?.toolCall || input?.conversationId || input?.artifactDirectoryPath || input?.workspacePaths);
@@ -288,31 +973,65 @@ function main() {
288
973
  return;
289
974
  }
290
975
 
291
- const isGuarded = GUARDED_PATTERNS.some((re) => re.test(command));
292
- if (!isGuarded) {
976
+ const storeReason = gateStoreReason(command);
977
+ if (storeReason) {
978
+ respond(isAgy, false, storeReason, command);
979
+ return;
980
+ }
981
+
982
+ if (!isGuardedCommand(command)) {
293
983
  respond(isAgy, true);
294
984
  return;
295
985
  }
296
986
 
297
987
  const transcriptPath = input?.transcriptPath || input?.transcript_path;
298
- if (!transcriptPath) {
299
- respond(isAgy, false, "no transcriptPath in hook input — cannot verify GO", command);
988
+ if (typeof transcriptPath !== "string" || !transcriptPath) {
989
+ respond(isAgy, false, "no transcript path in hook input — cannot verify GO", command);
990
+ return;
991
+ }
992
+ if (input.session_id !== undefined && typeof input.session_id !== "string") {
993
+ respond(isAgy, false, "session_id in hook input is not a string", command);
300
994
  return;
301
995
  }
302
996
 
303
- let result = lastUserMessageIsGo(transcriptPath);
304
- if (!result.ok) {
305
- const token = tokenGrantsGo(ownSessionName(transcriptPath));
306
- if (token.ok) result = token;
307
- else result.reason += `; ${token.reason}`;
997
+ const go = lastUserMessageIsGo(transcriptPath);
998
+ if (goAllows(go, command)) return respond(isAgy, true, "", command);
999
+ const reasons = [go.ok ? "a GO with PR numbers covers only gh pr merge/edit/close/review/comment for those PRs; type a plain GO" : go.reason];
1000
+
1001
+ // Modes and grants exist only where go-grant.mjs runs (Claude Code: session_id present).
1002
+ const key = !isAgy && input.session_id ? sessionKey(input.session_id) : "";
1003
+ if (key) {
1004
+ const eff = effectiveGate(key, { source: transcriptPath, cmds: claudeGogateCommands(transcriptPath) });
1005
+ for (const r of eff.rejected) gateLog(key, `rejected: ${r}`);
1006
+ if (eff.mode === "off") {
1007
+ gateLog(key, `off: allowed ${JSON.stringify(command.slice(0, 200))}`);
1008
+ return respond(isAgy, true, "", command);
1009
+ }
1010
+ if (eff.mode === "soft" && grantsCover(command, eff.grants, makeBranchResolver(typeof input.cwd === "string" ? input.cwd : process.cwd(), spawnSync))) {
1011
+ gateLog(key, `grant: allowed ${JSON.stringify(command.slice(0, 200))}`);
1012
+ return respond(isAgy, true, "", command);
1013
+ }
1014
+ reasons.push(`mode ${eff.mode}, no active grant covers ${JSON.stringify(commandScopes(command) ?? "an unscoped command")}`);
308
1015
  }
309
- if (result.ok) {
310
- respond(isAgy, true, "", command);
311
- } else {
312
- respond(isAgy, false, result.reason, command);
1016
+
1017
+ const token = tokenGrantsGo(ownSessionName(transcriptPath));
1018
+ if (token.ok) return respond(isAgy, true, "", command);
1019
+ reasons.push(token.reason);
1020
+ respond(isAgy, false, reasons.join("; "), command);
1021
+ }
1022
+
1023
+ // Never fail open: any exception (bad input shape, unreadable state, a bug) denies.
1024
+ function safeMain() {
1025
+ try {
1026
+ main();
1027
+ } catch (e) {
1028
+ const reason = `go-gate internal error, failing closed: ${String(e?.message || e).slice(0, 200)}`;
1029
+ console.log(JSON.stringify({ decision: "deny", reason: `[MECHANICAL GO-GATE BLOCKED] ${reason}` }));
1030
+ process.stderr.write(`Blocked by go-gate hook: ${reason}\n`);
1031
+ process.exit(2);
313
1032
  }
314
1033
  }
315
1034
 
316
1035
  // Run as a hook (node go-gate.mjs); importing it (bin/aos-acp.mjs) must not.
317
1036
  const invokedAs = (() => { try { return realpathSync(process.argv[1] || ""); } catch { return ""; } })();
318
- if (invokedAs === fileURLToPath(import.meta.url)) main();
1037
+ if (invokedAs === fileURLToPath(import.meta.url)) safeMain();