@aperant/framework 0.6.4 → 0.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +54 -234
- package/agents/apt-improver.md +99 -0
- package/agents/apt-planner.md +115 -10
- package/dist/__test-helpers/run-cmd.d.mts +4 -2
- package/dist/__test-helpers/run-cmd.d.mts.map +1 -1
- package/dist/__test-helpers/run-cmd.mjs +56 -15
- package/dist/__test-helpers/run-cmd.mjs.map +1 -1
- package/dist/cli/ci-watch/lock.d.mts +53 -0
- package/dist/cli/ci-watch/lock.d.mts.map +1 -0
- package/dist/cli/ci-watch/lock.mjs +172 -0
- package/dist/cli/ci-watch/lock.mjs.map +1 -0
- package/dist/cli/ci-watch/state.d.mts +36 -0
- package/dist/cli/ci-watch/state.d.mts.map +1 -0
- package/dist/cli/ci-watch/state.mjs +103 -0
- package/dist/cli/ci-watch/state.mjs.map +1 -0
- package/dist/cli/ci-watch/stop-matrix.d.mts +58 -0
- package/dist/cli/ci-watch/stop-matrix.d.mts.map +1 -0
- package/dist/cli/ci-watch/stop-matrix.mjs +164 -0
- package/dist/cli/ci-watch/stop-matrix.mjs.map +1 -0
- package/dist/cli/cli-wrappers/ci-watch.d.mts +2 -0
- package/dist/cli/cli-wrappers/ci-watch.d.mts.map +1 -0
- package/dist/cli/cli-wrappers/ci-watch.mjs +9 -0
- package/dist/cli/cli-wrappers/ci-watch.mjs.map +1 -0
- package/dist/cli/commands/adr.d.mts +5 -0
- package/dist/cli/commands/adr.d.mts.map +1 -0
- package/dist/cli/commands/adr.mjs +228 -0
- package/dist/cli/commands/adr.mjs.map +1 -0
- package/dist/cli/commands/ci-watch.d.mts +7 -0
- package/dist/cli/commands/ci-watch.d.mts.map +1 -0
- package/dist/cli/commands/ci-watch.mjs +465 -0
- package/dist/cli/commands/ci-watch.mjs.map +1 -0
- package/dist/cli/commands/commit.d.mts.map +1 -1
- package/dist/cli/commands/commit.mjs +25 -8
- package/dist/cli/commands/commit.mjs.map +1 -1
- package/dist/cli/commands/context.d.mts +7 -0
- package/dist/cli/commands/context.d.mts.map +1 -0
- package/dist/cli/commands/context.mjs +224 -0
- package/dist/cli/commands/context.mjs.map +1 -0
- package/dist/cli/commands/event.d.mts.map +1 -1
- package/dist/cli/commands/event.mjs +125 -24
- package/dist/cli/commands/event.mjs.map +1 -1
- package/dist/cli/commands/host-detect.d.mts +1 -1
- package/dist/cli/commands/host-detect.d.mts.map +1 -1
- package/dist/cli/commands/host-detect.mjs +30 -3
- package/dist/cli/commands/host-detect.mjs.map +1 -1
- package/dist/cli/commands/modes.d.mts +13 -0
- package/dist/cli/commands/modes.d.mts.map +1 -0
- package/dist/cli/commands/modes.mjs +221 -0
- package/dist/cli/commands/modes.mjs.map +1 -0
- package/dist/cli/commands/route.mjs +1 -1
- package/dist/cli/commands/route.mjs.map +1 -1
- package/dist/cli/commands/task.d.mts.map +1 -1
- package/dist/cli/commands/task.mjs +393 -145
- package/dist/cli/commands/task.mjs.map +1 -1
- package/dist/cli/commands/tokens.d.mts.map +1 -1
- package/dist/cli/commands/tokens.mjs +150 -6
- package/dist/cli/commands/tokens.mjs.map +1 -1
- package/dist/cli/commands/triage.d.mts +8 -0
- package/dist/cli/commands/triage.d.mts.map +1 -0
- package/dist/cli/commands/triage.mjs +263 -0
- package/dist/cli/commands/triage.mjs.map +1 -0
- package/dist/cli/config/post-merge-sweep.d.mts +18 -0
- package/dist/cli/config/post-merge-sweep.d.mts.map +1 -1
- package/dist/cli/config/post-merge-sweep.mjs +48 -3
- package/dist/cli/config/post-merge-sweep.mjs.map +1 -1
- package/dist/cli/consistency/registry.d.mts +11 -0
- package/dist/cli/consistency/registry.d.mts.map +1 -1
- package/dist/cli/consistency/registry.mjs +13 -0
- package/dist/cli/consistency/registry.mjs.map +1 -1
- package/dist/cli/coordination/agent-identity.d.mts +9 -0
- package/dist/cli/coordination/agent-identity.d.mts.map +1 -1
- package/dist/cli/coordination/agent-identity.mjs +11 -0
- package/dist/cli/coordination/agent-identity.mjs.map +1 -1
- package/dist/cli/coordination/auto-emit-artifact-ready.d.mts +16 -0
- package/dist/cli/coordination/auto-emit-artifact-ready.d.mts.map +1 -0
- package/dist/cli/coordination/auto-emit-artifact-ready.mjs +88 -0
- package/dist/cli/coordination/auto-emit-artifact-ready.mjs.map +1 -0
- package/dist/cli/coordination/event-log.d.mts +27 -0
- package/dist/cli/coordination/event-log.d.mts.map +1 -1
- package/dist/cli/coordination/event-log.mjs +72 -2
- package/dist/cli/coordination/event-log.mjs.map +1 -1
- package/dist/cli/coordination/event-schema.d.mts +16 -0
- package/dist/cli/coordination/event-schema.d.mts.map +1 -0
- package/dist/cli/coordination/event-schema.mjs +161 -0
- package/dist/cli/coordination/event-schema.mjs.map +1 -0
- package/dist/cli/coordination/store.d.mts +6 -0
- package/dist/cli/coordination/store.d.mts.map +1 -1
- package/dist/cli/coordination/store.mjs +14 -0
- package/dist/cli/coordination/store.mjs.map +1 -1
- package/dist/cli/dispatch.d.mts.map +1 -1
- package/dist/cli/dispatch.mjs +14 -1
- package/dist/cli/dispatch.mjs.map +1 -1
- package/dist/cli/gate/registry.d.mts +11 -0
- package/dist/cli/gate/registry.d.mts.map +1 -1
- package/dist/cli/gate/registry.mjs +13 -0
- package/dist/cli/gate/registry.mjs.map +1 -1
- package/dist/cli/help.d.mts.map +1 -1
- package/dist/cli/help.mjs +3 -2
- package/dist/cli/help.mjs.map +1 -1
- package/dist/cli/host/detect.d.mts +1 -0
- package/dist/cli/host/detect.d.mts.map +1 -1
- package/dist/cli/host/detect.mjs +5 -0
- package/dist/cli/host/detect.mjs.map +1 -1
- package/dist/cli/roadmap/conductor-view.d.mts +13 -0
- package/dist/cli/roadmap/conductor-view.d.mts.map +1 -0
- package/dist/cli/roadmap/conductor-view.mjs +31 -0
- package/dist/cli/roadmap/conductor-view.mjs.map +1 -0
- package/dist/cli/route/envelope.d.mts +68 -4
- package/dist/cli/route/envelope.d.mts.map +1 -1
- package/dist/cli/route/envelope.mjs +140 -103
- package/dist/cli/route/envelope.mjs.map +1 -1
- package/dist/cli/route/skill-discover.d.mts +9 -0
- package/dist/cli/route/skill-discover.d.mts.map +1 -1
- package/dist/cli/route/skill-discover.mjs +13 -1
- package/dist/cli/route/skill-discover.mjs.map +1 -1
- package/dist/cli/skill-author/contract.d.mts +1 -0
- package/dist/cli/skill-author/contract.d.mts.map +1 -1
- package/dist/cli/skill-author/contract.mjs +7 -0
- package/dist/cli/skill-author/contract.mjs.map +1 -1
- package/dist/cli/task/ids.d.mts +7 -4
- package/dist/cli/task/ids.d.mts.map +1 -1
- package/dist/cli/task/ids.mjs +11 -10
- package/dist/cli/task/ids.mjs.map +1 -1
- package/dist/cli/task/worktree-cleanup.d.mts +9 -1
- package/dist/cli/task/worktree-cleanup.d.mts.map +1 -1
- package/dist/cli/task/worktree-cleanup.mjs +190 -9
- package/dist/cli/task/worktree-cleanup.mjs.map +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/agents/apt-planner.md +1 -1
- package/dist/plugin/skills/apt/SKILL.md +111 -5
- package/dist/plugin/skills/apt-author-skill/SKILL.md +11 -0
- package/dist/plugin/skills/apt-bootstrap/SKILL.md +1 -0
- package/dist/plugin/skills/apt-classify/SKILL.md +1 -0
- package/dist/plugin/skills/apt-close-task/SKILL.md +1 -0
- package/dist/plugin/skills/apt-create-docs/SKILL.md +1 -0
- package/dist/plugin/skills/apt-debug/SKILL.md +2 -0
- package/dist/plugin/skills/apt-design/SKILL.md +2 -0
- package/dist/plugin/skills/apt-discuss/SKILL.md +2 -0
- package/dist/plugin/skills/apt-docs/SKILL.md +2 -0
- package/dist/plugin/skills/apt-execute/SKILL.md +1 -0
- package/dist/plugin/skills/apt-mockup/SKILL.md +2 -0
- package/dist/plugin/skills/apt-pause/SKILL.md +1 -0
- package/dist/plugin/skills/apt-personas/SKILL.md +1 -0
- package/dist/plugin/skills/apt-plan/SKILL.md +2 -0
- package/dist/plugin/skills/apt-pr-review/SKILL.md +1 -0
- package/dist/plugin/skills/apt-quick/SKILL.md +2 -0
- package/dist/plugin/skills/apt-resume/SKILL.md +1 -0
- package/dist/plugin/skills/apt-review/SKILL.md +1 -0
- package/dist/plugin/skills/apt-roadmap/SKILL.md +1 -0
- package/dist/plugin/skills/apt-roundtable/SKILL.md +2 -0
- package/dist/plugin/skills/apt-run/SKILL.md +1 -0
- package/dist/plugin/skills/apt-scan/SKILL.md +1 -0
- package/dist/plugin/skills/apt-setup/SKILL.md +1 -0
- package/dist/plugin/skills/apt-ship/SKILL.md +6 -5
- package/dist/plugin/skills/apt-stress-test/SKILL.md +1 -0
- package/dist/plugin/skills/apt-terminal/SKILL.md +1 -0
- package/dist/plugin/skills/apt-update/SKILL.md +5 -0
- package/dist/plugin/skills/apt-verify/SKILL.md +1 -0
- package/dist/plugin/skills/apt-verify-proof/SKILL.md +1 -0
- package/dist/types/config.d.ts +85 -0
- package/dist/types/config.d.ts.map +1 -1
- package/package.json +9 -1
- package/prompts/coder.md +2 -0
- package/prompts/conductor-status-check.md +23 -0
- package/prompts/conductor-sub-agent.md +57 -0
- package/prompts/conductor-system.md +172 -0
- package/prompts/planner.md +12 -0
- package/prompts/spec_writer.md +9 -0
- package/skills/apt/SKILL.md +1 -0
- package/skills/apt-close-task/SKILL.md +32 -1
- package/skills/apt-debug/SKILL.md +39 -6
- package/skills/apt-debug/appendices/diagnose-discipline.md +119 -0
- package/skills/apt-diagram/SKILL.md +342 -0
- package/skills/apt-diagram/appendices/design-discipline.md +97 -0
- package/skills/apt-discuss/SKILL.md +25 -0
- package/skills/apt-discuss/appendices/grill-discipline.md +104 -0
- package/skills/apt-discuss/appendices/zoom-out-helper.md +79 -0
- package/skills/apt-execute/SKILL.md +48 -5
- package/skills/apt-execute/appendices/tdd-mode.md +107 -0
- package/skills/apt-improve/DEEPENING.md +84 -0
- package/skills/apt-improve/INTERFACE-DESIGN.md +97 -0
- package/skills/apt-improve/LANGUAGE.md +104 -0
- package/skills/apt-improve/SKILL.md +141 -0
- package/skills/apt-plan/SKILL.md +159 -4
- package/skills/apt-plan/adapters/conductor.md +98 -0
- package/skills/apt-planner.md +42 -1
- package/skills/apt-pr-review/SKILL.md +46 -16
- package/skills/apt-prototype/LOGIC.md +109 -0
- package/skills/apt-prototype/SKILL.md +143 -0
- package/skills/apt-prototype/UI.md +90 -0
- package/skills/apt-quick/SKILL.md +30 -0
- package/skills/apt-review/SKILL.md +2 -0
- package/skills/apt-run/SKILL.md +32 -4
- package/skills/apt-setup/SKILL.md +130 -2
- package/skills/apt-ship/SKILL.md +63 -1
- package/skills/apt-spar/SKILL.md +290 -0
- package/skills/apt-triage/AGENT-BRIEF.md +84 -0
- package/skills/apt-triage/OUT-OF-SCOPE.md +75 -0
- package/skills/apt-triage/SKILL.md +169 -0
- package/skills/apt-verify/SKILL.md +3 -0
- package/skills/apt-verify-proof/SKILL.md +3 -0
- package/skills/apt-watch-ci/SKILL.md +163 -0
- package/skills/apt-zoom-out/SKILL.md +130 -0
- package/src/cli/ci-watch/lock.mjs +178 -0
- package/src/cli/ci-watch/state.mjs +103 -0
- package/src/cli/ci-watch/stop-matrix.mjs +181 -0
- package/src/cli/cli-wrappers/ci-watch.mjs +9 -0
- package/src/cli/commands/adr.mjs +243 -0
- package/src/cli/commands/ci-watch.mjs +503 -0
- package/src/cli/commands/commit.mjs +27 -8
- package/src/cli/commands/context.mjs +244 -0
- package/src/cli/commands/event.mjs +131 -24
- package/src/cli/commands/host-detect.mjs +33 -7
- package/src/cli/commands/modes.mjs +216 -0
- package/src/cli/commands/route.mjs +1 -1
- package/src/cli/commands/task.mjs +430 -170
- package/src/cli/commands/tokens.mjs +157 -6
- package/src/cli/commands/triage.mjs +286 -0
- package/src/cli/config/post-merge-sweep.mjs +49 -3
- package/src/cli/consistency/registry.mjs +14 -0
- package/src/cli/coordination/agent-identity.mjs +12 -0
- package/src/cli/coordination/auto-emit-artifact-ready.mjs +96 -0
- package/src/cli/coordination/event-log.mjs +73 -2
- package/src/cli/coordination/event-schema.d.ts +13 -0
- package/src/cli/coordination/event-schema.mjs +174 -0
- package/src/cli/coordination/store.mjs +14 -0
- package/src/cli/dispatch.mjs +15 -1
- package/src/cli/gate/registry.mjs +14 -0
- package/src/cli/help.mjs +3 -2
- package/src/cli/host/detect.mjs +5 -0
- package/src/cli/roadmap/conductor-view.d.ts +10 -0
- package/src/cli/roadmap/conductor-view.mjs +31 -0
- package/src/cli/route/envelope.mjs +140 -106
- package/src/cli/route/skill-discover.mjs +14 -1
- package/src/cli/skill-author/contract.mjs +7 -0
- package/src/cli/task/ids.mjs +15 -13
- package/src/cli/task/worktree-cleanup.mjs +191 -9
- package/templates/adr-format.md +56 -0
- package/templates/config.json +4 -0
- package/templates/context-format.md +34 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: apt:watch-ci
|
|
3
|
+
description: "Post-ship CI watcher — polls PR checks, auto-fixes red, pushes, reschedules"
|
|
4
|
+
apt-skill-version: {{APT_VERSION}}
|
|
5
|
+
stage: ship
|
|
6
|
+
intent: x-watch-ci
|
|
7
|
+
when_to_use: "A PR has been shipped and CI is running. Use to watch its checks on a recurring tick, auto-fix red checks from first red, push fixes, and reschedule until green / merged / closed / 5 attempts / 4h wall."
|
|
8
|
+
user_invocable: true
|
|
9
|
+
internal: false
|
|
10
|
+
spawns_agent: false
|
|
11
|
+
agent_name: null
|
|
12
|
+
task_context: require-existing
|
|
13
|
+
default_execution_mode: auto
|
|
14
|
+
execution_modes:
|
|
15
|
+
- auto
|
|
16
|
+
- step
|
|
17
|
+
allowed-tools: "Bash, Read, Write, Edit, Grep, Glob"
|
|
18
|
+
argument-hint: "apt:watch-ci [--pr N | --tick --pr N | --stop --pr N | --resume --pr N]"
|
|
19
|
+
gates: []
|
|
20
|
+
config_keys:
|
|
21
|
+
- ci_watch.after_ship
|
|
22
|
+
- ci_watch.auto_merge_on_green
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
# /apt:watch-ci — Post-ship CI Watcher
|
|
26
|
+
|
|
27
|
+
You are a **post-ship CI watcher**. After a PR has been opened by `/apt:ship`, you watch its checks on a recurring tick, auto-fix red checks from the first red, push the fix, and reschedule yourself until the PR is green / merged / closed, or you hit the 5-attempt / 4h wall budget.
|
|
28
|
+
|
|
29
|
+
This skill is **one-tick-per-invocation** (D-01). It MUST NOT enter a polling loop. The recurring beat is delivered externally — by Claude Code's `CronCreate` on cron-capable hosts, by Pi's `pi-schedule-prompt` on Pi, or by the user re-invoking `/apt:watch-ci --tick --pr N` on non-cron hosts. Each invocation does at most one of: poll + classify + maybe-fix + push + reschedule, then exits.
|
|
30
|
+
|
|
31
|
+
The locked decisions backing every section are in `.aperant/context/notes/framework-post-ship-ci-watcher.md` (D-01..D-08).
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Your environment
|
|
36
|
+
|
|
37
|
+
- **Working directory:** the task's worktree (or the project root if this is a non-worktree task).
|
|
38
|
+
- **State files:** `.aperant/ci-watches/{pr}.json` (per-PR state, D-04); `.aperant/ci-watches/{pr}.lock` (per-PR atomic lock, D-05). Both ephemeral — they are NOT committed.
|
|
39
|
+
- **Events:** every tick appends a `ci-watch.tick` event to `.aperant/events/{today}.jsonl`; `start` appends `ci-watch.armed`; the fix-and-push path appends `ci-watch.fix-pushed`; terminal actions append `ci-watch.stopped` with a `reason`.
|
|
40
|
+
- **CLI surface:** `apt-tools ci-watch start|stop|status|tick --pr N`. The skill body shells out to those subcommands — DO NOT re-implement the classifier in your prompt; the cmd already does it.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## §1. Resolve mode
|
|
45
|
+
|
|
46
|
+
Parse the flags:
|
|
47
|
+
- No flag → "start a watch for the active task's PR" (resolve PR number from `.aperant/state.json` active task's `pr_url` field).
|
|
48
|
+
- `--tick --pr N` → run one tick for PR N. This is the cron payload's invocation form.
|
|
49
|
+
- `--stop --pr N` → tear down the watch.
|
|
50
|
+
- `--resume --pr N` → clear the `paused: true` flag and return to normal tick.
|
|
51
|
+
|
|
52
|
+
If no PR can be resolved (no active task, no `pr_url`), print an error and exit — there is nothing to watch.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## §2. Lock acquire
|
|
57
|
+
|
|
58
|
+
For everything except `status`, the imperative work happens inside `apt-tools ci-watch tick`, which acquires the per-PR lock itself. If `tick` returns exit code 2 with `{status:"busy", reason:"lock-held"}`, another invocation is already running for this PR. Print:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
Watcher already running for PR {N} (held by sessionId={x}, pid={y}); exiting.
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
…and exit. Do NOT block, retry, or kill the holder.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## §3. One-tick semantics (the heart of D-01)
|
|
69
|
+
|
|
70
|
+
For the `--tick` mode, execute exactly the following sequence — once — inside this single skill invocation:
|
|
71
|
+
|
|
72
|
+
1. **Poll.** Call `gh pr checks <N> --json name,bucket,state,conclusion,detailsUrl` and `gh pr view <N> --json state,reviewDecision`. On any `gh` failure, write the captured stderr to a temp file and let the cmd's `tick` route to `infra-backoff` (it surfaces the same action when the gh payload is unavailable).
|
|
73
|
+
2. **Classify + state update.** Call `apt-tools ci-watch tick {worktree} --pr {N}`. The cmd internally:
|
|
74
|
+
- acquires the per-PR lock (BUSY → exit 2 → step §2);
|
|
75
|
+
- reads the per-PR state file;
|
|
76
|
+
- runs the stop-matrix classifier (`packages/framework/src/cli/ci-watch/stop-matrix.mjs`);
|
|
77
|
+
- applies the action's state mutation (attempts++, last_status fingerprint, paused, etc.);
|
|
78
|
+
- releases the lock;
|
|
79
|
+
- returns `{status, action, attempts_used, next_tick_at, reason}`.
|
|
80
|
+
3. **Branch on `action`** — see §4 (terminal), §5 (fix), §6 (non-counting), §7 (pause/resume).
|
|
81
|
+
4. **Reschedule (next tick).** On Claude Code: refresh the `CronCreate` entry's next-fire timestamp (CronCreate is idempotent on the same payload). On Pi: do the equivalent via `pi-schedule-prompt`. On other hosts: print `Next tick: run /apt:watch-ci --tick --pr {N} when you return.` Do NOT register a cron on a non-cron host — the framework would be lying.
|
|
82
|
+
5. **Emit event.** `apt-tools event append . --type ci-watch.tick --data '{...}'` with the cmd's envelope as the data payload. Non-fatal if it fails.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## §4. Terminal branches (green / merged / closed / *-cap / identical-log)
|
|
87
|
+
|
|
88
|
+
When `action ∈ {green, merged, closed, attempts-cap, wall-cap, identical-log}`:
|
|
89
|
+
|
|
90
|
+
- **green** — print "CI green for PR {N}." If `ci_watch.auto_merge_on_green` is `true` in `.aperant/config.json`, run `gh pr merge {N} --auto --squash` then suggest `/apt:close-task`. Else print the exact D-08 text:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
✅ CI green — merge in browser or run /apt:close-task post-merge
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- **merged** / **closed** — print a one-line summary. No PR comment needed.
|
|
97
|
+
|
|
98
|
+
- **attempts-cap** / **wall-cap** / **identical-log** — these are bail conditions. Leave a PR comment via `gh pr comment {N} --body "<summary>"` describing why the watcher stopped (attempt count, deadline, fingerprint). The user comes back to find a structured note in the PR.
|
|
99
|
+
|
|
100
|
+
In all terminal cases:
|
|
101
|
+
- Stop the host scheduler (`CronDelete` on Claude Code, `pi schedule remove` on Pi, no-op elsewhere).
|
|
102
|
+
- The cmd already deleted the state file when it returned `status: 'stopped'`. Do NOT re-delete it.
|
|
103
|
+
- Emit `ci-watch.stopped` event with the matching `reason`.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## §5. Fix-and-push (D-02 — the auto-fix path)
|
|
108
|
+
|
|
109
|
+
When `action === 'fix'` (or `diverged-rebased`/`diverged-bailed`):
|
|
110
|
+
|
|
111
|
+
1. **Fetch the failed-check log.** From the gh payload, find the failed check's `detailsUrl` and call `gh run view {runId} --log` to capture the log into a temp file.
|
|
112
|
+
2. **Shell out to the host CLI** to fix it. The watcher does NOT inline a custom fix prompt; it hands the worktree + failed-log to the same CLI binary that's running this skill (detected by `apt-tools host-detect`). Pseudocode:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
<host-cli-bin> --print "Fix this CI failure for PR {N}. The failed-run log is at {log_path}. Use the worktree at {worktree}. Land + push a commit with the fix." \
|
|
116
|
+
--cwd {worktree}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The fix-CLI is responsible for the actual edits + verification + commit + push. This is a clean shell-out boundary — the watcher trusts the fix-CLI to land a commit (or to bail with a non-zero exit code, which the watcher treats as "no progress made this tick").
|
|
120
|
+
3. **Did the fix change anything?** Run `git -C {worktree} status --porcelain`. If empty, the fixer made no changes → the next tick's classifier will see the same failure log and emit `identical-log`. Do NOT push.
|
|
121
|
+
4. **Push.** `git -C {worktree} push origin {branch}`. On push failure, the next tick will route through `infra-backoff`.
|
|
122
|
+
5. **Emit event.** `apt-tools event append . --type ci-watch.fix-pushed --data '{pr, attempts, commit_sha}'`.
|
|
123
|
+
6. **Reschedule** per §3 step 4.
|
|
124
|
+
|
|
125
|
+
For `diverged-rebased`: the cmd already returned the verb; the orchestrator runs `git -C {worktree} fetch && git rebase origin/{base}` BEFORE the fix step. On conflict the action becomes `diverged-bailed` and the watcher exits with a PR comment summarizing the conflict.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## §6. Non-counting branches (flake-skip / infra-backoff)
|
|
130
|
+
|
|
131
|
+
When `action === 'flake-skip'` or `'infra-backoff'`:
|
|
132
|
+
|
|
133
|
+
- **flake-skip:** Skip the fix path entirely. Reschedule at the normal interval. No PR comment.
|
|
134
|
+
- **infra-backoff:** Same skip — but the next tick interval is doubled by the cmd (it wrote `next_tick_interval_ms *= 2` to state). The cron host re-invocation reads it. After one retry, if the same infra signal persists, post a PR comment marking the failure as `infra-incident`; do NOT keep doubling forever.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## §7. --stop / --resume flags
|
|
139
|
+
|
|
140
|
+
- `--stop --pr N` — releases scheduler handle (`CronDelete` on CC, `pi schedule remove` on Pi, no-op elsewhere), deletes state file, emits `ci-watch.stopped` event with `reason:"user-stop"`. Idempotent — calling `--stop` on a PR with no active watch is fine.
|
|
141
|
+
- `--resume --pr N` — only valid when state has `paused: true` (set by `reviewer-pause`). Clears the flag, returns to normal tick. Error envelope if no paused state for the PR.
|
|
142
|
+
|
|
143
|
+
When the classifier returns `reviewer-pause`, the state's `paused: true` is set by the cmd, but the scheduler is NOT removed — the cron payload keeps firing, and each tick the cmd checks `paused` and exits early with action `reviewer-pause`. Cheaper than re-registering on `--resume`.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## §8. Single-execution-mode contract
|
|
148
|
+
|
|
149
|
+
Reiterating because this is the most easily-broken invariant:
|
|
150
|
+
|
|
151
|
+
- One tick = one invocation. **Do not** wrap the §3 sequence in a `while`/`until` loop.
|
|
152
|
+
- **Do not** sleep + re-invoke yourself inside a single session.
|
|
153
|
+
- The recurring beat is delivered by host cron OR by the user re-invoking the skill manually. The watcher's value comes from the host scheduler — it does not impersonate one.
|
|
154
|
+
|
|
155
|
+
If you find yourself wanting to add "and then wait 5 minutes and run again," STOP. The skill is finished after one tick. Exit.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Notes for the orchestrator
|
|
160
|
+
|
|
161
|
+
- All gh CLI calls run with the host's existing auth. The watcher introduces no new secret material (per task `dimensions_covered.token-encryption.crossed: false`).
|
|
162
|
+
- `auto_merge_on_green` is snapshotted from `.aperant/config.json` at `start` time (state file's `auto_merge_on_green` field). Changing the config mid-watch does NOT retroactively affect armed watches — re-run `start` to pick up the new value.
|
|
163
|
+
- The fix-CLI shell-out is the cleanest abstraction we have for portability. It's NOT a sub-agent. On Claude Code that means another `claude` invocation; on Codex/Gemini/OpenCode/Pi, the equivalent binary. Detection lives in `apt-tools host-detect`'s `cli` field.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: apt:zoom-out
|
|
3
|
+
description: "Orient yourself in an unfamiliar area of the codebase — load CONTEXT.md, map modules + callers, output a one-page orientation"
|
|
4
|
+
apt-skill-version: {{APT_VERSION}}
|
|
5
|
+
stage: plan
|
|
6
|
+
intent: investigate
|
|
7
|
+
when_to_use: "The user is dropped into a part of the codebase they don't know and wants a one-page architectural orientation before planning or editing. Natural-language triggers: 'give me a broader picture of X', 'I'm lost in this area', 'explain how this fits'."
|
|
8
|
+
user_invocable: true
|
|
9
|
+
internal: false
|
|
10
|
+
spawns_agent: false
|
|
11
|
+
agent_name: null
|
|
12
|
+
task_context: none
|
|
13
|
+
default_track: STANDARD
|
|
14
|
+
default_execution_mode: auto
|
|
15
|
+
execution_modes:
|
|
16
|
+
- auto
|
|
17
|
+
- step
|
|
18
|
+
allowed-tools: "Bash, Read, Grep, Glob"
|
|
19
|
+
argument-hint: "apt:zoom-out [area-name]"
|
|
20
|
+
gates: []
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
# Zoom-Out — One-page Architectural Orientation
|
|
24
|
+
|
|
25
|
+
Tiny skill. High signal. The user named an area; you produce a one-page
|
|
26
|
+
orientation so they (or a future agent) can plan or edit without
|
|
27
|
+
re-learning the codebase from scratch.
|
|
28
|
+
|
|
29
|
+
**Pocock adoption note.** This skill descends from Matt Pocock's `zoom-out`
|
|
30
|
+
discipline. Aperant's router is dynamic and LLM-driven, so unlike Pocock's
|
|
31
|
+
upstream this skill is **router-invocable** (no `disable-model-invocation:
|
|
32
|
+
true` flag). The work-intent — "give me a broader picture of X" — is
|
|
33
|
+
genuine and the router resolves natural-language triggers without forcing
|
|
34
|
+
the user to remember the verb name.
|
|
35
|
+
|
|
36
|
+
## When to Use
|
|
37
|
+
|
|
38
|
+
- Dropped into a new module / package / area of the monorepo
|
|
39
|
+
- About to plan a feature that crosses an unfamiliar boundary
|
|
40
|
+
- The codebase has CONTEXT.md (Pocock glossary) and you want to load it
|
|
41
|
+
alongside the structural map
|
|
42
|
+
|
|
43
|
+
## When Not to Use
|
|
44
|
+
|
|
45
|
+
- The area is one you know well — `/apt:plan` already does targeted
|
|
46
|
+
investigation
|
|
47
|
+
- You need to lock decisions (use `/apt:discuss` with the
|
|
48
|
+
`appendices/grill-discipline.md` posture loader instead)
|
|
49
|
+
- The user wants to refactor proactively — that's `/apt:improve`
|
|
50
|
+
|
|
51
|
+
## Process
|
|
52
|
+
|
|
53
|
+
### 1. Parse the area name
|
|
54
|
+
|
|
55
|
+
Extract the area from `$ARGUMENTS`. The area is a noun phrase, not a
|
|
56
|
+
file path: e.g., `"chat session lifecycle"`, `"sparring engine"`,
|
|
57
|
+
`"feature registry"`. If the argument is a file path, narrow the
|
|
58
|
+
orientation to the file's enclosing module.
|
|
59
|
+
|
|
60
|
+
### 2. Load CONTEXT.md (if present)
|
|
61
|
+
|
|
62
|
+
If `CONTEXT.md` exists at the project root, read it and pre-load any
|
|
63
|
+
term-blocks whose `Term` matches keywords in the area name (or whose
|
|
64
|
+
`Aliases to avoid` field hints at the area). Carry these terms forward
|
|
65
|
+
into the orientation report so the reader sees the vocabulary the
|
|
66
|
+
codebase actually uses.
|
|
67
|
+
|
|
68
|
+
If `CONTEXT.md` does not exist, skip silently — orientation still works,
|
|
69
|
+
just without the glossary anchor.
|
|
70
|
+
|
|
71
|
+
### 3. Map modules and callers
|
|
72
|
+
|
|
73
|
+
Run Glob + Grep to identify:
|
|
74
|
+
|
|
75
|
+
- **Entry points** — files in the area that other modules import (high
|
|
76
|
+
fan-in: many `import`-ers).
|
|
77
|
+
- **State owners** — files that declare the area's persistent
|
|
78
|
+
state (Zustand stores, Convex queries, libSQL tables).
|
|
79
|
+
- **External boundaries** — files that touch external systems (HTTP,
|
|
80
|
+
IPC, native modules, child processes).
|
|
81
|
+
- **Known fragile areas** — search for `// TODO`, `// FIXME`, `// HACK`
|
|
82
|
+
comments + any recent commits touching the area (last 30 days).
|
|
83
|
+
|
|
84
|
+
### 4. Emit the one-page orientation
|
|
85
|
+
|
|
86
|
+
Output to stdout (no file written — this is a read-only orientation):
|
|
87
|
+
|
|
88
|
+
```markdown
|
|
89
|
+
# Orientation: {area name}
|
|
90
|
+
|
|
91
|
+
## Purpose
|
|
92
|
+
{One paragraph: what does this area exist to do?}
|
|
93
|
+
|
|
94
|
+
## Entry points
|
|
95
|
+
- `{path}` — {one-line role}
|
|
96
|
+
- `{path}` — {one-line role}
|
|
97
|
+
|
|
98
|
+
## State owners
|
|
99
|
+
- `{path}` — {one-line: what state, what scope}
|
|
100
|
+
|
|
101
|
+
## External boundaries
|
|
102
|
+
- `{path}` — {one-line: which external system, what direction}
|
|
103
|
+
|
|
104
|
+
## Known fragile areas
|
|
105
|
+
- `{path}:{line}` — `// TODO` / `// FIXME` summary + age
|
|
106
|
+
- {recent-commit-shortlog one-liner if relevant}
|
|
107
|
+
|
|
108
|
+
## Glossary (from CONTEXT.md)
|
|
109
|
+
- **{Term}** — definition + aliases-to-avoid
|
|
110
|
+
- {…}
|
|
111
|
+
|
|
112
|
+
## Pointers for the next agent
|
|
113
|
+
- {one-line: where to start reading}
|
|
114
|
+
- {one-line: which test file to read first}
|
|
115
|
+
- {one-line: which CONTEXT.md term resolves the most ambiguity}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The orientation MUST fit on one screen. If it doesn't, you've gone too
|
|
119
|
+
deep — re-scope the area name and try again.
|
|
120
|
+
|
|
121
|
+
## Output contract
|
|
122
|
+
|
|
123
|
+
- **Read-only.** This skill never writes files. It is a context-loader,
|
|
124
|
+
not a planner.
|
|
125
|
+
- **Stops at orientation.** Do NOT pivot into `/apt:plan` automatically.
|
|
126
|
+
The user invokes `/apt:plan` (or `/apt:improve`) as a follow-up if the
|
|
127
|
+
orientation surfaces a planning opportunity.
|
|
128
|
+
- **No grill posture.** This skill does NOT load
|
|
129
|
+
`apt-discuss/appendices/grill-discipline.md` — orientation is
|
|
130
|
+
descriptive, not normative. Locking decisions is `apt:discuss`'s job.
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ci-watch/lock.mjs — per-PR O_EXCL atomic lock (D-05).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors Claude Code's `cronTasksLock.ts`: a per-PR `.aperant/ci-watches/{pr}.lock`
|
|
5
|
+
* file created with `openSync(..., 'wx')` (O_EXCL). Two concurrent callers
|
|
6
|
+
* cannot both create the file; the loser reads the existing lock and decides
|
|
7
|
+
* whether to declare BUSY or recover a stale lock.
|
|
8
|
+
*
|
|
9
|
+
* Stale-recovery (spec.md §8): a lock is recoverable iff
|
|
10
|
+
* (a) holder PID is dead (`!isPidAlive`), AND
|
|
11
|
+
* (b) `Date.now() - acquiredAt > ttl_seconds*1000`.
|
|
12
|
+
* Live PID alone is NEVER recoverable, even past TTL — TTL alone is not
|
|
13
|
+
* enough. Per D-05: PID-based stale recovery.
|
|
14
|
+
*
|
|
15
|
+
* Lock file shape (spec.md §8):
|
|
16
|
+
* { sessionId, pid, acquiredAt, ttl_seconds }
|
|
17
|
+
*
|
|
18
|
+
* Return shapes:
|
|
19
|
+
* acquireLock → { status: 'ok', lock: {...} }
|
|
20
|
+
* OR { status: 'busy', holder: {...} }
|
|
21
|
+
* releaseLock → boolean (true on release, false if owner mismatch / missing)
|
|
22
|
+
* readLock → lock object or null
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
closeSync,
|
|
27
|
+
existsSync,
|
|
28
|
+
mkdirSync,
|
|
29
|
+
openSync,
|
|
30
|
+
readFileSync,
|
|
31
|
+
unlinkSync,
|
|
32
|
+
writeSync,
|
|
33
|
+
} from 'node:fs'
|
|
34
|
+
import { join, resolve } from 'node:path'
|
|
35
|
+
import { isPidAlive } from '../lock/pid.mjs'
|
|
36
|
+
|
|
37
|
+
const TTL_SECONDS = 1800 // 30 min — D-05
|
|
38
|
+
|
|
39
|
+
function watchesDir(root) {
|
|
40
|
+
return join(resolve(root), '.aperant', 'ci-watches')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function lockPath(root, pr) {
|
|
44
|
+
return join(watchesDir(root), `${pr}.lock`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} root
|
|
49
|
+
* @param {number|string} pr
|
|
50
|
+
*/
|
|
51
|
+
export function readLock(root, pr) {
|
|
52
|
+
const path = lockPath(root, pr)
|
|
53
|
+
if (!existsSync(path)) return null
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(readFileSync(path, 'utf-8'))
|
|
56
|
+
} catch {
|
|
57
|
+
// A corrupted lock is treated as no lock — re-acquire will overwrite.
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeLockFile(path, payload) {
|
|
63
|
+
mkdirSync(resolve(path, '..'), { recursive: true })
|
|
64
|
+
const fd = openSync(path, 'wx')
|
|
65
|
+
try {
|
|
66
|
+
writeSync(fd, `${JSON.stringify(payload, null, 2)}\n`, 0, 'utf-8')
|
|
67
|
+
} finally {
|
|
68
|
+
closeSync(fd)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Try to acquire the per-PR lock. On EEXIST, parse the existing lock — if the
|
|
74
|
+
* holder PID is dead AND TTL has elapsed, unlink + retry once. Otherwise
|
|
75
|
+
* return `{status:'busy', holder}`.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} root
|
|
78
|
+
* @param {number|string} pr
|
|
79
|
+
* @param {string} sessionId
|
|
80
|
+
* @param {number} [pid=process.pid]
|
|
81
|
+
*/
|
|
82
|
+
export function acquireLock(root, pr, sessionId, pid = process.pid) {
|
|
83
|
+
const path = lockPath(root, pr)
|
|
84
|
+
const payload = {
|
|
85
|
+
sessionId,
|
|
86
|
+
pid,
|
|
87
|
+
acquiredAt: new Date().toISOString(),
|
|
88
|
+
ttl_seconds: TTL_SECONDS,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
writeLockFile(path, payload)
|
|
93
|
+
return { status: 'ok', lock: payload }
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (err.code !== 'EEXIST') throw err
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Lock file exists — inspect.
|
|
99
|
+
const existing = readLock(root, pr)
|
|
100
|
+
if (!existing) {
|
|
101
|
+
// Corrupted lock — treat as recoverable, unlink + retry once.
|
|
102
|
+
try {
|
|
103
|
+
unlinkSync(path)
|
|
104
|
+
} catch {
|
|
105
|
+
/* race — another caller cleaned up */
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
writeLockFile(path, payload)
|
|
109
|
+
return { status: 'ok', lock: payload }
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (err.code === 'EEXIST') {
|
|
112
|
+
return { status: 'busy', holder: readLock(root, pr) }
|
|
113
|
+
}
|
|
114
|
+
throw err
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (isStale(existing)) {
|
|
119
|
+
try {
|
|
120
|
+
unlinkSync(path)
|
|
121
|
+
} catch {
|
|
122
|
+
/* race — another caller cleaned up */
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
writeLockFile(path, payload)
|
|
126
|
+
return { status: 'ok', lock: payload }
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (err.code === 'EEXIST') {
|
|
129
|
+
return { status: 'busy', holder: readLock(root, pr) }
|
|
130
|
+
}
|
|
131
|
+
throw err
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { status: 'busy', holder: existing }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Release the per-PR lock. Owner must match (`sessionId` equality). Returns
|
|
140
|
+
* `true` if released, `false` if the lock is missing or owned by someone
|
|
141
|
+
* else.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} root
|
|
144
|
+
* @param {number|string} pr
|
|
145
|
+
* @param {string} sessionId
|
|
146
|
+
*/
|
|
147
|
+
export function releaseLock(root, pr, sessionId) {
|
|
148
|
+
const path = lockPath(root, pr)
|
|
149
|
+
if (!existsSync(path)) return false
|
|
150
|
+
const existing = readLock(root, pr)
|
|
151
|
+
if (!existing) {
|
|
152
|
+
// Corrupted — fail open (let next caller clean up).
|
|
153
|
+
return false
|
|
154
|
+
}
|
|
155
|
+
if (existing.sessionId !== sessionId) return false
|
|
156
|
+
try {
|
|
157
|
+
unlinkSync(path)
|
|
158
|
+
return true
|
|
159
|
+
} catch {
|
|
160
|
+
return false
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Is the given lock stale (i.e., recoverable by a new caller)? Per D-05,
|
|
166
|
+
* BOTH conditions are required: dead PID AND past TTL.
|
|
167
|
+
*
|
|
168
|
+
* Exposed for unit tests; the orchestrator does not call this directly.
|
|
169
|
+
*
|
|
170
|
+
* @param {{ pid: number, acquiredAt: string, ttl_seconds: number }} lockData
|
|
171
|
+
*/
|
|
172
|
+
export function isStale(lockData) {
|
|
173
|
+
if (!lockData || typeof lockData !== 'object') return false
|
|
174
|
+
const ttlMs = (lockData.ttl_seconds || TTL_SECONDS) * 1000
|
|
175
|
+
const age = Date.now() - new Date(lockData.acquiredAt).getTime()
|
|
176
|
+
if (age <= ttlMs) return false
|
|
177
|
+
return !isPidAlive(lockData.pid)
|
|
178
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ci-watch/state.mjs — per-PR state-file primitives for the /apt:watch-ci
|
|
3
|
+
* post-ship watcher (D-04).
|
|
4
|
+
*
|
|
5
|
+
* Storage shape (`.aperant/ci-watches/{pr}.json`), spec.md §8:
|
|
6
|
+
*
|
|
7
|
+
* {
|
|
8
|
+
* pr, task_id, host, scheduler_handle | null,
|
|
9
|
+
* attempts, started_at, deadline, last_poll,
|
|
10
|
+
* last_status | null, next_tick_interval_ms,
|
|
11
|
+
* paused, auto_merge_on_green
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* This module is pure read/write — classification logic lives in
|
|
15
|
+
* stop-matrix.mjs, the orchestrator lives in commands/ci-watch.mjs.
|
|
16
|
+
*
|
|
17
|
+
* Crash-safety: `writeState` goes through `atomicWriteJson` (tmp + fsync +
|
|
18
|
+
* rename). `readState` returns `null` for a missing file and never throws on
|
|
19
|
+
* a missing path — callers can distinguish "no watch yet" from "watch is
|
|
20
|
+
* corrupted".
|
|
21
|
+
*
|
|
22
|
+
* The corrupted-JSON branch returns a recoverable error envelope rather than
|
|
23
|
+
* throwing so the orchestrator can surface it without crashing the cron
|
|
24
|
+
* payload mid-tick.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs'
|
|
28
|
+
import { join, resolve } from 'node:path'
|
|
29
|
+
import { atomicWriteJson } from '../util/io.mjs'
|
|
30
|
+
|
|
31
|
+
function watchesDir(root) {
|
|
32
|
+
return join(resolve(root), '.aperant', 'ci-watches')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stateFile(root, pr) {
|
|
36
|
+
return join(watchesDir(root), `${pr}.json`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read the per-PR state file. Returns `null` when the file does not exist.
|
|
41
|
+
* Returns `{ error: 'corrupted', message }` when the file exists but is not
|
|
42
|
+
* parseable JSON — callers route that to the infra-backoff branch.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} root
|
|
45
|
+
* @param {number|string} pr
|
|
46
|
+
*/
|
|
47
|
+
export function readState(root, pr) {
|
|
48
|
+
const path = stateFile(root, pr)
|
|
49
|
+
if (!existsSync(path)) return null
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(readFileSync(path, 'utf-8'))
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return { error: 'corrupted', message: err.message }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Atomically write the per-PR state file. Caller passes the full state object
|
|
59
|
+
* — this function does not merge, it overwrites. Use `readState` first if a
|
|
60
|
+
* merge is needed.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} root
|
|
63
|
+
* @param {number|string} pr
|
|
64
|
+
* @param {object} state
|
|
65
|
+
*/
|
|
66
|
+
export function writeState(root, pr, state) {
|
|
67
|
+
atomicWriteJson(stateFile(root, pr), state)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Delete the per-PR state file. No-op when the file does not exist.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} root
|
|
74
|
+
* @param {number|string} pr
|
|
75
|
+
*/
|
|
76
|
+
export function deleteState(root, pr) {
|
|
77
|
+
const path = stateFile(root, pr)
|
|
78
|
+
if (existsSync(path)) unlinkSync(path)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Enumerate every per-PR state file under `.aperant/ci-watches/`. Returns an
|
|
83
|
+
* array of parsed state objects in lexicographic filename order. Skips files
|
|
84
|
+
* whose name does not match `<pr>.json` and skips corrupted files (their
|
|
85
|
+
* presence does not block the rest of the listing).
|
|
86
|
+
*
|
|
87
|
+
* @param {string} root
|
|
88
|
+
*/
|
|
89
|
+
export function listWatches(root) {
|
|
90
|
+
const dir = watchesDir(root)
|
|
91
|
+
if (!existsSync(dir)) return []
|
|
92
|
+
const watches = []
|
|
93
|
+
for (const f of readdirSync(dir).sort()) {
|
|
94
|
+
if (!/^\d+\.json$/.test(f)) continue
|
|
95
|
+
try {
|
|
96
|
+
watches.push(JSON.parse(readFileSync(join(dir, f), 'utf-8')))
|
|
97
|
+
} catch {
|
|
98
|
+
// Skip corrupted state files — listWatches is a read-only enumerator
|
|
99
|
+
// and must not throw on partial corruption.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return watches
|
|
103
|
+
}
|