frayui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +186 -0
- package/dist/claude-agent-broker.js +21641 -0
- package/dist/codex-app-server-daemon.js +354 -0
- package/dist/dev-child.js +40319 -0
- package/dist/frayui.js +8738 -0
- package/package.json +46 -0
- package/runtime/cc/scripts/fray/agent-bindings.mjs +287 -0
- package/runtime/cc/scripts/fray/agent-liveness.mjs +367 -0
- package/runtime/cc/scripts/fray/agent-status.mjs +178 -0
- package/runtime/cc/scripts/fray/config.mjs +982 -0
- package/runtime/cc/scripts/fray/decisions.mjs +97 -0
- package/runtime/cc/scripts/fray/index.mjs +699 -0
- package/runtime/cc/scripts/fray/notify-shared.mjs +90 -0
- package/runtime/cc/scripts/fray/notify.mjs +81 -0
- package/runtime/cc/scripts/fray/ownership.mjs +120 -0
- package/runtime/cc/scripts/fray/rest-detect.mjs +213 -0
- package/runtime/cc/scripts/fray/thread-excerpt.mjs +162 -0
- package/runtime/cc/scripts/fray/thread-update.mjs +285 -0
- package/runtime/cc-worker/.claude-plugin/plugin.json +10 -0
- package/runtime/cc-worker/DECISIONS.md +923 -0
- package/runtime/cc-worker/LICENSE +21 -0
- package/runtime/cc-worker/agents/fable-high.md +8 -0
- package/runtime/cc-worker/agents/fable-low.md +8 -0
- package/runtime/cc-worker/agents/fable-max.md +8 -0
- package/runtime/cc-worker/agents/fable-medium.md +8 -0
- package/runtime/cc-worker/agents/fable-xhigh.md +8 -0
- package/runtime/cc-worker/agents/haiku.md +7 -0
- package/runtime/cc-worker/agents/opus-high.md +8 -0
- package/runtime/cc-worker/agents/opus-low.md +8 -0
- package/runtime/cc-worker/agents/opus-max.md +8 -0
- package/runtime/cc-worker/agents/opus-medium.md +8 -0
- package/runtime/cc-worker/agents/opus-xhigh.md +8 -0
- package/runtime/cc-worker/agents/sonnet-high.md +8 -0
- package/runtime/cc-worker/agents/sonnet-low.md +8 -0
- package/runtime/cc-worker/agents/sonnet-max.md +8 -0
- package/runtime/cc-worker/agents/sonnet-medium.md +8 -0
- package/runtime/cc-worker/agents/sonnet-xhigh.md +8 -0
- package/runtime/cc-worker/bin/fray +17 -0
- package/runtime/cc-worker/bin/fray-mcp.mjs +248 -0
- package/runtime/cc-worker/bin/fray-update +18 -0
- package/runtime/cc-worker/hooks/agent-bind.mjs +40 -0
- package/runtime/cc-worker/hooks/agent-dispatch.mjs +74 -0
- package/runtime/cc-worker/hooks/bash-background.d.mts +6 -0
- package/runtime/cc-worker/hooks/bash-background.mjs +188 -0
- package/runtime/cc-worker/hooks/deny-ask.mjs +38 -0
- package/runtime/cc-worker/hooks/deny-plan.mjs +61 -0
- package/runtime/cc-worker/hooks/hooks.json +111 -0
- package/runtime/cc-worker/hooks/perm-policy.mjs +211 -0
- package/runtime/cc-worker/hooks/precompact-instructions.mjs +122 -0
- package/runtime/cc-worker/hooks/scratchpad-stop.mjs +125 -0
- package/runtime/cc-worker/hooks/scratchpad.mjs +419 -0
- package/runtime/cc-worker/hooks/session-seed.mjs +105 -0
- package/runtime/cc-worker/scripts/fray/agent-bindings.mjs +9 -0
- package/runtime/cc-worker/scripts/fray/config.mjs +12 -0
- package/runtime/cc-worker/skills/gh/SKILL.md +132 -0
- package/runtime/cc-worker/skills/gh/scripts/ci-watch.mjs +60 -0
- package/runtime/cc-worker/skills/gh/scripts/github-watch.mjs +130 -0
- package/runtime/cc-worker/skills/gh/scripts/review-watch.mjs +54 -0
- package/runtime/cc-worker/skills/handoff/SKILL.md +203 -0
- package/runtime/cc-worker/skills/waits/SKILL.md +83 -0
- package/web-dist/apple-touch-icon.png +0 -0
- package/web-dist/assets/TerminalPane-fMGrgB-a.js +7 -0
- package/web-dist/assets/abnfDiagram-VRR7QNED-BtlrDuYX.js +1 -0
- package/web-dist/assets/arc-BSyeo0Gb.js +1 -0
- package/web-dist/assets/architecture-TIHT7OUA-CuD8jpr-.js +1 -0
- package/web-dist/assets/architectureDiagram-ZJ3FMSHR-CdLQ71be.js +36 -0
- package/web-dist/assets/array-BifhSqXX.js +1 -0
- package/web-dist/assets/blockDiagram-677ZJIJ3-CBk2FHEj.js +132 -0
- package/web-dist/assets/c4Diagram-LMCZKHZV-mZ3HQ6WX.js +10 -0
- package/web-dist/assets/channel-C2eUWc74.js +1 -0
- package/web-dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/web-dist/assets/chunk-32BRIVSS-Daxvi7f5.js +1 -0
- package/web-dist/assets/chunk-52WLFC77-BGJoZvry.js +10 -0
- package/web-dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/web-dist/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
- package/web-dist/assets/chunk-C7G6YPKG-Ycd1yheh.js +1 -0
- package/web-dist/assets/chunk-EX3LRPZG-BPFhlsHp.js +231 -0
- package/web-dist/assets/chunk-FWX5IMBZ--xy8DTon.js +2 -0
- package/web-dist/assets/chunk-HOUHSVGY-Dq3zoygp.js +1 -0
- package/web-dist/assets/chunk-ICXQ74PX-FfKP-7yM.js +2 -0
- package/web-dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/web-dist/assets/chunk-KEIR6QF5-BfrZ3jm6.js +161 -0
- package/web-dist/assets/chunk-MOJQB5TN-BeiHnsLF.js +88 -0
- package/web-dist/assets/chunk-OGEWGWER-CjDU_9fC.js +1 -0
- package/web-dist/assets/chunk-PUDLZKDR-BO7tm5QX.js +156 -0
- package/web-dist/assets/chunk-Q4XR5HBZ-BPefovOg.js +70 -0
- package/web-dist/assets/chunk-RYQCIY6F-Cu_KplZW.js +1 -0
- package/web-dist/assets/chunk-V7JOEXUC-DWnebFHh.js +206 -0
- package/web-dist/assets/chunk-VAUOI2AC-BL6qWFhW.js +1 -0
- package/web-dist/assets/chunk-VR4S4FIN-Mfs__7L9.js +1 -0
- package/web-dist/assets/chunk-WYO6CB5R-Bf2IYbEU.js +127 -0
- package/web-dist/assets/chunk-XXDRQBXY-DYlTP5J-.js +1 -0
- package/web-dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/web-dist/assets/chunk-ZGVPDNZ5-CJ9ZHZgU.js +62 -0
- package/web-dist/assets/chunk-ZIRB5QZD-C6fEPe3t.js +32 -0
- package/web-dist/assets/classDiagram-OUVF2IWQ-GaOJNgfE.js +1 -0
- package/web-dist/assets/classDiagram-v2-EOCWNBFH-GaOJNgfE.js +1 -0
- package/web-dist/assets/cose-bilkent-JH36ORCC-BUIsLrGc.js +1 -0
- package/web-dist/assets/cynefin-VYW2F7L2-COSC0oNL.js +1 -0
- package/web-dist/assets/cynefinDiagram-TSTJHNR4-mInhJdg1.js +62 -0
- package/web-dist/assets/cytoscape.esm-B3I8pqwA.js +321 -0
- package/web-dist/assets/dagre-CXRCoUWR.js +1 -0
- package/web-dist/assets/dagre-VKFMJZFB-D-T5z05M.js +4 -0
- package/web-dist/assets/defaultLocale-C8Fc0cco.js +1 -0
- package/web-dist/assets/diagram-FQU43EPY-HbMkSb3j.js +3 -0
- package/web-dist/assets/diagram-G47NLZAW-DBwZLcZh.js +24 -0
- package/web-dist/assets/diagram-NH7WQ7WH-DG_Mhays.js +24 -0
- package/web-dist/assets/diagram-OA4YK3LP-hH39gqpu.js +30 -0
- package/web-dist/assets/diagram-WEI45ONY-DhKZuwxW.js +41 -0
- package/web-dist/assets/dist-DoH_9pyS.js +1 -0
- package/web-dist/assets/ebnfDiagram-CCIWWBDH-B7BiO-NR.js +1 -0
- package/web-dist/assets/erDiagram-Q63AITRT-DHjG5RDQ.js +85 -0
- package/web-dist/assets/eventmodeling-45OFAUF4-C0eV8RBx.js +1 -0
- package/web-dist/assets/flowDiagram-23GEKE2U-BbldRZUK.js +1 -0
- package/web-dist/assets/ganttDiagram-NO4QXBWP-BZ-98wTa.js +292 -0
- package/web-dist/assets/gitGraph-TEB2WS4Q-nF22R2jO.js +1 -0
- package/web-dist/assets/gitGraphDiagram-IHSO6WYX-CoYQQ22V.js +106 -0
- package/web-dist/assets/graphlib-B8gBHxth.js +1 -0
- package/web-dist/assets/index-BHzIN-tQ.js +357 -0
- package/web-dist/assets/index-Duiy4w7C.css +1 -0
- package/web-dist/assets/info-DKCQHKI2-Cy2BCbBW.js +1 -0
- package/web-dist/assets/infoDiagram-FWYZ7A6U-CmhR2R1x.js +2 -0
- package/web-dist/assets/init-D6jRqBbL.js +1 -0
- package/web-dist/assets/ishikawaDiagram-FXEZZL3T-CfG59afl.js +70 -0
- package/web-dist/assets/journeyDiagram-5HDEW3XC-BvFmG20q.js +139 -0
- package/web-dist/assets/kanban-definition-HUTT4EX6-BHGEecsY.js +89 -0
- package/web-dist/assets/katex-CddkPoXu.js +257 -0
- package/web-dist/assets/line-Ds4xvN3d.js +1 -0
- package/web-dist/assets/linear-z2V0wJk9.js +1 -0
- package/web-dist/assets/map-DsCK-0Cs.js +1 -0
- package/web-dist/assets/mermaid-parser.core-Gow4kgYJ.js +7 -0
- package/web-dist/assets/mermaid.core-B7Qc_kbm.js +11 -0
- package/web-dist/assets/mindmap-definition-LN4V7U3C-ayXKGcy_.js +96 -0
- package/web-dist/assets/ordinal-hYBb2elL.js +1 -0
- package/web-dist/assets/packet-7NZHBO7P-oLBdtkD8.js +1 -0
- package/web-dist/assets/path-BWPyau1x.js +1 -0
- package/web-dist/assets/pegDiagram-2B236MQR-DwJyc_1B.js +1 -0
- package/web-dist/assets/pie-RZYD4A2V-CXzad3fT.js +1 -0
- package/web-dist/assets/pieDiagram-ENE6RG2P-CZzgSGtM.js +39 -0
- package/web-dist/assets/quadrantDiagram-ABIIQ3AL--IPIp8yo.js +7 -0
- package/web-dist/assets/radar-I7S5WNFK-DldySvtZ.js +1 -0
- package/web-dist/assets/railroad-3IZDKUUU-CiWFZfXM.js +1 -0
- package/web-dist/assets/railroad-abnf-AHOZXSZD-bXMYUOCD.js +1 -0
- package/web-dist/assets/railroad-ebnf-EBAXGLYW-D11ZwZby.js +1 -0
- package/web-dist/assets/railroad-peg-LSFZ7HO6-DD1AC5X-.js +1 -0
- package/web-dist/assets/railroadDiagram-RFXS5EU6-DcCMNGGb.js +1 -0
- package/web-dist/assets/requirementDiagram-TGXJPOKE-C-6j8urk.js +84 -0
- package/web-dist/assets/rolldown-runtime-Bh1tDfsg.js +1 -0
- package/web-dist/assets/rough.esm-CSKSodPl.js +1 -0
- package/web-dist/assets/sankeyDiagram-HTMAVEWB-CPtiOpdz.js +40 -0
- package/web-dist/assets/sequenceDiagram-DBY2YBRQ-B8FnQTdn.js +162 -0
- package/web-dist/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
- package/web-dist/assets/src-C4XfhTaE.js +1 -0
- package/web-dist/assets/stateDiagram-2N3HPSRC-CxG9nX2P.js +1 -0
- package/web-dist/assets/stateDiagram-v2-6OUMAXLB-SGarOFIy.js +1 -0
- package/web-dist/assets/swimlanes-5IMT3BWC-D49-t0hV.js +2 -0
- package/web-dist/assets/swimlanesDiagram-G3AALYLV-krF0bh4b.js +8 -0
- package/web-dist/assets/timeline-definition-FHXFAJF6-C9OEd9SE.js +120 -0
- package/web-dist/assets/treeView-QDETBFTQ-D1AKqVPa.js +1 -0
- package/web-dist/assets/treemap-6X3UGDF4-BRssB7hM.js +1 -0
- package/web-dist/assets/vennDiagram-L72KCM5P-CftkAbY0.js +34 -0
- package/web-dist/assets/wardley-OPB4EBWU-RLMgzRq4.js +1 -0
- package/web-dist/assets/wardleyDiagram-EHGQE667-BLytgXPS.js +78 -0
- package/web-dist/assets/xychartDiagram-FW5EYKEG-BYgK1Y3H.js +7 -0
- package/web-dist/favicon-16.png +0 -0
- package/web-dist/favicon-32.png +0 -0
- package/web-dist/favicon.svg +78 -0
- package/web-dist/icon-192.png +0 -0
- package/web-dist/icon-512.png +0 -0
- package/web-dist/icon-maskable-512.png +0 -0
- package/web-dist/index.html +33 -0
- package/web-dist/manifest.webmanifest +16 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
// SessionStart hook (fray-worker) — SEEDS a fray-ui WORKER session's context. Run directly with
|
|
4
|
+
// node (zero deps, max Node compat), mirroring cc's hook idiom.
|
|
5
|
+
//
|
|
6
|
+
// A fray-ui worker is a top-level interactive `claude` the UI spawns per effort; the slug arrives in
|
|
7
|
+
// env FRAY_UI_THREAD (and a `THREAD:` line in the first prompt). There are NO thread files, no
|
|
8
|
+
// frontmatter, no status field — a worker SIGNALS through its final message (fences) and PERSISTS
|
|
9
|
+
// through a scratchpad. This hook injects, on every session start (startup/resume/clear/compact):
|
|
10
|
+
// 1. `core` — a runtime re-grounding + pointer, NOT a second copy of the contract: the full worker
|
|
11
|
+
// contract lives ONCE in the system prompt (workerPrompt.ts) the server injects at spawn. This
|
|
12
|
+
// carries only what a static system prompt can't: the runtime scratchpad PATH + an essential
|
|
13
|
+
// signal-at-rest anchor + a pointer to the system-prompt contract.
|
|
14
|
+
// 2. the SCRATCHPAD PATH — `.fray/threads/<session_id>/scratch.md`, the worker's compaction-proof memory.
|
|
15
|
+
// 3. on `compact` — a short re-grounding (compaction drops the deep model + the scratchpad reminder).
|
|
16
|
+
//
|
|
17
|
+
// GATE: everything is gated on FRAY_UI_THREAD being set, so the plugin is completely inert when
|
|
18
|
+
// loaded outside a fray-ui worker (e.g. a plain `claude --plugin-dir cc-worker` smoke run).
|
|
19
|
+
//
|
|
20
|
+
// STALE-INSTALL DEFENSE: the `cc` orchestrator plugin is retired — the marketplace ships only this
|
|
21
|
+
// worker plugin, and cc's hooks/skills/agents are gone. But a machine can still carry a CACHED cc
|
|
22
|
+
// install from before the retirement, whose hooks fire in every repo gated on cc's opt-IN sentinel.
|
|
23
|
+
// A fresh worker never runs `fray on`, so such a cc is already dormant; we write cc's own per-session
|
|
24
|
+
// `off` sentinel anyway via the shared config API (cc/scripts/fray survives as cc-worker's board
|
|
25
|
+
// implementation) so a stale install is guaranteed inert. Cheap belt-and-suspenders. See DECISIONS.md.
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { execFileSync } from 'node:child_process';
|
|
28
|
+
import { setSessionOverride, currentSessionId } from '../scripts/fray/config.mjs';
|
|
29
|
+
|
|
30
|
+
/** @type {{ agent_id?: unknown, agentId?: unknown, source?: string, session_id?: string }} */
|
|
31
|
+
let input = {};
|
|
32
|
+
try {
|
|
33
|
+
input = JSON.parse(readFileSync(0, 'utf8'));
|
|
34
|
+
} catch {
|
|
35
|
+
/* no stdin / not JSON → input stays {} → proceed (fail-open to inject) */
|
|
36
|
+
}
|
|
37
|
+
// Skip inside sub-agent contexts (they carry agent_id) — the seed is for the top-level worker.
|
|
38
|
+
if (input.agent_id ?? input.agentId) process.exit(0);
|
|
39
|
+
|
|
40
|
+
// WORKER GATE — inert unless this is a fray-ui worker session.
|
|
41
|
+
const thread = (process.env.FRAY_UI_THREAD ?? '').trim();
|
|
42
|
+
if (!thread) process.exit(0);
|
|
43
|
+
|
|
44
|
+
const dir = process.env.CLAUDE_PROJECT_DIR ?? '.';
|
|
45
|
+
|
|
46
|
+
// Neutralize the orchestrator cc plugin for THIS session (defensive; see header + DECISIONS.md).
|
|
47
|
+
// The session id also names the worker's scratchpad (`.fray/threads/<session_id>/scratch.md`).
|
|
48
|
+
let sid = null;
|
|
49
|
+
try {
|
|
50
|
+
sid = currentSessionId(input.session_id);
|
|
51
|
+
if (sid) setSessionOverride(dir, sid, 'off');
|
|
52
|
+
} catch {
|
|
53
|
+
/* best-effort — a failed sentinel write just leaves cc at its dormant default */
|
|
54
|
+
}
|
|
55
|
+
const scratch = sid
|
|
56
|
+
? '.fray/threads/' + sid + '/scratch.md'
|
|
57
|
+
: '.fray/threads/<session-id>/scratch.md';
|
|
58
|
+
|
|
59
|
+
// A RUNTIME re-grounding + pointer, NOT a second copy of the contract. The full worker contract
|
|
60
|
+
// (signal fences, scratchpad rules, sub-agent rules, the settings-toggled runtime release gate) lives
|
|
61
|
+
// ONCE in the system prompt fray-ui injects at spawn (workerPrompt.ts / loadWorkerPrompt) — which is
|
|
62
|
+
// re-applied on every resume and survives compaction. This hook adds only what a static system prompt
|
|
63
|
+
// CANNOT carry: the runtime-derived scratchpad PATH, an essential signal-at-rest anchor, and (below)
|
|
64
|
+
// the compaction re-read nudge, gh guidance, and the defensive cc-orchestrator off-sentinel.
|
|
65
|
+
const core =
|
|
66
|
+
'⟦fray worker contract⟧ You are a fray-ui WORKER driving EXACTLY ONE effort. Your FULL operating contract — the end-of-turn signal fences, scratchpad rules, sub-agent rules, and the runtime release gate — lives in your SYSTEM PROMPT; follow it there (this is a runtime re-grounding, not a second copy). The human + the fray-ui app are the ORCHESTRATOR; you drive ONE effort and never scan the board or touch other efforts. There is no orchestrator mode and no fleet to run: doing the work yourself is the default, and you dispatch a sub-agent only when the work genuinely decomposes into independent prongs.\n' +
|
|
67
|
+
'SCRATCHPAD: `' + scratch + '` — the CANONICAL record of this thread, your compaction-survival mechanism, and your sub-agents\' shared blackboard. Write your task list, the approach and what you rejected, and anything that must outlive your context there AS YOU GO; re-read it after any compaction or resume, and pass its PATH into every sub-agent prompt.\n' +
|
|
68
|
+
'SIGNAL AT REST through your FINAL MESSAGE, per the fence rules in your system prompt: bare rest is the ordinary handoff and queues for the human; ```done only when the effort\'s real work is COMPLETE (code LANDED on the mainline — an open PR is NOT done, park it on ```awaiting until it MERGES) and is a DISMISSAL (its card files the thread away where nobody looks again), so if the thread points at future work AT ALL — a pre-fix investigation, a live code-change discussion — bare rest instead, and uncertain is not done; the ONE exception is a planning session whose plan file is fully written and persisted, because that artifact outlives the thread; ```awaiting parks only a human:/timer:/pr-watch: gate, never CI/releases/merge progression (those stay ACTIVE); ```question is the operator ask. Load `fray:handoff` for the full fence reference.\n' +
|
|
69
|
+
'DECIDE rather than ask: anything derivable from the code, the conventions, or ordinary engineering judgment is YOURS to settle — asking permission to do the work you were dispatched to do is not a question, it is the job. Reserve the operator for the irreversible and the genuinely human-owned.';
|
|
70
|
+
|
|
71
|
+
const grounding =
|
|
72
|
+
'⟦fray worker re-grounding (post-compaction)⟧ Context was just compacted. You are still the fray-ui worker for effort `' + thread + '` — re-read your scratchpad `' + scratch + '` NOW to recover your working state and to-do list before asserting anything, and re-read any code before claiming how it is structured. Signal at rest through your FINAL MESSAGE: bare rest queues an ordinary handoff; ```done queues a checked completion until Archive and is a DISMISSAL — completed work only, and never when the thread still points at future work (a pre-fix investigation, a live code-change discussion); use a question or bare rest; ```awaiting parks only a human:/timer: gate (or a pr-watch: PR watcher, which wakes on any new review/comment, bot or human); ```question is the explicit higher-priority operator ask. CI/releases/merge progression stay active through Monitor/background Bash.';
|
|
73
|
+
|
|
74
|
+
// AUTH-GATED gh guidance — teach the worker to use `gh` well, but ONLY when signed in.
|
|
75
|
+
// Shell `gh auth status --active`: exit 0 = an active gh account is authenticated. The whole gate is
|
|
76
|
+
// wrapped so it can NEVER throw into SessionStart, and it fails CLOSED — no gh binary, not authed, a
|
|
77
|
+
// stall past the timeout, or any other error → we inject NOTHING (guidance is absent, not stale/wrong).
|
|
78
|
+
// It re-evaluates on every start/resume/clear/compact, so a later `gh auth login` starts injecting on
|
|
79
|
+
// the next turn boundary (and a `gh auth logout` stops it). See DECISIONS.md / plan §8.
|
|
80
|
+
const ghBlock =
|
|
81
|
+
'⟦gh available⟧ You are signed into the `gh` CLI and in a GitHub repo. Use `gh` EAGERLY and well — it is the fastest path to issue/PR/CI/release context, and you should reach for it before guessing:\n' +
|
|
82
|
+
'• READ freely: `gh issue view N -R OWNER/REPO --comments`, `gh pr view N`, `gh pr diff N`, `gh pr checks N`, `gh run list`/`gh run view`, `gh api repos/OWNER/REPO/…`. Prefer `--json <fields>` over scraping human text.\n' +
|
|
83
|
+
'• SEARCH across the repo (and GitHub) with `gh search issues`/`gh search prs` when hunting related work, duplicates, or prior art.\n' +
|
|
84
|
+
'• READ-ONLY BOUNDARY: never comment, label, assign, close, review, approve, or merge — no mutation of any kind — UNLESS the human explicitly asks in this session. Default to producing your findings/review as your final message, not as a GitHub post.\n' +
|
|
85
|
+
'• TOON: pipe LARGE, FLAT `gh … --json` output through `toon` when `command -v toon` finds it. Skip it when unavailable, for tiny payloads, or for deeply-nested output — the savings are noise and nesting defeats tabularization.\n' +
|
|
86
|
+
'Load the `fray:gh` skill for the full playbook (recipes + explicit project-local monitor selection + native Monitor/background-Bash CI/PR watches).';
|
|
87
|
+
|
|
88
|
+
let ghAuthed = false;
|
|
89
|
+
try {
|
|
90
|
+
execFileSync('gh', ['auth', 'status', '--active'], { stdio: 'ignore', timeout: 4000 });
|
|
91
|
+
ghAuthed = true;
|
|
92
|
+
} catch {
|
|
93
|
+
/* no gh / not authed / stalled → fail CLOSED: leave ghAuthed false, inject nothing */
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const parts = [core];
|
|
97
|
+
if (input.source === 'compact') parts.push(grounding);
|
|
98
|
+
if (ghAuthed) parts.push(ghBlock);
|
|
99
|
+
|
|
100
|
+
process.stdout.write(
|
|
101
|
+
JSON.stringify({
|
|
102
|
+
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: parts.join('\n\n') },
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
process.exit(0);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* THIN SHIM — re-exports cc's `scripts/fray/agent-bindings.mjs` so the worker's PostToolUse
|
|
4
|
+
* `agent-bind` hook writes `.fray/.agent-bindings.jsonl` records in the EXACT format cc's board
|
|
5
|
+
* (`bindingsByThread`) + Stop-hook liveness consume. Keeping the writer shared is what lets a
|
|
6
|
+
* worker's own THREAD-tagged sub-agent show up on the fray-ui board's per-thread liveness.
|
|
7
|
+
* Never fork this — the record shape is a cross-plugin contract.
|
|
8
|
+
*/
|
|
9
|
+
export * from '../../../cc/scripts/fray/agent-bindings.mjs';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* THIN SHIM — do NOT fork config logic. cc-worker shares cc's single source of truth for the
|
|
4
|
+
* activation gate, config schema, status vocab, and the per-session sentinel/heartbeat helpers.
|
|
5
|
+
* This re-exports cc's `scripts/fray/config.mjs` verbatim so cc-worker hooks can `import ...
|
|
6
|
+
* from '../scripts/fray/config.mjs'` with cc's exact idiom while the real code lives in ONE place.
|
|
7
|
+
*
|
|
8
|
+
* Coupling note: cc-worker assumes cc is a SIBLING dir (`../../cc/` from the plugin root) — the
|
|
9
|
+
* same assumption fray-ui's server makes (see ARCHITECTURE.md: it imports the board logic from
|
|
10
|
+
* `../../cc/scripts/fray/*.mjs`). If that layout changes, this one path changes with it.
|
|
11
|
+
*/
|
|
12
|
+
export * from '../../../cc/scripts/fray/config.mjs';
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: gh
|
|
3
|
+
description: The gh-CLI playbook for a fray-ui worker signed into GitHub (invoke as fray:gh). Load this whenever your effort touches GitHub — reading or triaging an issue or PR, reviewing a diff, checking CI/release status, or searching issues/PRs — to use `gh` eagerly and correctly: the read-vs-write boundary (never comment/label/close/merge unless the human asks), optional toon use for large JSON, concrete read recipes, and active Monitor/background-Bash CI/PR watches. Only meaningful when you are signed in (`gh auth status --active` exit 0); the session-seed hook injects a pointer here when you are.
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
metadata:
|
|
6
|
+
internal: true
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# fray:gh — the gh-CLI playbook
|
|
10
|
+
|
|
11
|
+
You are a **fray-ui worker** and you are **signed into the `gh` CLI in a GitHub repo** (the session-seed hook confirmed `gh auth status --active` before pointing you here). `gh` is the fastest path to issue / PR / CI / release context — reach for it before guessing, and prefer it over scraping the web UI or reasoning from memory.
|
|
12
|
+
|
|
13
|
+
This skill is the full playbook the injected `⟦gh available⟧` block summarizes: the **read-vs-write boundary**, optional **toon** use for large JSON, concrete **read recipes**, and how to keep a **CI/PR watch** active until the next actionable event.
|
|
14
|
+
|
|
15
|
+
## The one hard rule — READ freely, WRITE only when asked
|
|
16
|
+
|
|
17
|
+
`gh` can mutate the repo, and your token has the scopes to do it. **Do not.** Unless the human **explicitly asks in this session**, you are strictly read-only:
|
|
18
|
+
|
|
19
|
+
- **NEVER** comment, review, approve, request-changes, label, assign, milestone, edit, close, reopen, merge, or push — no state change of any kind on GitHub.
|
|
20
|
+
- Your deliverable is your **final message** (a findings write-up, a review, a recommendation) — NOT a GitHub post. Producing the review in-session is the job; posting it is a separate action the human authorizes.
|
|
21
|
+
- If posting would genuinely help, don't just do it — **ask** with a two-option ` ```question ` block ("A. Post this review to the PR / B. Keep it in-session only", Recommendation), then rest. When the destructive edge is real (a force-merge, a close), that's a ` ```question danger ` gate.
|
|
22
|
+
- When the human HAS asked you to write, do exactly the scoped thing and report the resulting URL — nothing extra.
|
|
23
|
+
|
|
24
|
+
There is no server-side enforcement of this; the boundary is yours to hold.
|
|
25
|
+
|
|
26
|
+
## toon — pipe LARGE, FLAT gh JSON through the shim
|
|
27
|
+
|
|
28
|
+
`toon` (Token-Oriented Object Notation) losslessly re-encodes JSON ~30–40% smaller for LLM context. Use it only when a `gh … --json` result you're reading into YOUR context is **large and flat** (a list page: `gh issue list`, `gh pr list`, `gh search`, `gh api` list endpoints) and `command -v toon` succeeds. It is optional: do not install it or assume a home-directory-specific location.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
if command -v toon >/dev/null 2>&1; then
|
|
32
|
+
gh issue list -R OWNER/REPO --json number,title,url,updatedAt --limit 50 | toon
|
|
33
|
+
else
|
|
34
|
+
gh issue list -R OWNER/REPO --json number,title,url,updatedAt --limit 50
|
|
35
|
+
fi
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Skip toon** for tiny payloads (a handful of fields, one item) and for **deeply-nested** JSON (`reactionGroups`, review threads, nested files) — nesting defeats tabularization, so the savings collapse to noise and you add a parse tax for yourself. A single `gh pr view N --json …` is small — read it raw.
|
|
39
|
+
|
|
40
|
+
## Read recipes
|
|
41
|
+
|
|
42
|
+
Always scope with `-R OWNER/REPO` so a command is dir-independent, and prefer `--json <fields>` (+ `-q <jq>`) so you pull exactly what you need.
|
|
43
|
+
|
|
44
|
+
**Issues**
|
|
45
|
+
```bash
|
|
46
|
+
gh issue view N -R OWNER/REPO --comments # full thread, human-readable
|
|
47
|
+
gh issue view N -R OWNER/REPO --json title,body,labels,state,url # structured
|
|
48
|
+
gh issue list -R OWNER/REPO --search "sort:updated-desc" --json number,title,url,updatedAt --limit 30
|
|
49
|
+
gh issue list -R OWNER/REPO --search "sort:reactions-desc is:open" --json number,title,url --limit 30
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**PRs + diffs**
|
|
53
|
+
```bash
|
|
54
|
+
gh pr view N -R OWNER/REPO --json title,body,state,labels,files,additions,deletions,url
|
|
55
|
+
gh pr diff N -R OWNER/REPO # the unified diff — pipe through toon only if HUGE and you just need shape
|
|
56
|
+
gh pr checks N -R OWNER/REPO # CI check rollup for the PR
|
|
57
|
+
gh pr view N -R OWNER/REPO --comments # review threads + conversation
|
|
58
|
+
```
|
|
59
|
+
Read the changed files **in context**, not just the hunks — `gh pr diff` shows what changed, but correctness lives in the surrounding code.
|
|
60
|
+
|
|
61
|
+
**CI / runs / releases**
|
|
62
|
+
```bash
|
|
63
|
+
gh run list -R OWNER/REPO --branch BRANCH --limit 10
|
|
64
|
+
gh run view RUN_ID -R OWNER/REPO --log-failed # just the failing step logs
|
|
65
|
+
gh release view -R OWNER/REPO # latest release
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Search (across issues/PRs)**
|
|
69
|
+
```bash
|
|
70
|
+
gh search issues -R OWNER/REPO "crash on startup" --state open --json number,title,url --limit 30
|
|
71
|
+
gh search prs --repo OWNER/REPO "author:@me" --json number,title,url --limit 30
|
|
72
|
+
```
|
|
73
|
+
Use search to find duplicates, related work, and prior art before you conclude something is novel.
|
|
74
|
+
|
|
75
|
+
**Raw API** for anything the porcelain doesn't cover:
|
|
76
|
+
```bash
|
|
77
|
+
gh api repos/OWNER/REPO/commits/SHA/check-runs --jq '.check_runs[] | {name, conclusion}'
|
|
78
|
+
gh api "repos/OWNER/REPO/issues?state=open&labels=bug&per_page=50" | { command -v toon >/dev/null 2>&1 && toon || cat; }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Keep GitHub automation active
|
|
82
|
+
|
|
83
|
+
CI, automated review, releases, merge queues, and already-authorized merge progression are work you
|
|
84
|
+
can observe with `gh`; they do not earn an `awaiting` fence. Keep a live operation attached to the
|
|
85
|
+
thread and continue when it reports.
|
|
86
|
+
|
|
87
|
+
### Select monitor tooling explicitly
|
|
88
|
+
|
|
89
|
+
Before launching any CI/review monitor, inspect project-local `AGENTS.md`, active skills, repository
|
|
90
|
+
docs, `package.json` scripts, and declared monitor tooling. Prefer an explicit project-local monitor
|
|
91
|
+
only if it documents terminal semantics for this gate. Validate its absolute command and terminal
|
|
92
|
+
event/exit contract before launch. If declared tooling is missing, invalid, or has no terminal
|
|
93
|
+
semantics, stop and report that configuration error; never silently shadow it with a Fray script, and
|
|
94
|
+
never execute a monitor merely because its filename looks plausible.
|
|
95
|
+
|
|
96
|
+
When no project monitor is declared, the bundled fallback scripts are
|
|
97
|
+
`<this-skill-dir>/scripts/ci-watch.mjs` and `review-watch.mjs`. They are generated byte-for-byte from
|
|
98
|
+
Fray's canonical `monitors/` source and require only Node plus logged-in `gh`. Their stdout is
|
|
99
|
+
`fray.github-monitor/v1` NDJSON: `status` means keep waiting; `terminal` is a verdict. They join
|
|
100
|
+
exact-head workflow runs with PR checks, keeping `ACTION_REQUIRED` pending, and baseline every review
|
|
101
|
+
and comment so any new one wakes — bot or human, with no actor
|
|
102
|
+
filter. A GitHub/auth error is terminal exit 3; SIGINT/SIGTERM produces terminal
|
|
103
|
+
`cancelled` and exit 130. A `--once` pending/baseline snapshot is deliberately non-terminal exit 0.
|
|
104
|
+
For CI, retries are collapsed only within the same workflow name and event; distinct exact-head events
|
|
105
|
+
such as `push` and `pull_request` both contribute to the aggregate verdict.
|
|
106
|
+
|
|
107
|
+
- One-shot completion: launch `Bash` with `run_in_background: true`, for example
|
|
108
|
+
`gh run watch RUN_ID -R OWNER/REPO --exit-status` or a repo watcher that exits when all PR checks
|
|
109
|
+
settle. The completion task-notification re-invokes you. Diagnose/fix on red; continue the authorized
|
|
110
|
+
release/merge path on green.
|
|
111
|
+
- State transitions: use native `Monitor` with a quiet loop that prints only changes or the terminal
|
|
112
|
+
event. It is the Claude adapter for the selected script; do not make a sub-agent the monitor
|
|
113
|
+
abstraction.
|
|
114
|
+
Finite monitors run up to one hour; `persistent: true` runs until `TaskStop` or the Claude session
|
|
115
|
+
ends. Stop a watch once its gate is obsolete.
|
|
116
|
+
- A background Bash launch exposes an output-file path. Use `Read` on that path only for diagnostics;
|
|
117
|
+
`TaskOutput` is deprecated. Do not fake waiting with `echo waiting` or sleep-only Bash calls.
|
|
118
|
+
|
|
119
|
+
Both mechanisms are session-bound. If the next check deliberately belongs at a named wall-clock
|
|
120
|
+
instant, park with a durable `timer:` fence. If a specific external human reviewer/approver is the
|
|
121
|
+
only remaining gate, park with `human: <actor + exact action>`. For a GitHub PR, pair it with
|
|
122
|
+
`pr-watch: OWNER/REPO#NUMBER`: fray-ui baselines current reviews/comments and wakes on ANY new
|
|
123
|
+
activity after the fence — bot or human — durably across restarts. Otherwise optionally pair a timer for
|
|
124
|
+
a scheduled recheck. The dashboard operator's own go/no-go remains a ` ```question ` block. `pr:` / `ci:` /
|
|
125
|
+
`session:` awaiting hints are legacy compatibility only — do not emit them for new automated waits.
|
|
126
|
+
|
|
127
|
+
## Fitting gh work into your thread type
|
|
128
|
+
|
|
129
|
+
- **Investigating an issue** (a research thread): reproduce → trace to `file:line` (cite every load-bearing claim) → recommend the smallest correct fix; read the full thread and linked issues/PRs with `gh` for context. Don't implement — stop at the recommendation. Handback = findings in your final message.
|
|
130
|
+
- **Reviewing a PR** (an audit thread): read the diff AND the files in context, verify correctness/edges/tests, check CI (`gh pr checks`), then produce a review (blocking issues vs nits, each citing `file:line`) as your final message. Approve/merge only if explicitly asked.
|
|
131
|
+
|
|
132
|
+
In both cases: read-only on GitHub unless told otherwise, and the review/findings live in your session, not in a GitHub post.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { classifyChecks, gh, latestWorkflowRuns, parseArgs, report, sleep } from "./github-watch.mjs"
|
|
3
|
+
|
|
4
|
+
const usage = "Usage: ci-watch.mjs --repo OWNER/REPO --pr NUMBER [--interval SECONDS] [--once]"
|
|
5
|
+
|
|
6
|
+
async function main() {
|
|
7
|
+
let options
|
|
8
|
+
try { options = parseArgs(process.argv.slice(2), usage) } catch (error) {
|
|
9
|
+
process.stderr.write(`ci-watch: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
10
|
+
report("terminal", { kind: "ci", state: "error", error: error instanceof Error ? error.message : String(error) })
|
|
11
|
+
process.exitCode = 3
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
if (options.help) return console.log(usage)
|
|
15
|
+
let emittedTerminal = false
|
|
16
|
+
const terminal = (state, exitCode) => {
|
|
17
|
+
if (emittedTerminal) return
|
|
18
|
+
emittedTerminal = true
|
|
19
|
+
report("terminal", { kind: "ci", repo: options.repo, pr: options.pr, state })
|
|
20
|
+
process.exitCode = exitCode
|
|
21
|
+
}
|
|
22
|
+
const cancelled = () => { terminal("cancelled", 130); process.exit(130) }
|
|
23
|
+
process.once("SIGINT", cancelled)
|
|
24
|
+
process.once("SIGTERM", cancelled)
|
|
25
|
+
let previous
|
|
26
|
+
for (;;) {
|
|
27
|
+
try {
|
|
28
|
+
const pr = JSON.parse(await gh(["pr", "view", String(options.pr), "--repo", options.repo, "--json", "headRefOid"]))
|
|
29
|
+
const checks = JSON.parse(await gh(["pr", "checks", String(options.pr), "--repo", options.repo, "--json", "name,state,bucket,workflow,link"]))
|
|
30
|
+
const runs = pr.headRefOid ? JSON.parse(await gh(["run", "list", "--repo", options.repo, "--commit", pr.headRefOid, "--limit", "100", "--json", "name,workflowName,status,conclusion,databaseId,event,createdAt"])) : []
|
|
31
|
+
const workflows = latestWorkflowRuns(runs).map((run) => ({
|
|
32
|
+
name: run.name,
|
|
33
|
+
state: String(run.status).toUpperCase() === "COMPLETED" ? run.conclusion : run.status,
|
|
34
|
+
workflow: run.event,
|
|
35
|
+
link: run.databaseId ? `https://github.com/${options.repo}/actions/runs/${run.databaseId}` : undefined,
|
|
36
|
+
}))
|
|
37
|
+
const result = classifyChecks([...checks, ...workflows])
|
|
38
|
+
const signature = JSON.stringify(result)
|
|
39
|
+
if (signature !== previous) {
|
|
40
|
+
if (result.state === "pending") report("status", { kind: "ci", repo: options.repo, pr: options.pr, ...result })
|
|
41
|
+
else { emittedTerminal = true; report("terminal", { kind: "ci", repo: options.repo, pr: options.pr, ...result }) }
|
|
42
|
+
}
|
|
43
|
+
previous = signature
|
|
44
|
+
if (result.state === "passed") process.exitCode = 0
|
|
45
|
+
if (result.state === "failed") process.exitCode = 2
|
|
46
|
+
if (result.state !== "pending" || options.once) return
|
|
47
|
+
} catch (error) {
|
|
48
|
+
process.stderr.write(`ci-watch: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
49
|
+
terminal("error", 3)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
await sleep(options.interval * 1000)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
main().catch((error) => {
|
|
57
|
+
process.stderr.write(`ci-watch: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
58
|
+
report("terminal", { kind: "ci", state: "error", error: error instanceof Error ? error.message : String(error) })
|
|
59
|
+
process.exitCode = 3
|
|
60
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
|
|
3
|
+
export const PROTOCOL = "fray.github-monitor/v1"
|
|
4
|
+
const SUCCESS = new Set(["SUCCESS", "SKIPPED", "NEUTRAL"])
|
|
5
|
+
const FAILURE = new Set(["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "STARTUP_FAILURE"])
|
|
6
|
+
export const GH_TIMEOUT_MS = 30_000
|
|
7
|
+
const GH_ATTEMPTS = 3
|
|
8
|
+
|
|
9
|
+
export function classifyChecks(checks) {
|
|
10
|
+
if (!Array.isArray(checks) || checks.length === 0) return { state: "pending", checks: [] }
|
|
11
|
+
let failed = false
|
|
12
|
+
let pending = false
|
|
13
|
+
for (const check of checks) {
|
|
14
|
+
const state = String(check?.state ?? check?.bucket ?? "").toUpperCase()
|
|
15
|
+
// A fork-gated workflow is reported as ACTION_REQUIRED after its skipped run completes. It is
|
|
16
|
+
// not CI success: continue watching for an approved replacement run instead of waking green.
|
|
17
|
+
if (state.includes("ACTION_REQUIRED") || state === "PENDING" || state === "QUEUED" || state === "IN_PROGRESS" || state === "WAITING") pending = true
|
|
18
|
+
else if (FAILURE.has(state) || state.includes("FAIL")) failed = true
|
|
19
|
+
else if (!SUCCESS.has(state) && state !== "PASS") pending = true
|
|
20
|
+
}
|
|
21
|
+
return { state: failed ? "failed" : pending ? "pending" : "passed", checks }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Every review and comment counts, whoever filed it. Most PR review now arrives from an app —
|
|
25
|
+
// Pullfrog, Copilot, CodeRabbit, Greptile — and the reviewers that post their findings as a
|
|
26
|
+
// conversation comment are exactly what an actor-type filter used to throw away.
|
|
27
|
+
export function reviewActivity(raw) {
|
|
28
|
+
const pr = raw?.data?.repository?.pullRequest
|
|
29
|
+
const nodes = [...(pr?.reviews?.nodes ?? []), ...(pr?.comments?.nodes ?? [])]
|
|
30
|
+
return new Set(nodes.map((node) => String(node?.id ?? "")).filter(Boolean))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function latestWorkflowRuns(runs) {
|
|
34
|
+
const latest = new Map()
|
|
35
|
+
for (const run of runs ?? []) {
|
|
36
|
+
const key = `${run.workflowName ?? run.name ?? "unknown"}\u0000${run.event ?? ""}`
|
|
37
|
+
const old = latest.get(key)
|
|
38
|
+
const stamp = String(run.createdAt ?? "")
|
|
39
|
+
const oldStamp = String(old?.createdAt ?? "")
|
|
40
|
+
const id = Number(run.databaseId ?? 0)
|
|
41
|
+
const oldId = Number(old?.databaseId ?? 0)
|
|
42
|
+
if (!old || stamp > oldStamp || (stamp === oldStamp && id > oldId)) latest.set(key, run)
|
|
43
|
+
}
|
|
44
|
+
return [...latest.values()]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function ghOnce(args, timeoutMs) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
let settled = false
|
|
50
|
+
const child = spawn("gh", args, {
|
|
51
|
+
env: { ...process.env, GH_PAGER: "cat", GH_PROMPT_DISABLED: "1" },
|
|
52
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
53
|
+
// A separate process group lets POSIX hosts terminate a hung credential helper too. Windows
|
|
54
|
+
// uses ChildProcess.kill below; both paths remain Node-only and need no `timeout` utility.
|
|
55
|
+
detached: process.platform !== "win32",
|
|
56
|
+
})
|
|
57
|
+
let stdout = ""
|
|
58
|
+
let stderr = ""
|
|
59
|
+
const fail = (error) => {
|
|
60
|
+
if (settled) return
|
|
61
|
+
settled = true
|
|
62
|
+
reject(error)
|
|
63
|
+
}
|
|
64
|
+
const kill = (signal) => {
|
|
65
|
+
if (process.platform !== "win32" && child.pid) {
|
|
66
|
+
try { process.kill(-child.pid, signal); return } catch { /* child may already be gone */ }
|
|
67
|
+
}
|
|
68
|
+
child.kill(signal)
|
|
69
|
+
}
|
|
70
|
+
const timeout = setTimeout(() => {
|
|
71
|
+
kill("SIGTERM")
|
|
72
|
+
// Some broken credential helpers ignore SIGTERM. Do not let one wedged gh process hold a
|
|
73
|
+
// monitor forever; a second, portable child-process kill keeps the watch bounded.
|
|
74
|
+
setTimeout(() => kill("SIGKILL"), 1_000).unref()
|
|
75
|
+
fail(new Error(`gh timed out after ${timeoutMs / 1000} seconds`))
|
|
76
|
+
}, timeoutMs)
|
|
77
|
+
child.stdout.on("data", (chunk) => { stdout += chunk })
|
|
78
|
+
child.stderr.on("data", (chunk) => { stderr += chunk })
|
|
79
|
+
child.once("error", (error) => { clearTimeout(timeout); fail(error) })
|
|
80
|
+
child.once("close", (status) => {
|
|
81
|
+
clearTimeout(timeout)
|
|
82
|
+
if (settled) return
|
|
83
|
+
settled = true
|
|
84
|
+
if (status !== 0) reject(new Error(stderr.trim() || `gh exited ${status ?? "without a status"}`))
|
|
85
|
+
else resolve(stdout)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function gh(args, { attempts = GH_ATTEMPTS, timeoutMs = GH_TIMEOUT_MS } = {}) {
|
|
91
|
+
if (!Number.isInteger(attempts) || attempts < 1) throw new Error("gh attempts must be a positive integer")
|
|
92
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 1) throw new Error("gh timeout must be positive")
|
|
93
|
+
let lastError
|
|
94
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
95
|
+
try { return await ghOnce(args, timeoutMs) } catch (error) { lastError = error }
|
|
96
|
+
// A short bounded backoff covers transient GitHub and credential-helper failures. The final
|
|
97
|
+
// failure remains a terminal monitor error rather than an unbounded retry loop.
|
|
98
|
+
if (attempt + 1 < attempts) await sleep(250 * 2 ** attempt)
|
|
99
|
+
}
|
|
100
|
+
throw lastError
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function sleep(ms) {
|
|
104
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function parseArgs(argv, usage) {
|
|
108
|
+
const out = { interval: 60, once: false }
|
|
109
|
+
for (let i = 0; i < argv.length; i++) {
|
|
110
|
+
const arg = argv[i]
|
|
111
|
+
if (arg === "--help") return { help: true }
|
|
112
|
+
if (arg === "--once") out.once = true
|
|
113
|
+
else if (arg === "--repo" || arg === "--pr" || arg === "--interval") out[arg.slice(2)] = argv[++i]
|
|
114
|
+
else throw new Error(`Unknown argument: ${arg}\n${usage}`)
|
|
115
|
+
}
|
|
116
|
+
if (!out.repo || !out.pr) throw new Error(usage)
|
|
117
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(out.repo)) throw new Error("--repo must be OWNER/REPO")
|
|
118
|
+
if (!/^\d+$/.test(String(out.pr)) || Number(out.pr) < 1) throw new Error("--pr must be a positive number")
|
|
119
|
+
out.interval = Number(out.interval)
|
|
120
|
+
if (!Number.isFinite(out.interval) || out.interval < 5) throw new Error("--interval must be at least 5 seconds")
|
|
121
|
+
return out
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Every stdout line is one schema-versioned NDJSON event. A monitor may emit many status events
|
|
125
|
+
// but exactly one terminal event when it reaches a terminal verdict. Exit codes: passed/new activity
|
|
126
|
+
// 0, CI failure 2, invocation/GitHub error 3. `--once` may end after a non-terminal snapshot with 0.
|
|
127
|
+
export function report(type, value) {
|
|
128
|
+
if (type !== "status" && type !== "terminal") throw new Error(`invalid monitor event type: ${type}`)
|
|
129
|
+
process.stdout.write(`${JSON.stringify({ protocol: PROTOCOL, type, at: new Date().toISOString(), ...value })}\n`)
|
|
130
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { gh, parseArgs, report, reviewActivity, sleep } from "./github-watch.mjs"
|
|
3
|
+
|
|
4
|
+
const usage = "Usage: review-watch.mjs --repo OWNER/REPO --pr NUMBER [--interval SECONDS] [--once]"
|
|
5
|
+
const QUERY = `query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviews(last:50){nodes{id}} comments(last:50){nodes{id}}}}}`
|
|
6
|
+
|
|
7
|
+
async function main() {
|
|
8
|
+
let options
|
|
9
|
+
try { options = parseArgs(process.argv.slice(2), usage) } catch (error) {
|
|
10
|
+
process.stderr.write(`review-watch: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
11
|
+
report("terminal", { kind: "review", state: "error", error: error instanceof Error ? error.message : String(error) })
|
|
12
|
+
process.exitCode = 3
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
if (options.help) return console.log(usage)
|
|
16
|
+
const [owner, repo] = options.repo.split("/")
|
|
17
|
+
if (!owner || !repo) throw new Error("--repo must be OWNER/REPO")
|
|
18
|
+
let emittedTerminal = false
|
|
19
|
+
const terminal = (state, exitCode, extra = {}) => {
|
|
20
|
+
if (emittedTerminal) return
|
|
21
|
+
emittedTerminal = true
|
|
22
|
+
report("terminal", { kind: "review", repo: options.repo, pr: options.pr, state, ...extra })
|
|
23
|
+
process.exitCode = exitCode
|
|
24
|
+
}
|
|
25
|
+
const cancelled = () => { terminal("cancelled", 130); process.exit(130) }
|
|
26
|
+
process.once("SIGINT", cancelled)
|
|
27
|
+
process.once("SIGTERM", cancelled)
|
|
28
|
+
let baseline
|
|
29
|
+
for (;;) {
|
|
30
|
+
try {
|
|
31
|
+
const raw = JSON.parse(await gh(["api", "graphql", "-f", `query=${QUERY}`, "-F", `owner=${owner}`, "-F", `repo=${repo}`, "-F", `number=${options.pr}`]))
|
|
32
|
+
const current = reviewActivity(raw)
|
|
33
|
+
if (!baseline) {
|
|
34
|
+
baseline = current
|
|
35
|
+
report("status", { kind: "review", repo: options.repo, pr: options.pr, state: "armed", seen: current.size })
|
|
36
|
+
} else {
|
|
37
|
+
const added = [...current].filter((id) => !baseline.has(id))
|
|
38
|
+
if (added.length) { terminal("new-activity", 0, { ids: added }); return }
|
|
39
|
+
}
|
|
40
|
+
if (options.once) return
|
|
41
|
+
} catch (error) {
|
|
42
|
+
process.stderr.write(`review-watch: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
43
|
+
terminal("error", 3)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
await sleep(options.interval * 1000)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
main().catch((error) => {
|
|
51
|
+
process.stderr.write(`review-watch: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
52
|
+
report("terminal", { kind: "review", state: "error", error: error instanceof Error ? error.message : String(error) })
|
|
53
|
+
process.exitCode = 3
|
|
54
|
+
})
|