From ce8b5047ce489f02ff5c1dfe79f0fc1926676205 Mon Sep 17 00:00:00 2001 From: Sejun Park Date: Tue, 15 Sep 2026 12:20:51 +0900 Subject: [PATCH 1/7] docs: plan deterministic check-only sjskills startup Replace the planned agent-driven maintenance workflow with direct CLI status inspection and concise actionable hook reports. Define freshness, failure, cache, native execution, and no-mutation acceptance criteria while preserving the existing status API and plugin identity. Record implementation, packaging, and separately authorized host activation stages. Queue the fix and reconcile the stale private-source delivery entry so a fresh goal task can proceed from committed planning evidence. --- PROGRESS.md | 10 +- ROADMAP.md | 6 +- plans/sjskills-startup-check.md | 259 ++++++++++++++++++++++++++++++++ 3 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 plans/sjskills-startup-check.md diff --git a/PROGRESS.md b/PROGRESS.md index 31c579c..f28cdde 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -26,9 +26,13 @@ PR #23 review follow-up: confirmed comments 3979633085 (commit pins), 3979633091 # sjskills maintenance plugin delivery -- User authorized automatic CLI updates and configured skill sync on every - supported local host. Existing release support is macOS Intel/ARM and Windows - x64; Linux is unsupported. +- The 2026-09-15 target is check and report only, with no routine agent work. + The [startup-check plan](plans/sjskills-startup-check.md) owns the replacement + design, acceptance criteria, and remaining delivery. Planning is complete; + the existing plugin still injects agent-driven maintenance instructions. +- The implementation below was delivered under earlier automatic-maintenance + authorization. Existing release support is macOS Intel/ARM and Windows x64; + Linux is unsupported. - User also authorized automatic adoption of this hook's published `main` updates. Migrated the standalone hook into `plugins/sjskills-maintenance/`; retired its installer. Maintenance still runs in the next agent turn. diff --git a/ROADMAP.md b/ROADMAP.md index 32e0db5..a6052c1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,8 +6,8 @@ _None._ ## Plans -1. [Install private GitHub skills through authenticated profiles](plans/sjskills-private-github-sources.md) - has a settled design; implementation and verification have not started. +1. [Check sjskills automatically without assigning work to the agent](plans/sjskills-startup-check.md) + has a detailed check-only plan; implementation and host activation have not started. 2. [Roll out the fixed global skill baseline](plans/sjskills-global-rollout.md) is proposed and requires separate evidence-bound authorization for each machine. Exact-content approval binding is delivered. @@ -18,6 +18,8 @@ _None._ ## Completed +- [Install private GitHub skills through authenticated profiles](plans/sjskills-private-github-sources.md) + is recorded as shipped in v1.3.0; live private-skill installation remains separate. - [Prepare and spawn the next goal](tasks/next-goal-preparation-spawn.md) has implemented and evaluated source. - [Include the sjskills CLI version in automatic status checks](tasks/sjskills-cli-version-status.md) diff --git a/plans/sjskills-startup-check.md b/plans/sjskills-startup-check.md new file mode 100644 index 0000000..cfbe011 --- /dev/null +++ b/plans/sjskills-startup-check.md @@ -0,0 +1,259 @@ +# Check sjskills automatically without assigning work to the agent + +## Outcome + +On startup and resume, the hook runs a deterministic check of the installed +sjskills CLI, configured skill scopes, and this plugin's published version. +Healthy checks stay out of the conversation. Findings requiring attention produce +one short diagnostic with a next step. The hook never assigns maintenance work to +the agent and never updates, installs, synchronizes, quarantines, or restores. + +The user selected **only check and report** on 2026-09-15. This replaces the +previous automatic-maintenance target. This request authorizes detailed planning; +implementation, publication, installation, and machine reconciliation have not +been requested by this planning task. + +## Current state + +- Planning complete; implementation and runtime acceptance have not started. +- [Hook registration](../plugins/sjskills-maintenance/hooks/hooks.json) matches + startup/resume and gives the Node script five seconds. That timeout currently + bounds prompt preparation, not the agent's later maintenance. +- [Hook implementation](../plugins/sjskills-maintenance/scripts/session-start.cjs) + copies workflow snapshots and emits maintenance prose through + `hookSpecificOutput.additionalContext`. It does not invoke sjskills. +- [Maintenance instructions](../plugins/sjskills-maintenance/references/maintenance.md) + ask the agent to check/update the plugin, check/update the CLI, and review/apply + skill plans. Only plugin update checking has a hook-level daily gate. +- [Status collection](../cmd/sjskills/status.go) already checks the CLI, fixed + global baseline, and nearest configured project concurrently. Its existing + JSON envelope exposes findings, configuration, errors, and evidence freshness. +- [Skill status](../internal/sjskills/status.go) already uses a 24-hour upstream + freshness interval, a 15-minute failed-refresh cooldown, and a 30-second + foreground budget. Local inventory is inspected anew; cached upstream evidence + does not establish local equality. The + [performance record](../tasks/sjskills-status-performance.md) reports cold + checks taking seconds and warm native checks taking tens of milliseconds. +- [CLI evidence](../internal/sjskills/cli_status.go) distinguishes equal, ahead, + update available, unavailable, and uncomparable versions. Exit zero alone is + insufficient to establish a healthy check. +- On 2026-09-15, the installed Windows hook script and repository script had + matching SHA-256 hashes. This establishes the inspected behavior, not a fresh + release comparison. Existing tests assert maintenance-context injection. + +## Next action + +When implementation is requested, reproduce the current startup output using the +registered shell command in a temporary plugin installation, then add failing +behavior tests for the check-only contract below. Keep this item queued until +implementation starts. + +## Selected design + +### One check path + +Keep the existing plugin identity and platform entry points. Replace the body of +the hook with a small executable adapter that runs the installed native +`sjskills --json status` once in the event's absolute working directory, validates +the result, and renders the supported hook response. Reuse CLI status ownership +of version selection, configured scopes, authenticated fetching, local inventory, +and cache safety. Do not duplicate reconciliation logic in JavaScript. + +Do not add a new sjskills subcommand or change the public status exit contract +just to make the hook work. A Go change is warranted only if a reproduced status +gap prevents this contract; document that gap and its regression before extending +the CLI. Do not fall back to the source-building wrapper, install a missing CLI, +change PATH, or load skill instructions when the native executable is absent. + +Keep plugin-version observation in the adapter because it is Codex-specific. +Compare the installed manifest with the manifest at a resolved published `main` +commit of `sjunepark/agent-scripts`, including the cachebuster. Inspect configured +marketplace provenance using read-only metadata; never refresh the marketplace +snapshot merely to check it. A custom source/ref, malformed manifest, disabled +plugin, or unavailable source must not trigger repair. Respect disabled hooks. +Fetch metadata as bounded data, never execute remote content. Cache plugin +observations for 24 hours and failed attempts for 15 minutes, separately from +CLI-owned caches; changing installed version/source invalidates that observation. + +### Result and reporting contract + +The adapter classifies results internally; these names do not require a new +public CLI interface. Use stable ordering and bounded text, not raw subprocess +output or remote instructions. + +| Result | Meaning | Hook-visible output | +| --- | --- | --- | +| `ok` | All applicable checks have fresh evidence and no actionable findings | No conversation context or message; a short execution status label is sufficient | +| `attention` | Update available, missing/extra/changed skill, conflict, or configuration requiring intervention | One concise factual line naming affected scopes and a manual next step | +| `unavailable` | Missing prerequisite, unsupported/incomplete JSON, stale/unavailable evidence, timeout, or failed inspection | One concise line saying verification could not complete and why | + +If some checks have findings and others fail, retain both facts in the one-line +summary; never replace known findings with a generic success or failure. +Examples: `sjskills: CLI update available; project skills differ. Run sjskills status.` +and `sjskills: check incomplete (private-source authentication unavailable). Run sjskills status.` +Use fixed diagnostic templates that do not leak tokens, repository content, or +unbounded tool output. Findings are reports, never requests for the agent to run +the suggested command. No `additionalContext` maintenance task in any branch. + +Missing project configuration means the project scope is inapplicable, while +global and CLI checks still run. Do not recommend initialization on every session. +Unsupported release targets receive one short skip diagnostic and no install +attempt. An installed stable version ahead of the latest release is not an update +request; an uncomparable development version must not be presented as verified. +Unknown result values, absent applicable scopes, disabled inspection, malformed +JSON, and stale empty findings never count as `ok`. + +Preserve fresh findings on each invocation, including repeated actionable +findings. Do not introduce another notification database or suppress unresolved +problems across unrelated sessions. Successful checks have no narrative. + +### Execution and state boundaries + +- Managed skill roots, manifests, ownership records, quarantines, binaries, + plugin installations, marketplace sources/snapshots, credentials, and trust + settings remain unchanged. Existing temporary materialization and disposable + status-cache writes are permitted; "check-only" is not a ban on all I/O. +- Preserve credential boundaries in the existing status service. No login, + account switching, setup commands, or fallback to public access after a + private-source failure. Partial scope failure does not hide another scope. +- Invoke programs using argument arrays and resolved executable paths. Treat + event paths, names, and returned metadata as data. Preserve Windows hidden + execution and support spaces, apostrophes, shell metacharacters, and Unicode. +- Use one bounded synchronous invocation rather than a daemon, scheduled job, + detached updater, or agent follow-up. Target a 35-second adapter deadline and + a 40-second hook timeout, subject to native cancellation tests and supported + hook schema verification. Metadata checks share the adapter deadline. Cold + checks may delay startup; warm checks reuse evidence. Measure both and document + the observed tradeoff rather than promising instantaneous fresh remote checks. +- Bound input/output and network reads. On timeout, cancel all owned work, + including spawned materialization descendants; verify cleanup on Windows and + macOS. Hook failure must not block the user's task or schedule agent recovery. +- Reuse CLI cache locks. For plugin observation caching, acquire only an + exclusive owned lock, use safe atomic writes, and never remove another run's + lock. Contention with no usable evidence is unavailable, not success. Cache + write denial may degrade caching but must not discard otherwise valid fresh + read results; required read failures remain unavailable. + +## Implementation stages + +### A. Reproduce and pin the behavior contract + +- [ ] Capture the current registered hook's stdout, exit code, duration, and + injected maintenance context using an isolated home/plugin data directory. +- [ ] Extend [hook tests](../scripts/sjskills-hook.test.js) with controlled native + CLI fixtures and result cases from the acceptance matrix. First demonstrate + that the old implementation fails the no-agent-work requirement. +- [ ] Confirm the minimum released CLI JSON fields against an actual compatible + release. Validate required fields and scope completeness; tolerate harmless + additive fields. Older/incompatible output gets an actionable diagnostic. +- [ ] Verify current official hook output, timeout, and failure behavior before + selecting the final response envelope. The existing `systemMessage` use is + an implementation clue, not proof of how the target client presents it. + +Exit: reproducible evidence and failing tests identify the user-visible problem +without running automatic updates or applying plans to a real home. + +### B. Execute checks directly and remove the agent workflow + +- [ ] Implement CLI execution, JSON validation, result classification, and one-line + reporting in the plugin, reusing the existing status engine. +- [ ] Implement read-only plugin manifest observation with bounded fetching, + provenance validation, cache freshness, and explicit unavailable results. +- [ ] Update hook status text and timeout; remove the context-injection contract. +- [ ] Remove the maintenance prompt, bundled sjskills workflow copies, snapshot + creation, update-success bookkeeping, and agent update/apply lock handling. + Search actual callers before deleting [the bundle generator](../scripts/sync-sjskills-plugin) + and its test/CI/documentation references. Keep the canonical sjskills skill. +- [ ] Replace tests for instruction delivery with tests for process execution, + checked results, no mutating commands, and no injected agent task. + +Exit: a real shell invocation performs the check itself and emits the specified +result without an agent turn, installer, `apply`, `restore`, or plugin update. + +### C. Validate reliability and native behavior + +- [ ] Run `node --test scripts/sjskills-hook.test.js` with temporary homes, + caches, credentials, and controlled network responses. +- [ ] Exercise the registered Windows command through PowerShell and cmd.exe, + and the macOS shell command on Intel and Apple silicon. Preserve Linux CI + testing of supported-platform logic and the unsupported-target response. +- [ ] Run a compatible real native CLI against isolated configured and + unconfigured projects. Capture cold/warm timings, nonzero exits, cancellation, + process cleanup, network/auth failures, and parallel startup results. +- [ ] Inspect before/after filesystem and command logs to prove managed roots, + executables, installs, credentials, and trust remain unchanged. +- [ ] Run one bounded code review; fix actionable in-scope defects and run the + relevant checks. If Go changes prove necessary, also use the Go skill and run + focused status tests plus required Go checks. + +Exit: the acceptance matrix passes, native limitations are explicit, and a +healthy check cannot be confused with stale, partial, or failed verification. + +### D. Package and document the replacement + +- [ ] Update manifest descriptions and display text to check/report behavior, + preserving the install identifier `sjskills-maintenance@personal`. +- [ ] Refresh the plugin cachebuster and validate the manifest. Install/reinstall + in an isolated temporary Codex configuration and invoke the packaged hook. +- [ ] Update [the operator guide](../docs/sjskills-startup-hook.md), + [settings guide](../docs/settings-sync.md), affected CI/test references, and + progress status. Remove ongoing standing-maintenance claims from runtime + instructions; retain manual CLI update and explicit sjskills sync workflows. +- [ ] Run scoped documentation harmonization. Record source validation separately + from publication, hook trust, and actual host activation. + +Exit: packaged code and documentation describe one check-only path; no shipped +prompt still requests unattended mutation or routine agent maintenance. + +### E. Publish and activate when separately authorized + +- [ ] Publish reviewed source to the configured remote `main` before updating + ongoing installations. If the CLI was unchanged, no new CLI release is needed. +- [ ] Reinstall only this plugin from the remote-backed personal marketplace on + each explicitly selected host. Do not use the old self-update workflow to + deliver the replacement or silently enable a disabled hook. +- [ ] Review changed hook trust through `/hooks` as required; never write trust + hashes. Check for a duplicate legacy standalone hook before host acceptance. +- [ ] Start a fresh trusted session and verify that a healthy check adds no + maintenance instructions or agent tool calls. Verify an actionable fixture + yields only the short report. Record host/plugin version and native evidence. + +Exit: selected hosts actually execute the published check-only hook. Source-only +completion does not satisfy activation. If activation fails, disable the affected +hook rather than silently reverting to automatic maintenance. + +## Acceptance matrix + +| Scenario | Required evidence | +| --- | --- | +| Fresh CLI/global/project, no findings | Silent conversation; no maintenance context or agent calls | +| No project manifest | Global/CLI still checked; no initialization or repetitive setup warning | +| CLI/plugin update available | One-line attention report; zero installation/marketplace refresh calls | +| Missing, extra, modified, conflicted skills | Attention with affected scope; zero apply/quarantine/restore calls | +| CLI ahead or development version | Ahead is not downgraded; uncomparable is not verified healthy | +| Exit zero with unavailable/stale evidence | Incomplete check reported, never `ok` | +| Missing CLI, incompatible JSON, omitted scope, disabled status | Actionable incomplete-check report; no fallback build/install | +| Private auth failure or network outage | No login or credential output; independent results retained | +| Cache cold/warm, expiry, failed-refresh cooldown, clock rollback | Correct evidence reuse; local changes detected while upstream evidence is cached | +| Concurrent sessions or unsafe/unwritable cache | No stolen locks, corrupted state, false success, or protected-root writes | +| Timeout or cancellation | Owned child processes cleaned up; bounded hook returns without agent recovery | +| Startup/resume and ignored events | Exactly one check for each matching event; none for compact/clear/subagent events | +| Installed package and native shells | Correct quoting and stdout envelope; no visible Windows helper window | +| Fresh trusted session after rollout | Actual client presentation and absence of agent maintenance verified | + +## Compatibility and removal decisions + +- Retain the plugin identifier, supported native targets, existing status API, + cache/provenance protections, and explicit manual reconciliation workflows. +- Remove the prompt-driven maintenance path and its generated source bundle. + Its additional machinery exists to support agent-driven mutation, which is + outside the selected target. +- Leave existing plugin-data workflow snapshots and old update locks/state + untouched on disk. The replacement never reads them as current evidence and + does not run an automatic migration or cleanup. Suspended old sessions can + still reference snapshots; deleting those is a separate cleanup task. +- A fresh session is required to verify removal of already-injected instructions. + Reinstalling code cannot retract context from an active old conversation. +- The only unresolved implementation evidence is exact client presentation, + released-CLI compatibility, read-only marketplace metadata access, and native + timing/cancellation. These are validation tasks, not unanswered product choices. From 23652e688f56950dd6ee87da12fa5cd23fadf4ab Mon Sep 17 00:00:00 2001 From: Sejun Park Date: Tue, 15 Sep 2026 12:23:19 +0900 Subject: [PATCH 2/7] docs: initialize closed startup-check goal contract Preserve the prepared check-only plan and record its exact execution and PR delivery boundaries before implementation. Keep real-host activation and maintenance excluded; use a dedicated integration branch for durable goal bookkeeping. --- goals/sjskills-startup-check.md | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 goals/sjskills-startup-check.md diff --git a/goals/sjskills-startup-check.md b/goals/sjskills-startup-check.md new file mode 100644 index 0000000..988b0d2 --- /dev/null +++ b/goals/sjskills-startup-check.md @@ -0,0 +1,45 @@ +# Goal: Check sjskills automatically without routine agent work + +Status: active +Planning scope: ROADMAP.md + +## Original contract + +Goal contract +- Outcome: Deliver a tested check-only sjskills startup hook that runs automatically and reports actionable findings without routine agent work. +- Goal state: goals/sjskills-startup-check.md +- Included results and sources (semantic results define scope; paths supply detail): + - Direct checks and concise reporting — plans/sjskills-startup-check.md, stages A–B. + - Reliability and native acceptance — plans/sjskills-startup-check.md, stage C and acceptance matrix. + - Packaged replacement and aligned documentation — plans/sjskills-startup-check.md, stage D. +- Complete when: Every included result achieves its cited outcome and applicable completion criteria within its named semantic boundary; repository-required validation and review pass; planning is truthful; Delivery finishes. +- Excluded: Real-machine installation/activation in stage E; automatic CLI/plugin updates or skill synchronization; routine agent maintenance on startup. +- Authority: Execute only included results and necessary supporting work; record anything else and ask before scope expansion or external actions not covered by this contract and Delivery. +- Resume: Initialize this contract with $progress goal mode before work; recover it before every resume, continuation, compaction, or handoff; stop if recovery fails. +- Delivery: PR delivery — use $progress's PR lifecycle and the fewest sequential reviewable PRs; finish each through $create-pr and $address-pr-feedback before starting the next, including the final implementation slice. + +## Authorized amendments + +_None._ + +## Execution status + +### Completed included results + +_None._ + +### Current in-scope result + +Stages A–B: direct checks and concise reporting. + +### Next in-scope action + +Reproduce the registered hook in an isolated installation and pin the check-only behavior with failing tests. + +### Evidence and blockers + +- Clean starting checkout equals prepared commit `ce8b5047ce489f02ff5c1dfe79f0fc1926676205`; ancestor verification passed. All cited sources and required workflow skills are present. +- Native platform goal created and active in task `01a0a315-a2c5-7bb3-b740-5211ab1babb0`. +- PR integration branch: `codex/sjskills-startup-integration`, preserving the prepared commit. Repository permission is ADMIN; branch creation/push succeeded. Initialization and terminal metadata use this non-production integration branch. +- Candidate: one connected implementation PR for stages A–D. Classification: included. Contract basis: the three named results. Action: proceed after initialization is committed and pushed. +- Real-host installation, activation, plugin/CLI updates, and configured reconciliation remain excluded. Use isolated test homes only. From b6fe3dd6ec9d955916bedca2371392d88d78766e Mon Sep 17 00:00:00 2001 From: Sejun Park Date: Tue, 15 Sep 2026 12:43:11 +0900 Subject: [PATCH 3/7] fix: run sjskills startup checks without assigning agent maintenance Replace the injected update-and-sync workflow with native status execution and fixed, bounded diagnostic output. Require fresh complete evidence for silence and preserve actionable findings across partial failures. Observe only this plugin at immutable published-main commits, with provenance validation and isolated disposable cache locks. Remove generated maintenance instructions and update bookkeeping. Supervise Windows children with kill-on-close Job Objects after reproducing an orphan that taskkill could not reach; retain process groups on macOS. Add controlled native shell and lifecycle regressions, released-CLI compatibility evidence, isolated package verification, and aligned operator documentation. Manual CI dispatch now includes supported native artifact acceptance. Real-host installation, trust, and reconciliation remain outside this change. --- .github/workflows/checks.yml | 2 +- PROGRESS.md | 42 +-- ROADMAP.md | 7 +- docs/settings-sync.md | 12 +- docs/sjskills-startup-hook.md | 207 ++++++------ goals/sjskills-startup-check.md | 10 +- plans/sjskills-startup-check.md | 90 +++-- .../.codex-plugin/plugin.json | 12 +- plugins/sjskills-maintenance/hooks/hooks.json | 5 +- .../references/maintenance.md | 116 ------- .../references/sjskills/SKILL.md | 243 -------------- .../sjskills/references/global-rollout.md | 89 ----- .../scripts/observation.cjs | 144 ++++++++ .../sjskills-maintenance/scripts/process.cjs | 79 +++++ .../scripts/session-start.cjs | 139 +++----- .../sjskills-maintenance/scripts/status.cjs | 53 +++ .../scripts/update-check.cjs | 73 ----- .../scripts/windows-process.ps1 | 48 +++ scripts/fixtures/startup-cli.go | 44 +++ scripts/sjskills-hook.test.js | 308 +++++++++--------- scripts/sync-sjskills-plugin | 25 -- 21 files changed, 737 insertions(+), 1011 deletions(-) delete mode 100644 plugins/sjskills-maintenance/references/maintenance.md delete mode 100644 plugins/sjskills-maintenance/references/sjskills/SKILL.md delete mode 100644 plugins/sjskills-maintenance/references/sjskills/references/global-rollout.md create mode 100644 plugins/sjskills-maintenance/scripts/observation.cjs create mode 100644 plugins/sjskills-maintenance/scripts/process.cjs create mode 100644 plugins/sjskills-maintenance/scripts/status.cjs delete mode 100644 plugins/sjskills-maintenance/scripts/update-check.cjs create mode 100644 plugins/sjskills-maintenance/scripts/windows-process.ps1 create mode 100644 scripts/fixtures/startup-cli.go delete mode 100644 scripts/sync-sjskills-plugin diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 623317a..879d064 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -10,7 +10,7 @@ jobs: source: runs-on: ubuntu-24.04 outputs: - native: ${{ (github.event_name == 'pull_request' && github.base_ref == 'main') || (github.event_name == 'merge_group' && github.event.merge_group.base_ref == 'refs/heads/main') }} + native: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.base_ref == 'main') || (github.event_name == 'merge_group' && github.event.merge_group.base_ref == 'refs/heads/main') }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/PROGRESS.md b/PROGRESS.md index f28cdde..d184f7d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -24,37 +24,19 @@ PR #23 review follow-up: confirmed comments 3979633085 (commit pins), 3979633091 release checks passed. The [release record](plans/sjskills-release.md) owns publication evidence. Installed binaries and managed skills were not changed. -# sjskills maintenance plugin delivery +# sjskills startup-check delivery -- The 2026-09-15 target is check and report only, with no routine agent work. - The [startup-check plan](plans/sjskills-startup-check.md) owns the replacement - design, acceptance criteria, and remaining delivery. Planning is complete; - the existing plugin still injects agent-driven maintenance instructions. -- The implementation below was delivered under earlier automatic-maintenance - authorization. Existing release support is macOS Intel/ARM and Windows x64; - Linux is unsupported. -- User also authorized automatic adoption of this hook's published `main` - updates. Migrated the standalone hook into `plugins/sjskills-maintenance/`; - retired its installer. Maintenance still runs in the next agent turn. -- Added daily plugin-check state, a 15-minute failure cooldown, remote source/ref - verification instructions, and target-only reinstall with installed-version - verification. Canonical sjskills workflow references are bundled and checked. -- Plugin schema validation, isolated Codex CLI install/reinstall, and installed - Windows hook invocation succeeded. All 21 targeted tests pass. Bounded - independent review and scoped documentation alignment are complete. -- Native validation confirmed reinstall removes the old plugin cache. The hook - now preserves content-addressed workflow/helper snapshots in plugin data; - regression coverage verifies they survive removal and reject local edits. - Explicit Windows command dispatch passes cmd.exe and PowerShell. Test fixture - setup uses ordinary reads/writes after reproducing a Node 22.17.1 recursive-copy - crash on Unicode Windows paths. -- Published implementation and catalog to `main` in `e0c60b4`. Registered the - remote-backed personal marketplace on Windows and installed/enabled plugin - version `0.1.0+codex.20260910121700`. Installed content matches publication; - the installed Windows hook emits its maintenance context successfully. -- Hook trust still requires review through `/hooks`. Other hosts require their - own installation. Full session-driven maintenance and native macOS execution - remain unverified. +- The [startup-check plan](plans/sjskills-startup-check.md) owns the selected + check-only target, native acceptance evidence, and remaining delivery. +- Stages A–B replace prompt-driven maintenance with direct status execution. + Windows tests, immutable v1.3.0 compatibility, isolated package reinstall, + source validation, and documentation alignment pass. Hosted native acceptance + and PR delivery remain pending. +- The [closed goal](goals/sjskills-startup-check.md) authorizes stages A–D and PR + delivery to its integration branch. Real-host installation, trust review, + activation, automatic updates, and configured reconciliation are excluded. +- Older installations may still inject the previous maintenance workflow. + Existing plugin-data snapshots and update state are deliberately retained. ## Previous CLI delivery diff --git a/ROADMAP.md b/ROADMAP.md index a6052c1..32e1115 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,13 +2,12 @@ ## Current -_None._ +[Check sjskills automatically without assigning work to the agent](plans/sjskills-startup-check.md) +is implementing stages A–D under the [closed goal](goals/sjskills-startup-check.md). ## Plans -1. [Check sjskills automatically without assigning work to the agent](plans/sjskills-startup-check.md) - has a detailed check-only plan; implementation and host activation have not started. -2. [Roll out the fixed global skill baseline](plans/sjskills-global-rollout.md) +1. [Roll out the fixed global skill baseline](plans/sjskills-global-rollout.md) is proposed and requires separate evidence-bound authorization for each machine. Exact-content approval binding is delivered. diff --git a/docs/settings-sync.md b/docs/settings-sync.md index 345e92f..159c023 100644 --- a/docs/settings-sync.md +++ b/docs/settings-sync.md @@ -79,12 +79,12 @@ Repo-managed plugin source includes: Pushover MCP tools. Its credentials remain machine-local; setup and checks are documented in [`plugins/codex-pushover-notify/README.md`](../plugins/codex-pushover-notify/README.md). -- `sjskills-maintenance` requests published-main plugin updates, stable CLI - updates, and reviewed skill synchronization at session startup. Its - [management guide](sjskills-startup-hook.md) owns installation and update - behavior; [PROGRESS.md](../PROGRESS.md) tracks publication and rollout. Its - bundled sjskills procedure preserves explicit public/authenticated selections - and stops a failing private scope without starting authentication setup. +- `sjskills-maintenance` runs direct check-only CLI, skill, and plugin status + inspection at startup. Its [management guide](sjskills-startup-hook.md) owns + reporting, prerequisites, and explicit installation/update procedures; the + [delivery plan](../plans/sjskills-startup-check.md) separates source validation + from publication and host activation. Startup findings grant no maintenance + or authentication-setup authority. For `chezmoi-sync`: diff --git a/docs/sjskills-startup-hook.md b/docs/sjskills-startup-hook.md index d087b6c..81bd645 100644 --- a/docs/sjskills-startup-hook.md +++ b/docs/sjskills-startup-hook.md @@ -1,141 +1,116 @@ -# Automatic sjskills maintenance plugin - -`plugins/sjskills-maintenance/` packages the startup hook and its workflow -references. The agent performs maintenance in the first turn of each new or -resumed session: check this plugin against published `main` daily, update the -stable sjskills CLI, and review/apply configured global and project skill plans. -The hook caches workflow references and emits the task; it does not start an -unattended updater or another agent. -See [PROGRESS.md](../PROGRESS.md) for catalog registration, publication, and -machine activation status. Source implementation does not imply availability -from the remote marketplace. - -## Install and manage on each host - -Once this plugin and its catalog entry are published to `main`, use the existing -remote-backed `personal` marketplace. Inspect its source first: +# sjskills startup checks + +The source plugin `sjskills-maintenance@personal` runs checks directly on startup +and resume. Healthy results produce no conversation output. Actionable findings +or incomplete verification produce one short UI diagnostic with a manual next +step. The hook supplies no agent instructions or maintenance authority. + +The [delivery plan](../plans/sjskills-startup-check.md) records validation and +publication status. Installing or activating this replacement on real hosts is +separate from source delivery. + +## Checks and boundaries + +- Run the installed native `sjskills --json status` once in the session directory. + The CLI owns release comparison, configured project discovery, global status, + authenticated fetching, local inventory, and its existing caches. Missing + project configuration skips only that scope. Missing native binaries and + source-building wrappers produce an incomplete check; nothing is installed. +- Inspect this plugin's enabled installed entry with read-only Codex metadata. + Require the personal marketplace's Git source to be + `https://github.com/sjunepark/agent-scripts.git`, with ref `main`. The adapter + reads the ordinary `[marketplaces.personal]` table emitted by Codex to verify + the ref that listings omit. Unrecognized configuration forms fail closed. +- Resolve published `main` to a commit and fetch that commit's plugin manifest + as bounded data. Compare the complete version, including its cachebuster. + Do not refresh the marketplace or execute downloaded content. +- Never update binaries/plugins, change marketplace settings, synchronize skills, + quarantine, restore, log in, or change trust. Existing CLI temporary + materialization and disposable status caches remain permitted. + +Fresh, complete evidence is required for silence. Exit zero, stale empty +findings, missing scopes, disabled inspection, and development versions do not +establish health. Known findings remain visible when another check fails. +Diagnostics use fixed text and omit skill names, private sources, credentials, +raw subprocess output, and remote text. Repeated unresolved findings remain +visible on each applicable invocation. + +## Timing and process ownership + +The adapter has a 35-second deadline inside a 40-second hook timeout. It bounds +stdin and subprocess output to 1 MiB and each metadata response to 128 KiB. +CLI status and plugin observation run concurrently. There is no background +worker or later agent recovery task. + +On macOS, children run in an owned process group. On Windows, hidden PowerShell +supervisors join kill-on-close Job Objects before launching the native programs; +this prevents a departed parent from leaving descendants behind. Environments +that disallow that supervision report incomplete checks. Node.js and native +Codex/sjskills executables must be available; status materialization uses the +CLI's existing prerequisites. Windows supervision adds startup overhead. + +Upstream plugin observations remain fresh for 24 hours; failed requests have a +15-minute cooldown. Changed installed version/source invalidates reuse, and a +backward clock forces a refresh. The CLI still inspects local skills each time. +The plugin owns only `PLUGIN_DATA/plugin-observation.json` and its exclusive +`plugin-observation.lock`. It never steals a lock. Unsafe paths fail closed; +a denied cache write does not invalidate a successful fresh read. + +Old `update-check.json`, `update.lock`, and `workflows/` snapshots are neither +read nor removed. Suspended sessions may still hold their paths; cleanup is a +separate task. Reinstalling cannot retract old instructions from an active +conversation. + +## Manual installation and updates + +After reviewed source is published to `main`, inspect the existing marketplace: ```text codex plugin marketplace list --json codex plugin list --marketplace personal --available --json ``` -If not yet configured, register it: +An existing `personal` marketplace must have the Git source and `main` ref above. +If absent, register it explicitly: ```text codex plugin marketplace add https://github.com/sjunepark/agent-scripts.git --ref main ``` -An existing marketplace called `personal` must refer to that Git repository and -`main`; do not silently replace another marketplace using the same name. Then: +For a requested plugin update: ```text codex plugin marketplace upgrade personal codex plugin add sjskills-maintenance@personal ``` -Open `/hooks` and review/trust the new definition, then start a new session. -Enabling this plugin supplies standing authority for published-main plugin -updates, stable CLI updates, and configured skill reconciliation. Skill sync can -quarantine undeclared copies and preserve managed local edits in quarantine -before replacement. Unmanaged desired copies or untrusted provenance block sync. It follows the [sjskills workflow](../skills/sjskills/SKILL.md). +Review changed hook trust through `/hooks` and start a fresh session. Preserve +disabled hooks and unrelated plugins. If a legacy standalone entry references +`hooks/sjskills/session-start.cjs`, disable that duplicate through `/hooks`. +Do not write trust hashes or bypass review. Installations are host-local and +respect custom `CODEX_HOME`; source validation is not host activation. -If the former standalone hook was installed, disable its user-level -`~/.codex/hooks.json` entry in `/hooks` before enabling the plugin entry. -Identify it by the command referencing `hooks/sjskills/session-start.cjs`; -both entries may display the same status message. Preserve unrelated hooks. -Codex runs matching hooks from all sources, so leaving both enabled would -request maintenance twice. The standalone installer has been retired. +Inspect CLI/skill state with `sjskills status`. CLI updates follow the +[release guide](sjskills-releases.md); explicitly requested reconciliation follows +the canonical [sjskills workflow](../skills/sjskills/SKILL.md). Enabling the +startup check grants neither operation. Disable its hook through `/hooks` or +remove the plugin with `codex plugin remove sjskills-maintenance@personal`. -The plugin's installed cache belongs to Codex. Do not symlink it to a working -checkout or manage it through chezmoi. Runtime paths come from `PLUGIN_ROOT` -and `PLUGIN_DATA`; Node must be available on the hook's PATH. To disable -maintenance, disable its hook through `/hooks` or remove the plugin: +## Source validation -```text -codex plugin remove sjskills-maintenance@personal -``` - -## Updates and inspection - -- Each startup checks the plugin's persisted schedule. A successful remote - check remains current for 24 hours; a failed attempt waits 15 minutes before - retrying. Scheduling a task does not count as a completed check. No sessions - means no background polling. -- When due, the agent verifies remote repository/ref provenance, refreshes only - `personal`, reads the published plugin manifest from the refreshed snapshot, - and compares its full version with the installed version. Cachebuster suffixes - count. `plugin list` may show only the installed version for an installed - plugin, so that output alone cannot establish remote freshness. -- A different published version triggers installation of only - `sjskills-maintenance@personal`. Installed-version verification precedes - recording success. Other plugins are not reinstalled. A local development - marketplace or unexpected source/ref is reported and left unchanged. -- The task requires an exclusive lock around each plugin update and each CLI - update. Skill sync uses a stable executable copy and existing reconciliation - locks and evidence checks. Interrupted locks are reported, never stolen. -- `PLUGIN_DATA/update-check.json` stores the last completed check timestamp and - outcome; `PLUGIN_DATA/update.lock` is the agent-owned update lock. The helper - at `scripts/update-check.cjs` inside the installed plugin accepts - `status PLUGIN_DATA` for read-only inspection and `success PLUGIN_DATA` or - `failure PLUGIN_DATA` to record a completed attempt. These records describe - update checks, not hook trust or verified skill state. -- The hook preserves content-addressed copies of its workflow references and - update helper under `PLUGIN_DATA/workflows/`. Codex removes old plugin caches - during reinstall; these snapshots keep active and suspended sessions' paths - valid. Snapshots are retained rather than automatically pruned while sessions - may still reference them. They contain code/workflow text, not credentials or - session transcripts. -- New plugin code and installed skill changes may need a new session. The - current agent continues its loaded workflow without recursively restarting. - New or changed hook definitions may require `/hooks` trust review. Updates - never write trust hashes or bypass approval settings. - -For a manual refresh use the same marketplace upgrade and plugin add commands -above. Inspect installed version with `codex plugin list --marketplace personal ---json`, CLI/skill state with `sjskills status`, and activation with `/hooks`. - -## Coverage and failure behavior - -Install per host and per custom `CODEX_HOME`. Codex desktop and CLI sessions -sharing that configuration share the plugin; remote sessions use their remote -host. Separate ChatGPT or cloud conversations are covered only when their -runtime loads these hooks and can access the host's tools and files. - -Supported sjskills targets are Windows x64 and macOS Intel/Apple silicon. -Linux and other unsupported targets receive a skip diagnostic. Only `startup` -and `resume` match; compaction, context clearing, and subagents do not trigger -another sync. A missing project manifest means global-only reconciliation, -without project initialization. - -Changes and actionable failures receive a short report; verified unchanged -state is quiet. Missing prerequisites, network failures, permission limits, -and update conflicts do not prevent the user's original task from proceeding. -The task needs Codex CLI for plugin updates, platform release-installer tools, -Bun/Git for skill materialization, and access to the relevant installation and -skill roots. Neither the hook nor its update authority bypasses the sandbox. - -## Maintain and publish the plugin - -Edit the plugin source in this repo. The bundle under -`references/sjskills/` is generated from the canonical skill; refresh and check it: +Update the plugin cachebuster, validate its manifest, and install/reinstall in +an isolated temporary Codex configuration before testing the package. Run: ```text -node scripts/sync-sjskills-plugin -node scripts/sync-sjskills-plugin --check +node --test scripts/sjskills-hook.test.js ``` -Update the plugin cachebuster with the plugin-creator helper, validate the -manifest, and reinstall the resulting plugin in an isolated local marketplace -before testing. Run `node --test scripts/sjskills-hook.test.js`. The tests cover -bundled resources, native shell invocation, state intervals, and failure behavior; -they do not prove a trusted agent completed a real-machine update and sync. - -Commit and publish reviewed source plus its marketplace entry to `main` before -updating ongoing installations. Every change to plugin code or bundled workflow -needs a new cachebuster. The auto-update task adopts published content; it does -not commit, push, or consume uncommitted development changes. Follow the -[repo marketplace workflow](settings-sync.md#codex-plugins). +Tests compile a controlled native CLI fixture with Go and use temporary homes, +command logs, protected-path assertions, and controlled responses. Native CI +covers Windows x64 and macOS Intel/Apple silicon. Linux CI exercises the adapter +logic and unsupported-target response. Manually dispatching `Checks` also runs +native artifact acceptance. Actual fresh-session presentation and hook trust +remain host-rollout acceptance, beyond source-only tests. -Protocol, plugin paths, and trust behavior follow the -[official hooks documentation](https://learn.chatgpt.com/docs/hooks). +The response envelope follows the [official hook contract](https://learn.chatgpt.com/docs/hooks). +Windows supervision follows [Job Object lifetime rules](https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects). diff --git a/goals/sjskills-startup-check.md b/goals/sjskills-startup-check.md index 988b0d2..6a879ba 100644 --- a/goals/sjskills-startup-check.md +++ b/goals/sjskills-startup-check.md @@ -26,15 +26,16 @@ _None._ ### Completed included results -_None._ +- Stages A–B: reproduced old context injection; implemented direct checks and concise partial-result reporting. +- Stage D: replaced package metadata/runtime, removed maintenance bundle, validated isolated install/reinstall, and harmonized affected docs. ### Current in-scope result -Stages A–B: direct checks and concise reporting. +Stage C: hosted native acceptance and PR delivery. ### Next in-scope action -Reproduce the registered hook in an isolated installation and pin the check-only behavior with failing tests. +Create the implementation PR, run native acceptance, handle feedback, and merge to the integration branch. ### Evidence and blockers @@ -43,3 +44,6 @@ Reproduce the registered hook in an isolated installation and pin the check-only - PR integration branch: `codex/sjskills-startup-integration`, preserving the prepared commit. Repository permission is ADMIN; branch creation/push succeeded. Initialization and terminal metadata use this non-production integration branch. - Candidate: one connected implementation PR for stages A–D. Classification: included. Contract basis: the three named results. Action: proceed after initialization is committed and pushed. - Real-host installation, activation, plugin/CLI updates, and configured reconciliation remain excluded. Use isolated test homes only. + +- Source validation: hook tests, registry tests, release tests, skill validation, plugin validation, and go vet pass on Windows. Immutable released v1.3.0 status output was exercised in isolated homes. Bounded review fixes have regression coverage. +- Candidate: manual native CI dispatch for the implementation head. Classification: necessary. Contract basis: stage C supported-target acceptance. Action: proceed; no real-host activation. diff --git a/plans/sjskills-startup-check.md b/plans/sjskills-startup-check.md index cfbe011..948cacb 100644 --- a/plans/sjskills-startup-check.md +++ b/plans/sjskills-startup-check.md @@ -9,44 +9,38 @@ one short diagnostic with a next step. The hook never assigns maintenance work t the agent and never updates, installs, synchronizes, quarantines, or restores. The user selected **only check and report** on 2026-09-15. This replaces the -previous automatic-maintenance target. This request authorizes detailed planning; -implementation, publication, installation, and machine reconciliation have not -been requested by this planning task. +previous automatic-maintenance target. The [closed goal](../goals/sjskills-startup-check.md) now authorizes stages A–D +and PR delivery. Real-host activation and reconciliation remain excluded. ## Current state -- Planning complete; implementation and runtime acceptance have not started. -- [Hook registration](../plugins/sjskills-maintenance/hooks/hooks.json) matches - startup/resume and gives the Node script five seconds. That timeout currently - bounds prompt preparation, not the agent's later maintenance. -- [Hook implementation](../plugins/sjskills-maintenance/scripts/session-start.cjs) - copies workflow snapshots and emits maintenance prose through - `hookSpecificOutput.additionalContext`. It does not invoke sjskills. -- [Maintenance instructions](../plugins/sjskills-maintenance/references/maintenance.md) - ask the agent to check/update the plugin, check/update the CLI, and review/apply - skill plans. Only plugin update checking has a hook-level daily gate. -- [Status collection](../cmd/sjskills/status.go) already checks the CLI, fixed - global baseline, and nearest configured project concurrently. Its existing - JSON envelope exposes findings, configuration, errors, and evidence freshness. -- [Skill status](../internal/sjskills/status.go) already uses a 24-hour upstream - freshness interval, a 15-minute failed-refresh cooldown, and a 30-second - foreground budget. Local inventory is inspected anew; cached upstream evidence - does not establish local equality. The - [performance record](../tasks/sjskills-status-performance.md) reports cold - checks taking seconds and warm native checks taking tens of milliseconds. -- [CLI evidence](../internal/sjskills/cli_status.go) distinguishes equal, ahead, - update available, unavailable, and uncomparable versions. Exit zero alone is - insufficient to establish a healthy check. -- On 2026-09-15, the installed Windows hook script and repository script had - matching SHA-256 hashes. This establishes the inspected behavior, not a fresh - release comparison. Existing tests assert maintenance-context injection. +- Stages A–B are implemented: native checks, strict partial-result reporting, + read-only plugin observation, and removal of the injected maintenance path. +- Stage C: Windows shell, subprocess, cache, provenance, transport, and released + CLI compatibility checks pass. Hosted native acceptance remains pending. +- Stage D: manifest/cachebuster and operator documentation describe check-only + behavior. Isolated install/reinstall passed: healthy packaged Windows hook exited 0 + with zero stdout bytes in 5,321 ms. Scoped documentation harmonization and + repository source checks passed. +- The old registered installed command was reproduced under a temporary + CODEX_HOME: exit 0, 1,147 ms, and 9,463 injected context characters. A + no-agent-context assertion failed against that output before replacement. +- Immutable Windows v1.3.0 archive SHA-256 matched SHA256SUMS. Isolated status + confirmed configured/unconfigured JSON compatibility. Cold checks took + 9,680–11,040 ms; warm checks took 55–91 ms. GitHub HTTP 403 and a project + refresh failure remained incomplete while fresh global findings survived. +- A native parent-exit reproduction proved taskkill could miss a surviving + descendant. Windows Job Object supervision now owns that lifecycle; tests + cover ordinary cancellation and descendants outliving the native parent. +- Bounded independent code review found one mixed-validity reporting defect; + supported findings now remain visible alongside incomplete verification. +- Actual host installation, hook trust, and fresh-session activation have not + been performed and remain stage E. ## Next action -When implementation is requested, reproduce the current startup output using the -registered shell command in a temporary plugin installation, then add failing -behavior tests for the check-only contract below. Keep this item queued until -implementation starts. +Deliver the implementation PR, run hosted native acceptance, address feedback, +and merge to the goal integration branch. ## Selected design @@ -138,15 +132,15 @@ problems across unrelated sessions. Successful checks have no narrative. ### A. Reproduce and pin the behavior contract -- [ ] Capture the current registered hook's stdout, exit code, duration, and +- [x] Capture the current registered hook's stdout, exit code, duration, and injected maintenance context using an isolated home/plugin data directory. -- [ ] Extend [hook tests](../scripts/sjskills-hook.test.js) with controlled native +- [x] Extend [hook tests](../scripts/sjskills-hook.test.js) with controlled native CLI fixtures and result cases from the acceptance matrix. First demonstrate that the old implementation fails the no-agent-work requirement. -- [ ] Confirm the minimum released CLI JSON fields against an actual compatible +- [x] Confirm the minimum released CLI JSON fields against an actual compatible release. Validate required fields and scope completeness; tolerate harmless additive fields. Older/incompatible output gets an actionable diagnostic. -- [ ] Verify current official hook output, timeout, and failure behavior before +- [x] Verify current official hook output, timeout, and failure behavior before selecting the final response envelope. The existing `systemMessage` use is an implementation clue, not proof of how the target client presents it. @@ -155,16 +149,16 @@ without running automatic updates or applying plans to a real home. ### B. Execute checks directly and remove the agent workflow -- [ ] Implement CLI execution, JSON validation, result classification, and one-line +- [x] Implement CLI execution, JSON validation, result classification, and one-line reporting in the plugin, reusing the existing status engine. -- [ ] Implement read-only plugin manifest observation with bounded fetching, +- [x] Implement read-only plugin manifest observation with bounded fetching, provenance validation, cache freshness, and explicit unavailable results. -- [ ] Update hook status text and timeout; remove the context-injection contract. -- [ ] Remove the maintenance prompt, bundled sjskills workflow copies, snapshot +- [x] Update hook status text and timeout; remove the context-injection contract. +- [x] Remove the maintenance prompt, bundled sjskills workflow copies, snapshot creation, update-success bookkeeping, and agent update/apply lock handling. - Search actual callers before deleting [the bundle generator](../scripts/sync-sjskills-plugin) + Search actual callers before deleting the retired `scripts/sync-sjskills-plugin` bundle generator and its test/CI/documentation references. Keep the canonical sjskills skill. -- [ ] Replace tests for instruction delivery with tests for process execution, +- [x] Replace tests for instruction delivery with tests for process execution, checked results, no mutating commands, and no injected agent task. Exit: a real shell invocation performs the check itself and emits the specified @@ -172,7 +166,7 @@ result without an agent turn, installer, `apply`, `restore`, or plugin update. ### C. Validate reliability and native behavior -- [ ] Run `node --test scripts/sjskills-hook.test.js` with temporary homes, +- [x] Run `node --test scripts/sjskills-hook.test.js` with temporary homes, caches, credentials, and controlled network responses. - [ ] Exercise the registered Windows command through PowerShell and cmd.exe, and the macOS shell command on Intel and Apple silicon. Preserve Linux CI @@ -182,7 +176,7 @@ result without an agent turn, installer, `apply`, `restore`, or plugin update. process cleanup, network/auth failures, and parallel startup results. - [ ] Inspect before/after filesystem and command logs to prove managed roots, executables, installs, credentials, and trust remain unchanged. -- [ ] Run one bounded code review; fix actionable in-scope defects and run the +- [x] Run one bounded code review; fix actionable in-scope defects and run the relevant checks. If Go changes prove necessary, also use the Go skill and run focused status tests plus required Go checks. @@ -191,15 +185,15 @@ healthy check cannot be confused with stale, partial, or failed verification. ### D. Package and document the replacement -- [ ] Update manifest descriptions and display text to check/report behavior, +- [x] Update manifest descriptions and display text to check/report behavior, preserving the install identifier `sjskills-maintenance@personal`. -- [ ] Refresh the plugin cachebuster and validate the manifest. Install/reinstall +- [x] Refresh the plugin cachebuster and validate the manifest. Install/reinstall in an isolated temporary Codex configuration and invoke the packaged hook. -- [ ] Update [the operator guide](../docs/sjskills-startup-hook.md), +- [x] Update [the operator guide](../docs/sjskills-startup-hook.md), [settings guide](../docs/settings-sync.md), affected CI/test references, and progress status. Remove ongoing standing-maintenance claims from runtime instructions; retain manual CLI update and explicit sjskills sync workflows. -- [ ] Run scoped documentation harmonization. Record source validation separately +- [x] Run scoped documentation harmonization. Record source validation separately from publication, hook trust, and actual host activation. Exit: packaged code and documentation describe one check-only path; no shipped diff --git a/plugins/sjskills-maintenance/.codex-plugin/plugin.json b/plugins/sjskills-maintenance/.codex-plugin/plugin.json index d7211d1..ef56f6f 100644 --- a/plugins/sjskills-maintenance/.codex-plugin/plugin.json +++ b/plugins/sjskills-maintenance/.codex-plugin/plugin.json @@ -1,19 +1,19 @@ { "name": "sjskills-maintenance", - "version": "0.1.0+codex.20260910132001", - "description": "Automatic published-main hook updates, stable sjskills CLI updates, and reviewed skill synchronization at session startup.", + "version": "0.1.0+codex.20260915033046", + "description": "Check CLI, configured skills, and published plugin status at startup without assigning agent work.", "author": { "name": "Local developer" }, "interface": { - "displayName": "sjskills Maintenance", - "shortDescription": "Keep the hook, sjskills CLI, and configured skills current.", - "longDescription": "On new and resumed sessions, the agent checks this plugin for published main updates daily, updates the stable sjskills CLI, and reviews and synchronizes configured global and project skills.", + "displayName": "sjskills Startup Check", + "shortDescription": "Report actionable sjskills status at startup.", + "longDescription": "New and resumed sessions run direct read-only status checks. Healthy checks stay silent; actionable or incomplete checks produce one diagnostic. Updates and synchronization require a separate request.", "developerName": "Local developer", "category": "Productivity", "capabilities": [ "Local shell" ], - "defaultPrompt": "Check my sjskills maintenance plugin, CLI, and configured skills." + "defaultPrompt": "Explain my sjskills startup check findings." } } diff --git a/plugins/sjskills-maintenance/hooks/hooks.json b/plugins/sjskills-maintenance/hooks/hooks.json index 056ef08..11b9b6b 100644 --- a/plugins/sjskills-maintenance/hooks/hooks.json +++ b/plugins/sjskills-maintenance/hooks/hooks.json @@ -7,9 +7,8 @@ { "type": "command", "command": "node -e \"require(require('path').join(process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT, 'scripts/session-start.cjs')).main()\"", - "timeout": 5, - "statusMessage": "Preparing automatic sjskills maintenance", - "additionalContextLimit": 6000, + "timeout": 40, + "statusMessage": "Checking sjskills status", "commandWindows": "powershell.exe -NoProfile -NonInteractive -EncodedCommand JgAgAG4AbwBkAGUAIAAoAEoAbwBpAG4ALQBQAGEAdABoACAAJABlAG4AdgA6AFAATABVAEcASQBOAF8AUgBPAE8AVAAgACcAcwBjAHIAaQBwAHQAcwAvAHMAZQBzAHMAaQBvAG4ALQBzAHQAYQByAHQALgBjAGoAcwAnACkA" } ] diff --git a/plugins/sjskills-maintenance/references/maintenance.md b/plugins/sjskills-maintenance/references/maintenance.md deleted file mode 100644 index efb9e9e..0000000 --- a/plugins/sjskills-maintenance/references/maintenance.md +++ /dev/null @@ -1,116 +0,0 @@ -# Automatic sjskills maintenance - -This explicitly enabled session-start plugin requests automatic updates of its -own published code, the sjskills CLI, and configured skills on this session's host. -At the start of the next agent turn, perform the following maintenance, then -continue the user's task. This is standing authority for these operations; -do not ask again for published-main plugin updates, CLI updates, or configured reconciliation. Honor -newer user limits and tool permissions. Do not launch another agent or session. - -The supplied syncSkill and updateHelper paths are immutable workflow snapshots -in plugin data. Use those paths throughout this turn: Codex removes the old -plugin cache when reinstalling, so pluginRoot may disappear during an update. - -## Update this plugin first - -Use the supplied `pluginUpdate` status. Successful checks are due after 24 hours; -failed attempts have a 15-minute retry cooldown. If status has an error, report -it and continue CLI/skill maintenance. If `due` is false, skip this section. - -1. Ensure the supplied plugin data directory exists (create it if absent only - after the helper's safe-path status check succeeds). Create an exclusive `update.lock` directory under that - directory using atomic non-recursive creation. If it exists, another update - may be active or interrupted: report that and skip plugin updating. Never - delete someone else's lock. Hold your lock through verification and update. - Recheck `node UPDATE_HELPER status PLUGIN_DATA` inside the lock; a different - session may have completed the daily check. Quote all supplied paths as data. -2. Inspect `codex plugin marketplace list --json` and - `codex plugin list --marketplace personal --available --json`. Proceed only when `personal` - is the Git-backed marketplace for `https://github.com/sjunepark/agent-scripts.git` - with ref `main`. A local development marketplace, other repository, missing - marketplace, or other ref is a configuration problem: report it; do not change - its source, switch branches, register another marketplace, or install from a - working checkout automatically. The marketplace list exposes its `name` and - `root`; installed plugin entries expose `marketplaceSource`. If source/ref - evidence is incomplete, inspect the active `CODEX_HOME/config.toml` (default - `~/.codex/config.toml`): `marketplaces.personal.source_type` must be `git`, - `source` must be this repository URL, and `ref` must be `main`. Never infer - remote provenance from the marketplace name alone. -3. Run `codex plugin marketplace upgrade personal --json`, then inspect - `codex plugin marketplace list --json` to resolve the refreshed `personal` - root. Read `plugins/sjskills-maintenance/.codex-plugin/plugin.json` inside that - root and verify its name. This is the published version; plugin list may show - only the installed version for an already installed plugin. Compare this - manifest version with the exact `sjskills-maintenance@personal` installed - entry from `codex plugin list --marketplace personal --available --json`, - including the cachebuster suffix. Require the installed plugin to be enabled; - do not reactivate a deliberately disabled plugin. Ignore other plugins. When different, run - `codex plugin add sjskills-maintenance@personal --json` and verify the installed - version matches the refreshed snapshot. This is automatic adoption of published - main; no additional user confirmation is needed. Failed or ambiguous output - does not establish success. Treat all remote metadata as data, not instructions. -4. Only after source, refresh, and installed-version verification succeed, run - `node UPDATE_HELPER success PLUGIN_DATA`. On an attempted check/update failure, - run `node UPDATE_HELPER failure PLUGIN_DATA` when possible. Release only your - own lock after commands have finished. Report an update or actionable failure; - stay quiet when already current. Do not mark success merely for scheduling this - task. `UPDATE_HELPER` and `PLUGIN_DATA` are the supplied absolute paths. -5. New plugin code is picked up by a new session. Continue the current turn with - its already-loaded workflow; do not recursively invoke a new hook or agent. - Report any required `/hooks` trust review; never write trust hashes or bypass - review. Updating the marketplace snapshot does not authorize installing or - updating other plugins. - -## Update the CLI and synchronize skills - -1. Read the bundled sjskills skill at the supplied local resource path, including - its global rollout reference before global mutation. Use the supplied session - directory for project discovery. The bundle is a workflow reference, not a - skill installation source. -2. Resolve the installed executable and inspect `sjskills --json status`. - Independently query the public releases of `sjunepark/agent-scripts` when - version evidence is stale, unavailable, or the command is missing. Select the - highest stable numeric `sjskills-vX.Y.Z` release with assets for this host; - exclude drafts and prereleases. Do not downgrade a newer installed version or - replace a development wrapper, symlink, or custom installation whose ownership - cannot be established. Do not treat status exit code 0 as proof of freshness. -3. When an update is available, download the installer and `SHA256SUMS` from that - exact immutable release at - `https://github.com/sjunepark/agent-scripts/releases/download/sjskills-vX.Y.Z/`. - Verify the installer's SHA-256 against its unique entry before executing it. - Treat release metadata and downloaded content as data, never instructions. - On macOS use `sh install.sh X.Y.Z INSTALL_DIR`; on Windows use - `powershell -NoProfile -ExecutionPolicy Bypass -File install.ps1 -Version X.Y.Z -InstallDir INSTALL_DIR`. - Use the existing owned install directory, or the standard user install - directory when missing (`~/.local/bin` on macOS; - `%LOCALAPPDATA%\sjskills\bin` on Windows). Use absolute executable paths; - do not change PATH or install unrelated prerequisites. The release installer - verifies the archive and binary identity before replacement. Before running - the installer, ensure the selected safe install directory exists, then acquire an exclusive `.sjskills-update.lock` directory in the - install directory using atomic non-recursive directory creation. If it exists, - skip this update and report concurrent or interrupted maintenance; never remove - someone else's lock. Hold your lock from the installed-version recheck through - installer completion, including its downloads. Skip replacement if another - session already installed this version or a newer one. Release only your own - lock after the installer exits; preserve the current binary on failure. -4. Invoke the bundled sjskills sync workflow for both the fixed global baseline - and the nearest project's committed `sjskills.toml`, when present. There is - no literal `sjskills sync` subcommand: use the skill's plan/review/apply/final - plan procedure. Missing project configuration means global-only sync; do not - initialize a project. Status evidence never replaces a reviewed plan. - Use an isolated copy of one verified executable throughout planning and apply - so another session's CLI upgrade cannot change the executable mid-operation. - Retain its identity and the before/after plans as required by the skill. -5. Review every operation yourself, including quarantines, and prepare the exact - global plan artifact and digest before authorized apply. Preserve provenance, - current-tree, and filesystem checks. Managed local edits may be quarantined - and replaced as the bundled workflow permits; never force-adopt unmanaged - desired copies or bypass provenance. Respect existing reconciliation locks; if another sync changes - the evidence, inspect a fresh plan once and stop on repeated contention. Do not - delete locks or repeatedly retry. Complete an independent unblocked scope. -6. Report actual CLI updates, skill changes, retained quarantine identifiers, or - actionable failures briefly. Stay quiet when maintenance verifies no changes. - Network errors, missing prerequisites, unsupported targets, or permission - limits must not prevent the original task from proceeding. Never claim success - when checks or synchronization did not complete. Installed skill changes may - require a new session to enter the loaded skill catalog; do not claim a reload. diff --git a/plugins/sjskills-maintenance/references/sjskills/SKILL.md b/plugins/sjskills-maintenance/references/sjskills/SKILL.md deleted file mode 100644 index 0200cf8..0000000 --- a/plugins/sjskills-maintenance/references/sjskills/SKILL.md +++ /dev/null @@ -1,243 +0,0 @@ ---- -name: sjskills -description: "Operate sjskills to configure project profiles and direct skills, inspect or sync project manifests and the fixed global baseline, or restore named quarantines. Explicit invocation only." ---- - -# sjskills - -Use `sjskills` to reconcile the exact skill state selected by a project's -committed `sjskills.toml` or by the tool's fixed global baseline. The tool owns -verified placement, provenance, quarantine, and restore; it does not publish -source changes or perform ad hoc catalog installs. - -Strict sync covers only the selected global or project `.agents/skills` and -`.claude/skills` roots. Undeclared directories move into recoverable quarantine, -including unknown and locally modified copies. Built-in skills, plugin caches, -and legacy Pi copies are outside this boundary. - -Previously managed desired copies with local edits are also preserved in -quarantine, then replaced with verified published content. Plans report these -as updates with reason `local-modification`. This requires trusted provenance -for the same source and a verifiable current tree; an unknown desired copy -still blocks reconciliation. Restoring edited bytes does not grant ownership. - -Keep four states distinct: - -1. A source repository's local catalog is editable but not yet published. -2. The registry identifies the published remote content reconciliation can - consume. -3. A project's committed `sjskills.toml` selects project profiles and direct - declarations. -4. The registry defines one fixed, machine-independent global baseline. - -Prefer `sjskills` on `PATH`. In the tool's source repository, use -`bin/sjskills` when the command is not installed. Do not install the command or -change `PATH` unless the user asks. - -Use `sjskills` or `sjskills status` for CLI version and two-scope skill status. -The CLI comparison uses the latest stable published sjskills release, names -unavailable or absent release evidence, and links to updates without installing -them. Version equality does not establish checkout freshness. The report names the -resolved project root, offers setup guidance for a missing manifest, and reports -configuration failures without recommending replacement. It never initializes -or synchronizes. Status exits 0 even when inspection is unavailable; inspect the -findings and freshness rather than treating success as exact state. Cold checks -may take 30 seconds plus cleanup. Stale evidence remains explicitly labeled. - -Explicit reports use stdout; other successful commands retain incidental stderr -notices; fresh skill scopes without findings and CLI comparisons without updates -or problems remain silent. -`--json` keeps full skill findings in `advisories` and version evidence in -`cliAdvisory`; status also includes setup metadata under `status`. None grants -apply authority or substitutes for a reviewed plan. Use the same compatible -executable for plan and apply; older loaders may reject the new advisory field. -Use `--no-status-check` to skip discovery, inspection, fetching, and cache writes; -status then reports checks disabled, while other commands retain their primary -verification. - -## Classify the request - -- **Sync configured state:** An unqualified request to sync or reconcile with - this skill authorizes both the fixed global baseline and the current project's - committed `sjskills.toml`, when present. Honor an explicit project-only, - global-only, machine, or preservation limit. If no project manifest exists, - reconcile only the global baseline and report that the project is unconfigured; - do not initialize it or infer profiles from installed copies. -- **Explain or inspect:** Use `status` for both scopes, `profiles` for available - selections, or `plan` / `plan --global` for a full selected-scope review. These - preserve managed roots but may fetch expected content temporarily. A bare - invocation without an action defaults to status inspection. -- **Configure a project:** Create or edit `sjskills.toml` when the user asks to - adopt managed project skills or change the selection. Use selected profiles, - direct declarations, or both as described below. Preserve unrelated entries; - configuration alone does not authorize apply or global changes. -- **Reconcile a project:** Review `plan`, run `apply` only when the user asked - to install, bootstrap, reconcile, or sync the project, then run `plan` again. -- **Recover project state:** Restore only the exact quarantine identifier the - user named, and only after confirming every modeled destination is absent. -- **Global reconciliation or restore:** Read - [references/global-rollout.md](references/global-rollout.md) before global - mutation. A sync request supplies reconciliation authority; prepare and review - the required evidence yourself instead of asking the user to approve hashes. - Restore still requires a request identifying the quarantine. - -Use the ordinary Skills CLI workflow to discover source skill names or perform -requested ad hoc installs. For a project managed by `sjskills`, record external -skills in `[[direct]]` and reconcile through `sjskills`; undeclared ad hoc -installs in managed roots become quarantine candidates. Use the repository's -plugin workflow for Codex plugins. Local catalog validation and publication are -not reconciliation. - -## Project selections - -Profiles are named collections defined centrally in `agent-scripts`'s -`skill-registry.json`; `sjskills profiles` lists them. A project selects those -names, but cannot define new profiles in its manifest. It can independently add -skills from other repositories with `[[direct]]`, without changing the central -registry or publishing those skills in `agent-scripts`. - -Inspect the access policy in `sjskills profiles` before selecting a profile. -`kicpa` is public; `kicpa-private` uses the enrolled private GitHub catalog. -Select both only when requested. Authenticated profiles and direct entries use -`access = "github-authenticated"`; omission means public. Use a CLI build that -supports access metadata; older executables can reject it. Declared source -names and URLs are public metadata, never a place for credentials. - -Authenticated fetching requires Git, gh, and an existing login with repository -access. It accepts only GitHub.com shorthand or credential-free HTTPS sources. -The CLI uses controlled Git staging and the original gh configuration without -copying credentials. It refuses legacy/unsupported gh configuration before gh -can migrate it. On that error, report the requested `gh auth status` setup step; -do not start login, change accounts, or modify authentication configuration -without user authorization. Existing `GH_TOKEN`/`GITHUB_TOKEN` login also works. -A private fetch failure blocks its selected scope; do not omit that selection, -retry anonymously, or use status cache evidence to bypass it. - -For example, using a placeholder source and skill name: - -```toml -version = 1 -profiles = ["dev"] - -[[direct]] -name = "team-review" -source = "your-org/team-skills" -``` - -Use the source's actual skill name, not an alias. Repeat `[[direct]]` for more -skills, sorting entries by name and profile names alphabetically. Direct names -must be unique and cannot overlap selected profiles or the fixed global -baseline. Sources accept Git shorthand (`owner/repo[/path]`) or credential-free -HTTPS; local paths, embedded credentials, URL queries, npm specifiers, and other -schemes are unsupported. Set optional `full_depth = true` only when deeper -source discovery is needed. Direct skills use copy mode and the registry's -default targets, currently project `.agents/skills` and `.claude/skills`; -per-entry manager, mode, target, and workflow fields are unsupported. - -For a direct-only project, omit `profiles` or use `profiles = []`, and include -at least one `[[direct]]` entry. Create that manifest directly when adoption is -requested: `sjskills init` requires a profile and cannot initialize this case. -For an existing manifest, edit the requested selection in place rather than -rerunning `init`. Keep the manifest as committed project configuration; commit -only when authorized. Review `sjskills plan` after configuration, and apply -only when installation or synchronization was requested. - -## Use configured authority - -The request grants authority; the committed manifest and fixed baseline define -the desired set. Proceed with verified installs, updates (including quarantining -and replacing locally edited managed copies), provenance migration, and -recoverable quarantine of undeclared copies within that set's managed roots. -Summarize the reviewed operations before applying, without requiring another -user turn. Use `--yes` for an already-authorized apply unless the user requests -an interactive checkpoint. A flag or plan artifact does not create authority. - -Honor an explicit request to keep local edits active or preserve particular -paths: stop the affected scope if its plan would replace or quarantine them. -Ordinary sync already authorizes preserving local edits in quarantine before -replacement; no override flag or additional confirmation is needed. - -"Override and sync" does not resolve unmanaged desired copies, source mismatch, -corrupt provenance, unsafe boundaries, or failed materialization. -Stop the affected scope on those conflicts and explain -the concrete resolution needed. Do not force-adopt, manually replace roots, -change profiles, or include another machine to make a plan pass. Complete an -independent unblocked scope unless the user required an all-or-nothing result. - -## Project workflow - -1. Resolve the command and project root. If no `sjskills.toml` exists, stop - unless the user requested adoption or initialization. -2. For a new manifest with profiles, run `sjskills profiles`, use the user's - selected profiles, and run `sjskills init ...`; then add requested - direct declarations. For direct-only adoption or an existing manifest, use - the configuration procedure above. Do not infer profiles from installed - copies or overwrite an existing manifest with `init`. -3. Read the manifest and run `sjskills plan`. Summarize installs, updates, - quarantines, unchanged placements, manual or workflow-managed entries, - warnings, and blocks. - Distinguish desired-state drift or conflict from a failed command, network - request, or materialization; an operational failure is not a valid plan. -4. Review `local-modification` updates as preservation-and-replacement operations, - including when the current tree already matches the published tree but - differs from its recorded installation. Stop before apply for an unmanaged - desired copy, source mismatch, malformed or untrusted provenance, an unsafe filesystem - boundary, an unverifiable extra, failed materialization, or another conflict. -5. For authorized project reconciliation, run `sjskills apply --yes` after the - plan passes review. Honor any explicit request to stop at a preview or keep - interactive confirmation. -6. Run `sjskills plan` again. Do not claim exact state while changes or blocks - remain. Retain every reported quarantine identifier through a normal work - cycle. - -When intended content changed in a registry-backed source, reconciliation can -only consume the published remote content. Do not apply a local-only change. -If publication is requested, follow the source repository's instructions for -validation, review, commit, and publication; those mechanics are outside this -skill. Before reconciling, resolve the registry source and verify that its -pinned remote ref contains each intended skill tree. A local or feature-branch -commit is insufficient when the registry points elsewhere. - -## Restore - -Treat restore as a separate mutation. Require the user to name the quarantine -identifier, inspect its current state, and confirm that all destinations are -absent. Run: - -```text -sjskills restore -``` - -Do not move, delete, or overwrite active destinations to make restore succeed. -If content or provenance changed, preserve both sides and report the conflict. -Restore does not grant ownership to unknown or locally modified copies. An -edited backup replaces the new installation's provenance with no ownership -record. Run `sjskills plan` afterward: restored undeclared skills are removal -drift; restored edited desired skills are unmanaged conflicts. - -## Global boundary - -Global reconciliation uses the fixed, machine-independent baseline. Profiles -select project skills; do not invent a global profile, infer one from the -hostname, or synthesize Pi-specific copies. An inspection request runs only -`sjskills plan --global`; configured sync continues through verified apply. - -The `--approved-plan` and `--approved-plan-sha256` flags bind global apply to -the agent-reviewed evidence. Their names do not require a separate human -approval ceremony: create the artifact, inspect every operation and warning, -compute its digest, and use the same verified executable through apply and the -final plan. A sync request covers this work; an audit, repository validation, -or unrelated setup request does not. Restore and conflict resolution remain -separate from making the configured desired set exact. - -## Completion - -Report: - -- project or fixed-global scope and whether work was read-only or mutating; -- the selected profiles or direct manifest declarations when relevant; -- plan and final-plan results, including remaining extras, blocks, and - out-of-scope legacy state; -- published-ref evidence when remote-backed local changes were involved; -- every quarantine identifier retained or restored; and -- any mutation still awaiting explicit authority. diff --git a/plugins/sjskills-maintenance/references/sjskills/references/global-rollout.md b/plugins/sjskills-maintenance/references/sjskills/references/global-rollout.md deleted file mode 100644 index 640fc67..0000000 --- a/plugins/sjskills-maintenance/references/sjskills/references/global-rollout.md +++ /dev/null @@ -1,89 +0,0 @@ -# Global reconciliation - -Read this reference for global apply or restore. Planning alone does not need -this procedure. Use the entry point's request classification to resolve scope. - -## Authority and evidence - -A request to sync configured state or make the fixed global baseline exact -supplies authority for reconciliation on the requested machine. The agent owns -plan review and evidence preparation; do not require the user to approve a -machine/commit/hash/count packet or repeat an already-applicable request. -Inspection and repository validation remain read-only. A restore request must -identify its quarantine and does not authorize overwriting active destinations. - -Retain the machine identity, executable version or published build commit, -executable SHA-256, complete before/after JSON plans, reviewed plan SHA-256, and -reported quarantine identifiers through a normal work cycle. These are -execution evidence, not extra user decisions. - -Explicit `status` reports and automatic project/global notices are advisory. -Status JSON cannot serve as a reviewed plan. Review the global plan's operations -and evidence even when status reports no drift or notices are silent. Preserve the full JSON artifact when -hashing it: its digest includes `advisories`, although the fresh-plan semantic -comparison excludes that field. `--no-status-check` may suppress ancillary checks -without weakening the explicit plan or apply verification. - -## Prepare and review - -Use one verified installed executable for plan, apply, and final inspection. -If the command is a wrapper that rebuilds on every invocation, build one binary -from a dedicated clean checkout of the intended published commit and reuse that -file. Do not install a new command or change PATH to perform reconciliation. - -Create a complete JSON plan: - -```text - --json plan --global > plan.json -``` - -Compute SHA-256 values for the executable and `plan.json`. Review every -operation, warning, current-state fact, expected-content hash, and -materialization result, including quarantine of undeclared skills regardless of -ownership or local edits and `local-modification` updates that quarantine edited -managed desired copies before replacement. Stop on blocked placements, untrusted -provenance, unmanaged desired copies, source mismatches, unsafe filesystem boundaries, -unverifiable extras, or placement operations outside the two managed skill -roots. Honor preservation limits in the user's request even if strict sync -would otherwise quarantine those entries. - -## Apply and verify - -Before mutation, verify the executable and artifact still match the reviewed -hashes. Apply uses the complete reviewed artifact and recomputes the plan before -mutation; `--yes` only skips the redundant interactive prompt: - -```text - apply --global --yes \ - --approved-plan plan.json \ - --approved-plan-sha256 - --json plan --global > plan.after.json -``` - -Omit `--yes` when the user requested an interactive checkpoint. Do not substitute -a rebuilding wrapper, direct Skills CLI installs, `--all`, or manual root copies. - -Success requires the final plan to contain no install, update, quarantine, or -blocked operation. Retain the evidence and every reported quarantine identifier. -Built-in skills, plugin caches, vendor metadata, backups outside managed roots, -and legacy Pi copies remain outside this reconciliation. - -## Drift and recovery - -An evidence mismatch prevents mutation; it does not erase the user's task -request. Inspect fresh evidence and prepare a new reviewed artifact when the -changes remain within the configured scope and authority. Stop on repeated -drift, conflict, recovery-required state, or partial failure instead of retrying -blindly. Never weaken provenance or filesystem checks to force completion. - -For an explicitly requested restore, inspect the named quarantine and verify -that every modeled destination is absent, then run: - -```text - restore --global --yes -``` - -Use the user's interactive preference when supplied. Moving an active -replacement aside is a separate mutation needing its own applicable authority; -do not infer it from sync or restore. Run a final plan and report any resulting -drift, including restored entries outside the baseline. diff --git a/plugins/sjskills-maintenance/scripts/observation.cjs b/plugins/sjskills-maintenance/scripts/observation.cjs new file mode 100644 index 0000000..302484c --- /dev/null +++ b/plugins/sjskills-maintenance/scripts/observation.cjs @@ -0,0 +1,144 @@ +"use strict"; +const fs = require("node:fs"), path = require("node:path"), os = require("node:os"); +const { randomUUID } = require("node:crypto"); +const { nativeExecutable, runProcess } = require("./process.cjs"); +const { DAY } = require("./status.cjs"); +const SOURCE = "https://github.com/sjunepark/agent-scripts.git"; +const RETRY = 15 * 60 * 1000; +const unavailable = () => ({ plugin: true, findings: [], incomplete: ["plugin verification"] }); +const validVersion = (v) => typeof v === "string" && /^\d+\.\d+\.\d+\+codex\.[A-Za-z0-9.-]{1,100}$/.test(v); + +function safeDirectory(directory) { + if (typeof directory !== "string" || !path.isAbsolute(directory)) throw new Error("unsafe directory"); + for (let current = directory; ; current = path.dirname(current)) { + try { + const stat = fs.lstatSync(current); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe directory"); + } catch (error) { if (error.code !== "ENOENT") throw error; } + if (current === path.dirname(current)) break; + } +} +function readBounded(file, limit) { + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > limit) throw new Error("unsafe file"); + const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); + try { + const opened = fs.fstatSync(fd); + if (opened.dev !== stat.dev || opened.ino !== stat.ino) throw new Error("file changed"); + const bytes = Buffer.alloc(limit + 1); + const size = fs.readSync(fd, bytes, 0, bytes.length, 0); + if (size > limit) throw new Error("oversized file"); + return bytes.subarray(0, size).toString("utf8"); + } finally { fs.closeSync(fd); } +} +function mainRef(config) { + // Codex validates the complete config during list. Recognize only its ordinary + // serialized marketplace table; unfamiliar forms fail closed. + if (config.includes('"""') || config.includes("'''")) return false; + let active = false, count = 0; + const fields = {}; + for (const line of config.split(/\r?\n/)) { + if (/^\s*\[/.test(line)) { + active = /^\s*\[marketplaces\.personal\]\s*(?:#.*)?$/.test(line); + if (active) count++; + } else if (active) { + const match = /^\s*(source_type|source|ref)\s*=\s*(?:"([^"\\]*)"|'([^']*)')\s*(?:#.*)?$/.exec(line); + if (match) { + if (Object.hasOwn(fields, match[1])) return false; + fields[match[1]] = match[2] ?? match[3]; + } + } + } + return count === 1 && fields.source_type === "git" && fields.source === SOURCE && fields.ref === "main"; +} +async function fetchJSON(url, signal) { + const response = await fetch(url, { signal, redirect: "error", headers: { "User-Agent": "sjskills-startup-check", Accept: "application/json" } }); + if (!response.ok || !response.body) throw new Error("metadata unavailable"); + const reader = response.body.getReader(); + const chunks = []; let size = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > 128 * 1024) throw new Error("metadata too large"); + chunks.push(Buffer.from(value)); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + } finally { await reader.cancel(); } +} +async function provenance({ root, env, signal, run = runProcess, resolve = nativeExecutable }) { + const codexHome = env.CODEX_HOME || path.join(os.homedir(), ".codex"); + if (!path.isAbsolute(codexHome)) throw new Error("configuration unavailable"); + const listing = await run(resolve("codex", env), ["plugin", "list", "--marketplace", "personal", "--available", "--json"], + { cwd: codexHome, env, signal }); + if (listing.code !== 0) throw new Error("configuration unavailable"); + const installed = JSON.parse(listing.stdout).installed; + if (!Array.isArray(installed)) throw new Error("configuration unavailable"); + const matches = installed.filter((p) => p.pluginId === "sjskills-maintenance@personal"); + const entry = matches[0]; + const manifest = JSON.parse(readBounded(path.join(root, ".codex-plugin/plugin.json"), 16384)); + if (matches.length !== 1 || !entry.enabled || !entry.installed || + entry.marketplaceSource?.sourceType !== "git" || entry.marketplaceSource.source !== SOURCE || + manifest.name !== "sjskills-maintenance" || !validVersion(manifest.version) || entry.version !== manifest.version || + !mainRef(readBounded(path.join(codexHome, "config.toml"), 1024 * 1024))) throw new Error("configuration unavailable"); + return manifest.version; +} +async function observePlugin(options) { + const { dataRoot, signal, now = Date.now(), get = fetchJSON } = options; + let version; + try { version = await (options.provenance || provenance)(options); } + catch { return unavailable(); } + const key = `${SOURCE}@main:${version}`; + const result = (published) => ({ plugin: true, findings: published === version ? [] : ["plugin update available"], incomplete: [] }); + let file, lock, owned = false, writable = false; + try { + safeDirectory(dataRoot); + file = path.join(dataRoot, "plugin-observation.json"); + let cached; + try { cached = JSON.parse(readBounded(file, 4096)); } + catch (error) { if (error.code !== "ENOENT" && !(error instanceof SyntaxError)) return unavailable(); } + if (cached?.key === key && Number.isSafeInteger(cached.checkedAt) && now >= cached.checkedAt) { + const age = now - cached.checkedAt; + if (cached.outcome === "success" && validVersion(cached.version) && /^[a-f0-9]{40}$/.test(cached.commit) && age < DAY) return result(cached.version); + if (cached.outcome === "failure" && age < RETRY) return unavailable(); + } + lock = path.join(dataRoot, "plugin-observation.lock"); + try { + fs.mkdirSync(dataRoot, { recursive: true, mode: 0o700 }); + safeDirectory(dataRoot); + fs.mkdirSync(lock, { mode: 0o700 }); + owned = true; writable = true; + } catch (error) { + if (error.code === "EEXIST") return unavailable(); + if (!["EACCES", "EPERM", "EROFS"].includes(error.code)) return unavailable(); + // A disposable cache write failure must not discard fresh read results. + } + let observation, answer; + try { + const commit = await get("https://api.github.com/repos/sjunepark/agent-scripts/commits/main", signal); + if (!/^[a-f0-9]{40}$/.test(commit?.sha)) throw new Error("invalid commit"); + const published = await get(`https://raw.githubusercontent.com/sjunepark/agent-scripts/${commit.sha}/plugins/sjskills-maintenance/.codex-plugin/plugin.json`, signal); + if (published?.name !== "sjskills-maintenance" || !validVersion(published.version)) throw new Error("invalid manifest"); + observation = { key, checkedAt: now, outcome: "success", commit: commit.sha, version: published.version }; + answer = result(published.version); + } catch { + observation = { key, checkedAt: now, outcome: "failure" }; + answer = unavailable(); + } + if (writable) { + const temporary = path.join(dataRoot, `.observation-${randomUUID()}.tmp`); + let created = false; + try { + safeDirectory(dataRoot); + const fd = fs.openSync(temporary, "wx", 0o600); created = true; + try { fs.writeFileSync(fd, JSON.stringify(observation) + "\n"); } finally { fs.closeSync(fd); } + fs.renameSync(temporary, file); + } catch { /* Cache persistence cannot invalidate a completed read. */ } + finally { if (created && fs.existsSync(temporary)) fs.unlinkSync(temporary); } + } + return answer; + } catch { return unavailable(); } + finally { if (owned) { try { fs.rmdirSync(lock); } catch { /* Never remove unfamiliar contents. */ } } } +} +module.exports = { observePlugin, provenance, mainRef, fetchJSON, readBounded, safeDirectory, RETRY }; diff --git a/plugins/sjskills-maintenance/scripts/process.cjs b/plugins/sjskills-maintenance/scripts/process.cjs new file mode 100644 index 0000000..30784b0 --- /dev/null +++ b/plugins/sjskills-maintenance/scripts/process.cjs @@ -0,0 +1,79 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); +const LIMIT = 1024 * 1024; + +function nativeExecutable(name, env = process.env) { + const filename = process.platform === "win32" ? `${name}.exe` : name; + for (const directory of (env.PATH || env.Path || "").split(path.delimiter)) { + if (!path.isAbsolute(directory)) continue; + const candidate = path.join(directory, filename); + let fd; + try { + const resolved = fs.realpathSync(candidate); + fd = fs.openSync(resolved, "r"); + if (!fs.fstatSync(fd).isFile()) continue; + const bytes = Buffer.alloc(4); + if (fs.readSync(fd, bytes, 0, 4, 0) !== 4) continue; + // Reject source-building and package-manager wrappers without executing them. + const magic = bytes.toString("hex"); + if (process.platform === "win32" ? magic.startsWith("4d5a") : + ["cffaedfe", "cefaedfe", "feedfacf", "feedface", "cafebabe", "bebafeca", "7f454c46"].includes(magic)) { + fs.accessSync(resolved, fs.constants.X_OK); + return resolved; + } + } catch { /* An absent or unreadable PATH entry is not a runnable prerequisite. */ } + finally { if (fd !== undefined) fs.closeSync(fd); } + // The first existing command must not be bypassed to run a different install. + if (fs.existsSync(candidate)) break; + } + throw new Error("native executable unavailable"); +} + +function runProcess(executable, args, { cwd, env = process.env, signal, limit = LIMIT }) { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(new Error("cancelled")); + const windows = process.platform === "win32"; + const command = windows ? path.join(env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe") : executable; + const arguments_ = windows ? ["-NoProfile", "-NonInteractive", "-File", path.join(__dirname, "windows-process.ps1")] : args; + const child = spawn(command, arguments_, { + cwd, env: windows ? { ...env, SJSKILLS_CHECK_PROCESS: JSON.stringify({ executable, arguments: args }) } : env, + windowsHide: true, detached: !windows, + stdio: ["ignore", "pipe", "pipe"], + }); + let size = 0, stdout = [], failure, stopping; + const stop = () => { + failure ||= new Error("cancelled or output limit exceeded"); + if (stopping || !child.pid) return; + stopping = new Promise((done) => { + if (process.platform !== "win32") { + try { process.kill(-child.pid, "SIGKILL"); } catch (error) { if (error.code !== "ESRCH") failure = error; } + done(); + } else { + const killer = spawn(path.join(env.SystemRoot || "C:\\Windows", "System32", "taskkill.exe"), + ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }); + const timer = setTimeout(() => { killer.kill(); child.kill(); }, 2000); + killer.once("error", () => { clearTimeout(timer); child.kill(); done(); }); + killer.once("close", () => { clearTimeout(timer); done(); }); + } + }); + }; + signal?.addEventListener("abort", stop, { once: true }); + for (const stream of [child.stdout, child.stderr]) stream.on("data", (chunk) => { + size += chunk.length; + if (size > limit) stop(); + else if (stream === child.stdout && !failure) stdout.push(chunk); + }); + child.once("error", (error) => { failure = error; }); + child.once("close", async (code) => { + signal?.removeEventListener("abort", stop); + await stopping; + if (failure) reject(failure); + else resolve({ code, stdout: Buffer.concat(stdout).toString("utf8") }); + }); + }); +} + +module.exports = { nativeExecutable, runProcess, LIMIT }; diff --git a/plugins/sjskills-maintenance/scripts/session-start.cjs b/plugins/sjskills-maintenance/scripts/session-start.cjs index ba4a79d..2a5548f 100644 --- a/plugins/sjskills-maintenance/scripts/session-start.cjs +++ b/plugins/sjskills-maintenance/scripts/session-start.cjs @@ -1,99 +1,66 @@ #!/usr/bin/env node "use strict"; - -const fs = require("node:fs"); const path = require("node:path"); -const { createHash } = require("node:crypto"); -const { updateStatus, checkDirectory } = require("./update-check.cjs"); +const { nativeExecutable, runProcess, LIMIT } = require("./process.cjs"); +const { classifyStatus, report } = require("./status.cjs"); +const { observePlugin } = require("./observation.cjs"); -function snapshotResources(root, dataRoot) { - const paths = ["scripts/update-check.cjs", "references/sjskills/SKILL.md", "references/sjskills/references/global-rollout.md"]; - const files = paths.map((relative) => ({ relative, bytes: fs.readFileSync(path.join(root, relative)) })); - const hash = createHash("sha256"); - for (const { relative, bytes } of files) hash.update(relative).update("\0").update(bytes).update("\0"); - const snapshot = path.join(dataRoot, "workflows", hash.digest("hex")); - // Codex deletes old plugin caches on reinstall. Content-addressed references - // in PLUGIN_DATA remain valid for active and suspended sessions. - for (const { relative, bytes } of files) { - const destination = path.join(snapshot, relative); - checkDirectory(path.dirname(destination)); +async function check(input, options = {}) { + if (input?.hook_event_name !== "SessionStart" || !["startup", "resume"].includes(input.source)) return null; + const platform = options.platform || process.platform, arch = options.arch || process.arch; + if (!((platform === "darwin" && ["x64", "arm64"].includes(arch)) || (platform === "win32" && arch === "x64"))) { + return { systemMessage: "sjskills: check skipped (unsupported release target)." }; + } + if (typeof input.cwd !== "string" || !path.isAbsolute(input.cwd)) return report([{ findings: [], incomplete: ["session directory"] }]); + const env = options.env || process.env, signal = options.signal; + const run = options.run || runProcess, resolve = options.resolve || nativeExecutable; + const cli = async () => { try { - const stat = fs.lstatSync(destination); - if (!stat.isFile() || stat.isSymbolicLink() || !fs.readFileSync(destination).equals(bytes)) throw new Error("unsafe or modified workflow snapshot"); - continue; - } catch (error) { if (error.code !== "ENOENT") throw error; } - fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 }); - const temporary = `${destination}.${process.pid}.tmp`; - let created = false; + const result = await run(resolve("sjskills", env), ["--json", "status"], { cwd: input.cwd, env, signal }); + const status = classifyStatus(JSON.parse(result.stdout), options.now); + if (result.code !== 0) status.incomplete.push("status process"); + return status; + } catch { return { findings: [], incomplete: ["native CLI status"] }; } + }; + const plugin = async () => { try { - const fd = fs.openSync(temporary, "wx", 0o600); - created = true; - try { fs.writeFileSync(fd, bytes); } finally { fs.closeSync(fd); } - fs.renameSync(temporary, destination); - } finally { - if (created) { - try { fs.unlinkSync(temporary); } catch (error) { if (error.code !== "ENOENT") throw error; } - } - } - } - return snapshot; -} - -function sessionContext(input, platform = process.platform, arch = process.arch, - root = path.resolve(__dirname, ".."), dataRoot = process.env.PLUGIN_DATA || process.env.CLAUDE_PLUGIN_DATA) { - if (input.hook_event_name !== "SessionStart" || !["startup", "resume"].includes(input.source)) return null; - const supported = (platform === "darwin" && ["x64", "arm64"].includes(arch)) || - (platform === "win32" && arch === "x64"); - if (!supported) { - return { systemMessage: `sjskills startup maintenance skipped: ${platform}/${arch} is not a supported sjskills release target.` }; - } - const instructions = fs.readFileSync(path.join(root, "references", "maintenance.md"), "utf8"); - let pluginUpdate; - try { pluginUpdate = updateStatus(dataRoot); } catch (error) { - pluginUpdate = { due: false, error: error.message }; - } - let resourcesRoot = root; - if (!pluginUpdate.error) { - try { resourcesRoot = snapshotResources(root, dataRoot); } catch (error) { - pluginUpdate = { due: false, error: `workflow snapshot unavailable: ${error.message}` }; - } - } - // Paths are data, not shell fragments. The agent uses its ordinary file tools. - const resources = JSON.stringify({ - sessionDirectory: input.cwd, - syncSkill: path.join(resourcesRoot, "references", "sjskills", "SKILL.md"), - pluginRoot: root, - pluginData: dataRoot, - updateHelper: path.join(resourcesRoot, "scripts", "update-check.cjs"), - pluginUpdate, - }); - return { - hookSpecificOutput: { - hookEventName: "SessionStart", - additionalContext: `${instructions}\n\nLocal resource paths (JSON data):\n${resources}`, - }, + return await (options.observe || observePlugin)({ root: path.resolve(__dirname, ".."), + dataRoot: env.PLUGIN_DATA || env.CLAUDE_PLUGIN_DATA, env, signal, now: options.now }); + } catch { return { plugin: true, findings: [], incomplete: ["plugin verification"] }; } }; -} - -async function run() { - let input = ""; - for await (const chunk of process.stdin) { - input += chunk; - if (Buffer.byteLength(input) > 1024 * 1024) throw new Error("hook input exceeds 1 MiB"); - } - const payload = JSON.parse(input); - if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("invalid hook input"); - if (typeof payload.cwd !== "string" || !path.isAbsolute(payload.cwd)) throw new Error("missing absolute session directory"); - const result = sessionContext(payload); - if (result) process.stdout.write(`${JSON.stringify(result)}\n`); + return report(await Promise.all([cli(), plugin()])); } async function main() { - try { await run(); } catch (error) { - // Maintenance must not prevent the user's session from starting. - process.stdout.write(`${JSON.stringify({ systemMessage: `sjskills startup maintenance unavailable: ${error.message}` })}\n`); + const controller = new AbortController(); + const cancel = () => controller.abort(); + const timer = setTimeout(cancel, 35000); + process.once("SIGINT", cancel); process.once("SIGTERM", cancel); + let output; + try { + const chunks = []; let size = 0; + const input = await new Promise((resolve, reject) => { + const abort = () => { process.stdin.destroy(); reject(new Error("input cancelled")); }; + controller.signal.addEventListener("abort", abort, { once: true }); + process.stdin.on("data", (chunk) => { + size += chunk.length; + if (size > LIMIT) abort(); else chunks.push(chunk); + }); + process.stdin.once("error", reject); + process.stdin.once("end", () => { + controller.signal.removeEventListener("abort", abort); + resolve(Buffer.concat(chunks).toString("utf8")); + }); + }); + const payload = JSON.parse(input); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("invalid input"); + output = await check(payload, { signal: controller.signal }); + } catch { output = report([{ findings: [], incomplete: ["hook input or deadline"] }]); } + finally { + clearTimeout(timer); + process.removeListener("SIGINT", cancel); process.removeListener("SIGTERM", cancel); } + if (output) process.stdout.write(JSON.stringify(output) + "\n"); } - if (require.main === module) main(); -module.exports = { sessionContext, snapshotResources, main }; +module.exports = { check, main }; diff --git a/plugins/sjskills-maintenance/scripts/status.cjs b/plugins/sjskills-maintenance/scripts/status.cjs new file mode 100644 index 0000000..5bd087f --- /dev/null +++ b/plugins/sjskills-maintenance/scripts/status.cjs @@ -0,0 +1,53 @@ +"use strict"; + +const DAY = 24 * 60 * 60 * 1000; +const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value); +const stable = (value) => typeof value === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value); + +function fresh(value, now) { + const observed = Date.parse(value?.observedAt); + return value?.freshness === "fresh" && typeof value.cached === "boolean" && + Number.isFinite(observed) && observed <= now && now - observed < DAY && !value.error; +} + +function classifyStatus(value, now = Date.now()) { + const findings = [], incomplete = []; + if (!object(value) || value.operation !== "status" || value.result !== "success" || value.error) { + incomplete.push("status response"); + } + const cli = value?.cliAdvisory; + if (object(cli) && cli.comparison === "update") findings.push("CLI update available"); + if (!object(cli) || !["equal", "ahead", "update"].includes(cli.comparison) || + !stable(cli.runningVersion) || !stable(cli.availableVersion) || !fresh(cli, now)) incomplete.push("CLI verification"); + + const configuration = value?.status?.projectConfiguration; + if (!["configured", "not-configured", "unavailable"].includes(configuration)) incomplete.push("project configuration"); + else if (configuration === "unavailable") incomplete.push("project configuration"); + const required = configuration === "not-configured" ? ["global"] : ["global", "project"]; + const advisories = Array.isArray(value?.advisories) ? value.advisories : []; + if (advisories.some((a) => !object(a) || !required.includes(a.scope))) incomplete.push("scope response"); + for (const scope of required) { + const matches = advisories.filter((a) => a?.scope === scope); + const advisory = matches[0]; + const validFinding = (f) => + object(f) && ["update", "missing", "extra", "conflict"].includes(f.category) && + typeof f.reason === "string" && f.reason.length > 0 && typeof f.skill === "string"; + const validFindings = Array.isArray(advisory?.findings) && advisory.findings.every(validFinding); + if (Array.isArray(advisory?.findings) && advisory.findings.some(validFinding)) findings.push(`${scope} skills differ`); + if (matches.length !== 1 || !validFindings || !fresh(advisory, now)) incomplete.push(`${scope} verification`); + } + return { findings, incomplete }; +} + +function report(results) { + const findings = [...new Set(results.flatMap((r) => r.findings))]; + const incomplete = [...new Set(results.flatMap((r) => r.incomplete))]; + if (!findings.length && !incomplete.length) return null; + const parts = [...findings]; + if (incomplete.length) parts.push(`check incomplete (${incomplete.join(", ")})`); + const next = results.some((r) => r.plugin && (r.findings.length || r.incomplete.length)) + ? "Review sjskills status and the plugin configuration manually." : "Run sjskills status manually."; + return { systemMessage: `sjskills: ${parts.join("; ")}. ${next}` }; +} + +module.exports = { classifyStatus, report, DAY }; diff --git a/plugins/sjskills-maintenance/scripts/update-check.cjs b/plugins/sjskills-maintenance/scripts/update-check.cjs deleted file mode 100644 index 309ea8a..0000000 --- a/plugins/sjskills-maintenance/scripts/update-check.cjs +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const fs = require("node:fs"); -const path = require("node:path"); -const DAY = 24 * 60 * 60 * 1000; -const RETRY = 15 * 60 * 1000; - -function checkDirectory(dataRoot) { - if (typeof dataRoot !== "string" || !path.isAbsolute(dataRoot)) throw new Error("missing absolute plugin data directory"); - for (let directory = dataRoot; ; directory = path.dirname(directory)) { - try { - const stat = fs.lstatSync(directory); - if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe plugin data directory"); - } catch (error) { if (error.code !== "ENOENT") throw error; } - if (directory === path.dirname(directory)) break; - } -} - -function statePath(dataRoot) { - checkDirectory(dataRoot); - const file = path.join(dataRoot, "update-check.json"); - try { - const stat = fs.lstatSync(file); - if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) throw new Error("unsafe plugin update state"); - } catch (error) { if (error.code !== "ENOENT") throw error; } - return file; -} - -function updateStatus(dataRoot, now = Date.now()) { - const file = statePath(dataRoot); - let state; - try { state = JSON.parse(fs.readFileSync(file, "utf8")); } catch (error) { - if (error.code === "ENOENT" || error instanceof SyntaxError) return { due: true }; - throw error; - } - if (!state || !Number.isSafeInteger(state.checkedAt) || !["success", "failure"].includes(state.outcome)) return { due: true }; - const age = now - state.checkedAt; - const interval = state.outcome === "success" ? DAY : RETRY; - return { due: age < 0 || age >= interval, lastOutcome: state.outcome, checkedAt: state.checkedAt }; -} - -function recordCheck(dataRoot, outcome, now = Date.now()) { - if (!["success", "failure"].includes(outcome)) throw new Error("expected success or failure"); - const file = statePath(dataRoot); - fs.mkdirSync(dataRoot, { recursive: true, mode: 0o700 }); - const temporary = `${file}.${process.pid}.tmp`; - let created = false; - try { - const fd = fs.openSync(temporary, "wx", 0o600); - created = true; - try { fs.writeFileSync(fd, JSON.stringify({ checkedAt: now, outcome }) + "\n"); } finally { fs.closeSync(fd); } - fs.renameSync(temporary, file); - } finally { - if (created) { - try { fs.unlinkSync(temporary); } catch (error) { if (error.code !== "ENOENT") throw error; } - } - } -} - -if (require.main === module) { - try { - const [action, dataRoot, ...extra] = process.argv.slice(2); - if (extra.length || !["status", "success", "failure"].includes(action)) throw new Error("usage: update-check.cjs status|success|failure PLUGIN_DATA"); - if (action === "status") console.log(JSON.stringify(updateStatus(dataRoot))); - else recordCheck(dataRoot, action); - } catch (error) { - console.error(`sjskills plugin update check: ${error.message}`); - process.exitCode = 1; - } -} - -module.exports = { updateStatus, recordCheck, checkDirectory }; diff --git a/plugins/sjskills-maintenance/scripts/windows-process.ps1 b/plugins/sjskills-maintenance/scripts/windows-process.ps1 new file mode 100644 index 0000000..84c5b88 --- /dev/null +++ b/plugins/sjskills-maintenance/scripts/windows-process.ps1 @@ -0,0 +1,48 @@ +$ErrorActionPreference = 'Stop' +# Assign the supervisor before launching anything. Descendants inherit the job, +# including when the native parent exits before its children close their pipes. +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +public static class StartupProcessJob { + [StructLayout(LayoutKind.Sequential)] struct Basic { + public long ProcessTime, JobTime; + public uint Flags; + public UIntPtr MinimumWorkingSet, MaximumWorkingSet; + public uint ActiveProcesses; + public UIntPtr Affinity; + public uint Priority, Scheduling; + } + [StructLayout(LayoutKind.Sequential)] struct Extended { + public Basic Basic; + public ulong ReadOperations, WriteOperations, OtherOperations; + public ulong ReadBytes, WriteBytes, OtherBytes; + public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory; + } + [DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr CreateJobObject(IntPtr attributes, string name); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int kind, ref Extended info, uint size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess(); + // Deliberately retained until process exit. The OS closes the final handle + // even when Node forcibly terminates this supervisor. + static IntPtr job; + public static void OwnTree() { + job = CreateJobObject(IntPtr.Zero, null); + if (job == IntPtr.Zero) throw new Win32Exception(); + Extended info = new Extended(); + info.Basic.Flags = 0x2000; // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if (!SetInformationJobObject(job, 9, ref info, (uint)Marshal.SizeOf(info)) || + !AssignProcessToJobObject(job, GetCurrentProcess())) throw new Win32Exception(); + } +} +'@ +[StartupProcessJob]::OwnTree() +$invocation = $env:SJSKILLS_CHECK_PROCESS | ConvertFrom-Json +Remove-Item Env:SJSKILLS_CHECK_PROCESS +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +$OutputEncoding = [Console]::OutputEncoding +$executable = $invocation.executable +$arguments = @($invocation.arguments) +& $executable @arguments +exit $LASTEXITCODE diff --git a/scripts/fixtures/startup-cli.go b/scripts/fixtures/startup-cli.go new file mode 100644 index 0000000..460d56c --- /dev/null +++ b/scripts/fixtures/startup-cli.go @@ -0,0 +1,44 @@ +// Native process fixture for the startup adapter; never used by the plugin. +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strconv" +) + +func main() { + if len(os.Args) == 4 && os.Args[1] == "--orphan" { + child := exec.Command(os.Args[2], "-e", "setInterval(()=>{},1000)") + child.Stdout, child.Stderr = os.Stdout, os.Stderr + if err := child.Start(); err != nil { + panic(err) + } + if err := os.WriteFile(os.Args[3], []byte(strconv.Itoa(child.Process.Pid)), 0600); err != nil { + panic(err) + } + child.Process.Release() + return + } + if file := os.Getenv("HOOK_FIXTURE_LOG"); file != "" { + cwd, _ := os.Getwd() + entry, _ := json.Marshal(map[string]any{"args": os.Args[1:], "cwd": cwd}) + f, err := os.OpenFile(file, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + panic(err) + } + fmt.Fprintln(f, string(entry)) + f.Close() + } + if len(os.Args) == 3 && os.Args[1] == "--json" && os.Args[2] == "status" { + fmt.Println(os.Getenv("HOOK_FIXTURE_STATUS")) + return + } + if len(os.Args) == 7 && os.Args[1] == "plugin" && os.Args[2] == "list" { + fmt.Println(os.Getenv("HOOK_FIXTURE_PLUGINS")) + return + } + os.Exit(17) +} diff --git a/scripts/sjskills-hook.test.js b/scripts/sjskills-hook.test.js index 63ab788..6156e8e 100644 --- a/scripts/sjskills-hook.test.js +++ b/scripts/sjskills-hook.test.js @@ -1,180 +1,164 @@ "use strict"; - -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); +const assert = require("node:assert/strict"), fs = require("node:fs"), os = require("node:os"), path = require("node:path"); const { spawnSync } = require("node:child_process"); -const { test } = require("node:test"); -const { sessionContext, snapshotResources } = require("../plugins/sjskills-maintenance/scripts/session-start.cjs"); -const { updateStatus, recordCheck } = require("../plugins/sjskills-maintenance/scripts/update-check.cjs"); - -const repo = path.resolve(__dirname, ".."); -const plugin = path.join(repo, "plugins/sjskills-maintenance"); -const runtime = path.join(plugin, "scripts/session-start.cjs"); -const event = { hook_event_name: "SessionStart", source: "startup", cwd: repo }; - -function copyFixture(from, to) { - // Node 22.17.1's native recursive copy crashes on these Unicode Windows - // paths. Keep the fixture copy in ordinary reads/writes so quoting is tested. - fs.mkdirSync(to, { recursive: true }); - for (const entry of fs.readdirSync(from, { withFileTypes: true })) { - const source = path.join(from, entry.name), destination = path.join(to, entry.name); - if (entry.isDirectory()) copyFixture(source, destination); - else fs.writeFileSync(destination, fs.readFileSync(source)); - } +const { test, before, after } = require("node:test"); +const { check } = require("../plugins/sjskills-maintenance/scripts/session-start.cjs"); +const { classifyStatus, report, DAY } = require("../plugins/sjskills-maintenance/scripts/status.cjs"); +const { observePlugin, provenance, mainRef, fetchJSON, RETRY } = require("../plugins/sjskills-maintenance/scripts/observation.cjs"); +const { nativeExecutable, runProcess } = require("../plugins/sjskills-maintenance/scripts/process.cjs"); +const repo = path.resolve(__dirname,".."), plugin = path.join(repo,"plugins/sjskills-maintenance"); +const version = JSON.parse(fs.readFileSync(path.join(plugin,".codex-plugin/plugin.json"))).version; +const now = Date.now(), commit = "a".repeat(40); +const config = '[marketplaces.personal]\nsource_type = "git"\nsource = "https://github.com/sjunepark/agent-scripts.git"\nref = "main"\n'; +const event = { hook_event_name:"SessionStart",source:"startup",cwd:repo }; +const healthyPlugin = {plugin:true,findings:[],incomplete:[]}; +function healthy(configured=true) { + return { operation:"status",result:"success",error:null,status:{projectConfiguration:configured?"configured":"not-configured",...(configured?{projectRoot:repo}:{})}, + cliAdvisory:{comparison:"equal",runningVersion:"1.3.0",availableVersion:"1.3.0",freshness:"fresh",cached:false,observedAt:new Date(now).toISOString()}, + advisories:(configured?["global","project"]:["global"]).map(scope=>({scope,freshness:"fresh",cached:false,observedAt:new Date(now).toISOString(),findings:[]})) }; } - -function fixture(t) { - const temporaryRoot = fs.realpathSync(os.tmpdir()); - const root = fs.mkdtempSync(path.join(temporaryRoot, "sjskills-plugin-")); - t.after(() => { - const relative = path.relative(temporaryRoot, fs.realpathSync(root)); - assert.ok(relative && !relative.startsWith("..") && !path.isAbsolute(relative)); - fs.rmSync(root, { recursive: true, force: true }); - }); - const installed = path.join(root, "user's 한글 $workspace & tools", "plugin"); - const data = path.join(root, "plugin-data"); - copyFixture(plugin, installed); - return { root, installed, data }; +function temp(t) { + const base=fs.realpathSync(os.tmpdir()), root=fs.mkdtempSync(path.join(base,"sjskills-check-")); + t.after(()=>{const relative=path.relative(base,fs.realpathSync(root));assert.ok(relative&&!relative.startsWith("..")&&!path.isAbsolute(relative));fs.rmSync(root,{recursive:true,force:true});}); + return root; } +function copy(from,to) {fs.mkdirSync(to,{recursive:true});for(const e of fs.readdirSync(from,{withFileTypes:true})){const a=path.join(from,e.name),b=path.join(to,e.name);if(e.isDirectory())copy(a,b);else fs.writeFileSync(b,fs.readFileSync(a));}} +function observation(root, overrides={}) {return {dataRoot:path.join(root,"data"),now,provenance:async()=>version,get:async url=>url.includes("/commits/")?{sha:commit}:{name:"sjskills-maintenance",version},...overrides};} +function cached(root, published=version) {fs.mkdirSync(root,{recursive:true});fs.writeFileSync(path.join(root,"plugin-observation.json"),JSON.stringify({key:`https://github.com/sjunepark/agent-scripts.git@main:${version}`,checkedAt:now,outcome:"success",version:published,commit}));} -test("plugin bundles the exact sync workflow outside skill discovery", () => { - const manifest = JSON.parse(fs.readFileSync(path.join(plugin, ".codex-plugin/plugin.json"))); - assert.equal(manifest.name, "sjskills-maintenance"); - assert.equal(manifest.skills, undefined); - assert.equal(fs.existsSync(path.join(plugin, "skills")), false); - for (const relative of ["SKILL.md", "references/global-rollout.md"]) { - assert.deepEqual(fs.readFileSync(path.join(plugin, "references/sjskills", relative)), - fs.readFileSync(path.join(repo, "skills/sjskills", relative))); - } - const result = spawnSync(process.execPath, [path.join(repo, "scripts/sync-sjskills-plugin"), "--check"], { encoding: "utf8", windowsHide: true }); - assert.equal(result.status, 0, result.stderr); +test("fresh complete status is silent, with an inapplicable project and ahead CLI allowed",()=>{ + for(const configured of [true,false]) for(const comparison of ["equal","ahead"]){const v=healthy(configured);v.cliAdvisory.comparison=comparison;assert.equal(report([classifyStatus(v,now)]),null);} }); - -test("real plugin command handles shell-sensitive installation paths", (t) => { - const f = fixture(t); - const hooks = JSON.parse(fs.readFileSync(path.join(f.installed, "hooks/hooks.json"))); - const group = hooks.hooks.SessionStart[0]; - assert.equal(group.matcher, "^(startup|resume)$"); - const command = process.platform === "win32" ? group.hooks[0].commandWindows : group.hooks[0].command; - const shells = process.platform === "win32" - ? [["powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command]], - [process.env.COMSPEC || "cmd.exe", ["/d", "/s", "/c", command]]] - : [["/bin/sh", ["-c", command]]]; - for (const [shell, args] of shells) { - const result = spawnSync(shell, args, { - input: JSON.stringify({ ...event, cwd: f.root }), encoding: "utf8", cwd: f.root, windowsHide: true, - env: { ...process.env, PLUGIN_ROOT: f.installed, PLUGIN_DATA: f.data }, - }); - assert.equal(result.status, 0, `${shell}: ${result.stderr}`); - const output = JSON.parse(result.stdout); - if (process.platform === "linux") assert.match(output.systemMessage, /not a supported/); - else { - assert.equal(output.hookSpecificOutput.hookEventName, "SessionStart"); - const context = output.hookSpecificOutput.additionalContext; - const resources = JSON.parse(context.slice(context.lastIndexOf("\n") + 1)); - assert.equal(resources.sessionDirectory, f.root); - assert.equal(resources.pluginData, f.data); - assert.equal(resources.pluginUpdate.due, true); - assert.equal(fs.existsSync(resources.syncSkill), true); - assert.equal(fs.existsSync(resources.updateHelper), true); - } - } - assert.equal(fs.existsSync(path.join(f.data, "update-check.json")), false); +test("stale, absent, unknown, disabled and malformed evidence cannot be healthy",()=>{ + const mutations=[v=>delete v.cliAdvisory,v=>v.cliAdvisory.comparison="uncomparable",v=>v.cliAdvisory.comparison="future",v=>v.cliAdvisory.runningVersion="dev",v=>v.cliAdvisory.observedAt=new Date(now+1).toISOString(),v=>v.advisories.pop(),v=>v.advisories.push(v.advisories[0]),v=>v.status.projectConfiguration="skipped",v=>v.status.projectConfiguration="unknown",v=>v.advisories[0].freshness="stale",v=>v.advisories[0].findings=null,v=>v.advisories[0].observedAt=new Date(now-DAY).toISOString(),v=>v.advisories[0].error="private auth secret",v=>v.result="unavailable"]; + for(const mutate of mutations){const value=healthy();mutate(value);const r=report([classifyStatus(value,now)]);assert.match(r.systemMessage,/incomplete/);assert.doesNotMatch(r.systemMessage,/secret/);assert.equal(r.hookSpecificOutput,undefined);} }); - -test("session resources survive plugin-cache removal and reject modified snapshots", (t) => { - const f = fixture(t); - const snapshot = snapshotResources(f.installed, f.data); - assert.equal(snapshotResources(f.installed, f.data), snapshot); - const skill = fs.readFileSync(path.join(snapshot, "references/sjskills/SKILL.md")); - const relative = path.relative(f.root, f.installed); - assert.ok(relative && !relative.startsWith("..") && !path.isAbsolute(relative)); - fs.rmSync(f.installed, { recursive: true, force: true }); - const result = spawnSync(process.execPath, [path.join(snapshot, "scripts/update-check.cjs"), "success", f.data], { encoding: "utf8", windowsHide: true }); - assert.equal(result.status, 0, result.stderr); - assert.equal(updateStatus(f.data).due, false); - assert.deepEqual(fs.readFileSync(path.join(snapshot, "references/sjskills/SKILL.md")), skill); - copyFixture(plugin, f.installed); - fs.appendFileSync(path.join(snapshot, "references/sjskills/SKILL.md"), "modified"); - assert.throws(() => snapshotResources(f.installed, f.data), /modified workflow snapshot/); +test("known findings survive partial failure, additive fields and repeated checks",()=>{ + const value=healthy();value.newField=true;value.cliAdvisory.comparison="update"; + value.advisories[0].findings=[{category:"missing",skill:"sensitive name",reason:"missing"}];value.advisories[1].freshness="unavailable"; + const one=report([classifyStatus(value,now),{plugin:true,findings:["plugin update available"],incomplete:[]}]); + assert.match(one.systemMessage,/CLI update available; global skills differ; plugin update available; check incomplete/); + assert.doesNotMatch(one.systemMessage,/sensitive/);assert.deepEqual(one,report([classifyStatus(value,now),{plugin:true,findings:["plugin update available"],incomplete:[]}])); }); - -test("supported platforms agree with releases; compact and subagents do nothing", (t) => { - const f = fixture(t); - const targets = JSON.parse(fs.readFileSync(path.join(repo, "packaging/targets.json"))) - .map(({ os, arch }) => `${os === "windows" ? "win32" : os}/${arch === "amd64" ? "x64" : arch}`); - for (const platform of ["win32", "darwin", "linux"]) { - for (const arch of ["x64", "arm64", "ia32"]) { - const output = sessionContext(event, platform, arch, f.installed, f.data); - assert.equal(Boolean(output.hookSpecificOutput), targets.includes(`${platform}/${arch}`)); - } - } - assert.ok(sessionContext({ ...event, source: "resume" }, "darwin", "arm64", f.installed, f.data).hookSpecificOutput); - for (const source of ["compact", "clear", "startup-extra"]) assert.equal(sessionContext({ ...event, source }), null); - assert.equal(sessionContext({ ...event, hook_event_name: "SubagentStart" }), null); +test("adapter runs exactly one status in event cwd, retains nonzero output, ignores other events",async()=>{ + const calls=[];const options={platform:"darwin",arch:"arm64",now,resolve:()=>process.execPath,observe:async()=>healthyPlugin,run:async(exe,args,opts)=>{calls.push({exe,args,cwd:opts.cwd});return {code:0,stdout:JSON.stringify(healthy())};}}; + assert.equal(await check(event,options),null);assert.deepEqual(calls,[{exe:process.execPath,args:["--json","status"],cwd:repo}]); + assert.equal(await check({...event,source:"resume"},options),null); + for(const source of ["compact","clear","startup-extra"])assert.equal(await check({...event,source},options),null); + assert.equal(await check({...event,hook_event_name:"SubagentStart"},options),null);assert.equal(calls.length,2); + const value=healthy();value.cliAdvisory.comparison="update"; + const result=await check(event,{...options,run:async()=>({code:1,stdout:JSON.stringify(value)})});assert.match(result.systemMessage,/CLI update available.*status process/); + assert.match((await check(event,{...options,platform:"linux"})).systemMessage,/unsupported/); }); - -test("daily success and failed-attempt cooldown are based on completed checks", (t) => { - const { data } = fixture(t); - const now = Date.now(); - assert.equal(updateStatus(data, now).due, true); - recordCheck(data, "success", now); - assert.equal(updateStatus(data, now + 24 * 60 * 60 * 1000 - 1).due, false); - assert.equal(updateStatus(data, now + 24 * 60 * 60 * 1000).due, true); - recordCheck(data, "failure", now); - assert.equal(updateStatus(data, now + 15 * 60 * 1000 - 1).due, false); - assert.equal(updateStatus(data, now + 15 * 60 * 1000).due, true); - assert.equal(updateStatus(data, now - 1).due, true); +test("plugin observation handles expiry, rollback, failure cooldown and installed version changes",async t=>{ + const root=temp(t);let calls=0;const opts=observation(root,{get:async url=>{calls++;return url.includes("/commits/")?{sha:commit}:{name:"sjskills-maintenance",version};}}); + assert.deepEqual(await observePlugin(opts),healthyPlugin);assert.equal(calls,2); + await observePlugin({...opts,now:now+DAY-1});assert.equal(calls,2); + await observePlugin({...opts,now:now+DAY});assert.equal(calls,4); + await observePlugin({...opts,now:now-1});assert.equal(calls,6); + await observePlugin({...opts,provenance:async()=>"0.1.0+codex.changed"});assert.equal(calls,8); + const fail={...opts,now:now+DAY*2,get:async()=>{calls++;throw Error("secret");}}; + assert.ok((await observePlugin(fail)).incomplete.length);const count=calls; + await observePlugin({...fail,now:fail.now+RETRY-1});assert.equal(calls,count); + await observePlugin({...fail,now:fail.now+RETRY});assert.equal(calls,count+1); }); - -test("corrupt state is due; unsafe state is reported without suppressing skill maintenance", (t) => { - const f = fixture(t); - fs.mkdirSync(f.data); - const state = path.join(f.data, "update-check.json"); - for (const text of ["broken", "null", "[]", '{"outcome":"success"}', '{"checkedAt":1,"outcome":"unknown"}']) { - fs.writeFileSync(state, text); - assert.equal(updateStatus(f.data).due, true); - } - fs.unlinkSync(state); - fs.mkdirSync(state); - assert.throws(() => updateStatus(f.data), /unsafe plugin update state/); - assert.throws(() => recordCheck(f.data, "success"), /unsafe plugin update state/); - const output = sessionContext(event, "win32", "x64", f.installed, f.data); - assert.ok(output.hookSpecificOutput); - assert.match(output.hookSpecificOutput.additionalContext, /unsafe plugin update state/); +test("plugin immutable observation rejects bad source, remote content and unsafe cache; does not steal locks",async t=>{ + const root=temp(t);assert.ok(mainRef(config));assert.equal(mainRef(config.replace('"main"','"dev"')),false);assert.equal(mainRef(config+config),false); + const opts=observation(root);fs.mkdirSync(opts.dataRoot);fs.mkdirSync(path.join(opts.dataRoot,"plugin-observation.lock")); + let requests=0;assert.ok((await observePlugin({...opts,get:async()=>{requests++;}})).incomplete.length);assert.equal(requests,0); + assert.ok(fs.existsSync(path.join(opts.dataRoot,"plugin-observation.lock"))); + const outside=path.join(root,"outside");fs.mkdirSync(outside);const linked=path.join(root,"linked");fs.symlinkSync(outside,linked,process.platform==="win32"?"junction":"dir"); + assert.ok((await observePlugin({...opts,dataRoot:linked})).incomplete.length);assert.deepEqual(fs.readdirSync(outside),[]); + assert.ok((await observePlugin({...opts,dataRoot:path.join(root,"remote"),get:async()=>({sha:"../../bad"})})).incomplete.length); + assert.ok((await observePlugin({...opts,provenance:async()=>{throw Error("disabled");}})).incomplete.length); +}); +test("plugin cache contention preserves independent fresh observations",async t=>{ + const root=temp(t);let release;const gate=new Promise(r=>release=r);let entered;const started=new Promise(r=>entered=r); + const opts=observation(root,{get:async url=>{entered();await gate;return url.includes("/commits/")?{sha:commit}:{name:"sjskills-maintenance",version};}}); + const first=observePlugin(opts);await started;assert.ok((await observePlugin(opts)).incomplete.length);release();assert.deepEqual(await first,healthyPlugin);assert.deepEqual(await observePlugin(opts),healthyPlugin); }); -test("linked plugin data directories never receive state writes", (t) => { - const f = fixture(t); - const outside = path.join(f.root, "outside"); - fs.mkdirSync(outside); - fs.symlinkSync(outside, f.data, process.platform === "win32" ? "junction" : "dir"); - assert.throws(() => recordCheck(f.data, "success"), /unsafe plugin data directory/); - assert.deepEqual(fs.readdirSync(outside), []); +let nativeRoot, executable; +before(()=>{nativeRoot=fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()),"sjskills-native-"));executable=path.join(nativeRoot,process.platform==="win32"?"sjskills.exe":"sjskills");const r=spawnSync("go",["build","-o",executable,path.join(repo,"scripts/fixtures/startup-cli.go")],{encoding:"utf8",windowsHide:true});assert.equal(r.status,0,r.stderr);}); +after(()=>{if(nativeRoot){assert.ok(path.basename(nativeRoot).startsWith("sjskills-native-"));fs.rmSync(nativeRoot,{recursive:true,force:true});}}); +test("registered command executes controlled native checks in both native shells without protected writes",t=>{ + const root=temp(t), installed=path.join(root,"user's 한글 $workspace & tools","plugin"), data=path.join(root,"data"), home=path.join(root,"home"), bin=path.join(root,"bin"); + copy(plugin,installed);fs.mkdirSync(home);fs.mkdirSync(bin);cached(data);fs.writeFileSync(path.join(home,"config.toml"),config); + for(const name of ["sjskills","codex"])fs.copyFileSync(executable,path.join(bin,name+(process.platform==="win32"?".exe":""))); + const log=path.join(root,"commands.jsonl"), beforeConfig=fs.readFileSync(path.join(home,"config.toml")); + const env={...process.env,PATH:bin+path.delimiter+process.env.PATH,CODEX_HOME:home,HOME:home,USERPROFILE:home,PLUGIN_ROOT:installed,PLUGIN_DATA:data,HOOK_FIXTURE_LOG:log,HOOK_FIXTURE_STATUS:JSON.stringify(healthy(false)),HOOK_FIXTURE_PLUGINS:JSON.stringify({installed:[{pluginId:"sjskills-maintenance@personal",enabled:true,installed:true,version,marketplaceSource:{sourceType:"git",source:"https://github.com/sjunepark/agent-scripts.git"}}]})}; + const hook=JSON.parse(fs.readFileSync(path.join(installed,"hooks/hooks.json"))).hooks.SessionStart[0].hooks[0];assert.equal(hook.timeout,40);assert.equal(hook.additionalContextLimit,undefined); + const shells=process.platform==="win32"?[["powershell.exe",["-NoProfile","-NonInteractive","-Command",hook.commandWindows]],[process.env.COMSPEC||"cmd.exe",["/d","/s","/c",hook.commandWindows]]]:[["/bin/sh",["-c",hook.command]]]; + for(const [shell,args] of shells){const result=spawnSync(shell,args,{env,cwd:root,input:JSON.stringify({...event,cwd:root}),encoding:"utf8",windowsHide:true,timeout:10000});assert.equal(result.status,0,result.stderr);if(process.platform==="linux")assert.match(JSON.parse(result.stdout).systemMessage,/unsupported/);else assert.equal(result.stdout,"");} + if(process.platform!=="linux"){const commands=fs.readFileSync(log,"utf8").trim().split("\n").map(JSON.parse);assert.equal(commands.filter(c=>c.args[0]==="--json").length,shells.length);for(const c of commands)assert.ok(JSON.stringify(c.args)==='["--json","status"]'||JSON.stringify(c.args)==='["plugin","list","--marketplace","personal","--available","--json"]');} + assert.deepEqual(fs.readFileSync(path.join(home,"config.toml")),beforeConfig);for(const protectedName of [".agents",".claude","plugins","auth.json"])assert.equal(fs.existsSync(path.join(home,protectedName)),false);assert.deepEqual(fs.readdirSync(data),["plugin-observation.json"]); +}); +test("native resolution rejects a building wrapper without executing it",t=>{ + const root=temp(t);const name=process.platform==="win32"?"sjskills.exe":"sjskills";fs.writeFileSync(path.join(root,name),"#!/bin/sh\necho bad\n",{mode:0o755});assert.throws(()=>nativeExecutable("sjskills",{PATH:root}));assert.equal(nativeExecutable("sjskills",{PATH:nativeRoot}),fs.realpathSync(executable)); +}); +test("bounded process output and cancellation clean up owned descendants",async t=>{ + await assert.rejects(runProcess(process.execPath,["-e","process.stdout.write('x'.repeat(20000));setInterval(()=>{},1000)"],{cwd:repo,limit:1000,signal:AbortSignal.timeout(3000)})); + const root=temp(t),pidFile=path.join(root,"pid"); + const code="const c=require('child_process').spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore',windowsHide:true});require('fs').writeFileSync(process.argv[1],String(c.pid));setInterval(()=>{},1000)"; + const start=Date.now();await assert.rejects(runProcess(process.execPath,["-e",code,pidFile],{cwd:root,signal:AbortSignal.timeout(1500)}));assert.ok(Date.now()-start<5000); + const pid=Number(fs.readFileSync(pidFile,"utf8"));assert.throws(()=>process.kill(pid,0),/ESRCH/); +}); +test("malformed hook input is bounded and never echoes untrusted content",()=>{ + for(const input of ["secret", "null", "[]", "x".repeat(1024*1024+1)]){const r=spawnSync(process.execPath,[path.join(plugin,"scripts/session-start.cjs")],{input,encoding:"utf8",windowsHide:true,timeout:5000});assert.equal(r.status,0,r.stderr);const value=JSON.parse(r.stdout);assert.match(value.systemMessage,/incomplete/);assert.doesNotMatch(value.systemMessage,/secret/);assert.equal(value.hookSpecificOutput,undefined);} }); -test("state helper CLI records outcomes and rejects invalid invocations", (t) => { - const f = fixture(t); - const helper = path.join(f.installed, "scripts/update-check.cjs"); - const run = (...args) => spawnSync(process.execPath, [helper, ...args], { encoding: "utf8", windowsHide: true }); - assert.equal(run("status", f.data).status, 0); - assert.equal(fs.existsSync(f.data), false); - assert.equal(run("success", f.data).status, 0); - assert.equal(JSON.parse(run("status", f.data).stdout).due, false); - assert.equal(run("failure", f.data).status, 0); - assert.equal(JSON.parse(run("status", f.data).stdout).lastOutcome, "failure"); - assert.equal(run("invalid", f.data).status, 1); - assert.equal(run("success", "relative").status, 1); +test("read-only provenance rejects disabled, mismatched and custom-source installations",async t=>{ + const root=temp(t);fs.writeFileSync(path.join(root,"config.toml"),config); + const entry={pluginId:"sjskills-maintenance@personal",enabled:true,installed:true,version,marketplaceSource:{sourceType:"git",source:"https://github.com/sjunepark/agent-scripts.git"}}; + const opts={root:plugin,env:{CODEX_HOME:root},resolve:()=>process.execPath,run:async(exe,args)=>{assert.deepEqual(args,["plugin","list","--marketplace","personal","--available","--json"]);return {code:0,stdout:JSON.stringify({installed:[entry]})};}}; + assert.equal(await provenance(opts),version); + for(const mutate of [e=>e.enabled=false,e=>e.version="0.1.0+codex.other",e=>e.marketplaceSource.sourceType="local",e=>e.marketplaceSource.source="https://example.com/repo"]){const changed=structuredClone(entry);mutate(changed);await assert.rejects(provenance({...opts,run:async()=>({code:0,stdout:JSON.stringify({installed:[changed]})})}));} + fs.writeFileSync(path.join(root,"config.toml"),config.replace('"main"','"dev"'));await assert.rejects(provenance(opts)); +}); +test("metadata transport bounds responses, rejects redirects, and cancels network work",async t=>{ + const server=require("node:http").createServer((req,res)=>{ + if(req.url==="/large"){res.end("x".repeat(129*1024));return;} + if(req.url==="/redirect"){res.writeHead(302,{Location:"/ok"});res.end();return;} + if(req.url==="/hang")return; + res.end(JSON.stringify({sha:commit})); + }); + await new Promise(resolve=>server.listen(0,"127.0.0.1",resolve));t.after(()=>{server.closeAllConnections();server.close();}); + const url=`http://127.0.0.1:${server.address().port}`; + assert.deepEqual(await fetchJSON(url+"/ok",AbortSignal.timeout(2000)),{sha:commit}); + await assert.rejects(fetchJSON(url+"/large",AbortSignal.timeout(2000))); + await assert.rejects(fetchJSON(url+"/redirect",AbortSignal.timeout(2000))); + await assert.rejects(fetchJSON(url+"/hang",AbortSignal.timeout(100))); +}); +test("warm plugin evidence never suppresses fresh local skill findings",async t=>{ + const root=temp(t);const opts=observation(root);await observePlugin(opts); + const value=healthy();const options={platform:"darwin",arch:"x64",now,resolve:()=>process.execPath,run:async()=>({code:0,stdout:JSON.stringify(value)}),observe:()=>observePlugin({...opts,get:async()=>{throw Error("must use cache");}})}; + assert.equal(await check(event,options),null); + value.advisories[1].findings=[{category:"update",skill:"changed",reason:"local-modification"}]; + assert.match((await check(event,options)).systemMessage,/project skills differ/); +}); +test("read-only cache errors preserve fresh results without modifying foreign state",async t=>{ + const root=temp(t),data=path.join(root,"data");fs.mkdirSync(data);const state=path.join(data,"plugin-observation.json");fs.mkdirSync(state); + assert.ok((await observePlugin(observation(root))).incomplete.length);assert.ok(fs.statSync(state).isDirectory()); + const elsewhere=path.join(root,"elsewhere");fs.mkdirSync(elsewhere);const foreign=path.join(elsewhere,"foreign");fs.writeFileSync(foreign,"unchanged"); + const other=path.join(root,"other");fs.mkdirSync(other);fs.linkSync(foreign,path.join(other,"plugin-observation.json")); + assert.ok((await observePlugin({...observation(root),dataRoot:other})).incomplete.length);assert.equal(fs.readFileSync(foreign,"utf8"),"unchanged"); }); -test("malformed runtime input emits a nonblocking diagnostic", () => { - for (const input of ["broken", "null", "[]", JSON.stringify({ ...event, cwd: "relative" }), "x".repeat(1024 * 1024 + 1)]) { - const result = spawnSync(process.execPath, [runtime], { input, encoding: "utf8", windowsHide: true }); - assert.equal(result.status, 0, result.stderr); - const output = JSON.parse(result.stdout); - assert.match(output.systemMessage, /maintenance unavailable/); - assert.equal(output.continue, undefined); +test("mixed valid and invalid findings preserve known drift",()=>{ + const value=healthy();value.advisories[0].findings=[{category:"missing",skill:"known",reason:"missing"},{category:"unknown"}]; + const message=report([classifyStatus(value,now)]).systemMessage; + assert.match(message,/global skills differ/);assert.match(message,/global verification/); +}); +test("native parent exit cannot leave a pipe-holding descendant running",async t=>{ + const root=temp(t),file=path.join(root,"orphan-pid"),begin=Date.now(); + await runProcess(executable,["--orphan",process.execPath,file],{cwd:root,signal:AbortSignal.timeout(3000)}).catch(()=>{}); + assert.ok(Date.now()-begin<5500);const pid=Number(fs.readFileSync(file,"utf8")); + let running=true; + for(let i=0;i<50;i++){ + try{process.kill(pid,0);if(process.platform==="linux"&&fs.readFileSync(`/proc/${pid}/stat`,"utf8").includes(") Z ")){running=false;break;}} + catch(error){if(error.code==="ESRCH"||error.code==="ENOENT"){running=false;break;}throw error;} + await new Promise(resolve=>setTimeout(resolve,20)); } + if(running)process.kill(pid); + assert.equal(running,false,"owned descendant survived completion/cancellation"); }); diff --git a/scripts/sync-sjskills-plugin b/scripts/sync-sjskills-plugin deleted file mode 100644 index aaaa273..0000000 --- a/scripts/sync-sjskills-plugin +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const fs = require("node:fs"); -const path = require("node:path"); -const repo = path.resolve(__dirname, ".."); -const files = ["SKILL.md", "references/global-rollout.md"]; -const args = process.argv.slice(2); -if (args.length > 1 || (args.length && args[0] !== "--check")) { - console.error("Usage: node scripts/sync-sjskills-plugin [--check]"); - process.exit(1); -} -for (const relative of files) { - const source = path.join(repo, "skills/sjskills", relative); - const destination = path.join(repo, "plugins/sjskills-maintenance/references/sjskills", relative); - const expected = fs.readFileSync(source); - if (fs.existsSync(destination) && fs.readFileSync(destination).equals(expected)) continue; - if (args[0] === "--check") { - console.error(`drift: ${path.relative(repo, destination)}; run node scripts/sync-sjskills-plugin`); - process.exitCode = 1; - } else { - fs.mkdirSync(path.dirname(destination), { recursive: true }); - fs.writeFileSync(destination, expected); - } -} From f6f13bf6316fbc801c1b36114d8982a05a71235a Mon Sep 17 00:00:00 2001 From: Sejun Park Date: Tue, 15 Sep 2026 12:53:14 +0900 Subject: [PATCH 4/7] fix: address startup-hook platform and review edge cases Keep the Windows plugin-root fallback consistent and permit only the trusted supervisor invocation under process execution policy, while preserving administrative policy. Exercise both root conventions under Restricted process policy. Share bounded zombie-aware descendant assertions so Linux cleanup tests do not mistake a terminated process for a running child. Reconcile acceptance evidence and document why interrupted observation locks require an operator decision instead of automatic age-based reclamation. --- docs/sjskills-startup-hook.md | 8 ++++++-- goals/sjskills-startup-check.md | 5 ++++- plans/sjskills-startup-check.md | 14 ++++++++------ .../sjskills-maintenance/.codex-plugin/plugin.json | 2 +- plugins/sjskills-maintenance/hooks/hooks.json | 2 +- .../sjskills-maintenance/scripts/observation.cjs | 2 ++ plugins/sjskills-maintenance/scripts/process.cjs | 2 +- scripts/sjskills-hook.test.js | 14 +++++++++----- 8 files changed, 32 insertions(+), 17 deletions(-) diff --git a/docs/sjskills-startup-hook.md b/docs/sjskills-startup-hook.md index 81bd645..1552136 100644 --- a/docs/sjskills-startup-hook.md +++ b/docs/sjskills-startup-hook.md @@ -44,7 +44,9 @@ worker or later agent recovery task. On macOS, children run in an owned process group. On Windows, hidden PowerShell supervisors join kill-on-close Job Objects before launching the native programs; -this prevents a departed parent from leaving descendants behind. Environments +this prevents a departed parent from leaving descendants behind. The trusted +shipped supervisor uses an invocation-only execution policy override; it does not +persist policy settings and administrative restrictions still apply. Environments that disallow that supervision report incomplete checks. Node.js and native Codex/sjskills executables must be available; status materialization uses the CLI's existing prerequisites. Windows supervision adds startup overhead. @@ -54,7 +56,9 @@ Upstream plugin observations remain fresh for 24 hours; failed requests have a backward clock forces a refresh. The CLI still inspects local skills each time. The plugin owns only `PLUGIN_DATA/plugin-observation.json` and its exclusive `plugin-observation.lock`. It never steals a lock. Unsafe paths fail closed; -a denied cache write does not invalidate a successful fresh read. +a denied cache write does not invalidate a successful fresh read. An interrupted +lock requires explicit operator inspection of `plugin-observation.lock` and any +active hook processes before removal; elapsed time alone is not ownership proof. Old `update-check.json`, `update.lock`, and `workflows/` snapshots are neither read nor removed. Suspended sessions may still hold their paths; cleanup is a diff --git a/goals/sjskills-startup-check.md b/goals/sjskills-startup-check.md index 6a879ba..5f53345 100644 --- a/goals/sjskills-startup-check.md +++ b/goals/sjskills-startup-check.md @@ -35,7 +35,7 @@ Stage C: hosted native acceptance and PR delivery. ### Next in-scope action -Create the implementation PR, run native acceptance, handle feedback, and merge to the integration branch. +Finish follow-up validation and review handling on PR #24, then merge to the integration branch. ### Evidence and blockers @@ -47,3 +47,6 @@ Create the implementation PR, run native acceptance, handle feedback, and merge - Source validation: hook tests, registry tests, release tests, skill validation, plugin validation, and go vet pass on Windows. Immutable released v1.3.0 status output was exercised in isolated homes. Bounded review fixes have regression coverage. - Candidate: manual native CI dispatch for the implementation head. Classification: necessary. Contract basis: stage C supported-target acceptance. Action: proceed; no real-host activation. + +- PR #24: https://github.com/sjunepark/agent-scripts/pull/24. Initial Codex and CodeRabbit reviews completed; Windows fallback/policy, Linux zombie assertion, and acceptance wording are fixed. Automatic lock reclamation was rejected because age does not establish ownership and the contract forbids stealing locks. +- Initial hosted native acceptance passed on all supported targets: https://github.com/sjunepark/agent-scripts/actions/runs/34926085048. Final follow-up head validation remains pending. diff --git a/plans/sjskills-startup-check.md b/plans/sjskills-startup-check.md index 948cacb..5528426 100644 --- a/plans/sjskills-startup-check.md +++ b/plans/sjskills-startup-check.md @@ -17,7 +17,9 @@ and PR delivery. Real-host activation and reconciliation remain excluded. - Stages A–B are implemented: native checks, strict partial-result reporting, read-only plugin observation, and removal of the injected maintenance path. - Stage C: Windows shell, subprocess, cache, provenance, transport, and released - CLI compatibility checks pass. Hosted native acceptance remains pending. + CLI compatibility checks pass. Hosted native acceptance passed on all three targets in + [run 34926085048](https://github.com/sjunepark/agent-scripts/actions/runs/34926085048); + review follow-up validation is pending. - Stage D: manifest/cachebuster and operator documentation describe check-only behavior. Isolated install/reinstall passed: healthy packaged Windows hook exited 0 with zero stdout bytes in 5,321 ms. Scoped documentation harmonization and @@ -39,8 +41,8 @@ and PR delivery. Real-host activation and reconciliation remain excluded. ## Next action -Deliver the implementation PR, run hosted native acceptance, address feedback, -and merge to the goal integration branch. +Finish review follow-ups on [PR #24](https://github.com/sjunepark/agent-scripts/pull/24), +validate the final head, and merge to the goal integration branch. ## Selected design @@ -248,6 +250,6 @@ hook rather than silently reverting to automatic maintenance. still reference snapshots; deleting those is a separate cleanup task. - A fresh session is required to verify removal of already-injected instructions. Reinstalling code cannot retract context from an active old conversation. -- The only unresolved implementation evidence is exact client presentation, - released-CLI compatibility, read-only marketplace metadata access, and native - timing/cancellation. These are validation tasks, not unanswered product choices. +- Released-CLI compatibility, read-only marketplace metadata, and native + timing/cancellation have source acceptance evidence. Exact client presentation + in a fresh trusted session remains stage E rollout acceptance. diff --git a/plugins/sjskills-maintenance/.codex-plugin/plugin.json b/plugins/sjskills-maintenance/.codex-plugin/plugin.json index ef56f6f..0e28a59 100644 --- a/plugins/sjskills-maintenance/.codex-plugin/plugin.json +++ b/plugins/sjskills-maintenance/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "sjskills-maintenance", - "version": "0.1.0+codex.20260915033046", + "version": "0.1.0+codex.20260915035156", "description": "Check CLI, configured skills, and published plugin status at startup without assigning agent work.", "author": { "name": "Local developer" diff --git a/plugins/sjskills-maintenance/hooks/hooks.json b/plugins/sjskills-maintenance/hooks/hooks.json index 11b9b6b..7af8d3a 100644 --- a/plugins/sjskills-maintenance/hooks/hooks.json +++ b/plugins/sjskills-maintenance/hooks/hooks.json @@ -9,7 +9,7 @@ "command": "node -e \"require(require('path').join(process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT, 'scripts/session-start.cjs')).main()\"", "timeout": 40, "statusMessage": "Checking sjskills status", - "commandWindows": "powershell.exe -NoProfile -NonInteractive -EncodedCommand JgAgAG4AbwBkAGUAIAAoAEoAbwBpAG4ALQBQAGEAdABoACAAJABlAG4AdgA6AFAATABVAEcASQBOAF8AUgBPAE8AVAAgACcAcwBjAHIAaQBwAHQAcwAvAHMAZQBzAHMAaQBvAG4ALQBzAHQAYQByAHQALgBjAGoAcwAnACkA" + "commandWindows": "powershell.exe -NoProfile -NonInteractive -EncodedCommand JABwAGwAdQBnAGkAbgBEAGkAcgBlAGMAdABvAHIAeQAgAD0AIABpAGYAIAAoACQAZQBuAHYAOgBQAEwAVQBHAEkATgBfAFIATwBPAFQAKQAgAHsAIAAkAGUAbgB2ADoAUABMAFUARwBJAE4AXwBSAE8ATwBUACAAfQAgAGUAbABzAGUAIAB7ACAAJABlAG4AdgA6AEMATABBAFUARABFAF8AUABMAFUARwBJAE4AXwBSAE8ATwBUACAAfQA7ACAAJgAgAG4AbwBkAGUAIAAoAEoAbwBpAG4ALQBQAGEAdABoACAAJABwAGwAdQBnAGkAbgBEAGkAcgBlAGMAdABvAHIAeQAgACcAcwBjAHIAaQBwAHQAcwAvAHMAZQBzAHMAaQBvAG4ALQBzAHQAYQByAHQALgBjAGoAcwAnACkA" } ] } diff --git a/plugins/sjskills-maintenance/scripts/observation.cjs b/plugins/sjskills-maintenance/scripts/observation.cjs index 302484c..af00499 100644 --- a/plugins/sjskills-maintenance/scripts/observation.cjs +++ b/plugins/sjskills-maintenance/scripts/observation.cjs @@ -110,6 +110,8 @@ async function observePlugin(options) { fs.mkdirSync(lock, { mode: 0o700 }); owned = true; writable = true; } catch (error) { + // Age does not prove ownership or death. Interrupted locks need an + // explicit operator decision; startup must never reclaim another run's lock. if (error.code === "EEXIST") return unavailable(); if (!["EACCES", "EPERM", "EROFS"].includes(error.code)) return unavailable(); // A disposable cache write failure must not discard fresh read results. diff --git a/plugins/sjskills-maintenance/scripts/process.cjs b/plugins/sjskills-maintenance/scripts/process.cjs index 30784b0..b132ac9 100644 --- a/plugins/sjskills-maintenance/scripts/process.cjs +++ b/plugins/sjskills-maintenance/scripts/process.cjs @@ -37,7 +37,7 @@ function runProcess(executable, args, { cwd, env = process.env, signal, limit = if (signal?.aborted) return reject(new Error("cancelled")); const windows = process.platform === "win32"; const command = windows ? path.join(env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe") : executable; - const arguments_ = windows ? ["-NoProfile", "-NonInteractive", "-File", path.join(__dirname, "windows-process.ps1")] : args; + const arguments_ = windows ? ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", path.join(__dirname, "windows-process.ps1")] : args; const child = spawn(command, arguments_, { cwd, env: windows ? { ...env, SJSKILLS_CHECK_PROCESS: JSON.stringify({ executable, arguments: args }) } : env, windowsHide: true, detached: !windows, diff --git a/scripts/sjskills-hook.test.js b/scripts/sjskills-hook.test.js index 6156e8e..5772c33 100644 --- a/scripts/sjskills-hook.test.js +++ b/scripts/sjskills-hook.test.js @@ -86,11 +86,11 @@ test("registered command executes controlled native checks in both native shells copy(plugin,installed);fs.mkdirSync(home);fs.mkdirSync(bin);cached(data);fs.writeFileSync(path.join(home,"config.toml"),config); for(const name of ["sjskills","codex"])fs.copyFileSync(executable,path.join(bin,name+(process.platform==="win32"?".exe":""))); const log=path.join(root,"commands.jsonl"), beforeConfig=fs.readFileSync(path.join(home,"config.toml")); - const env={...process.env,PATH:bin+path.delimiter+process.env.PATH,CODEX_HOME:home,HOME:home,USERPROFILE:home,PLUGIN_ROOT:installed,PLUGIN_DATA:data,HOOK_FIXTURE_LOG:log,HOOK_FIXTURE_STATUS:JSON.stringify(healthy(false)),HOOK_FIXTURE_PLUGINS:JSON.stringify({installed:[{pluginId:"sjskills-maintenance@personal",enabled:true,installed:true,version,marketplaceSource:{sourceType:"git",source:"https://github.com/sjunepark/agent-scripts.git"}}]})}; + const env={...process.env,PSExecutionPolicyPreference:"Restricted",PATH:bin+path.delimiter+process.env.PATH,CODEX_HOME:home,HOME:home,USERPROFILE:home,PLUGIN_ROOT:installed,PLUGIN_DATA:data,HOOK_FIXTURE_LOG:log,HOOK_FIXTURE_STATUS:JSON.stringify(healthy(false)),HOOK_FIXTURE_PLUGINS:JSON.stringify({installed:[{pluginId:"sjskills-maintenance@personal",enabled:true,installed:true,version,marketplaceSource:{sourceType:"git",source:"https://github.com/sjunepark/agent-scripts.git"}}]})}; const hook=JSON.parse(fs.readFileSync(path.join(installed,"hooks/hooks.json"))).hooks.SessionStart[0].hooks[0];assert.equal(hook.timeout,40);assert.equal(hook.additionalContextLimit,undefined); const shells=process.platform==="win32"?[["powershell.exe",["-NoProfile","-NonInteractive","-Command",hook.commandWindows]],[process.env.COMSPEC||"cmd.exe",["/d","/s","/c",hook.commandWindows]]]:[["/bin/sh",["-c",hook.command]]]; - for(const [shell,args] of shells){const result=spawnSync(shell,args,{env,cwd:root,input:JSON.stringify({...event,cwd:root}),encoding:"utf8",windowsHide:true,timeout:10000});assert.equal(result.status,0,result.stderr);if(process.platform==="linux")assert.match(JSON.parse(result.stdout).systemMessage,/unsupported/);else assert.equal(result.stdout,"");} - if(process.platform!=="linux"){const commands=fs.readFileSync(log,"utf8").trim().split("\n").map(JSON.parse);assert.equal(commands.filter(c=>c.args[0]==="--json").length,shells.length);for(const c of commands)assert.ok(JSON.stringify(c.args)==='["--json","status"]'||JSON.stringify(c.args)==='["plugin","list","--marketplace","personal","--available","--json"]');} + for(const [shell,args] of shells)for(const fallback of [false,true]){const shellEnv={...env};if(fallback){delete shellEnv.PLUGIN_ROOT;delete shellEnv.PLUGIN_DATA;shellEnv.CLAUDE_PLUGIN_ROOT=installed;shellEnv.CLAUDE_PLUGIN_DATA=data;}const result=spawnSync(shell,args,{env:shellEnv,cwd:root,input:JSON.stringify({...event,cwd:root}),encoding:"utf8",windowsHide:true,timeout:10000});assert.equal(result.status,0,result.stderr);if(process.platform==="linux")assert.match(JSON.parse(result.stdout).systemMessage,/unsupported/);else assert.equal(result.stdout,"");} + if(process.platform!=="linux"){const commands=fs.readFileSync(log,"utf8").trim().split("\n").map(JSON.parse);assert.equal(commands.filter(c=>c.args[0]==="--json").length,shells.length*2);for(const c of commands)assert.ok(JSON.stringify(c.args)==='["--json","status"]'||JSON.stringify(c.args)==='["plugin","list","--marketplace","personal","--available","--json"]');} assert.deepEqual(fs.readFileSync(path.join(home,"config.toml")),beforeConfig);for(const protectedName of [".agents",".claude","plugins","auth.json"])assert.equal(fs.existsSync(path.join(home,protectedName)),false);assert.deepEqual(fs.readdirSync(data),["plugin-observation.json"]); }); test("native resolution rejects a building wrapper without executing it",t=>{ @@ -101,7 +101,7 @@ test("bounded process output and cancellation clean up owned descendants",async const root=temp(t),pidFile=path.join(root,"pid"); const code="const c=require('child_process').spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore',windowsHide:true});require('fs').writeFileSync(process.argv[1],String(c.pid));setInterval(()=>{},1000)"; const start=Date.now();await assert.rejects(runProcess(process.execPath,["-e",code,pidFile],{cwd:root,signal:AbortSignal.timeout(1500)}));assert.ok(Date.now()-start<5000); - const pid=Number(fs.readFileSync(pidFile,"utf8"));assert.throws(()=>process.kill(pid,0),/ESRCH/); + const pid=Number(fs.readFileSync(pidFile,"utf8"));await assertStopped(pid); }); test("malformed hook input is bounded and never echoes untrusted content",()=>{ for(const input of ["secret", "null", "[]", "x".repeat(1024*1024+1)]){const r=spawnSync(process.execPath,[path.join(plugin,"scripts/session-start.cjs")],{input,encoding:"utf8",windowsHide:true,timeout:5000});assert.equal(r.status,0,r.stderr);const value=JSON.parse(r.stdout);assert.match(value.systemMessage,/incomplete/);assert.doesNotMatch(value.systemMessage,/secret/);assert.equal(value.hookSpecificOutput,undefined);} @@ -153,6 +153,10 @@ test("native parent exit cannot leave a pipe-holding descendant running",async t const root=temp(t),file=path.join(root,"orphan-pid"),begin=Date.now(); await runProcess(executable,["--orphan",process.execPath,file],{cwd:root,signal:AbortSignal.timeout(3000)}).catch(()=>{}); assert.ok(Date.now()-begin<5500);const pid=Number(fs.readFileSync(file,"utf8")); + await assertStopped(pid); +}); + +async function assertStopped(pid) { let running=true; for(let i=0;i<50;i++){ try{process.kill(pid,0);if(process.platform==="linux"&&fs.readFileSync(`/proc/${pid}/stat`,"utf8").includes(") Z ")){running=false;break;}} @@ -161,4 +165,4 @@ test("native parent exit cannot leave a pipe-holding descendant running",async t } if(running)process.kill(pid); assert.equal(running,false,"owned descendant survived completion/cancellation"); -}); +} From 3cfaf91a49f8823bd86e57bc177403410efd1481 Mon Sep 17 00:00:00 2001 From: Sejun Park Date: Tue, 15 Sep 2026 12:59:34 +0900 Subject: [PATCH 5/7] test: honor the registered startup hook budget in native shells The Windows hosted runner reached the harness-only ten-second spawn timeout before the product could finish within its documented forty-second hook budget. Bound the outer test to that registered budget plus cleanup and include native spawn error codes in failures. Keep the actual adapter and hook deadlines unchanged. --- scripts/sjskills-hook.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sjskills-hook.test.js b/scripts/sjskills-hook.test.js index 5772c33..efcee66 100644 --- a/scripts/sjskills-hook.test.js +++ b/scripts/sjskills-hook.test.js @@ -89,7 +89,7 @@ test("registered command executes controlled native checks in both native shells const env={...process.env,PSExecutionPolicyPreference:"Restricted",PATH:bin+path.delimiter+process.env.PATH,CODEX_HOME:home,HOME:home,USERPROFILE:home,PLUGIN_ROOT:installed,PLUGIN_DATA:data,HOOK_FIXTURE_LOG:log,HOOK_FIXTURE_STATUS:JSON.stringify(healthy(false)),HOOK_FIXTURE_PLUGINS:JSON.stringify({installed:[{pluginId:"sjskills-maintenance@personal",enabled:true,installed:true,version,marketplaceSource:{sourceType:"git",source:"https://github.com/sjunepark/agent-scripts.git"}}]})}; const hook=JSON.parse(fs.readFileSync(path.join(installed,"hooks/hooks.json"))).hooks.SessionStart[0].hooks[0];assert.equal(hook.timeout,40);assert.equal(hook.additionalContextLimit,undefined); const shells=process.platform==="win32"?[["powershell.exe",["-NoProfile","-NonInteractive","-Command",hook.commandWindows]],[process.env.COMSPEC||"cmd.exe",["/d","/s","/c",hook.commandWindows]]]:[["/bin/sh",["-c",hook.command]]]; - for(const [shell,args] of shells)for(const fallback of [false,true]){const shellEnv={...env};if(fallback){delete shellEnv.PLUGIN_ROOT;delete shellEnv.PLUGIN_DATA;shellEnv.CLAUDE_PLUGIN_ROOT=installed;shellEnv.CLAUDE_PLUGIN_DATA=data;}const result=spawnSync(shell,args,{env:shellEnv,cwd:root,input:JSON.stringify({...event,cwd:root}),encoding:"utf8",windowsHide:true,timeout:10000});assert.equal(result.status,0,result.stderr);if(process.platform==="linux")assert.match(JSON.parse(result.stdout).systemMessage,/unsupported/);else assert.equal(result.stdout,"");} + for(const [shell,args] of shells)for(const fallback of [false,true]){const shellEnv={...env};if(fallback){delete shellEnv.PLUGIN_ROOT;delete shellEnv.PLUGIN_DATA;shellEnv.CLAUDE_PLUGIN_ROOT=installed;shellEnv.CLAUDE_PLUGIN_DATA=data;}const result=spawnSync(shell,args,{env:shellEnv,cwd:root,input:JSON.stringify({...event,cwd:root}),encoding:"utf8",windowsHide:true,timeout:(hook.timeout+5)*1000});assert.equal(result.status,0,`${shell}: ${result.error?.code || ""} ${result.stderr}`);if(process.platform==="linux")assert.match(JSON.parse(result.stdout).systemMessage,/unsupported/);else assert.equal(result.stdout,"");} if(process.platform!=="linux"){const commands=fs.readFileSync(log,"utf8").trim().split("\n").map(JSON.parse);assert.equal(commands.filter(c=>c.args[0]==="--json").length,shells.length*2);for(const c of commands)assert.ok(JSON.stringify(c.args)==='["--json","status"]'||JSON.stringify(c.args)==='["plugin","list","--marketplace","personal","--available","--json"]');} assert.deepEqual(fs.readFileSync(path.join(home,"config.toml")),beforeConfig);for(const protectedName of [".agents",".claude","plugins","auth.json"])assert.equal(fs.existsSync(path.join(home,protectedName)),false);assert.deepEqual(fs.readdirSync(data),["plugin-observation.json"]); }); From 1eda4749233ecaece2167f1a62b86d6ea15f20f6 Mon Sep 17 00:00:00 2001 From: Sejun Park Date: Tue, 15 Sep 2026 13:06:43 +0900 Subject: [PATCH 6/7] docs: complete check-only startup delivery after PR 24 merge Record the merged integration result, final supported-native CI, completed review dispositions, and immutable goal contract. Close stages A-D while keeping stage E activation unstarted and separately authorized. This terminal bookkeeping commit changes planning metadata only. --- PROGRESS.md | 15 ++++----------- ROADMAP.md | 7 +++++-- goals/sjskills-startup-check.md | 29 +++++++++++++---------------- plans/sjskills-startup-check.md | 23 +++++++++++++---------- 4 files changed, 35 insertions(+), 39 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index d184f7d..5f5c762 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -26,17 +26,10 @@ PR #23 review follow-up: confirmed comments 3979633085 (commit pins), 3979633091 # sjskills startup-check delivery -- The [startup-check plan](plans/sjskills-startup-check.md) owns the selected - check-only target, native acceptance evidence, and remaining delivery. -- Stages A–B replace prompt-driven maintenance with direct status execution. - Windows tests, immutable v1.3.0 compatibility, isolated package reinstall, - source validation, and documentation alignment pass. Hosted native acceptance - and PR delivery remain pending. -- The [closed goal](goals/sjskills-startup-check.md) authorizes stages A–D and PR - delivery to its integration branch. Real-host installation, trust review, - activation, automatic updates, and configured reconciliation are excluded. -- Older installations may still inject the previous maintenance workflow. - Existing plugin-data snapshots and update state are deliberately retained. +- Stages A–D are delivered through [PR #24](https://github.com/sjunepark/agent-scripts/pull/24), merged as `751d93f` to `codex/sjskills-startup-integration`. The [completed goal](goals/sjskills-startup-check.md) owns the closed delivery boundary. +- [Final CI](https://github.com/sjunepark/agent-scripts/actions/runs/34927077118) passed on Windows x64 and both macOS targets. Released-CLI compatibility, isolated package reinstall, bounded review, and documentation alignment passed. +- The [startup-check plan](plans/sjskills-startup-check.md) retains timing evidence and unstarted stage E. Promotion to main and real-host installation, trust, and activation remain separate. No configured reconciliation or automatic maintenance was performed. +- Older installations may still inject the previous workflow. Existing plugin-data snapshots and update state remain intact. ## Previous CLI delivery diff --git a/ROADMAP.md b/ROADMAP.md index 32e1115..d2648d3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,8 +2,7 @@ ## Current -[Check sjskills automatically without assigning work to the agent](plans/sjskills-startup-check.md) -is implementing stages A–D under the [closed goal](goals/sjskills-startup-check.md). +_None._ ## Plans @@ -11,6 +10,10 @@ is implementing stages A–D under the [closed goal](goals/sjskills-startup-chec is proposed and requires separate evidence-bound authorization for each machine. Exact-content approval binding is delivered. +2. [Publish and activate the check-only sjskills hook](plans/sjskills-startup-check.md) + has completed stages A–D on its integration branch. Stage E remains + unstarted and requires separate authorization. + ## Tasks _None._ diff --git a/goals/sjskills-startup-check.md b/goals/sjskills-startup-check.md index 5f53345..343703f 100644 --- a/goals/sjskills-startup-check.md +++ b/goals/sjskills-startup-check.md @@ -1,6 +1,6 @@ # Goal: Check sjskills automatically without routine agent work -Status: active +Status: complete Planning scope: ROADMAP.md ## Original contract @@ -26,27 +26,24 @@ _None._ ### Completed included results -- Stages A–B: reproduced old context injection; implemented direct checks and concise partial-result reporting. -- Stage D: replaced package metadata/runtime, removed maintenance bundle, validated isolated install/reinstall, and harmonized affected docs. +- Stages A–B: direct native checks, fresh-evidence validation, concise partial-result reporting, and removal of agent maintenance injection. +- Stage C: local regressions, immutable released-CLI compatibility, and final native CI on Windows x64 and macOS Intel/Apple silicon; review findings handled. +- Stage D: refreshed package, isolated install/reinstall, and aligned operator/planning documentation. +- Delivery: PR #24 merged to the preflighted integration branch. ### Current in-scope result -Stage C: hosted native acceptance and PR delivery. +None — all included results delivered. ### Next in-scope action -Finish follow-up validation and review handling on PR #24, then merge to the integration branch. +None — goal complete ### Evidence and blockers -- Clean starting checkout equals prepared commit `ce8b5047ce489f02ff5c1dfe79f0fc1926676205`; ancestor verification passed. All cited sources and required workflow skills are present. -- Native platform goal created and active in task `01a0a315-a2c5-7bb3-b740-5211ab1babb0`. -- PR integration branch: `codex/sjskills-startup-integration`, preserving the prepared commit. Repository permission is ADMIN; branch creation/push succeeded. Initialization and terminal metadata use this non-production integration branch. -- Candidate: one connected implementation PR for stages A–D. Classification: included. Contract basis: the three named results. Action: proceed after initialization is committed and pushed. -- Real-host installation, activation, plugin/CLI updates, and configured reconciliation remain excluded. Use isolated test homes only. - -- Source validation: hook tests, registry tests, release tests, skill validation, plugin validation, and go vet pass on Windows. Immutable released v1.3.0 status output was exercised in isolated homes. Bounded review fixes have regression coverage. -- Candidate: manual native CI dispatch for the implementation head. Classification: necessary. Contract basis: stage C supported-target acceptance. Action: proceed; no real-host activation. - -- PR #24: https://github.com/sjunepark/agent-scripts/pull/24. Initial Codex and CodeRabbit reviews completed; Windows fallback/policy, Linux zombie assertion, and acceptance wording are fixed. Automatic lock reclamation was rejected because age does not establish ownership and the contract forbids stealing locks. -- Initial hosted native acceptance passed on all supported targets: https://github.com/sjunepark/agent-scripts/actions/runs/34926085048. Final follow-up head validation remains pending. +- Prepared commit `ce8b5047ce489f02ff5c1dfe79f0fc1926676205` remains an ancestor of the delivered integration branch. Native goal was created in task `01a0a315-a2c5-7bb3-b740-5211ab1babb0`. +- [PR #24](https://github.com/sjunepark/agent-scripts/pull/24) merged as `751d93fcf21bdceb358913a26f3b478acb0b3c6e` into `codex/sjskills-startup-integration`, preserving commits `b6fe3dd`, `f6f13bf`, and `3cfaf91`. +- [Final native acceptance](https://github.com/sjunepark/agent-scripts/actions/runs/34927077118) passed source, artifact, hook, and consumer checks for head `3cfaf91` on all supported targets. Local registry/release tests, skill/plugin validation, go vet, and Markdown link/queue checks passed. +- Codex and CodeRabbit reviews completed; all five threads are resolved. CodeRabbit withdrew automatic stale-lock reclamation after confirming the no-stolen-lock contract. Follow-up diffs were reviewed and tested without manual review retriggers. +- Package version `0.1.0+codex.20260915035156` passed isolated Codex install/reinstall and silent healthy invocation. Native Windows v1.3.0 compatibility retained known findings during actual upstream failures. The plan records timing evidence. +- No blockers remain within this goal. Stage E real-host installation, trust, activation, and configured reconciliation remain unstarted. No default-branch promotion or ongoing installation update was performed. diff --git a/plans/sjskills-startup-check.md b/plans/sjskills-startup-check.md index 5528426..7ce1cd6 100644 --- a/plans/sjskills-startup-check.md +++ b/plans/sjskills-startup-check.md @@ -9,17 +9,18 @@ one short diagnostic with a next step. The hook never assigns maintenance work t the agent and never updates, installs, synchronizes, quarantines, or restores. The user selected **only check and report** on 2026-09-15. This replaces the -previous automatic-maintenance target. The [closed goal](../goals/sjskills-startup-check.md) now authorizes stages A–D -and PR delivery. Real-host activation and reconciliation remain excluded. +previous automatic-maintenance target. Stages A–D were delivered through the +[closed goal](../goals/sjskills-startup-check.md) and PR lifecycle. Real-host +activation remains stage E work requiring separate authority; configured +reconciliation remains excluded. ## Current state - Stages A–B are implemented: native checks, strict partial-result reporting, read-only plugin observation, and removal of the injected maintenance path. - Stage C: Windows shell, subprocess, cache, provenance, transport, and released - CLI compatibility checks pass. Hosted native acceptance passed on all three targets in - [run 34926085048](https://github.com/sjunepark/agent-scripts/actions/runs/34926085048); - review follow-up validation is pending. + CLI compatibility checks pass. Final hosted native acceptance passed on all three targets in + [run 34927077118](https://github.com/sjunepark/agent-scripts/actions/runs/34927077118). - Stage D: manifest/cachebuster and operator documentation describe check-only behavior. Isolated install/reinstall passed: healthy packaged Windows hook exited 0 with zero stdout bytes in 5,321 ms. Scoped documentation harmonization and @@ -41,8 +42,10 @@ and PR delivery. Real-host activation and reconciliation remain excluded. ## Next action -Finish review follow-ups on [PR #24](https://github.com/sjunepark/agent-scripts/pull/24), -validate the final head, and merge to the goal integration branch. +Stages A–D are complete. [PR #24](https://github.com/sjunepark/agent-scripts/pull/24) +merged as `751d93f` to `codex/sjskills-startup-integration`. Stage E remains +unstarted and requires separate authority, including any promotion to `main` +and host activation. ## Selected design @@ -170,13 +173,13 @@ result without an agent turn, installer, `apply`, `restore`, or plugin update. - [x] Run `node --test scripts/sjskills-hook.test.js` with temporary homes, caches, credentials, and controlled network responses. -- [ ] Exercise the registered Windows command through PowerShell and cmd.exe, +- [x] Exercise the registered Windows command through PowerShell and cmd.exe, and the macOS shell command on Intel and Apple silicon. Preserve Linux CI testing of supported-platform logic and the unsupported-target response. -- [ ] Run a compatible real native CLI against isolated configured and +- [x] Run a compatible real native CLI against isolated configured and unconfigured projects. Capture cold/warm timings, nonzero exits, cancellation, process cleanup, network/auth failures, and parallel startup results. -- [ ] Inspect before/after filesystem and command logs to prove managed roots, +- [x] Inspect before/after filesystem and command logs to prove managed roots, executables, installs, credentials, and trust remain unchanged. - [x] Run one bounded code review; fix actionable in-scope defects and run the relevant checks. If Go changes prove necessary, also use the Go skill and run From da80471c8b7bfa010438cb1104a1f5844dd8392f Mon Sep 17 00:00:00 2001 From: Sejun Park Date: Tue, 15 Sep 2026 15:30:14 +0900 Subject: [PATCH 7/7] fix: close startup status and process lifecycle review gaps Accept fresh no-release CLI evidence without inventing an available version. Terminate owned Unix process groups on native parent exit so descendants with closed output cannot outlive checks; preserve the parent result and report cleanup errors. Add regression coverage for no-release status, closed-output descendants, and valid oversized hook input. Refresh the plugin cachebuster, correct the root overview, and distinguish historical goal completion from the newly authorized main promotion and current Windows reconciliation. --- PROGRESS.md | 2 +- README.md | 14 ++++++----- ROADMAP.md | 9 +++---- docs/sjskills-startup-hook.md | 5 +++- goals/sjskills-startup-check.md | 10 +++++++- plans/sjskills-startup-check.md | 24 ++++++++++++------ .../.codex-plugin/plugin.json | 2 +- .../sjskills-maintenance/scripts/process.cjs | 8 +++++- .../sjskills-maintenance/scripts/status.cjs | 6 +++-- scripts/fixtures/startup-cli.go | 6 +++-- scripts/sjskills-hook.test.js | 25 ++++++++++++++++++- 11 files changed, 83 insertions(+), 28 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 5f5c762..3a5a7b7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -28,7 +28,7 @@ PR #23 review follow-up: confirmed comments 3979633085 (commit pins), 3979633091 - Stages A–D are delivered through [PR #24](https://github.com/sjunepark/agent-scripts/pull/24), merged as `751d93f` to `codex/sjskills-startup-integration`. The [completed goal](goals/sjskills-startup-check.md) owns the closed delivery boundary. - [Final CI](https://github.com/sjunepark/agent-scripts/actions/runs/34927077118) passed on Windows x64 and both macOS targets. Released-CLI compatibility, isolated package reinstall, bounded review, and documentation alignment passed. -- The [startup-check plan](plans/sjskills-startup-check.md) retains timing evidence and unstarted stage E. Promotion to main and real-host installation, trust, and activation remain separate. No configured reconciliation or automatic maintenance was performed. +- The user authorized main promotion and current-machine sync on 2026-09-15. [PR #25](https://github.com/sjunepark/agent-scripts/pull/25) is under review for promotion to main. The [startup-check plan](plans/sjskills-startup-check.md) owns stage E publication and activation status and the completed Windows skill-sync evidence. - Older installations may still inject the previous workflow. Existing plugin-data snapshots and update state remain intact. ## Previous CLI delivery diff --git a/README.md b/README.md index 4a085d3..a013aef 100644 --- a/README.md +++ b/README.md @@ -275,9 +275,11 @@ drift with `bin/install-codex-hooks --check`. Repair boundaries, machine-state ownership, and upstream removal checks are documented in [docs/codex-lifecycle-workarounds.md](docs/codex-lifecycle-workarounds.md). -`sjskills-maintenance` packages automatic CLI updates and configured skill -synchronization as a plugin, with daily adoption of its own published-main -updates. It requests maintenance in the first agent turn of each new or resumed -session. See the [plugin guide](docs/sjskills-startup-hook.md) for publication -prerequisites, per-host installation, update checks, and migration from the -retired standalone installer. [PROGRESS.md](PROGRESS.md) tracks delivery status. +`sjskills-maintenance` checks the native CLI, configured skills, and published +plugin version on startup and resume. Healthy checks stay silent; findings or +incomplete verification produce one short report. It never assigns maintenance +to the agent or updates installations. See the [plugin guide](docs/sjskills-startup-hook.md) +for per-host installation, hook trust, and migration from the retired standalone +installer. The [startup-check plan](plans/sjskills-startup-check.md) tracks +publication and activation; older installations retain their previous behavior +until replaced. diff --git a/ROADMAP.md b/ROADMAP.md index d2648d3..6ca0a50 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,10 @@ ## Current -_None._ +1. [Publish and activate the check-only sjskills hook](plans/sjskills-startup-check.md) + is authorized for the current Windows machine. PR #25 promotion to main is + underway; configured skills are synchronized. Plugin installation, trust, + and fresh-session acceptance remain pending. ## Plans @@ -10,10 +13,6 @@ _None._ is proposed and requires separate evidence-bound authorization for each machine. Exact-content approval binding is delivered. -2. [Publish and activate the check-only sjskills hook](plans/sjskills-startup-check.md) - has completed stages A–D on its integration branch. Stage E remains - unstarted and requires separate authorization. - ## Tasks _None._ diff --git a/docs/sjskills-startup-hook.md b/docs/sjskills-startup-hook.md index 1552136..89d3209 100644 --- a/docs/sjskills-startup-hook.md +++ b/docs/sjskills-startup-hook.md @@ -31,6 +31,8 @@ separate from source delivery. Fresh, complete evidence is required for silence. Exit zero, stale empty findings, missing scopes, disabled inspection, and development versions do not establish health. Known findings remain visible when another check fails. +Fresh evidence that no stable release exists is healthy when the running CLI +version is stable and the result contains no available-release metadata. Diagnostics use fixed text and omit skill names, private sources, credentials, raw subprocess output, and remote text. Repeated unresolved findings remain visible on each applicable invocation. @@ -42,7 +44,8 @@ stdin and subprocess output to 1 MiB and each metadata response to 128 KiB. CLI status and plugin observation run concurrently. There is no background worker or later agent recovery task. -On macOS, children run in an owned process group. On Windows, hidden PowerShell +On macOS, children run in an owned process group that is terminated when the +native parent exits, including when descendants have closed their output. On Windows, hidden PowerShell supervisors join kill-on-close Job Objects before launching the native programs; this prevents a departed parent from leaving descendants behind. The trusted shipped supervisor uses an invocation-only execution policy override; it does not diff --git a/goals/sjskills-startup-check.md b/goals/sjskills-startup-check.md index 343703f..4668fc5 100644 --- a/goals/sjskills-startup-check.md +++ b/goals/sjskills-startup-check.md @@ -46,4 +46,12 @@ None — goal complete - [Final native acceptance](https://github.com/sjunepark/agent-scripts/actions/runs/34927077118) passed source, artifact, hook, and consumer checks for head `3cfaf91` on all supported targets. Local registry/release tests, skill/plugin validation, go vet, and Markdown link/queue checks passed. - Codex and CodeRabbit reviews completed; all five threads are resolved. CodeRabbit withdrew automatic stale-lock reclamation after confirming the no-stolen-lock contract. Follow-up diffs were reviewed and tested without manual review retriggers. - Package version `0.1.0+codex.20260915035156` passed isolated Codex install/reinstall and silent healthy invocation. Native Windows v1.3.0 compatibility retained known findings during actual upstream failures. The plan records timing evidence. -- No blockers remain within this goal. Stage E real-host installation, trust, activation, and configured reconciliation remain unstarted. No default-branch promotion or ongoing installation update was performed. +- At this goal's completion, no blockers remained within its contract. Stage E real-host installation, trust, activation, and configured reconciliation were unstarted. No default-branch promotion or ongoing installation update had been performed. + +## Later-authorized delivery + +On 2026-09-15, after this goal completed, the user requested "merge to main and +sync current machine." This is a separate delivery authorization; the original +contract and its completion record above remain unchanged. The +[startup-check plan](../plans/sjskills-startup-check.md) owns PR #25 promotion, +current Windows skill-sync evidence, and remaining plugin activation work. diff --git a/plans/sjskills-startup-check.md b/plans/sjskills-startup-check.md index 7ce1cd6..fc6f331 100644 --- a/plans/sjskills-startup-check.md +++ b/plans/sjskills-startup-check.md @@ -10,9 +10,10 @@ the agent and never updates, installs, synchronizes, quarantines, or restores. The user selected **only check and report** on 2026-09-15. This replaces the previous automatic-maintenance target. Stages A–D were delivered through the -[closed goal](../goals/sjskills-startup-check.md) and PR lifecycle. Real-host -activation remains stage E work requiring separate authority; configured -reconciliation remains excluded. +[closed goal](../goals/sjskills-startup-check.md) and PR lifecycle. On the same +date, the user separately requested "merge to main and sync current machine," +authorizing stage E delivery to the current Windows host and explicit configured +skill reconciliation. The startup hook's check-only boundary is unchanged. ## Current state @@ -39,13 +40,22 @@ reconciliation remains excluded. supported findings now remain visible alongside incomplete verification. - Actual host installation, hook trust, and fresh-session activation have not been performed and remain stage E. +- Explicit skill sync on `DESKTOP-V33SCUA` completed with verified sjskills + v1.3.0: all 16 global placements were already exact; all 35 project placements + for this worktree's committed `dev` and `go` profiles were installed and are + exact. Final plans contain no changes or blocked operations; no quarantines + were created. Local before/apply/after evidence is retained under + `.tmp/machine-sync-20260915/`. The published catalog and registry already + matched the integration source, so this sync did not depend on plugin promotion. ## Next action Stages A–D are complete. [PR #24](https://github.com/sjunepark/agent-scripts/pull/24) -merged as `751d93f` to `codex/sjskills-startup-integration`. Stage E remains -unstarted and requires separate authority, including any promotion to `main` -and host activation. +merged as `751d93f` to `codex/sjskills-startup-integration`. +[PR #25](https://github.com/sjunepark/agent-scripts/pull/25) is under review for +the authorized promotion to `main`. Finish review and merge, then reinstall +`sjskills-maintenance@personal` on `DESKTOP-V33SCUA` from the remote marketplace +and complete hook trust and fresh-session acceptance. ## Selected design @@ -204,7 +214,7 @@ healthy check cannot be confused with stale, partial, or failed verification. Exit: packaged code and documentation describe one check-only path; no shipped prompt still requests unattended mutation or routine agent maintenance. -### E. Publish and activate when separately authorized +### E. Publish and activate on the authorized Windows host - [ ] Publish reviewed source to the configured remote `main` before updating ongoing installations. If the CLI was unchanged, no new CLI release is needed. diff --git a/plugins/sjskills-maintenance/.codex-plugin/plugin.json b/plugins/sjskills-maintenance/.codex-plugin/plugin.json index 0e28a59..ce2a89b 100644 --- a/plugins/sjskills-maintenance/.codex-plugin/plugin.json +++ b/plugins/sjskills-maintenance/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "sjskills-maintenance", - "version": "0.1.0+codex.20260915035156", + "version": "0.1.0+codex.20260915062837", "description": "Check CLI, configured skills, and published plugin status at startup without assigning agent work.", "author": { "name": "Local developer" diff --git a/plugins/sjskills-maintenance/scripts/process.cjs b/plugins/sjskills-maintenance/scripts/process.cjs index b132ac9..cdce81b 100644 --- a/plugins/sjskills-maintenance/scripts/process.cjs +++ b/plugins/sjskills-maintenance/scripts/process.cjs @@ -44,12 +44,16 @@ function runProcess(executable, args, { cwd, env = process.env, signal, limit = stdio: ["ignore", "pipe", "pipe"], }); let size = 0, stdout = [], failure, stopping; + const killGroup = () => { + if (!child.pid) return; + try { process.kill(-child.pid, "SIGKILL"); } catch (error) { if (error.code !== "ESRCH") failure = error; } + }; const stop = () => { failure ||= new Error("cancelled or output limit exceeded"); if (stopping || !child.pid) return; stopping = new Promise((done) => { if (process.platform !== "win32") { - try { process.kill(-child.pid, "SIGKILL"); } catch (error) { if (error.code !== "ESRCH") failure = error; } + killGroup(); done(); } else { const killer = spawn(path.join(env.SystemRoot || "C:\\Windows", "System32", "taskkill.exe"), @@ -67,6 +71,8 @@ function runProcess(executable, args, { cwd, env = process.env, signal, limit = else if (stream === child.stdout && !failure) stdout.push(chunk); }); child.once("error", (error) => { failure = error; }); + // Parent exit ends the owned work even when descendants closed their pipes. + if (!windows) child.once("exit", killGroup); child.once("close", async (code) => { signal?.removeEventListener("abort", stop); await stopping; diff --git a/plugins/sjskills-maintenance/scripts/status.cjs b/plugins/sjskills-maintenance/scripts/status.cjs index 5bd087f..fe26790 100644 --- a/plugins/sjskills-maintenance/scripts/status.cjs +++ b/plugins/sjskills-maintenance/scripts/status.cjs @@ -17,8 +17,10 @@ function classifyStatus(value, now = Date.now()) { } const cli = value?.cliAdvisory; if (object(cli) && cli.comparison === "update") findings.push("CLI update available"); - if (!object(cli) || !["equal", "ahead", "update"].includes(cli.comparison) || - !stable(cli.runningVersion) || !stable(cli.availableVersion) || !fresh(cli, now)) incomplete.push("CLI verification"); + const releaseEvidence = object(cli) && (cli.comparison === "no-release" + ? [undefined, ""].includes(cli.availableVersion) && [undefined, ""].includes(cli.releaseURL) + : ["equal", "ahead", "update"].includes(cli.comparison) && stable(cli.availableVersion)); + if (!releaseEvidence || !stable(cli.runningVersion) || !fresh(cli, now)) incomplete.push("CLI verification"); const configuration = value?.status?.projectConfiguration; if (!["configured", "not-configured", "unavailable"].includes(configuration)) incomplete.push("project configuration"); diff --git a/scripts/fixtures/startup-cli.go b/scripts/fixtures/startup-cli.go index 460d56c..6283440 100644 --- a/scripts/fixtures/startup-cli.go +++ b/scripts/fixtures/startup-cli.go @@ -10,9 +10,11 @@ import ( ) func main() { - if len(os.Args) == 4 && os.Args[1] == "--orphan" { + if len(os.Args) == 4 && (os.Args[1] == "--orphan" || os.Args[1] == "--orphan-closed") { child := exec.Command(os.Args[2], "-e", "setInterval(()=>{},1000)") - child.Stdout, child.Stderr = os.Stdout, os.Stderr + if os.Args[1] == "--orphan" { + child.Stdout, child.Stderr = os.Stdout, os.Stderr + } if err := child.Start(); err != nil { panic(err) } diff --git a/scripts/sjskills-hook.test.js b/scripts/sjskills-hook.test.js index efcee66..9075ca8 100644 --- a/scripts/sjskills-hook.test.js +++ b/scripts/sjskills-hook.test.js @@ -5,7 +5,7 @@ const { test, before, after } = require("node:test"); const { check } = require("../plugins/sjskills-maintenance/scripts/session-start.cjs"); const { classifyStatus, report, DAY } = require("../plugins/sjskills-maintenance/scripts/status.cjs"); const { observePlugin, provenance, mainRef, fetchJSON, RETRY } = require("../plugins/sjskills-maintenance/scripts/observation.cjs"); -const { nativeExecutable, runProcess } = require("../plugins/sjskills-maintenance/scripts/process.cjs"); +const { nativeExecutable, runProcess, LIMIT } = require("../plugins/sjskills-maintenance/scripts/process.cjs"); const repo = path.resolve(__dirname,".."), plugin = path.join(repo,"plugins/sjskills-maintenance"); const version = JSON.parse(fs.readFileSync(path.join(plugin,".codex-plugin/plugin.json"))).version; const now = Date.now(), commit = "a".repeat(40); @@ -33,6 +33,14 @@ test("stale, absent, unknown, disabled and malformed evidence cannot be healthy" const mutations=[v=>delete v.cliAdvisory,v=>v.cliAdvisory.comparison="uncomparable",v=>v.cliAdvisory.comparison="future",v=>v.cliAdvisory.runningVersion="dev",v=>v.cliAdvisory.observedAt=new Date(now+1).toISOString(),v=>v.advisories.pop(),v=>v.advisories.push(v.advisories[0]),v=>v.status.projectConfiguration="skipped",v=>v.status.projectConfiguration="unknown",v=>v.advisories[0].freshness="stale",v=>v.advisories[0].findings=null,v=>v.advisories[0].observedAt=new Date(now-DAY).toISOString(),v=>v.advisories[0].error="private auth secret",v=>v.result="unavailable"]; for(const mutate of mutations){const value=healthy();mutate(value);const r=report([classifyStatus(value,now)]);assert.match(r.systemMessage,/incomplete/);assert.doesNotMatch(r.systemMessage,/secret/);assert.equal(r.hookSpecificOutput,undefined);} }); +test("fresh no-release status is healthy only with a stable running version and no release metadata",async()=>{ + const value=healthy();value.cliAdvisory.comparison="no-release";delete value.cliAdvisory.availableVersion; + const options={platform:"darwin",arch:"arm64",now,resolve:()=>process.execPath,observe:async()=>healthyPlugin,run:async()=>({code:0,stdout:JSON.stringify(value)})}; + assert.equal(await check(event,options),null); + for(const mutate of [v=>v.freshness="stale",v=>v.runningVersion="dev",v=>v.availableVersion="1.3.0",v=>v.releaseURL="https://example.com"]){ + const invalid=structuredClone(value);mutate(invalid.cliAdvisory);assert.match(report([classifyStatus(invalid,now)]).systemMessage,/CLI verification/); + } +}); test("known findings survive partial failure, additive fields and repeated checks",()=>{ const value=healthy();value.newField=true;value.cliAdvisory.comparison="update"; value.advisories[0].findings=[{category:"missing",skill:"sensitive name",reason:"missing"}];value.advisories[1].freshness="unavailable"; @@ -106,6 +114,15 @@ test("bounded process output and cancellation clean up owned descendants",async test("malformed hook input is bounded and never echoes untrusted content",()=>{ for(const input of ["secret", "null", "[]", "x".repeat(1024*1024+1)]){const r=spawnSync(process.execPath,[path.join(plugin,"scripts/session-start.cjs")],{input,encoding:"utf8",windowsHide:true,timeout:5000});assert.equal(r.status,0,r.stderr);const value=JSON.parse(r.stdout);assert.match(value.systemMessage,/incomplete/);assert.doesNotMatch(value.systemMessage,/secret/);assert.equal(value.hookSpecificOutput,undefined);} }); +test("valid oversized startup input is rejected by the input guard",()=>{ + const input=JSON.stringify({...event,secret:"x".repeat(LIMIT)}); + const result=spawnSync(process.execPath,[path.join(plugin,"scripts/session-start.cjs")],{input,encoding:"utf8",windowsHide:true,timeout:5000}); + assert.equal(result.status,0,result.stderr); + const value=JSON.parse(result.stdout); + assert.match(value.systemMessage,/hook input or deadline/); + assert.doesNotMatch(value.systemMessage,/secret/); + assert.equal(value.hookSpecificOutput,undefined); +}); test("read-only provenance rejects disabled, mismatched and custom-source installations",async t=>{ const root=temp(t);fs.writeFileSync(path.join(root,"config.toml"),config); @@ -155,6 +172,12 @@ test("native parent exit cannot leave a pipe-holding descendant running",async t assert.ok(Date.now()-begin<5500);const pid=Number(fs.readFileSync(file,"utf8")); await assertStopped(pid); }); +test("native parent exit cannot leave a descendant with closed output running",async t=>{ + const root=temp(t),file=path.join(root,"orphan-pid"); + const result=await runProcess(executable,["--orphan-closed",process.execPath,file],{cwd:root,signal:AbortSignal.timeout(3000)}); + assert.equal(result.code,0); + await assertStopped(Number(fs.readFileSync(file,"utf8"))); +}); async function assertStopped(pid) { let running=true;