sneakoscope 0.7.65 → 0.7.67
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/README.md +31 -111
- package/package.json +6 -5
- package/src/cli/install-helpers.mjs +53 -2
- package/src/cli/main.mjs +15 -11
- package/src/cli/maintenance-commands.mjs +2 -2
- package/src/core/fsx.mjs +1 -1
- package/src/core/hooks-runtime.mjs +110 -1
- package/src/core/init.mjs +35 -2
- package/src/core/team-live.mjs +33 -10
- package/src/core/tmux-ui.mjs +34 -12
package/README.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# Sneakoscope Codex
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Sneakoscope Codex (`sks`, displayed as `ㅅㅋㅅ`) is a Codex CLI/App harness for repeatable agent workflows. It adds terminal commands, Codex App `$` prompt commands, tmux-native CLI workspaces, Team/QA/Research routes, inspectable pipeline plans, a maximum-speed Computer Use lane, an imagegen/gpt-image-2 UI/UX review route, a fast Goal bridge for native `/goal` persistence, Context7 evidence checks, DB safety, TriWiki context tracking, design-system SSOT routing, lightweight skill dreaming, Honest Mode, and release-readiness gates.
|
|
3
|
+
Sneakoscope Codex (`sks`) is a Codex CLI/App harness for repeatable workflows. It adds terminal commands, Codex App `$` commands, tmux workspaces, Team/QA/Research routes, pipeline plans, Computer Use, imagegen UI/UX review, Goal, Context7, DB safety, TriWiki, design-system routing, skill dreaming, Honest Mode.
|
|
6
4
|
|
|
7
5
|
## Quick Start
|
|
8
6
|
|
|
@@ -42,30 +40,30 @@ sks selftest --mock
|
|
|
42
40
|
|
|
43
41
|
| Area | What it does |
|
|
44
42
|
| --- | --- |
|
|
45
|
-
| CLI runtime | Bare `sks` opens
|
|
46
|
-
| Codex App commands | Installs generated skills
|
|
47
|
-
| OpenClaw agents | Generates an OpenClaw skill package so
|
|
48
|
-
| Pipeline plans | Writes `pipeline-plan.json`
|
|
49
|
-
| Team orchestration | Runs substantial work through
|
|
43
|
+
| CLI runtime | Bare `sks` opens/reuses the default tmux Codex CLI workspace. `sks tmux open` handles explicit session flags, and `sks --mad` launches a single-pane MAD session. |
|
|
44
|
+
| Codex App commands | Installs generated skills for `$Team`, `$DFix`, `$QA-LOOP`, `$PPT`, `$UX-Review`, `$Goal`, `$DB`, `$Wiki`, `$Help`, and related routes. |
|
|
45
|
+
| OpenClaw agents | Generates an OpenClaw skill package so agents can attach `sneakoscope-codex` and discover SKS commands from the repo root. |
|
|
46
|
+
| Pipeline plans | Writes `pipeline-plan.json` so runtime lanes, stages, verification, and no-fallback invariants are visible with `sks pipeline plan`. |
|
|
47
|
+
| Team orchestration | Runs substantial work through ambiguity handling, scouts, TriWiki, debate, runtime tasks, implementation, review, cleanup, reflection, and Honest Mode; narrow work should use Proof Field evidence. |
|
|
50
48
|
| Skill dreaming | Records cheap generated-skill usage counters in JSON and only periodically scans `.agents/skills` for keep, merge, prune, and improvement candidates. Reports are recommendation-only and never delete skills automatically. |
|
|
51
49
|
| From-Chat-IMG | Turns chat screenshots plus original attachments into source-bound work orders, then requires scoped QA evidence before completion. |
|
|
52
50
|
| QA loop | Dogfoods UI/API behavior with safety gates, Codex Computer Use-only UI evidence, safe fixes, and rechecks. |
|
|
53
|
-
| PPT pipeline | Uses `$PPT` for
|
|
54
|
-
| Image UX Review | Uses `$Image-UX-Review` / `$UX-Review` for UI/UX audits
|
|
55
|
-
| Computer Use fast lane | Uses `$Computer-Use` / `$CU` for UI/browser/visual work
|
|
56
|
-
| Goal |
|
|
57
|
-
| TriWiki voxels | Maintains `.sneakoscope/wiki/context-pack.json` as the context SSOT with coordinate anchors, voxel metadata,
|
|
51
|
+
| PPT pipeline | Uses `$PPT` for restrained HTML/PDF decks with sealed context, design SSOT, export QA, editable source HTML, and `$imagegen` assets when required. |
|
|
52
|
+
| Image UX Review | Uses `$Image-UX-Review` / `$UX-Review` for UI/UX audits that require generated annotated review images before issue extraction. |
|
|
53
|
+
| Computer Use fast lane | Uses `$Computer-Use` / `$CU` for fast UI/browser/visual work, then refreshes/validates TriWiki and runs Honest Mode. |
|
|
54
|
+
| Goal | Bridges Codex native `/goal` create, pause, resume, and clear controls while implementation continues through the selected SKS route. |
|
|
55
|
+
| TriWiki voxels | Maintains `.sneakoscope/wiki/context-pack.json` as the context SSOT with coordinate anchors, voxel metadata, attention hints, and mistake recall. |
|
|
58
56
|
| Context7 | Requires current docs for external packages, APIs, MCPs, SDKs, and framework/runtime behavior when correctness depends on current guidance. |
|
|
59
|
-
| Design SSOT | Treats `design.md` as the only design decision source of truth
|
|
57
|
+
| Design SSOT | Treats `design.md` as the only design decision source of truth; getdesign and curated DESIGN.md examples are inputs, not parallel authorities. |
|
|
60
58
|
| DB safety | Treats SQL, migrations, Supabase, RLS, and destructive operations as high risk. |
|
|
61
|
-
| Release hygiene | Checks versioning, changelog,
|
|
59
|
+
| Release hygiene | Checks versioning, changelog, size, syntax. |
|
|
62
60
|
|
|
63
61
|
## Requirements
|
|
64
62
|
|
|
65
63
|
- Node.js `>=20.11`
|
|
66
64
|
- npm
|
|
67
65
|
- Codex CLI for terminal workflows
|
|
68
|
-
- Codex App for app-facing workflows,
|
|
66
|
+
- Codex App for app-facing workflows, including Codex Computer Use and `$imagegen`/`gpt-image-2` evidence when required
|
|
69
67
|
- tmux for the CLI-first runtime
|
|
70
68
|
- Context7 MCP for current-docs-gated routes
|
|
71
69
|
|
|
@@ -75,12 +73,7 @@ Install tmux from [tmux.dev/download](https://www.tmux.dev/download). On macOS,
|
|
|
75
73
|
brew install tmux
|
|
76
74
|
```
|
|
77
75
|
|
|
78
|
-
The default `sks` runtime checks npm for newer `sneakoscope` and `@openai/codex` versions before opening tmux
|
|
79
|
-
|
|
80
|
-
- Checks npm for newer `sneakoscope` and `@openai/codex` versions before launch and asks whether to update when the terminal can answer y/n.
|
|
81
|
-
- Installs the latest Codex CLI with `npm i -g @openai/codex@latest` when it is missing and you approve or pass `--yes`.
|
|
82
|
-
- Requires tmux 3.x or newer before opening the session.
|
|
83
|
-
- Creates a named detached single-pane tmux session and prints only the session, gate, attach, and blocker details needed to act.
|
|
76
|
+
The default `sks` runtime checks npm for newer `sneakoscope` and `@openai/codex` versions before opening tmux. `sks --mad` also checks dependencies, requires tmux 3.x, and prints only the session, gate, attach, and blocker details needed to act.
|
|
84
77
|
|
|
85
78
|
## Installation
|
|
86
79
|
|
|
@@ -97,7 +90,7 @@ sks root
|
|
|
97
90
|
|
|
98
91
|
Project setup writes shared `.gitignore` entries for generated SKS files: `.sneakoscope/`, `.codex/`, `.agents/`, and managed `AGENTS.md`. Setup, doctor repair, and npm postinstall refreshes also compare the previous SKS generated-file manifest with the current package templates and prune stale SKS-generated legacy skills or agent files while preserving user-owned custom skills. Use `sks setup --local-only` when you want those excludes kept only in `.git/info/exclude`.
|
|
99
92
|
|
|
100
|
-
During npm postinstall, SKS
|
|
93
|
+
During npm postinstall, SKS installs generated Codex App skills and tries `skills add MohtashamMurshid/getdesign` when the `skills` CLI is available. Design work still flows through one authority: `design.md`.
|
|
101
94
|
|
|
102
95
|
### One-Shot Install
|
|
103
96
|
|
|
@@ -171,7 +164,7 @@ Bare `sks` creates or reuses the default named tmux session for Codex CLI and at
|
|
|
171
164
|
|
|
172
165
|
Before opening tmux, SKS checks the installed Codex CLI against npm `@openai/codex@latest`. If a newer version exists, it asks `Y/n`; answering `y` updates automatically with `npm i -g @openai/codex@latest` and then opens tmux with the updated Codex CLI.
|
|
173
166
|
|
|
174
|
-
|
|
167
|
+
For [codex-lb](https://github.com/Soju06/codex-lb), start the server, create a dashboard API key, then run:
|
|
175
168
|
|
|
176
169
|
```sh
|
|
177
170
|
sks codex-lb setup --host https://your-codex-lb.example.com --api-key "sk-clb-..."
|
|
@@ -179,30 +172,9 @@ sks codex-lb repair
|
|
|
179
172
|
sks
|
|
180
173
|
```
|
|
181
174
|
|
|
182
|
-
Bare `sks`
|
|
183
|
-
|
|
184
|
-
```text
|
|
185
|
-
Authenticate and route Codex through codex-lb? [y/N]
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
Answering `y` asks for the hosted domain and API key, writes `~/.codex/config.toml`, stores the key in `~/.codex/sks-codex-lb.env` with mode `0600`, syncs Codex CLI API-key auth through `codex login --with-api-key`, and sources that env file before launching Codex in tmux. When codex-lb is configured from this prompt, SKS opens a fresh tmux session for that launch so the new key is loaded by the Codex process immediately. SKS keeps Codex App Fast mode visible and defaulted by writing top-level `model = "gpt-5.5"`, `service_tier = "fast"`, `[features].fast_mode = true`, and the `sks-fast-high` profile while removing legacy top-level reasoning locks; route-specific reasoning stays in named profiles or explicit tmux launch args.
|
|
189
|
-
|
|
190
|
-
If Codex CLI auth drifts after a tmux/MAD launch, run `sks codex-lb repair` or `sks auth repair`. This reuses the stored `~/.codex/sks-codex-lb.env` key and re-syncs Codex CLI API-key auth without asking for the key again. To replace the key or host, run `sks codex-lb reconfigure --host <domain> --api-key <key>`.
|
|
191
|
-
|
|
192
|
-
The generated provider config follows the codex-lb README's Codex CLI API-key setup:
|
|
193
|
-
|
|
194
|
-
```toml
|
|
195
|
-
model_provider = "codex-lb"
|
|
196
|
-
service_tier = "fast"
|
|
175
|
+
Bare `sks` can also prompt for codex-lb auth before launch; SKS stores the key in `~/.codex/sks-codex-lb.env`, syncs `codex login --with-api-key`, and loads it into a fresh tmux session.
|
|
197
176
|
|
|
198
|
-
|
|
199
|
-
name = "OpenAI"
|
|
200
|
-
base_url = "http://127.0.0.1:2455/backend-api/codex"
|
|
201
|
-
wire_api = "responses"
|
|
202
|
-
env_key = "CODEX_LB_API_KEY"
|
|
203
|
-
supports_websockets = true
|
|
204
|
-
requires_openai_auth = true
|
|
205
|
-
```
|
|
177
|
+
If Codex CLI auth drifts after a launch or reinstall, run `sks codex-lb repair`; to replace it, run `sks codex-lb reconfigure --host <domain> --api-key <key>`.
|
|
206
178
|
|
|
207
179
|
### MAD tmux Launch
|
|
208
180
|
|
|
@@ -211,17 +183,9 @@ sks --mad
|
|
|
211
183
|
sks --mad --yes
|
|
212
184
|
```
|
|
213
185
|
|
|
214
|
-
This syncs existing codex-lb/Codex CLI auth
|
|
215
|
-
|
|
216
|
-
MAD does not disable the pipeline contract: stages, executors, reviewers, and auto-review policy still must not invent unrequested fallback implementation code. If the requested path cannot be implemented, SKS should block with evidence rather than add substitute behavior.
|
|
217
|
-
|
|
218
|
-
Before launching, SKS checks whether a newer `sneakoscope` exists on npm. In an interactive terminal it prompts:
|
|
186
|
+
This syncs existing codex-lb/Codex CLI auth, creates/uses the `sks-mad-high` full-access profile, opens the MAD-SKS permission gate for that tmux run, and launches a single Codex CLI pane. The session recreates the named session so stale split-pane MAD sessions collapse back to one pane. Catastrophic database wipe/all-row/project-management safeguards remain active, and the pipeline contract still forbids unrequested fallback implementation code.
|
|
219
187
|
|
|
220
|
-
|
|
221
|
-
SKS 0.x.y -> 0.x.z update before MAD launch? [Y/n]
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
Answer `y` to install `sneakoscope@latest`, then rerun `sks --mad`. Answer `n` to continue with the current version. Use `--yes` to approve missing dependency installs automatically.
|
|
188
|
+
Before launching, SKS checks npm for a newer `sneakoscope`; answer `y` to update or `n` to continue. Use `--yes` to approve missing dependency installs automatically.
|
|
225
189
|
|
|
226
190
|
### Team Missions
|
|
227
191
|
|
|
@@ -237,17 +201,7 @@ sks team dashboard latest
|
|
|
237
201
|
sks team log latest
|
|
238
202
|
```
|
|
239
203
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
Team mode prepares the mission, records live events, compiles runtime tasks and worker inboxes, writes schema-backed effort/work-order/dashboard artifacts, and reconciles split live lanes inside the current SKS-owned tmux session when available. Outside an SKS tmux session, `sks team open-tmux --separate-session` keeps the named `sks-team-*` fallback view. Use `--no-open-tmux` for artifact-only mission creation. The default terminal output stays compact: mission id, agent count, role count, tmux status, watch command, and artifact directory. `sks team dashboard` renders the cockpit panes for mission overview, agent lanes, task DAG, QA/dogfood, artifacts/evidence, and performance.
|
|
243
|
-
|
|
244
|
-
The tmux Team launch is a live orchestration screen in one tmux window: the main Codex pane stays alive, a managed overview pane follows `sks team watch <mission-id> --follow`, and neighboring managed split panes follow individual `sks team lane <mission-id> --agent <name> --follow` views. Pane headers show only mission, lane, phase, follow command, and cleanup command. SKS tags Team panes with tmux user options, closes only those managed panes when agent lanes complete or cleanup is requested, and recalculates the tiled layout after split/close operations. The separate `sks-team-*` session remains available as a fallback. SKS gives lanes role-specific colors, labels, and terminal titles, so scouts, planning/debate voices, executors, reviewers, and safety lanes are visually distinct while detailed evidence is mirrored into `team-transcript.jsonl`, `team-live.md`, and `team-dashboard.json`.
|
|
245
|
-
|
|
246
|
-
Team roster and runtime artifacts now include per-agent Fast reasoning metadata. Simple bounded Team lanes can use low reasoning, tool-heavy runtime/CLI/tmux work uses medium, and knowledge, current-docs, safety, DB, release, commit, or research-heavy lanes use high or xhigh as appropriate instead of opening every scout at high.
|
|
247
|
-
|
|
248
|
-
Agent sessions communicate through the bounded Team transcript. Use `sks team message <mission-id|latest> --from <agent> --to <agent|all> --message "..."` to add direct or broadcast messages; lane panes show messages addressed to that agent plus the fallback global tail.
|
|
249
|
-
|
|
250
|
-
When the Team route reaches `session_cleanup`, SKS marks the tmux session record complete and asks `watch --follow` / `lane --follow` panes to show a cleanup summary and stop. You can also run `sks team cleanup-tmux <mission-id|latest>` manually, or `sks team cleanup-tmux latest --close` to kill the recorded tmux session.
|
|
204
|
+
Team missions keep at least five QA/reviewer lanes active, record live events, compile runtime tasks and worker inboxes, write schema-backed effort/work-order/dashboard artifacts, and reconcile split live lanes in tmux when available. Use `sks team watch`, `sks team lane`, `sks team message`, and `sks team cleanup-tmux` to inspect or close the live view.
|
|
251
205
|
|
|
252
206
|
### QA, Computer Use, Goal, Research, DB, Wiki, GX
|
|
253
207
|
|
|
@@ -273,21 +227,11 @@ sks skill-dream run --json
|
|
|
273
227
|
sks code-structure scan --json
|
|
274
228
|
```
|
|
275
229
|
|
|
276
|
-
`sks pipeline plan`
|
|
277
|
-
|
|
278
|
-
`sks proof-field scan` is SKS's lightweight outcome rubric: it maps the goal to proof cones, records unrelated work that can be skipped with evidence, reports a simplicity score, and names escalation triggers for when the route must return to the full Team/Honest proof path. The rubric embeds Hyperplan-style adversarial pressure as compact lenses instead of a new command: challenge framing, subtract surface, demand evidence, test integration risk, and consider one simpler alternative. When `execution_lane.lane` is `proof_field_fast_lane`, SKS can keep the parent-owned minimal patch plus listed verification and skip Team debate, fresh executor teams, broad route rework, and unrelated checks. Database, security, visual-forensic, unknown, broad, failed, or unsupported-claim signals fail closed to the normal Team/Honest path. Use `sks pipeline plan --proof-field` after changed files are known to bind that Proof Field decision to the mission plan.
|
|
230
|
+
`sks pipeline plan` shows the active route lane, kept/skipped stages, verification commands, and no-unrequested-fallback invariant. `sks proof-field scan` is the lightweight rubric for small changes; risky or broad signals return to the full Team/Honest path.
|
|
279
231
|
|
|
280
232
|
### Ambiguity Questions
|
|
281
233
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
The design borrows two useful ideas from external planning systems without copying their route weight: Ouroboros-style ambiguity thresholds decide whether the prompt is clear enough to proceed, while Prometheus/Hyperplan-style adversarial lenses challenge framing, remove unnecessary surface, demand evidence, test integration risk, and consider a simpler alternative before Team work starts.
|
|
285
|
-
|
|
286
|
-
`sks skill-dream` keeps generated skill complexity bounded without doing a heavy evaluation on every prompt. Route use writes compact counters to `.sneakoscope/skills/dream-state.json`; after the configured 10-route-event threshold and cooldown, or when you run `sks skill-dream run`, SKS scans `.agents/skills` and writes `.sneakoscope/reports/skill-dream-latest.json` with keep, merge, prune, and improvement candidates. The report is intentionally advisory: deleting or merging skills requires explicit approval.
|
|
287
|
-
|
|
288
|
-
`sks goal` and `$Goal` only prepare/control the native `/goal` persistence bridge. They do not replace Team, QA, DB, or other implementation routes; use the selected execution route for the actual work and verification. Context7 is only needed for Goal when external API/library documentation becomes relevant.
|
|
289
|
-
|
|
290
|
-
Use `$Computer-Use` or `$CU` inside Codex App when the task specifically needs Codex Computer Use speed for UI/browser/visual work. This lane intentionally skips Team debate, QA-LOOP clarification, subagents, and upfront TriWiki refresh. It still requires Codex Computer Use as the evidence source, and it defers TriWiki refresh/validate plus Honest Mode to the final closeout. SKS does not install a generated skill named `computer-use`, because that name is reserved for the first-party Codex Computer Use plugin; use `$CU` or `$computer-use-fast` from the SKS picker for the SKS route, and use `@Computer` for the OpenAI plugin.
|
|
234
|
+
Clarification asks only for ambiguity that changes execution; predictable defaults are inferred and sealed. `sks skill-dream` records cheap counters and periodically writes advisory skill reports. `$Goal` controls native `/goal` persistence without replacing the selected execution route. `$Computer-Use` / `$CU` is the fast Codex Computer Use lane for UI/browser/visual work.
|
|
291
235
|
|
|
292
236
|
### Create A Presentation
|
|
293
237
|
|
|
@@ -295,9 +239,7 @@ Use `$Computer-Use` or `$CU` inside Codex App when the task specifically needs C
|
|
|
295
239
|
$PPT create a customer proposal deck as HTML/PDF
|
|
296
240
|
```
|
|
297
241
|
|
|
298
|
-
`$PPT` seals presentation
|
|
299
|
-
|
|
300
|
-
Design references do not compete with each other. `design.md` is the design decision SSOT; if it is missing, SKS uses `docs/Design-Sys-Prompt.md` to build or project the system. getdesign.md, official getdesign docs, and curated DESIGN.md examples from `VoltAgent/awesome-design-md` are source inputs that get fused into `design.md` or route-local `$PPT` style tokens. `$PPT` ignores installed design skills and MCP servers that are not in the route allowlist; generic design skills such as `design-artifact-expert`, `design-ui-editor`, and `design-system-builder` are not automatically used just because they are installed. This is an anti-AI-like-design guard: `$PPT` must ground visual choices in audience, source material, getdesign reference, and the design SSOT instead of freeform cards, gradients, and vague SaaS styling.
|
|
242
|
+
`$PPT` seals presentation context before artifact work and grounds design in `design.md`, getdesign inputs, and source material.
|
|
301
243
|
|
|
302
244
|
## Codex App Usage
|
|
303
245
|
|
|
@@ -535,7 +477,7 @@ node ./bin/sks.mjs --version
|
|
|
535
477
|
npm install -g .
|
|
536
478
|
```
|
|
537
479
|
|
|
538
|
-
If
|
|
480
|
+
If stale, reinstall globally from the repo or npm.
|
|
539
481
|
|
|
540
482
|
### tmux is missing
|
|
541
483
|
|
|
@@ -544,7 +486,7 @@ sks deps install tmux
|
|
|
544
486
|
sks tmux check
|
|
545
487
|
```
|
|
546
488
|
|
|
547
|
-
Install tmux from [tmux.dev/download](https://www.tmux.dev/download) or
|
|
489
|
+
Install tmux from [tmux.dev/download](https://www.tmux.dev/download) or `brew install tmux` on macOS, then re-run the check.
|
|
548
490
|
|
|
549
491
|
### Codex App tools are missing
|
|
550
492
|
|
|
@@ -553,9 +495,7 @@ sks codex-app check
|
|
|
553
495
|
codex mcp list
|
|
554
496
|
```
|
|
555
497
|
|
|
556
|
-
Codex App workflows need the app installed.
|
|
557
|
-
|
|
558
|
-
SKS setup removes old SKS-generated `computer-use` skills from `.agents/skills` so they cannot shadow the first-party Computer Use plugin. If a running Codex App thread was opened before setup or upgrade, start a fresh thread and invoke `@Computer` or Browser again so the host reloads plugin tools.
|
|
498
|
+
Codex App workflows need the app installed. UI/browser evidence requires first-party Codex Computer Use, and generated raster/image-review evidence requires real `$imagegen`/`gpt-image-2` output. After setup/upgrade, start a fresh thread so Codex reloads plugin tools.
|
|
559
499
|
|
|
560
500
|
### Setup is blocked by another harness
|
|
561
501
|
|
|
@@ -564,7 +504,7 @@ sks conflicts check
|
|
|
564
504
|
sks conflicts prompt
|
|
565
505
|
```
|
|
566
506
|
|
|
567
|
-
OMX/DCodex conflicts
|
|
507
|
+
OMX/DCodex conflicts block setup/doctor until the user approves cleanup.
|
|
568
508
|
|
|
569
509
|
### The route is stuck or a final hook keeps reopening
|
|
570
510
|
|
|
@@ -575,7 +515,7 @@ sks team lane latest --agent parent_orchestrator --follow
|
|
|
575
515
|
sks wiki validate .sneakoscope/wiki/context-pack.json
|
|
576
516
|
```
|
|
577
517
|
|
|
578
|
-
Finalization requires
|
|
518
|
+
Finalization requires evidence, valid Team cleanup artifacts, reflection when required, and Honest Mode.
|
|
579
519
|
|
|
580
520
|
## Development And Release
|
|
581
521
|
|
|
@@ -590,27 +530,7 @@ npm run sizecheck
|
|
|
590
530
|
npm run release:check
|
|
591
531
|
```
|
|
592
532
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
Dry-run publish:
|
|
596
|
-
|
|
597
|
-
```sh
|
|
598
|
-
npm run publish:dry
|
|
599
|
-
```
|
|
600
|
-
|
|
601
|
-
`publish:dry` proves the local package is packable. It does not prove npm ownership, OTP, or registry publish permission.
|
|
602
|
-
|
|
603
|
-
## Documentation Style
|
|
604
|
-
|
|
605
|
-
This README follows a common open-source CLI shape:
|
|
606
|
-
|
|
607
|
-
- quick start first
|
|
608
|
-
- explicit install paths
|
|
609
|
-
- separate CLI and app/plugin usage
|
|
610
|
-
- command examples before internal architecture
|
|
611
|
-
- troubleshooting and release checks near the end
|
|
612
|
-
|
|
613
|
-
That shape mirrors how projects such as `rdme` and Vite separate quick start, setup/configuration, and CLI usage while keeping copy-ready commands visible.
|
|
533
|
+
`release:check` runs audit, changelog, syntax, selftest, size, and registry checks.
|
|
614
534
|
|
|
615
535
|
## License
|
|
616
536
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sneakoscope",
|
|
3
3
|
"displayName": "ㅅㅋㅅ",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.67",
|
|
5
5
|
"description": "Sneakoscope Codex: database-safe Codex CLI/App harness with Team, Goal, AutoResearch, TriWiki, and Honest Mode.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"homepage": "https://github.com/mandarange/Sneakoscope-Codex#readme",
|
|
@@ -37,10 +37,11 @@
|
|
|
37
37
|
"packcheck": "find bin src scripts -name '*.mjs' -print0 | xargs -0 -n1 node --check",
|
|
38
38
|
"changelog:check": "node ./scripts/changelog-check.mjs",
|
|
39
39
|
"sizecheck": "node ./scripts/sizecheck.mjs",
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"publish:
|
|
43
|
-
"
|
|
40
|
+
"registry:check": "node ./scripts/release-registry-check.mjs",
|
|
41
|
+
"release:check": "npm run repo-audit && npm run changelog:check && npm run packcheck && npm run selftest && npm run sizecheck && npm run registry:check",
|
|
42
|
+
"publish:dry": "npm run release:check && npm --cache /tmp/sks-npm-cache publish --dry-run --registry https://registry.npmjs.org/ --access public",
|
|
43
|
+
"publish:npm": "npm --cache /tmp/sks-npm-cache publish --registry https://registry.npmjs.org/ --access public",
|
|
44
|
+
"prepublishOnly": "npm run release:check && node ./scripts/release-registry-check.mjs --require-unpublished"
|
|
44
45
|
},
|
|
45
46
|
"keywords": [
|
|
46
47
|
"sneakoscope",
|
|
@@ -6,7 +6,7 @@ import { stdin as input, stdout as output } from 'node:process';
|
|
|
6
6
|
import { ensureDir, exists, globalSksRoot, packageRoot, readText, runProcess, which, writeTextAtomic } from '../core/fsx.mjs';
|
|
7
7
|
import { getCodexInfo } from '../core/codex-adapter.mjs';
|
|
8
8
|
import { formatHarnessConflictReport, llmHarnessCleanupPrompt, scanHarnessConflicts } from '../core/harness-conflicts.mjs';
|
|
9
|
-
import { installSkills } from '../core/init.mjs';
|
|
9
|
+
import { initProject, installSkills } from '../core/init.mjs';
|
|
10
10
|
import { context7ConfigToml, DOLLAR_SKILL_NAMES, GETDESIGN_REFERENCE, hasContext7ConfigText, RECOMMENDED_SKILLS } from '../core/routes.mjs';
|
|
11
11
|
import { codexLaunchCommand, platformTmuxInstallHint, tmuxReadiness } from '../core/tmux-ui.mjs';
|
|
12
12
|
|
|
@@ -17,6 +17,7 @@ export async function postinstall({ bootstrap }) {
|
|
|
17
17
|
await postinstallHarnessConflictNotice(conflictScan);
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
20
|
+
const codexLbConfigSnapshot = await capturePostinstallCodexLbConfigSnapshot();
|
|
20
21
|
console.log('\nSKS installed.');
|
|
21
22
|
const shim = await ensureSksCommandDuringInstall();
|
|
22
23
|
if (shim.status === 'present') console.log(`SKS command: available (${shim.command}).`);
|
|
@@ -54,9 +55,11 @@ export async function postinstall({ bootstrap }) {
|
|
|
54
55
|
if (bootstrapDecision.run) {
|
|
55
56
|
console.log(`SKS bootstrap: ${bootstrapDecision.reason}.`);
|
|
56
57
|
await runPostinstallBootstrap(installRoot, bootstrap);
|
|
58
|
+
await restorePostinstallCodexLbConfigSnapshot(codexLbConfigSnapshot);
|
|
57
59
|
await reportPostinstallCodexLbAuth();
|
|
58
60
|
return;
|
|
59
61
|
}
|
|
62
|
+
await restorePostinstallCodexLbConfigSnapshot(codexLbConfigSnapshot);
|
|
60
63
|
await reportPostinstallCodexLbAuth();
|
|
61
64
|
console.log('\nNext:');
|
|
62
65
|
console.log(' sks bootstrap');
|
|
@@ -141,6 +144,28 @@ export function codexLbEnvPath(home = process.env.HOME || os.homedir()) {
|
|
|
141
144
|
return path.join(home, '.codex', 'sks-codex-lb.env');
|
|
142
145
|
}
|
|
143
146
|
|
|
147
|
+
async function capturePostinstallCodexLbConfigSnapshot(home = process.env.HOME || os.homedir()) {
|
|
148
|
+
const configPath = codexLbConfigPath(home);
|
|
149
|
+
const envPath = codexLbEnvPath(home);
|
|
150
|
+
const config = await readText(configPath, '');
|
|
151
|
+
if (!hasTopLevelCodexLbSelected(config)) return null;
|
|
152
|
+
const baseUrl = codexLbProviderBaseUrl(config);
|
|
153
|
+
if (!baseUrl) return null;
|
|
154
|
+
if (!parseCodexLbEnvKey(await readText(envPath, ''))) return null;
|
|
155
|
+
return { config_path: configPath, env_path: envPath, base_url: baseUrl };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function restorePostinstallCodexLbConfigSnapshot(snapshot) {
|
|
159
|
+
if (!snapshot?.base_url) return { status: 'skipped', reason: 'no_snapshot' };
|
|
160
|
+
const current = await readText(snapshot.config_path, '');
|
|
161
|
+
if (hasTopLevelCodexLbSelected(current) && codexLbProviderBaseUrl(current)) {
|
|
162
|
+
return { status: 'present', config_path: snapshot.config_path };
|
|
163
|
+
}
|
|
164
|
+
const next = normalizeCodexFastModeUiConfig(upsertCodexLbConfig(current, snapshot.base_url));
|
|
165
|
+
await writeTextAtomic(snapshot.config_path, next);
|
|
166
|
+
return { status: 'restored', config_path: snapshot.config_path };
|
|
167
|
+
}
|
|
168
|
+
|
|
144
169
|
export function normalizeCodexLbBaseUrl(input = '') {
|
|
145
170
|
let host = String(input || '').trim();
|
|
146
171
|
if (!host) host = 'http://127.0.0.1:2455';
|
|
@@ -189,6 +214,16 @@ export async function codexLbStatus(opts = {}) {
|
|
|
189
214
|
};
|
|
190
215
|
}
|
|
191
216
|
|
|
217
|
+
function hasTopLevelCodexLbSelected(text = '') {
|
|
218
|
+
const topLevel = String(text || '').split(/\n\s*\[/)[0] || '';
|
|
219
|
+
return /(^|\n)\s*model_provider\s*=\s*"codex-lb"\s*(?:#.*)?(?=\n|$)/.test(topLevel);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function codexLbProviderBaseUrl(text = '') {
|
|
223
|
+
const block = String(text || '').match(/(^|\n)\[model_providers\.codex-lb\]([\s\S]*?)(?=\n\[[^\]]+\]|\s*$)/)?.[2] || '';
|
|
224
|
+
return block.match(/(^|\n)\s*base_url\s*=\s*"([^"]+)"/)?.[2] || '';
|
|
225
|
+
}
|
|
226
|
+
|
|
192
227
|
export async function repairCodexLbAuth(opts = {}) {
|
|
193
228
|
const status = await codexLbStatus(opts);
|
|
194
229
|
if (!status.ok) {
|
|
@@ -869,6 +904,15 @@ export async function selftestCodexLb(tmp) {
|
|
|
869
904
|
const codexLbEnv = await safeReadText(path.join(codexLbHome, '.codex', 'sks-codex-lb.env'));
|
|
870
905
|
const codexLbAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
|
|
871
906
|
if (!codexLbSetupJson.ok || codexLbSetupJson.base_url !== 'https://lb.example.test/backend-api/codex' || !codexLbConfig.includes('model_provider = "codex-lb"') || !codexLbConfig.includes('[model_providers.codex-lb]') || !codexLbEnv.includes("CODEX_LB_API_KEY='sk-test'") || !/(\"auth_mode\"\s*:\s*\"apikey\")/.test(codexLbAuth)) throw new Error('selftest failed: codex-lb setup did not write provider config, env key, and Codex API-key auth');
|
|
907
|
+
await initProject(codexLbHome, { installScope: 'global', force: true, repair: true });
|
|
908
|
+
const codexLbRepairSetupConfig = await safeReadText(path.join(codexLbHome, '.codex', 'config.toml'));
|
|
909
|
+
if (!codexLbRepairSetupConfig.includes('model_provider = "codex-lb"') || !codexLbRepairSetupConfig.includes('[model_providers.codex-lb]') || !codexLbRepairSetupConfig.includes('https://lb.example.test/backend-api/codex') || codexLbRepairSetupConfig.includes('sk-test')) throw new Error('selftest failed: initProject repair lost codex-lb provider config or exposed the stored key');
|
|
910
|
+
await writeTextAtomic(path.join(codexLbHome, '.codex', 'config.toml'), `${codexLbConfig}\n[mcp_servers.supabase]\nurl = "https://mcp.supabase.com/mcp?project_ref=ref&read_only=true&features=database,docs"\n`);
|
|
911
|
+
const ptmp = path.join(tmp, 'codex-lb-project-config'), prevHome = process.env.HOME;
|
|
912
|
+
try { process.env.HOME = codexLbHome; await initProject(ptmp, { installScope: 'global' }); }
|
|
913
|
+
finally { if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome; }
|
|
914
|
+
const pcfg = await safeReadText(path.join(ptmp, '.codex', 'config.toml'));
|
|
915
|
+
if (!pcfg.includes('model_provider = "codex-lb"') || !pcfg.includes('[model_providers.codex-lb]') || !pcfg.includes('[mcp_servers.supabase]') || !pcfg.includes('read_only=true')) throw new Error('selftest failed: project bootstrap lost global codex-lb or MCP config');
|
|
872
916
|
await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), '{"auth_mode":"browser"}\n');
|
|
873
917
|
const codexLbRepair = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'auth', 'repair', '--json'], { cwd: tmp, env: codexLbEnvForSelftest, timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
|
|
874
918
|
if (codexLbRepair.code !== 0) throw new Error(`selftest failed: codex-lb repair exited ${codexLbRepair.code}: ${codexLbRepair.stderr}`);
|
|
@@ -912,7 +956,12 @@ export async function selftestCodexLb(tmp) {
|
|
|
912
956
|
SKS_SKIP_POSTINSTALL_GLOBAL_SKILLS: '1',
|
|
913
957
|
SKS_SKIP_POSTINSTALL_CODEX_LB_AUTH: '0'
|
|
914
958
|
});
|
|
915
|
-
await postinstall({
|
|
959
|
+
await postinstall({
|
|
960
|
+
bootstrap: async () => {
|
|
961
|
+
await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), '{"auth_mode":"browser"}\n');
|
|
962
|
+
await writeTextAtomic(path.join(codexLbHome, '.codex', 'config.toml'), 'model = "gpt-5.5"\nservice_tier = "fast"\n\n[features]\nhooks = true\n');
|
|
963
|
+
}
|
|
964
|
+
});
|
|
916
965
|
} finally {
|
|
917
966
|
for (const key of postinstallEnvKeys) {
|
|
918
967
|
if (postinstallEnvBefore[key] === undefined) delete process.env[key];
|
|
@@ -920,8 +969,10 @@ export async function selftestCodexLb(tmp) {
|
|
|
920
969
|
}
|
|
921
970
|
}
|
|
922
971
|
const codexLbPostBootstrapAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
|
|
972
|
+
const codexLbPostBootstrapConfig = await safeReadText(path.join(codexLbHome, '.codex', 'config.toml'));
|
|
923
973
|
const codexLbLoginCallsAfterBootstrap = (await safeReadText(path.join(codexLbHome, '.codex', 'login-calls.log'))).trim().split(/\r?\n/).filter(Boolean).length;
|
|
924
974
|
if (!codexLbPostBootstrapAuth.includes('"auth_mode":"apikey"') || !codexLbPostBootstrapAuth.includes('sk-test') || codexLbLoginCallsAfterBootstrap <= codexLbLoginCallsBeforeBootstrap) throw new Error('selftest failed: postinstall did not repair codex-lb auth after bootstrap drift');
|
|
975
|
+
if (!codexLbPostBootstrapConfig.includes('model_provider = "codex-lb"') || !codexLbPostBootstrapConfig.includes('[model_providers.codex-lb]') || !codexLbPostBootstrapConfig.includes('https://lb.example.test/backend-api/codex') || codexLbPostBootstrapConfig.includes('sk-test')) throw new Error('selftest failed: postinstall did not restore codex-lb provider config after bootstrap drift');
|
|
925
976
|
const codexLbContext7Bin = path.join(tmp, 'codex-lb-context7-bin');
|
|
926
977
|
await ensureDir(codexLbContext7Bin);
|
|
927
978
|
await writeTextAtomic(path.join(codexLbContext7Bin, 'codex'), '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "codex-cli 99.0.0"; exit 0; fi\nif [ "$CODEX_LB_API_KEY" ]; then echo "context7 leaked CODEX_LB_API_KEY" >&2; exit 77; fi\nif [ "$1" = "mcp" ] && [ "$2" = "list" ]; then echo ""; exit 0; fi\nif [ "$1" = "mcp" ] && [ "$2" = "add" ]; then echo "context7 added"; exit 0; fi\necho "unexpected codex $*" >&2\nexit 2\n');
|
package/src/cli/main.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { sealContract, validateAnswers } from '../core/decision-contract.mjs';
|
|
|
12
12
|
import { buildQaLoopQuestionSchema, buildQaLoopPrompt, defaultQaGate, evaluateQaGate, isQaReportFilename, qaStatus, writeMockQaResult, writeQaLoopArtifacts } from '../core/qa-loop.mjs';
|
|
13
13
|
import { containsUserQuestion, noQuestionContinuationReason } from '../core/no-question-guard.mjs';
|
|
14
14
|
import { evaluateDoneGate, defaultDoneGate } from '../core/hproof.mjs';
|
|
15
|
-
import { emitHook } from '../core/hooks-runtime.mjs';
|
|
15
|
+
import { emitHook, selftestCodexCommitHooks } from '../core/hooks-runtime.mjs';
|
|
16
16
|
import { storageReport, enforceRetention, pruneWikiArtifacts } from '../core/retention.mjs';
|
|
17
17
|
import { classifySql, classifyCommand, classifyToolPayload, checkDbOperation, handleMadSksUserConfirmation, loadDbSafetyPolicy, scanDbSafety } from '../core/db-safety.mjs';
|
|
18
18
|
import { checkHarnessModification, harnessGuardStatus, isHarnessSourceProject } from '../core/harness-guard.mjs';
|
|
@@ -2603,15 +2603,16 @@ async function selftest() {
|
|
|
2603
2603
|
const codexAppQuickRefExists = await exists(path.join(tmp, '.codex', 'SNEAKOSCOPE.md'));
|
|
2604
2604
|
if (!codexAppQuickRefExists) throw new Error('selftest failed: Codex App quick reference missing');
|
|
2605
2605
|
const codexAppQuickRefText = await safeReadText(path.join(tmp, '.codex', 'SNEAKOSCOPE.md'));
|
|
2606
|
-
if (!codexAppQuickRefText.includes('dollar-commands')) throw new Error('selftest failed:
|
|
2607
|
-
if (!codexAppQuickRefText.includes('Context Tracking') || !codexAppQuickRefText.includes('TriWiki')) throw new Error('selftest failed:
|
|
2608
|
-
if (!codexAppQuickRefText.includes('Before each route phase') || !codexAppQuickRefText.includes('every stage')) throw new Error('selftest failed:
|
|
2606
|
+
if (!codexAppQuickRefText.includes('dollar-commands')) throw new Error('selftest failed: quickref commands');
|
|
2607
|
+
if (!codexAppQuickRefText.includes('Context Tracking') || !codexAppQuickRefText.includes('TriWiki')) throw new Error('selftest failed: quickref TriWiki');
|
|
2608
|
+
if (!codexAppQuickRefText.includes('Before each route phase') || !codexAppQuickRefText.includes('every stage')) throw new Error('selftest failed: quickref stage policy');
|
|
2609
2609
|
for (const { command } of DOLLAR_COMMANDS) {
|
|
2610
2610
|
if (!codexAppQuickRefText.includes(command)) throw new Error(`selftest failed: Codex App quick reference missing ${command}`);
|
|
2611
2611
|
}
|
|
2612
2612
|
const hookGoalTmp = tmpdir();
|
|
2613
2613
|
await initProject(hookGoalTmp, {});
|
|
2614
2614
|
const hookBin = path.join(packageRoot(), 'bin', 'sks.mjs');
|
|
2615
|
+
await selftestCodexCommitHooks();
|
|
2615
2616
|
const hookImageUxTmp = tmpdir();
|
|
2616
2617
|
await initProject(hookImageUxTmp, {});
|
|
2617
2618
|
const hookImageUxPayload = JSON.stringify({ cwd: hookImageUxTmp, prompt: '$Image-UX-Review localhost 화면을 gpt-image-2 콜아웃 리뷰로 검수해줘' });
|
|
@@ -3279,7 +3280,7 @@ async function selftest() {
|
|
|
3279
3280
|
if (!tmuxTeam.agents?.length || !tmuxTeam.agents.some((entry) => entry.agent === 'analysis_scout_1') || !tmuxTeam.agents.every((entry) => String(entry.command || '').includes('team lane') && String(entry.command || '').includes('--agent'))) throw new Error('selftest failed: Team tmux view did not expose agent live lanes');
|
|
3280
3281
|
if (!roleTeamPlan.roster.analysis_team.every((agent) => tmuxTeam.agents.some((entry) => entry.agent === agent.id))) throw new Error('selftest failed: Team tmux view collapsed numbered analysis scout lanes');
|
|
3281
3282
|
if (!tmuxTeam.overview?.command?.includes('team watch') || !tmuxTeam.lanes?.some((entry) => entry.role === 'overview') || !tmuxTeam.lanes?.some((entry) => entry.agent === 'analysis_scout_1')) throw new Error('selftest failed: Team tmux view did not expose orchestration overview plus agent lanes');
|
|
3282
|
-
if (tmuxTeam.split_ui?.mode !== 'single_window_split_panes' || tmuxTeam.split_ui?.layout !== '
|
|
3283
|
+
if (tmuxTeam.split_ui?.mode !== 'single_window_split_panes' || tmuxTeam.split_ui?.layout !== 'main-vertical' || tmuxTeam.split_ui?.right_side_only !== true || tmuxTeam.split_ui?.live_updates !== true) throw new Error('selftest failed: tmux UI');
|
|
3283
3284
|
if (String(tmuxTeam.overview?.command || '').includes('SNEAKOSCOPE CODEX') || !String(tmuxTeam.overview?.command || '').includes('Follow: team watch')) throw new Error('selftest failed: Team tmux pane banner is too noisy or missing compact follow hint');
|
|
3284
3285
|
if (teamLaneStyle('analysis_scout_1').role !== 'scout' || teamLaneStyle('executor_1').role !== 'execution' || teamLaneStyle('reviewer_1').role !== 'review') throw new Error('selftest failed: Team tmux role palette did not classify lane roles');
|
|
3285
3286
|
if (!String(tmuxTeam.cleanup_policy || '').includes('mark-complete') || !tmuxTeam.lanes.every((entry) => entry.style?.color && entry.title)) throw new Error('selftest failed: Team tmux view did not expose color/title metadata and cleanup policy');
|
|
@@ -3288,7 +3289,7 @@ async function selftest() {
|
|
|
3288
3289
|
await ensureDir(fakeTmuxDir);
|
|
3289
3290
|
const fakeTmuxLog = path.join(fakeTmuxDir, 'tmux.log');
|
|
3290
3291
|
const fakeTmuxBin = path.join(fakeTmuxDir, 'tmux');
|
|
3291
|
-
await writeTextAtomic(fakeTmuxBin, `#!/usr/bin/env node\nconst
|
|
3292
|
+
await writeTextAtomic(fakeTmuxBin, `#!/usr/bin/env node\nconst{appendFileSync:a}=require('node:fs'),e=process.env,c=process.argv[2];if(e.SKS_FAKE_TMUX_LOG)a(e.SKS_FAKE_TMUX_LOG,process.argv.slice(2).join(' ')+'\\n');if(['has-session','kill-session','kill-pane','set-option','select-pane','select-layout','resize-window','set-window-option','set-hook'].includes(c))process.exit(0);if(c==='new-session'){console.log('%1');process.exit(0)}if(c==='split-window'){console.log(e.SKS_FAKE_TMUX_SPLIT_ID||'%2');process.exit(0)}if(c==='list-windows'){console.log('@1');process.exit(0)}if(c==='display-message'){console.log(e.SKS_FAKE_TMUX_DISPLAY||'sks-existing-selftest\\t@1\\t%1');process.exit(0)}if(c==='list-panes'){console.log(e.SKS_FAKE_TMUX_LIST||'');process.exit(0)}process.exit(0);\n`);
|
|
3292
3293
|
await fsp.chmod(fakeTmuxBin, 0o755);
|
|
3293
3294
|
const previousFakeTmuxLog = process.env.SKS_FAKE_TMUX_LOG;
|
|
3294
3295
|
const previousPath = process.env.PATH;
|
|
@@ -3327,20 +3328,22 @@ async function selftest() {
|
|
|
3327
3328
|
env: { ...process.env, TMUX: '/tmp/tmux-selftest/default,1,0' }
|
|
3328
3329
|
});
|
|
3329
3330
|
const cockpitOpenLog = await safeReadText(fakeTmuxLog);
|
|
3330
|
-
if (!cockpitOpen.ok || cockpitOpen.opened_lane_count !== 2 || !cockpitOpenLog.includes('display-message -p') || !cockpitOpenLog.includes('split-window -t
|
|
3331
|
+
if (!cockpitOpen.ok || cockpitOpen.opened_lane_count !== 2 || cockpitOpen.main_pane_id !== '%1' || cockpitOpen.relayout?.layout_name !== 'main-vertical' || !cockpitOpenLog.includes('display-message -p') || !cockpitOpenLog.includes('split-window -h -t %1') || !cockpitOpenLog.includes('set-option -pt %80 @sks_team_managed 1') || !cockpitOpenLog.includes('select-pane -t %1') || !cockpitOpenLog.includes('select-layout -t @1 main-vertical')) throw new Error('selftest failed: split');
|
|
3331
3332
|
await writeTextAtomic(fakeTmuxLog, '');
|
|
3332
|
-
|
|
3333
|
+
const fakePanes = `%81\tscout: analysis_scout_1\tnode\t1\t${teamId}\tanalysis_scout_1\tscout\n%82\tscout: analysis_scout_2\tnode\t1\t${teamId}\tanalysis_scout_2\tscout\n%83\tuser pane\tzsh\t\t\t\t`;
|
|
3334
|
+
process.env.SKS_FAKE_TMUX_LIST = fakePanes;
|
|
3333
3335
|
const cockpitClose = await reconcileTmuxTeamCockpit({
|
|
3334
3336
|
root: tmp,
|
|
3335
3337
|
missionId: teamId,
|
|
3336
3338
|
plan: roleTeamPlan,
|
|
3337
|
-
dashboard: { agents: {
|
|
3338
|
-
control: { status: '
|
|
3339
|
+
dashboard: { agents: { analysis_scout_2: { status: 'assigned' } } },
|
|
3340
|
+
control: { status: 'cleanup_requested' },
|
|
3341
|
+
close: true,
|
|
3339
3342
|
tmux: { bin: fakeTmuxBin },
|
|
3340
3343
|
env: { ...process.env, TMUX: '/tmp/tmux-selftest/default,1,0' }
|
|
3341
3344
|
});
|
|
3342
3345
|
const cockpitCloseLog = await safeReadText(fakeTmuxLog);
|
|
3343
|
-
if (!cockpitClose.ok || cockpitClose.closed_lane_count !==
|
|
3346
|
+
if (!cockpitClose.ok || cockpitClose.closed_lane_count !== 2 || !cockpitCloseLog.includes('kill-pane -t %81') || !cockpitCloseLog.includes('kill-pane -t %82') || cockpitCloseLog.includes('kill-pane -t %83')) throw new Error('selftest failed: cleanup');
|
|
3344
3347
|
delete process.env.SKS_FAKE_TMUX_DISPLAY;
|
|
3345
3348
|
delete process.env.SKS_FAKE_TMUX_LIST;
|
|
3346
3349
|
delete process.env.SKS_FAKE_TMUX_SPLIT_ID;
|
|
@@ -3443,6 +3446,7 @@ async function selftest() {
|
|
|
3443
3446
|
if (!(await readTeamTranscriptTail(teamDir, 1)).join('\n').includes('selftest mapped options')) throw new Error('selftest failed: team transcript tail missing event');
|
|
3444
3447
|
const teamLane = await renderTeamAgentLane(teamDir, { missionId: teamId, agent: 'analysis_scout_1', lines: 4 });
|
|
3445
3448
|
if (!teamLane.includes('selftest mapped repo slice')) throw new Error('selftest failed: team agent lane missing event context');
|
|
3449
|
+
if (!teamLane.includes('## Live Chat') || !teamLane.includes('selftest mapped repo slice') || teamLane.includes('## Global Tail')) throw new Error('selftest failed:cht');
|
|
3446
3450
|
const teamLaneCli = await runProcess(process.execPath, [hookBin, 'team', 'lane', teamId, '--agent', 'analysis_scout_1', '--lines', '4'], { cwd: tmp, env: { SKS_DISABLE_UPDATE_CHECK: '1' }, timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
|
|
3447
3451
|
if (teamLaneCli.code !== 0 || !String(teamLaneCli.stdout || '').includes('SKS Team Agent Lane') || !String(teamLaneCli.stdout || '').includes('analysis_scout_1')) throw new Error('selftest failed: sks team lane CLI did not render an agent lane');
|
|
3448
3452
|
await writeTextAtomic(path.join(teamDir, 'team-analysis.md'), '- claim: analysis scout mapped route registry | source: src/core/routes.mjs | risk: high | confidence: supported\n');
|
|
@@ -2052,7 +2052,7 @@ async function teamCommand(sub, args) {
|
|
|
2052
2052
|
missionId: id,
|
|
2053
2053
|
agent: readFlagValue(args, '--agent', 'parent_orchestrator'),
|
|
2054
2054
|
reason: message,
|
|
2055
|
-
finalMessage: 'Team cleanup event received.
|
|
2055
|
+
finalMessage: 'Team cleanup event received. Follow loops stop and managed tmux Team panes are closed when reachable.'
|
|
2056
2056
|
}).then(() => cleanupTmuxTeamView({ root, missionId: id, closeSession: flag(args, '--close-session') || flag(args, '--close') })).catch((err) => ({ ok: false, reason: err.message || 'tmux cleanup failed' }))
|
|
2057
2057
|
: null;
|
|
2058
2058
|
if (flag(args, '--json')) return console.log(JSON.stringify(record, null, 2));
|
|
@@ -2091,7 +2091,7 @@ async function teamCommand(sub, args) {
|
|
|
2091
2091
|
missionId: id,
|
|
2092
2092
|
agent: readFlagValue(args, '--agent', 'parent_orchestrator'),
|
|
2093
2093
|
reason: readFlagValue(args, '--reason', 'Team session ended; clean up live follow panes.'),
|
|
2094
|
-
finalMessage: 'Team session ended. Lane/watch follow loops will stop after showing this cleanup summary; managed tmux Team panes
|
|
2094
|
+
finalMessage: 'Team session ended. Lane/watch follow loops will stop after showing this cleanup summary; managed tmux Team panes are closed when reachable.'
|
|
2095
2095
|
});
|
|
2096
2096
|
await appendTeamEvent(dir, {
|
|
2097
2097
|
agent: readFlagValue(args, '--agent', 'parent_orchestrator'),
|
package/src/core/fsx.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import os from 'node:os';
|
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
7
|
|
|
8
|
-
export const PACKAGE_VERSION = '0.7.
|
|
8
|
+
export const PACKAGE_VERSION = '0.7.67';
|
|
9
9
|
export const DEFAULT_PROCESS_TAIL_BYTES = 256 * 1024;
|
|
10
10
|
export const DEFAULT_PROCESS_TIMEOUT_MS = 30 * 60 * 1000;
|
|
11
11
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { projectRoot, readJson, readText, writeJsonAtomic, appendJsonl, readStdin, nowIso, runProcess, which, PACKAGE_VERSION, sha256, packageRoot } from './fsx.mjs';
|
|
2
|
+
import { projectRoot, readJson, readText, writeJsonAtomic, appendJsonl, readStdin, nowIso, runProcess, which, PACKAGE_VERSION, sha256, packageRoot, tmpdir } from './fsx.mjs';
|
|
3
3
|
import { looksInteractiveCommand, interactiveCommandReason } from './no-question-guard.mjs';
|
|
4
4
|
import { missionDir, setCurrent, stateFile } from './mission.mjs';
|
|
5
5
|
import { checkDbOperation, dbBlockReason, handleMadSksUserConfirmation } from './db-safety.mjs';
|
|
@@ -15,9 +15,11 @@ const TEAM_DIGEST_CONTEXT_CHARS = 1600;
|
|
|
15
15
|
const TEAM_DIGEST_SYSTEM_CHARS = 260;
|
|
16
16
|
const STOP_REPEAT_GUARD_ARTIFACT = 'stop-hook-repeat-guard.json';
|
|
17
17
|
const LIGHT_ROUTE_STOP_ARTIFACT = 'light-route-stop.json';
|
|
18
|
+
const CODEX_GIT_ACTION_STOP_ARTIFACT = 'codex-git-action-stop-bypass.json';
|
|
18
19
|
const STOP_REPEAT_GUARD_WINDOW_MS = 10 * 60 * 1000;
|
|
19
20
|
const STOP_REPEAT_GUARD_MAX_ENTRIES = 25;
|
|
20
21
|
const DEFAULT_STOP_REPEAT_GUARD_LIMIT = 2;
|
|
22
|
+
const CODEX_GIT_ACTION_STOP_TTL_MS = 5 * 60 * 1000;
|
|
21
23
|
|
|
22
24
|
async function loadHookPayload() {
|
|
23
25
|
const raw = await readStdin();
|
|
@@ -143,6 +145,13 @@ function clientModelCandidates(value, depth = 0) {
|
|
|
143
145
|
}
|
|
144
146
|
|
|
145
147
|
async function hookUserPrompt(root, state, payload, noQuestion) {
|
|
148
|
+
if (looksLikeCodexGitCommitMessageGeneration(payload)) {
|
|
149
|
+
await armCodexGitActionStopBypass(root, payload).catch(() => null);
|
|
150
|
+
return {
|
|
151
|
+
continue: true,
|
|
152
|
+
systemMessage: 'SKS: Codex App git commit message generation bypassed route gates.'
|
|
153
|
+
};
|
|
154
|
+
}
|
|
146
155
|
if (!noQuestion) {
|
|
147
156
|
const prompt = stripVisibleDecisionAnswerBlocks(extractUserPrompt(payload));
|
|
148
157
|
const madSksConfirmation = await handleMadSksUserConfirmation(root, state, prompt);
|
|
@@ -348,6 +357,12 @@ function clarificationPauseBlockReason(state = {}) {
|
|
|
348
357
|
|
|
349
358
|
async function hookStop(root, state, payload, noQuestion) {
|
|
350
359
|
const last = extractLastMessage(payload);
|
|
360
|
+
if (await consumeCodexGitActionStopBypass(root, payload)) {
|
|
361
|
+
return {
|
|
362
|
+
continue: true,
|
|
363
|
+
systemMessage: 'SKS: Codex App git commit message generation accepted without route finalization gates.'
|
|
364
|
+
};
|
|
365
|
+
}
|
|
351
366
|
if (!noQuestion && (hasDfixLightCompletion(last) || await consumeLightRouteStop(root, payload))) {
|
|
352
367
|
return {
|
|
353
368
|
continue: true,
|
|
@@ -422,6 +437,79 @@ function explicitConversationId(payload = {}) {
|
|
|
422
437
|
return payload.conversation_id || payload.thread_id || payload.session_id || payload.chat_id || null;
|
|
423
438
|
}
|
|
424
439
|
|
|
440
|
+
function looksLikeCodexGitCommitMessageGeneration(payload = {}) {
|
|
441
|
+
const prompt = stripVisibleDecisionAnswerBlocks(extractUserPrompt(payload));
|
|
442
|
+
const haystack = [
|
|
443
|
+
payload.action,
|
|
444
|
+
payload.intent,
|
|
445
|
+
payload.operation,
|
|
446
|
+
payload.permission,
|
|
447
|
+
payload.description,
|
|
448
|
+
payload.kind,
|
|
449
|
+
payload.type,
|
|
450
|
+
payload.feature,
|
|
451
|
+
payload.tool_name,
|
|
452
|
+
payload.toolName,
|
|
453
|
+
payload.source,
|
|
454
|
+
payload.event,
|
|
455
|
+
payload.hook,
|
|
456
|
+
payload.hook_name,
|
|
457
|
+
payload.input?.action,
|
|
458
|
+
payload.input?.intent,
|
|
459
|
+
payload.input?.operation,
|
|
460
|
+
payload.input?.feature,
|
|
461
|
+
payload.input?.source,
|
|
462
|
+
payload.metadata?.action,
|
|
463
|
+
payload.metadata?.intent,
|
|
464
|
+
payload.metadata?.operation,
|
|
465
|
+
payload.metadata?.feature,
|
|
466
|
+
payload.metadata?.source
|
|
467
|
+
].filter(Boolean).join(' ');
|
|
468
|
+
const appSignal = /\b(?:codex[_\s-]*(?:app[_\s-]*)?)?(?:git[_\s-]*)?(?:commit[_\s-]*message|git[_\s-]*commit|codex_git_commit)\b/i.test(haystack)
|
|
469
|
+
|| /커밋\s*메시지\s*생성/i.test(haystack);
|
|
470
|
+
const promptSignal = /\bgenerate(?:\s+a)?(?:\s+git)?\s+commit\s+message\b/i.test(prompt)
|
|
471
|
+
|| /\bcommit\s+message\b[\s\S]{0,80}\b(?:staged|diff|changes?|git)\b/i.test(prompt)
|
|
472
|
+
|| /커밋\s*메시지\s*생성/i.test(prompt);
|
|
473
|
+
if (!appSignal && !promptSignal) return false;
|
|
474
|
+
if (appSignal) return true;
|
|
475
|
+
return !looksLikeUserImplementationRequest(prompt);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function looksLikeUserImplementationRequest(text = '') {
|
|
479
|
+
return /(fix|bug|broken|error|issue|implement|change|update|repair|수정|버그|오류|에러|문제|고쳐|고치|해결|변경|수리|패치|안생기|안\s*생기)/i.test(String(text || ''));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function armCodexGitActionStopBypass(root, payload = {}) {
|
|
483
|
+
const nowMs = Date.now();
|
|
484
|
+
const record = {
|
|
485
|
+
schema_version: 1,
|
|
486
|
+
route: 'codex_git_commit',
|
|
487
|
+
pending_stop_bypass: true,
|
|
488
|
+
conversation_id: conversationId(payload),
|
|
489
|
+
created_at: nowIso(),
|
|
490
|
+
expires_at: new Date(nowMs + CODEX_GIT_ACTION_STOP_TTL_MS).toISOString()
|
|
491
|
+
};
|
|
492
|
+
await writeJsonAtomic(path.join(root, '.sneakoscope', 'state', CODEX_GIT_ACTION_STOP_ARTIFACT), record);
|
|
493
|
+
return record;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function consumeCodexGitActionStopBypass(root, payload = {}) {
|
|
497
|
+
const file = path.join(root, '.sneakoscope', 'state', CODEX_GIT_ACTION_STOP_ARTIFACT);
|
|
498
|
+
const record = await readJson(file, null).catch(() => null);
|
|
499
|
+
if (!record?.pending_stop_bypass) return false;
|
|
500
|
+
if (record.route !== 'codex_git_commit') return false;
|
|
501
|
+
const expiresMs = Date.parse(record.expires_at || '');
|
|
502
|
+
if (!Number.isFinite(expiresMs) || expiresMs < Date.now()) return false;
|
|
503
|
+
const currentConversation = conversationId(payload);
|
|
504
|
+
if (record.conversation_id && explicitConversationId(payload) && record.conversation_id !== currentConversation) return false;
|
|
505
|
+
await writeJsonAtomic(file, {
|
|
506
|
+
...record,
|
|
507
|
+
pending_stop_bypass: false,
|
|
508
|
+
consumed_at: nowIso()
|
|
509
|
+
}).catch(() => null);
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
|
|
425
513
|
async function finalizationRepeatDecision(root, state = {}, payload = {}, reason = '', kind = 'finalization') {
|
|
426
514
|
const now = nowIso();
|
|
427
515
|
const guardPath = path.join(root, '.sneakoscope', 'state', STOP_REPEAT_GUARD_ARTIFACT);
|
|
@@ -812,6 +900,27 @@ export async function emitHook(name) {
|
|
|
812
900
|
process.stdout.write(`${JSON.stringify(normalizeHookResult(name, result))}\n`);
|
|
813
901
|
}
|
|
814
902
|
|
|
903
|
+
export async function selftestCodexCommitHooks() {
|
|
904
|
+
const root = tmpdir();
|
|
905
|
+
const hookBin = path.join(packageRoot(), 'bin', 'sks.mjs');
|
|
906
|
+
const env = { SKS_DISABLE_UPDATE_CHECK: '1' };
|
|
907
|
+
const setup = await runProcess(process.execPath, [hookBin, 'setup', '--install-scope', 'project'], { cwd: root, env, timeoutMs: 15000, maxOutputBytes: 128 * 1024 });
|
|
908
|
+
if (setup.code !== 0) throw new Error(`selftest failed: commit setup ${setup.code}: ${setup.stderr}`);
|
|
909
|
+
const runHook = (name, payload) => runProcess(process.execPath, [hookBin, 'hook', name], { cwd: root, input: JSON.stringify({ cwd: root, ...payload }), env, timeoutMs: 15000, maxOutputBytes: 128 * 1024 });
|
|
910
|
+
const id = 'commit-selftest';
|
|
911
|
+
const hook = await runHook('user-prompt-submit', { conversation_id: id, action: 'codex_git_commit', prompt: 'Generate a git commit message for the staged diff.' });
|
|
912
|
+
if (hook.code !== 0) throw new Error(`selftest failed: commit hook ${hook.code}: ${hook.stderr}`);
|
|
913
|
+
const hookJson = JSON.parse(hook.stdout);
|
|
914
|
+
if (hookJson.decision === 'block' || hookJson.hookSpecificOutput?.additionalContext || !String(hookJson.systemMessage || '').includes('git commit message generation')) throw new Error('selftest failed: commit route bypass');
|
|
915
|
+
const stop = await runHook('stop', { conversation_id: id, last_assistant_message: 'Fix Codex App commit message hook bypass' });
|
|
916
|
+
if (stop.code !== 0) throw new Error(`selftest failed: commit stop ${stop.code}: ${stop.stderr}`);
|
|
917
|
+
const stopJson = JSON.parse(stop.stdout);
|
|
918
|
+
if (stopJson.decision === 'block' || !String(stopJson.systemMessage || '').includes('accepted without route finalization')) throw new Error('selftest failed: commit stop bypass');
|
|
919
|
+
const userHook = await runHook('user-prompt-submit', { prompt: '[커밋 메시지를 생성하지 못했습니다.] 코덱스 앱에서 이 버그 수정해줘' });
|
|
920
|
+
if (userHook.code !== 0) throw new Error(`selftest failed: user commit hook ${userHook.code}: ${userHook.stderr}`);
|
|
921
|
+
if (!JSON.parse(userHook.stdout).hookSpecificOutput?.additionalContext?.includes('$Team route prepared')) throw new Error('selftest failed: user prompt route');
|
|
922
|
+
}
|
|
923
|
+
|
|
815
924
|
function normalizeHookResult(name, result = {}) {
|
|
816
925
|
const eventName = codexHookEventName(name);
|
|
817
926
|
const out = { ...result };
|
package/src/core/init.mjs
CHANGED
|
@@ -112,6 +112,7 @@ export async function initProject(root, opts = {}) {
|
|
|
112
112
|
const sine = path.join(root, '.sneakoscope');
|
|
113
113
|
const manifestPath = path.join(sine, 'manifest.json');
|
|
114
114
|
const previousManifest = await readJson(manifestPath, null);
|
|
115
|
+
const preRepairCodexConfig = opts.repair ? await readText(path.join(root, '.codex', 'config.toml'), '') : '';
|
|
115
116
|
if (opts.repair) {
|
|
116
117
|
const repair = await repairSksGeneratedArtifacts(root, { resetState: Boolean(opts.resetState) });
|
|
117
118
|
if (repair.removed.length) created.push(`repaired generated SKS files (${repair.removed.length})`);
|
|
@@ -462,6 +463,34 @@ function mergeManagedCodexConfigToml(existingContent = '') {
|
|
|
462
463
|
return `${next.trim()}\n`;
|
|
463
464
|
}
|
|
464
465
|
|
|
466
|
+
async function mergeGlobalCodexConfigIfAvailable(configText = '', configPath = '') {
|
|
467
|
+
const selectedRe = /(^|\n)\s*model_provider\s*=\s*"codex-lb"\s*(?:#.*)?(?=\n|$)/;
|
|
468
|
+
const home = process.env.HOME || '';
|
|
469
|
+
if (!home) return configText;
|
|
470
|
+
const globalConfigPath = path.join(home, '.codex', 'config.toml');
|
|
471
|
+
if (configPath && path.resolve(configPath) === path.resolve(globalConfigPath)) return configText;
|
|
472
|
+
const globalConfig = await readText(globalConfigPath, '');
|
|
473
|
+
let next = mergeGlobalMcpServers(configText, globalConfig);
|
|
474
|
+
if (selectedRe.test(next) && /\[model_providers\.codex-lb\]/.test(next)) return `${String(next || '').trim()}\n`;
|
|
475
|
+
if (!(await exists(path.join(home, '.codex', 'sks-codex-lb.env')))) return next;
|
|
476
|
+
const baseUrl = globalConfig.match(/(^|\n)\[model_providers\.codex-lb\][\s\S]*?\n\s*base_url\s*=\s*"([^"]+)"/)?.[2];
|
|
477
|
+
if (!selectedRe.test(globalConfig) || !baseUrl) return next;
|
|
478
|
+
next = upsertTopLevelTomlString(next, 'model_provider', 'codex-lb');
|
|
479
|
+
next = upsertTomlTable(next, 'model_providers.codex-lb', `[model_providers.codex-lb]\nname = "OpenAI"\nbase_url = "${baseUrl}"\nwire_api = "responses"\nenv_key = "CODEX_LB_API_KEY"\nsupports_websockets = true\nrequires_openai_auth = true`);
|
|
480
|
+
return `${next.trim()}\n`;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function mergeGlobalMcpServers(configText = '', globalConfig = '') {
|
|
484
|
+
let next = configText;
|
|
485
|
+
const re = /(?:^|\n)(\[(mcp_servers\.[^\]\r\n]+)\][\s\S]*?)(?=\n\[[^\]]+\]|\s*$)/g;
|
|
486
|
+
for (const match of String(globalConfig || '').matchAll(re)) {
|
|
487
|
+
const block = match[1].trim();
|
|
488
|
+
const table = match[2].trim();
|
|
489
|
+
if (!new RegExp(`(^|\\n)\\[${escapeRegExp(table)}\\]`).test(next)) next = upsertTomlTable(next, table, block);
|
|
490
|
+
}
|
|
491
|
+
return next;
|
|
492
|
+
}
|
|
493
|
+
|
|
465
494
|
function removeLegacyTopLevelCodexModeLocks(text = '') {
|
|
466
495
|
const legacy = {
|
|
467
496
|
model_reasoning_effort: new Set(['high'])
|
|
@@ -618,8 +647,12 @@ function upsertTomlTable(text, table, block) {
|
|
|
618
647
|
}
|
|
619
648
|
|
|
620
649
|
const generatedCodexConfigPath = path.join(root, '.codex', 'config.toml');
|
|
621
|
-
const existingCodexConfig = await readText(generatedCodexConfigPath, '');
|
|
622
|
-
await
|
|
650
|
+
const existingCodexConfig = await readText(generatedCodexConfigPath, '') || preRepairCodexConfig;
|
|
651
|
+
const managedCodexConfig = await mergeGlobalCodexConfigIfAvailable(
|
|
652
|
+
mergeManagedCodexConfigToml(existingCodexConfig),
|
|
653
|
+
generatedCodexConfigPath
|
|
654
|
+
);
|
|
655
|
+
await writeTextAtomic(generatedCodexConfigPath, managedCodexConfig);
|
|
623
656
|
created.push('.codex/config.toml');
|
|
624
657
|
|
|
625
658
|
await writeTextAtomic(path.join(root, '.codex', 'SNEAKOSCOPE.md'), codexAppQuickReference(installScope, hookCommandPrefix));
|
package/src/core/team-live.mjs
CHANGED
|
@@ -498,7 +498,7 @@ export async function requestTeamSessionCleanup(dir, opts = {}) {
|
|
|
498
498
|
cleanup_requested_at: opts.ts || nowIso(),
|
|
499
499
|
cleanup_requested_by: opts.agent || 'parent_orchestrator',
|
|
500
500
|
cleanup_reason: opts.reason || 'Team session cleanup requested.',
|
|
501
|
-
final_message: opts.finalMessage || 'Team session ended. Lane follow loops
|
|
501
|
+
final_message: opts.finalMessage || 'Team session ended. Lane/watch follow loops stop after this summary; managed tmux Team panes are closed when reachable.'
|
|
502
502
|
};
|
|
503
503
|
await writeJsonAtomic(files.control, next);
|
|
504
504
|
return next;
|
|
@@ -518,7 +518,7 @@ export function renderTeamCleanupSummary(control = {}) {
|
|
|
518
518
|
`Requested by: ${control.cleanup_requested_by || 'unknown'}`,
|
|
519
519
|
`Reason: ${control.cleanup_reason || 'Team session cleanup requested.'}`,
|
|
520
520
|
'',
|
|
521
|
-
control.final_message || 'Team session ended. managed tmux Team panes
|
|
521
|
+
control.final_message || 'Team session ended. managed tmux Team panes are closed when reachable.'
|
|
522
522
|
].join('\n');
|
|
523
523
|
}
|
|
524
524
|
|
|
@@ -554,6 +554,9 @@ export async function renderTeamAgentLane(dir, opts = {}) {
|
|
|
554
554
|
const assignedTasks = runtimeTasks.filter((task) => aliasSet.has(task?.worker) || aliasSet.has(task?.agent_hint));
|
|
555
555
|
const agentEvents = parsedWindow.filter((event) => aliasSet.has(event?.agent) || aliases.some((id) => eventAddressedTo(event, id))).slice(-lines);
|
|
556
556
|
const directMessages = parsedWindow.filter((event) => event?.type === 'message' && aliases.some((id) => eventAddressedTo(event, id))).slice(-lines);
|
|
557
|
+
const chatEvents = uniqueTranscriptEvents([...agentEvents, ...directMessages])
|
|
558
|
+
.sort((a, b) => String(a.ts || '').localeCompare(String(b.ts || '')))
|
|
559
|
+
.slice(-lines);
|
|
557
560
|
const laneStyle = teamLaneTextStyle(agent);
|
|
558
561
|
return [
|
|
559
562
|
`# SKS Team Agent Lane`,
|
|
@@ -573,11 +576,8 @@ export async function renderTeamAgentLane(dir, opts = {}) {
|
|
|
573
576
|
`## Assigned Runtime Tasks`,
|
|
574
577
|
...(runtime ? formatRuntimeTasks(assignedTasks) : ['- team-runtime-tasks.json not available yet.']),
|
|
575
578
|
'',
|
|
576
|
-
`##
|
|
577
|
-
...(
|
|
578
|
-
'',
|
|
579
|
-
`## Direct Messages`,
|
|
580
|
-
...(directMessages.length ? directMessages.map(formatTranscriptEvent) : ['- No direct or broadcast messages in the bounded tail.']),
|
|
579
|
+
`## Live Chat`,
|
|
580
|
+
...(chatEvents.length ? chatEvents.map((event) => formatChatTranscriptEvent(event, aliases[0])) : ['- waiting for live agent messages...']),
|
|
581
581
|
opts.includeGlobalTail ? '' : null,
|
|
582
582
|
opts.includeGlobalTail ? `## Global Tail` : null,
|
|
583
583
|
...(opts.includeGlobalTail
|
|
@@ -608,11 +608,11 @@ export async function renderTeamWatch(dir, opts = {}) {
|
|
|
608
608
|
'## Split-Screen Map',
|
|
609
609
|
'- This overview pane follows the whole mission transcript.',
|
|
610
610
|
'- Run `sks team open-tmux ...` to materialize or reopen the split-pane Team tmux view for an existing mission.',
|
|
611
|
-
'- Inside an SKS-owned tmux session, Team panes are reconciled in the current window
|
|
612
|
-
'- Neighbor tmux panes follow individual `sks team lane ... --agent <name>` views.',
|
|
611
|
+
'- Inside an SKS-owned tmux session, Team panes are reconciled in the current window with the Codex pane on the left and Team lanes stacked on the right.',
|
|
612
|
+
'- Neighbor tmux panes follow individual `sks team lane ... --agent <name>` chat-style views.',
|
|
613
613
|
'- Use `sks team event ...` to mirror scout, debate, executor, review, and verification status into the live panes.',
|
|
614
614
|
'- Use `sks team message ... --from <agent> --to <agent|all>` for bounded inter-agent communication in transcript/lane views.',
|
|
615
|
-
'- Use `sks team cleanup-tmux ...` at session end; follow loops show cleanup and
|
|
615
|
+
'- Use `sks team cleanup-tmux ...` at session end; follow loops show cleanup and managed Team panes close when reachable.',
|
|
616
616
|
'',
|
|
617
617
|
'## Cockpit Views',
|
|
618
618
|
'- Mission / Goal | Agents | MultiAgentV2 | Work Orders | Skills | Memory Health | Forget Queue',
|
|
@@ -697,6 +697,29 @@ function formatTranscriptEvent(event = {}) {
|
|
|
697
697
|
return `- ${parts.join(' ')}: ${String(event.message || '').slice(0, 500)}${suffix}`;
|
|
698
698
|
}
|
|
699
699
|
|
|
700
|
+
function uniqueTranscriptEvents(events = []) {
|
|
701
|
+
const seen = new Set();
|
|
702
|
+
const out = [];
|
|
703
|
+
for (const event of events) {
|
|
704
|
+
const key = event?.raw || [event?.ts, event?.agent, event?.to, event?.type, event?.message].map((value) => String(value || '')).join('\t');
|
|
705
|
+
if (seen.has(key)) continue;
|
|
706
|
+
seen.add(key);
|
|
707
|
+
out.push(event);
|
|
708
|
+
}
|
|
709
|
+
return out;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function formatChatTranscriptEvent(event = {}, laneAgent = '') {
|
|
713
|
+
if (event.raw) return `- system: ${event.raw}`;
|
|
714
|
+
const from = event.agent || 'unknown';
|
|
715
|
+
const to = event.to ? ` -> ${event.to}` : '';
|
|
716
|
+
const kind = event.type && event.type !== 'message' ? ` [${event.type}]` : '';
|
|
717
|
+
const ts = event.ts ? `${event.ts} ` : '';
|
|
718
|
+
const artifact = event.artifact ? ` (${event.artifact})` : '';
|
|
719
|
+
const marker = String(from) === String(laneAgent) ? 'me' : from;
|
|
720
|
+
return `- ${ts}${marker}${to}${kind}: ${String(event.message || '').slice(0, 500)}${artifact}`;
|
|
721
|
+
}
|
|
722
|
+
|
|
700
723
|
function eventAddressedTo(event = {}, agent = '') {
|
|
701
724
|
if (!event?.to) return false;
|
|
702
725
|
const target = String(event.to || '').trim().toLowerCase();
|
package/src/core/tmux-ui.mjs
CHANGED
|
@@ -121,6 +121,7 @@ const TERMINAL_TEAM_AGENT_STATUSES = new Set([
|
|
|
121
121
|
|
|
122
122
|
const LEGACY_TEAM_PANE_TITLE_RE = /^(?:overview: mission_overview|scout: analysis_scout|plan: (?:debate|consensus|planner|user)|exec: (?:executor|implementation|worker)|review: (?:reviewer|qa|validation)|safety:)/;
|
|
123
123
|
const GENERIC_TEAM_AGENT_IDS = new Set(['parent_orchestrator', 'analysis_scout', 'team_consensus', 'implementation_worker', 'db_safety_reviewer', 'qa_reviewer']);
|
|
124
|
+
const DYNAMIC_TEAM_TMUX_LAYOUT = 'main-vertical';
|
|
124
125
|
|
|
125
126
|
export function isTmuxShellSession(env = process.env) {
|
|
126
127
|
return Boolean(String(env.TMUX || '').trim());
|
|
@@ -582,14 +583,20 @@ export async function createTmuxSession(plan = {}, panes = [], opts = {}) {
|
|
|
582
583
|
}
|
|
583
584
|
const first = normalizedPanes[0] || { cwd: root, command: 'pwd' };
|
|
584
585
|
const dimensions = currentTerminalDimensions(opts);
|
|
585
|
-
const
|
|
586
|
+
const rightSidePanes = Boolean(opts.rightSidePanes || opts.rightSideOnly);
|
|
587
|
+
const layout = tmuxLayoutName(opts.layout || (rightSidePanes ? DYNAMIC_TEAM_TMUX_LAYOUT : 'tiled'));
|
|
586
588
|
const create = await tmuxRun(tmuxBin, ['new-session', '-d', '-x', dimensions.width, '-y', dimensions.height, '-s', session, '-c', path.resolve(first.cwd || root), '-n', 'sks', '-P', '-F', '#{pane_id}', first.command || 'pwd']);
|
|
587
589
|
if (create.code !== 0) return { ok: false, session, panes: [], stderr: create.stderr || create.stdout || 'tmux new-session failed' };
|
|
588
590
|
const created = [{ pane_id: paneId(create.stdout), role: first.role || 'overview', title: first.title || 'overview' }];
|
|
591
|
+
let rightStackTarget = created[0].pane_id || session;
|
|
589
592
|
for (const pane of normalizedPanes.slice(1)) {
|
|
590
|
-
const
|
|
593
|
+
const direction = rightSidePanes ? (created.length === 1 ? '-h' : '-v') : (pane.vertical ? '-v' : '-h');
|
|
594
|
+
const splitTarget = rightSidePanes ? rightStackTarget : session;
|
|
595
|
+
const split = await tmuxRun(tmuxBin, ['split-window', '-t', splitTarget, direction, '-d', '-P', '-F', '#{pane_id}', '-c', path.resolve(pane.cwd || root), pane.command || 'pwd']);
|
|
591
596
|
if (split.code !== 0) return { ok: false, session, panes: created, stderr: split.stderr || split.stdout || 'tmux split-window failed' };
|
|
592
|
-
|
|
597
|
+
const newPaneId = paneId(split.stdout);
|
|
598
|
+
created.push({ pane_id: newPaneId, role: pane.role || 'lane', title: pane.title || null });
|
|
599
|
+
if (rightSidePanes && newPaneId) rightStackTarget = newPaneId;
|
|
593
600
|
await tmuxRun(tmuxBin, ['select-layout', '-t', session, layout]).catch(() => null);
|
|
594
601
|
}
|
|
595
602
|
const dynamic_resize = await enableTmuxDynamicResize(tmuxBin, session, { layout });
|
|
@@ -732,6 +739,10 @@ export async function reconcileTmuxTeamCockpit({ root, missionId, plan = {}, pro
|
|
|
732
739
|
const desiredAgents = new Set(lanes.map((lane) => lane.agent));
|
|
733
740
|
const paneList = await listTmuxWindowPanes(tmuxBin, target.window_id);
|
|
734
741
|
if (!paneList.ok) return { ok: false, skipped: false, session: target.session, window_id: target.window_id, reason: paneList.stderr };
|
|
742
|
+
const cockpitState = await readJson(tmuxCockpitStatePath(resolvedRoot), {}).catch(() => ({}));
|
|
743
|
+
const previousCockpit = cockpitState?.missions?.[id] || {};
|
|
744
|
+
const currentPane = paneList.panes.find((pane) => pane.pane_id === target.pane_id);
|
|
745
|
+
const mainPaneId = previousCockpit.main_pane_id || (currentPane?.managed && currentPane?.mission_id === id ? null : target.pane_id);
|
|
735
746
|
const managed = paneList.panes.filter((pane) => pane.managed && pane.mission_id === id);
|
|
736
747
|
const byAgent = new Map();
|
|
737
748
|
for (const pane of managed) {
|
|
@@ -747,14 +758,19 @@ export async function reconcileTmuxTeamCockpit({ root, missionId, plan = {}, pro
|
|
|
747
758
|
else failed.push({ action: 'kill-pane', pane_id: pane.pane_id, agent: pane.agent, stderr: kill.stderr || kill.stdout || 'tmux kill-pane failed' });
|
|
748
759
|
}
|
|
749
760
|
}
|
|
761
|
+
const remainingManaged = managed.filter((pane) => desiredAgents.has(pane.agent) && !closed.some((entry) => entry.pane_id === pane.pane_id));
|
|
762
|
+
let rightStackTarget = remainingManaged.at(-1)?.pane_id || mainPaneId || target.window_id;
|
|
750
763
|
for (const lane of lanes) {
|
|
751
764
|
if (byAgent.has(lane.agent)) continue;
|
|
752
|
-
const
|
|
765
|
+
const firstRightPane = remainingManaged.length === 0 && opened.length === 0;
|
|
766
|
+
const direction = firstRightPane ? '-h' : '-v';
|
|
767
|
+
const split = await tmuxRun(tmuxBin, ['split-window', direction, '-t', rightStackTarget, '-d', '-P', '-F', '#{pane_id}', '-c', resolvedRoot, lane.command || 'pwd'], { timeoutMs: 5000, maxOutputBytes: 4096 });
|
|
753
768
|
const pane_id = paneId(split.stdout);
|
|
754
769
|
if (split.code !== 0 || !pane_id) {
|
|
755
770
|
failed.push({ action: 'split-window', agent: lane.agent, role: lane.role, stderr: split.stderr || split.stdout || 'tmux split-window failed' });
|
|
756
771
|
continue;
|
|
757
772
|
}
|
|
773
|
+
rightStackTarget = pane_id;
|
|
758
774
|
const optionResult = await setTmuxPaneUserOptions(tmuxBin, pane_id, {
|
|
759
775
|
'@sks_team_managed': '1',
|
|
760
776
|
'@sks_mission_id': id,
|
|
@@ -766,9 +782,10 @@ export async function reconcileTmuxTeamCockpit({ root, missionId, plan = {}, pro
|
|
|
766
782
|
}
|
|
767
783
|
let relayout = null;
|
|
768
784
|
if (opened.length || closed.length) {
|
|
769
|
-
const
|
|
785
|
+
const selectedMain = mainPaneId ? await tmuxRun(tmuxBin, ['select-pane', '-t', mainPaneId], { timeoutMs: 5000 }) : { code: 0 };
|
|
786
|
+
const tiled = await tmuxRun(tmuxBin, ['select-layout', '-t', target.window_id, DYNAMIC_TEAM_TMUX_LAYOUT], { timeoutMs: 5000 });
|
|
770
787
|
const even = await tmuxRun(tmuxBin, ['select-layout', '-t', target.window_id, '-E'], { timeoutMs: 5000 });
|
|
771
|
-
relayout = { ok: tiled.code === 0 && even.code === 0,
|
|
788
|
+
relayout = { ok: selectedMain.code === 0 && tiled.code === 0 && even.code === 0, selected_main: selectedMain.code, layout: tiled.code, even: even.code, layout_name: DYNAMIC_TEAM_TMUX_LAYOUT };
|
|
772
789
|
}
|
|
773
790
|
const nextPanes = [
|
|
774
791
|
...managed.filter((pane) => desiredAgents.has(pane.agent) && !closed.some((entry) => entry.pane_id === pane.pane_id)),
|
|
@@ -778,8 +795,10 @@ export async function reconcileTmuxTeamCockpit({ root, missionId, plan = {}, pro
|
|
|
778
795
|
mission_id: id,
|
|
779
796
|
session: target.session,
|
|
780
797
|
window_id: target.window_id,
|
|
781
|
-
main_pane_id: target.pane_id,
|
|
798
|
+
main_pane_id: mainPaneId || target.pane_id,
|
|
782
799
|
mode: 'current_session_dynamic_panes',
|
|
800
|
+
layout: DYNAMIC_TEAM_TMUX_LAYOUT,
|
|
801
|
+
right_side_only: true,
|
|
783
802
|
desired_lane_count: lanes.length,
|
|
784
803
|
panes: nextPanes,
|
|
785
804
|
opened,
|
|
@@ -791,7 +810,7 @@ export async function reconcileTmuxTeamCockpit({ root, missionId, plan = {}, pro
|
|
|
791
810
|
mode: 'current_session_dynamic_panes',
|
|
792
811
|
session: target.session,
|
|
793
812
|
window_id: target.window_id,
|
|
794
|
-
main_pane_id: target.pane_id,
|
|
813
|
+
main_pane_id: mainPaneId || target.pane_id,
|
|
795
814
|
desired_lane_count: lanes.length,
|
|
796
815
|
opened_lane_count: opened.length,
|
|
797
816
|
closed_lane_count: closed.length,
|
|
@@ -825,7 +844,8 @@ export async function launchTmuxTeamView({ root, missionId, plan = {}, promptFil
|
|
|
825
844
|
const splitUi = {
|
|
826
845
|
mode: 'single_window_split_panes',
|
|
827
846
|
window: 'sks',
|
|
828
|
-
layout:
|
|
847
|
+
layout: DYNAMIC_TEAM_TMUX_LAYOUT,
|
|
848
|
+
right_side_only: true,
|
|
829
849
|
dynamic_resize: true,
|
|
830
850
|
window_size: 'latest',
|
|
831
851
|
resize_hooks: ['client-attached', 'client-resized'],
|
|
@@ -866,6 +886,8 @@ export async function launchTmuxTeamView({ root, missionId, plan = {}, promptFil
|
|
|
866
886
|
mode: cockpit.mode,
|
|
867
887
|
current_session: true,
|
|
868
888
|
window_id: cockpit.window_id,
|
|
889
|
+
main_pane_id: cockpit.main_pane_id,
|
|
890
|
+
layout: cockpit.relayout?.layout_name || DYNAMIC_TEAM_TMUX_LAYOUT,
|
|
869
891
|
user_attach_command: cockpit.attach_command
|
|
870
892
|
};
|
|
871
893
|
await writeTmuxTeamRecord(launch.root, {
|
|
@@ -902,7 +924,7 @@ export async function launchTmuxTeamView({ root, missionId, plan = {}, promptFil
|
|
|
902
924
|
}
|
|
903
925
|
}
|
|
904
926
|
const panes = lanes.map((lane, index) => ({ cwd: launch.root, command: lane.command, focused: index === 0, role: lane.role, title: lane.title, vertical: index > 1 }));
|
|
905
|
-
const created = await createTmuxSession(launch, panes, { layout:
|
|
927
|
+
const created = await createTmuxSession(launch, panes, { layout: DYNAMIC_TEAM_TMUX_LAYOUT, recreate: true, rightSidePanes: true });
|
|
906
928
|
result.created = Boolean(created.ok);
|
|
907
929
|
result.opened = created;
|
|
908
930
|
result.session = created.session || launch.session;
|
|
@@ -1077,7 +1099,7 @@ async function cleanupLegacyTmuxTeamSurfaces(root, missionId, opts = {}) {
|
|
|
1077
1099
|
else failed.push({ pane_id: pane.pane_id, title: pane.title, stderr: kill.stderr || kill.stdout || 'tmux kill-pane failed' });
|
|
1078
1100
|
}
|
|
1079
1101
|
if (closed.length) {
|
|
1080
|
-
await tmuxRun(tmuxBin, ['select-layout', '-t', current.window_id,
|
|
1102
|
+
await tmuxRun(tmuxBin, ['select-layout', '-t', current.window_id, DYNAMIC_TEAM_TMUX_LAYOUT], { timeoutMs: 5000 }).catch(() => null);
|
|
1081
1103
|
await tmuxRun(tmuxBin, ['select-layout', '-t', current.window_id, '-E'], { timeoutMs: 5000 }).catch(() => null);
|
|
1082
1104
|
}
|
|
1083
1105
|
}
|
|
@@ -1117,7 +1139,7 @@ async function cleanupRecordedTmuxTeamPanes(root, missionId, record = {}) {
|
|
|
1117
1139
|
else failed.push({ pane_id: pane.pane_id, agent: pane.agent, stderr: kill.stderr || kill.stdout || 'tmux kill-pane failed' });
|
|
1118
1140
|
}
|
|
1119
1141
|
if (closed.length) {
|
|
1120
|
-
await tmuxRun(tmuxBin, ['select-layout', '-t', target,
|
|
1142
|
+
await tmuxRun(tmuxBin, ['select-layout', '-t', target, DYNAMIC_TEAM_TMUX_LAYOUT], { timeoutMs: 5000 }).catch(() => null);
|
|
1121
1143
|
await tmuxRun(tmuxBin, ['select-layout', '-t', target, '-E'], { timeoutMs: 5000 }).catch(() => null);
|
|
1122
1144
|
}
|
|
1123
1145
|
return {
|