Begin portable TORCH fleet rewrite

This commit is contained in:
thePR0M3TH3AN committed 2026-09-27 15:39:12 -04:00
1 parent a46832314f
commit cce059fc3a
16 files changed
+2258 -613

No files matched your search

+53 -417
View File
@@ -1,445 +1,81 @@
# TORCH: Task Orchestration via Relay-Coordinated Handoff
# TORCH
TORCH is a portable Nostr-based task locking toolkit for multi-agent coordination.
TORCH turns a Git repository into a persistent, coordinated team of
domain-specialized AI development sessions.
## Why Run Torch? (The Cool Stuff)
The new TORCH is being extracted from the agent-fleet operating model proven
in COMBATRIG. It is local-first, provider-independent, worktree-isolated, and
designed to manage its own development. The former Nostr lock/scheduler product
is preserved on the `legacy/nostr-torch` branch and is not part of this
architecture.
## Current status
TORCH isn't just another script—it's an operating system for your AI workforce. Here’s why it’s awesome:
The rewrite is under active development on `rewrite/portable-agent-fleet`.
Stage 1 currently provides the first portable kernel:
- **Decentralized Coordination:** No central server required. Agents coordinate via Nostr relays like digital telepathy.
- **Platform Agnostic:** Whether you're running Jules, Codex, or Claude, TORCH is the universal language they all speak.
- **Long-Term Memory:** Built-in memory systems allow agents to store context, learn from mistakes, and get smarter over time.
- **Verifiable Handoffs:** Trust, but verify. Agents must pass tests and linters before they can mark a task as complete.
- **Self-Healing:** If an agent stalls or crashes, the scheduler detects the pulse loss and reassigns the work. The factory must grow.
- **Drop-in Divinity:** No complex SaaS integrations. Just drop the `torch` folder into your repo and you're live.
- read-only Git repository inspection;
- repository inventory and check discovery;
- explicit install approval;
- tracked `.torch/` project configuration;
- XDG-local runtime state;
- installation ownership manifest;
- health diagnostics;
- conservative, reversible purge.
## Installation
Session orchestration, worktree generation, messaging, checks, integration,
runtime adapters, self-hosting, and the Fleet Console remain staged work.
Prerequisites:
- Node.js 22 or newer
First 5 minutes:
```bash
npm install https://github.com/PR0M3TH3AN/TORCH/archive/<commit-sha>.tar.gz --force \
&& npx --no-install torch-lock init
# sanity check (optional, but recommended)
npx --no-install torch-lock check --cadence daily
# start dashboard
npx --no-install torch-lock dashboard --port 4173 --host 127.0.0.1
```
`torch-lock init` and `torch-lock update` now auto-upsert the `## TORCH Memory Integration` block into root-level `AGENTS.md` and `CLAUDE.md` (and create `AGENTS.md` if neither file exists).
> TORCH is distributed from GitHub tarballs and is not currently published to the npm registry.
> Use pinned commit tarballs (`archive/<commit-sha>.tar.gz`) for repeatable installs. Avoid `refs/heads/main.tar.gz` for production rollouts.
### Upgrade TORCH In An Existing Repository
Use this when the repository already has a `torch/` directory:
```bash
npm install https://github.com/PR0M3TH3AN/TORCH/archive/<commit-sha>.tar.gz --force
npx --no-install torch-lock update --force
npm install --prefix torch
npm run --prefix torch lock:check:daily -- --json --quiet
```
Use this for first-time setup in a repository without `torch/`:
```bash
npm install https://github.com/PR0M3TH3AN/TORCH/archive/<commit-sha>.tar.gz --force
npx --no-install torch-lock init --force
npm install --prefix torch
npm run --prefix torch lock:check:daily -- --json --quiet
```
One-liner (auto-detect existing vs new install):
```bash
npm install https://github.com/PR0M3TH3AN/TORCH/archive/<commit-sha>.tar.gz --force && \
if [ -d torch ]; then npx --no-install torch-lock update --force; else npx --no-install torch-lock init --force; fi && \
npm install --prefix torch && \
npm run --prefix torch lock:check:daily -- --json --quiet
```
Post-upgrade validation checklist:
1. Confirm root config exists: `test -f torch-config.json`.
2. Confirm handoff defaults exist: `rg -n "handoffCommandByCadence" torch-config.json`.
3. Confirm host install dependencies exist: `test -d torch/node_modules`.
4. Run one scheduler cycle (expected to fail on Linux simulation due to artifact checks, but not due to missing config/handoff):
- `AGENT_PLATFORM=linux npm run --prefix torch scheduler:daily`
5. Verify installed package matches expected resolver behavior:
- `rg -n "cwdBaseName === 'torch' && fs.existsSync\\(parentPath\\)" node_modules/torch-lock/src/torch-config.mjs`
- `rg -n "cwdBaseName === 'torch' && fs.existsSync\\(parentPath\\)" torch/src/torch-config.mjs`
### Reusable Agent Prompt: Upgrade TORCH
Use this prompt in a project repository when you want an AI agent to perform a safe TORCH upgrade:
```md
You are updating TORCH in this repository to the latest pinned version.
Goal:
- Upgrade TORCH safely.
- Keep behavior stable.
- Validate the new memory event pipeline (`torch-lock memory add/build/verify`).
Constraints:
- Do not weaken or edit tests to make CI pass.
- Make minimal, reviewable changes.
- Use pinned TORCH commit tarball, never a moving branch tarball.
Steps:
1) Create a branch
- `git checkout -b chore/update-torch-393ba35`
2) Install new TORCH package (pinned)
- `npm install https://github.com/PR0M3TH3AN/TORCH/archive/393ba35.tar.gz --force`
3) Apply TORCH files into repo
- If `torch/` already exists:
- `npx --no-install torch-lock update --force`
- Else (fresh setup):
- `npx --no-install torch-lock init --force`
4) Install/update TORCH runtime deps
- If TORCH is installed under `torch/`:
- `npm install --prefix torch`
- If installed in root, run normal install:
- `npm install`
5) Validate lock/scheduler basics
- `npm run --prefix torch lock:check:daily -- --json --quiet` (or root equivalent)
- `npx --no-install torch-lock doctor`
6) Validate new memory contract (required)
- Ensure schema exists: `memory/schema/memory-event.schema.json`
- Run:
- `npx --no-install torch-lock memory build`
- `npx --no-install torch-lock memory verify`
- If verify fails, fix root cause (schema/path/duplicate/out-of-date), do not bypass checks.
7) Run project validation
- Run the project’s standard checks (at minimum lint + tests).
- Record exact commands run and pass/fail outcomes.
8) Commit
- Stage only relevant update files.
- Commit message:
- `chore(torch): update to 393ba35 and enable memory event verify pipeline`
9) Report back
Provide:
- Files changed
- Commands run
- Validation results
- Any migration notes (especially memory-related changes)
- Risks/follow-ups
```
The canonical architecture and delivery plan is
[docs/PORTABLE_AGENT_FLEET_SPEC.md](docs/PORTABLE_AGENT_FLEET_SPEC.md).
## Development
For instructions on how to contribute to TORCH, including building, testing, and linting, please see [CONTRIBUTING.md](CONTRIBUTING.md).
Requirements:
Quick start:
- Node.js 22 or later
- Git
Run the current verification suite:
```bash
npm install
npm run build
npm run format
npm test
npm run lint
npm run check
```
## Usage
### CLI
The `torch-lock` CLI allows you to check, lock, and list tasks.
Inspect a repository without modifying it:
```bash
# Check locks for a specific cadence
npx --no-install torch-lock check --cadence daily
# Lock a task for an agent
AGENT_PLATFORM=codex npx --no-install torch-lock lock --agent docs-agent --cadence daily
# List all active locks
npx --no-install torch-lock list
# Mark a task as completed (permanent)
AGENT_PLATFORM=codex npx --no-install torch-lock complete --agent docs-agent --cadence daily
# Convenience script for the above
npm run lock:complete -- --agent docs-agent --cadence daily
# Generic usage for lock completion
npm run lock:complete -- --agent <agent> --cadence <cadence>
# Initialize TORCH in a new repository
npx --no-install torch-lock init
# Update TORCH configuration and scripts
npx --no-install torch-lock update
# Validate setup and print actionable fixes
npx --no-install torch-lock doctor
# Remove TORCH completely from this project
npx --no-install torch-lock remove
# Run the Dashboard
npx --no-install torch-lock dashboard
node bin/torch.mjs init --json
node bin/torch.mjs analyze --json
node bin/torch.mjs install --dry-run --json
```
### Scheduler
The scheduler automates the selection and execution of agents in a round-robin rotation.
Install the current kernel only after reviewing the dry run:
```bash
# Run full daily scheduler cycle
npm run scheduler:daily
# Run full weekly scheduler cycle
npm run scheduler:weekly
node bin/torch.mjs install --yes --json
node bin/torch.mjs doctor --json
```
### Memory CLI
The alpha kernel is not yet a working multi-agent fleet. Its install format may
change before the first usable release.
The `torch-lock` CLI also supports memory operations:
## Product principles
```bash
# Append one schema-validated memory event (append-only)
npx --no-install torch-lock memory add \
--agent codex \
--topic memory \
--message "Lock preflight failed on relay outage; rerun with fallback relays."
- Git is required; GitHub is optional.
- The repository is durable organizational memory; chat sessions are workers.
- Every persistent domain owns one branch and one worktree.
- Every domain has explicit scope and explicit exclusions.
- Peers coordinate directly; the Session Manager controls priority and
ownership.
- Monitoring may be automatic; active worktrees are changed intentionally.
- Verification belongs to an exact commit.
- The TORCH engine runs outside the repository it manages.
- Installation is reversible and refuses unsafe deletion.
- Stable TORCH must be able to build its successor.
# Build deterministic memory_update.md from memory/events/**/*.json
npx --no-install torch-lock memory build
## License
# Verify schema + duplicates + determinism + generated-file no-diff
npx --no-install torch-lock memory verify
# List memories (optional filters: --agent, --type, --tags, --pinned)
npx --no-install torch-lock list-memories --limit 5
# Inspect a specific memory
npx --no-install torch-lock inspect-memory --id <memory-id>
# Pin/Unpin a memory
npx --no-install torch-lock pin-memory --id <memory-id>
npx --no-install torch-lock unpin-memory --id <memory-id>
# Check memory stats
npx --no-install torch-lock memory-stats
```
Memory event contract:
- Source: `memory/events/YYYY/MM/DD/<timestamp>_<id>.json` (append-only).
- Schema: `memory/schema/memory-event.schema.json`.
- Generated file: `memory_update.md` with auto-generated header.
- Full contract: `docs/memory-events-contract.md`.
### Prompt Governance & Versioning CLI
Agent prompts are changed through a governance workflow that archives the previous version before overwriting, enabling full rollback.
```bash
# Propose a change to an agent prompt
npx --no-install torch-lock proposal create \
--agent my-agent \
--target src/prompts/daily/my-agent.md \
--content /path/to/new-content.md \
--reason "Add missing EXIT CRITERIA section"
# List pending proposals
npx --no-install torch-lock proposal list
npx --no-install torch-lock proposal list --status pending
# Inspect or apply a proposal
npx --no-install torch-lock proposal show --id <proposal-id>
npx --no-install torch-lock proposal apply --id <proposal-id>
npx --no-install torch-lock proposal reject --id <proposal-id> --reason "..."
# List available archived versions of a prompt
npx --no-install torch-lock rollback --target src/prompts/daily/my-agent.md --list
# Roll back to the most recent archived version
npx --no-install torch-lock rollback --target src/prompts/daily/my-agent.md
# Roll back to a specific version by hash fragment
npx --no-install torch-lock rollback --target src/prompts/daily/my-agent.md --strategy <hash>
```
### State Backup CLI
Snapshot agent runtime state (long-term memory and scheduler state) to a timestamped directory under `.torch/backups/`.
```bash
# Create a snapshot
npx --no-install torch-lock backup
# List existing snapshots
npx --no-install torch-lock backup --list
# Write snapshot to a custom directory
npx --no-install torch-lock backup --output /path/to/backup-dir
```
See [docs/prompt-versioning.md](docs/prompt-versioning.md) for the full workflow, restore procedure, and storage layout.
### Configuration (`torch-config.json`)
Create a `torch-config.json` file in your project root to configure TORCH for your repository. You can use the included `torch-config.example.json` as a template.
Configuration options:
- `nostrLock.namespace` (d-tag/tag namespace)
- `nostrLock.relays` (array of relay URLs)
- `nostrLock.ttlSeconds` (lock duration in seconds)
- `nostrLock.queryTimeoutMs` (timeout for relay queries)
- `nostrLock.publishTimeoutMs` (per-relay publish timeout in ms)
- `nostrLock.minSuccessfulRelayPublishes` (minimum successful publishes before lock continues, default: `1`)
- `nostrLock.relayFallbacks` (optional fallback relay URLs used when primary relays fail quorum)
- `nostrLock.minActiveRelayPool` (minimum relays kept active even when others are quarantined, default: `1`)
- `nostrLock.dailyRoster` / `nostrLock.weeklyRoster` (explicit rosters)
- `dashboard.defaultCadenceView` (`daily`, `weekly`, or `all`)
- `dashboard.defaultStatusView` (`active` or `all`)
- `dashboard.hashtag` (custom hashtag for lock events)
- `scheduler.firstPromptByCadence.daily` / `.weekly` (first prompt to run)
- `scheduler.handoffCommandByCadence.daily` / `.weekly` (shell command run after lock acquisition; must use `SCHEDULER_AGENT`, `SCHEDULER_CADENCE`, `SCHEDULER_PROMPT_PATH` env vars)
- `scheduler.paused.daily` / `.weekly` (array of agent names to pause locally)
- `scheduler.strict_lock` (lock backend policy; when `false`, defers backend-unavailable failures before failing, default: `true`)
- `scheduler.degraded_lock_retry_window` (ms window for non-strict deferral of backend failures)
- `scheduler.max_deferrals` (max non-strict lock deferrals before recording a hard failure)
### Dashboard
The dashboard provides a live view of lock events.
```bash
npx --no-install torch-lock dashboard --port 4173 --host 127.0.0.1
# Open http://localhost:4173/dashboard/
```
By default, the dashboard binds to `127.0.0.1`. To allow external access, you can set `--host 0.0.0.0`. It is highly recommended to enable authentication if the dashboard is exposed to a network.
#### Authentication
The dashboard supports Basic Authentication. You can enable it by setting the `TORCH_DASHBOARD_AUTH` environment variable or the `dashboard.auth` field in `torch-config.json` with the format `username:password`.
## Removing TORCH
To completely remove TORCH from a project:
```bash
npx torch-lock remove
```
This removes the `torch/` directory, configuration files, runtime artifacts, host package.json scripts, and the npm package. Use `--force` to skip the confirmation prompt.
For manual removal steps and a full list of what gets cleaned up, see [docs/removal.md](docs/removal.md).
## Environment variables
You can override configuration using environment variables:
- `NOSTR_LOCK_NAMESPACE`
- `NOSTR_LOCK_HASHTAG`
- `NOSTR_LOCK_RELAYS` (comma-separated)
- `NOSTR_LOCK_TTL`
- `NOSTR_LOCK_QUERY_TIMEOUT_MS`
- `NOSTR_LOCK_PUBLISH_TIMEOUT_MS`
- `NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES`
- `NOSTR_LOCK_RELAY_FALLBACKS` (comma-separated fallback relay URLs)
- `NOSTR_LOCK_MIN_ACTIVE_RELAY_POOL`
- `NOSTR_LOCK_DAILY_ROSTER` (comma-separated list of agents)
- `NOSTR_LOCK_WEEKLY_ROSTER` (comma-separated list of agents)
- `TORCH_CONFIG_PATH` (path to config file)
- `TORCH_DASHBOARD_AUTH` (Basic Auth `user:pass` for the dashboard)
- `AGENT_PLATFORM` (platform identifier, e.g. `codex`)
- `TORCH_MEMORY_ENABLED` (`true`/`false`; global memory kill switch, defaults to enabled)
- `TORCH_MEMORY_INGEST_ENABLED` (`true`/`false` or comma-separated canary `agent_id` allow list)
- `TORCH_MEMORY_RETRIEVAL_ENABLED` (`true`/`false` or comma-separated canary `agent_id` allow list)
- `TORCH_MEMORY_PRUNE_ENABLED` (`true`, `false`, or `dry-run`)
## Roster precedence
The lock CLI resolves roster names in this order:
1. `NOSTR_LOCK_DAILY_ROSTER` / `NOSTR_LOCK_WEEKLY_ROSTER` (comma-separated env overrides).
2. `torch-config.json` (`nostrLock.dailyRoster` / `nostrLock.weeklyRoster`).
3. `src/prompts/roster.json` (canonical scheduler roster included in the package).
4. Built-in fallback roster.
## Included Resources
- `src/lib.mjs` — Core library logic (can be imported in scripts)
- `TORCH.md` — Protocol summary and usage
- `src/prompts/` — Generic scheduler prompts and flow
- `skills/` — Repository-local skill guides for agent onboarding and repeatable workflows
- `dashboard/` — Static lock dashboard assets
## NPM Scripts (for development)
If you are developing `torch-lock` itself:
- `npm run lock:check:daily`
- `npm run lock:check:weekly`
- `npm run lock:list`
- `npm run lock:lock` (manually acquire a lock)
- `npm run lock:health -- --cadence daily` (relay websocket + publish/read probe; writes history to `task-logs/relay-health/`)
- `npm run lock:complete` (manually complete a task)
- `npm run dashboard:serve`
- `npm test` (run unit tests)
- `npm run test:unit:lock-backend` (run lock backend unit tests)
- `npm run test:extended-main` (run extended integration tests)
- `npm run validate:scheduler` (validate scheduler roster and prompts)
- `npm run lint` (run linter)
- `npm run scheduler:daily` (run full daily scheduler cycle)
- `npm run scheduler:weekly` (run full weekly scheduler cycle)
- `npm run report:lock-reliability` (aggregate recent scheduler logs into markdown+JSON reliability reports)
- `npm run torch:memory:list` (list memories)
- `npm run torch:memory:inspect` (inspect a memory)
- `npm run torch:remove` (remove TORCH from the project)
## Scheduler lock reliability reporting
Use the reporting hook below to inspect lock backend reliability trends by platform/cadence/error category/relay:
```bash
npm run report:lock-reliability
```
This writes:
- `artifacts/lock-reliability/lock-reliability-summary.md`
- `artifacts/lock-reliability/lock-reliability-summary.json`
Recommended cadence: run this at least weekly (or wire into your CI/cron scheduler) and compare `codex` vs other `platform` values.
## Memory rollout plan
1. Deploy memory schema changes with scheduler jobs disabled (`TORCH_MEMORY_ENABLED=false` or subsystem flags set to `false`).
2. Enable ingest for one canary `agent_id` via `TORCH_MEMORY_INGEST_ENABLED=<agent_id>`.
3. Validate retrieval quality and storage growth metrics before expanding scope.
4. Enable broader retrieval (`TORCH_MEMORY_RETRIEVAL_ENABLED=<allow-list>` then `true`).
5. Enable pruning in `dry-run` mode first, then switch to active pruning after revalidation.
## Memory rollback plan
1. Disable memory flags (`TORCH_MEMORY_ENABLED=false` and/or set ingest/retrieval/prune flags to `false`).
2. Stop memory maintenance scheduler processes.
3. Preserve database state for post-incident analysis; do not drop or rewrite memory tables during rollback.
4. Keep prune actions in `dry-run` (or disabled) until lifecycle policy and data integrity are revalidated.
MIT
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env node
import { runCli } from '../src/cli.mjs';
process.exitCode = await runCli();
File diff suppressed because it is too large. Load diff
+5 -134
View File
@@ -1,19 +1,15 @@
{
"name": "torch-lock",
"version": "0.1.0",
"name": "torch-agent-fleet",
"version": "0.1.0-alpha.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "torch-lock",
"version": "0.1.0",
"name": "torch-agent-fleet",
"version": "0.1.0-alpha.0",
"license": "MIT",
"dependencies": {
"nostr-tools": "2.23.1",
"ws": "8.19.0"
},
"bin": {
"torch-lock": "bin/torch-lock.mjs"
"torch": "bin/torch.mjs"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -254,45 +250,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@noble/ciphers": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
@@ -309,42 +266,6 @@
"node": ">=18"
}
},
"node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
"license": "MIT",
"dependencies": {
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -1207,35 +1128,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/nostr-tools": {
"version": "2.23.1",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.1.tgz",
"integrity": "sha512-Q5SJ1omrseBFXtLwqDhufpFLA6vX3rS/IuBCc974qaYX6YKGwEPxa/ZsyxruUOr+b+5EpWL2hFmCB5AueYrfBw==",
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "2.1.1",
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0",
"@scure/bip32": "2.0.1",
"@scure/bip39": "2.0.1",
"nostr-wasm": "0.1.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/nostr-wasm": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"license": "MIT"
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -1526,27 +1418,6 @@
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+22 -62
View File
@@ -1,16 +1,17 @@
{
"name": "torch-lock",
"version": "0.1.0",
"name": "torch-agent-fleet",
"version": "0.1.0-alpha.0",
"type": "module",
"description": "TORCH — portable Nostr-based task locking toolkit for multi-agent coordination",
"main": "src/lib.mjs",
"description": "TORCH — a portable, provider-independent AI development fleet for Git repositories",
"main": "src/cli.mjs",
"exports": {
".": "./src/lib.mjs",
"./constants": "./src/constants.mjs",
"./lock-ops": "./src/lock-ops.mjs"
".": "./src/cli.mjs",
"./kernel/analyze": "./src/kernel/analyze.mjs",
"./kernel/git": "./src/kernel/git.mjs",
"./kernel/install": "./src/kernel/install.mjs"
},
"bin": {
"torch-lock": "./bin/torch-lock.mjs"
"torch": "./bin/torch.mjs"
},
"engines": {
"node": ">=22.0.0"
@@ -20,71 +21,30 @@
"url": "https://github.com/PR0M3TH3AN/TORCH.git"
},
"keywords": [
"nostr",
"task-locking",
"agent-fleet",
"worktrees",
"multi-agent",
"coordination",
"scheduler",
"orchestration",
"cli"
],
"license": "MIT",
"files": [
"bin/",
"src/*.mjs",
"src/services/",
"src/prompts/",
"src/docs/",
"scripts/check-file-size.mjs",
"scripts/check-innerhtml.mjs",
"scripts/test-audit/",
"scripts/memory/",
"memory/schema/",
"migrations/",
"skills/",
"dashboard/",
"assets/",
"eslint.config.mjs",
"torch-config.example.json",
"bin/torch.mjs",
"src/cli.mjs",
"src/kernel/",
"docs/PORTABLE_AGENT_FLEET_SPEC.md",
"README.md",
"TORCH.md",
"LICENSE"
],
"scripts": {
"test": "npm run validate:scheduler && npm run test:integration:e2e && node --test --test-timeout=30000 test/*.test.mjs test/*.test.js",
"test:unit:lock-backend": "node --test test/lock-ops.test.mjs test/lock-ops-publish-retry.test.mjs",
"lock:check:daily": "node bin/torch-lock.mjs check --cadence daily",
"lock:check:weekly": "node bin/torch-lock.mjs check --cadence weekly",
"lock:list": "node bin/torch-lock.mjs list",
"lock:lock": "node bin/torch-lock.mjs lock",
"lock:health": "node bin/torch-lock.mjs health",
"dashboard:serve": "node bin/torch-lock.mjs dashboard",
"build": "node build.mjs",
"format": "eslint . --fix",
"lint": "eslint .",
"lint:inline-styles": "! grep -r \"style=\" src/ dashboard/ --exclude-dir=test_logs --exclude-dir=task-logs",
"lock:complete": "node bin/torch-lock.mjs complete",
"validate:scheduler": "node scripts/validate-scheduler-roster.mjs && node scripts/validate-prompt-contract.mjs && node scripts/validate-scheduler-flow-parity.mjs && npm run validate:scheduler-failure-schema",
"scheduler:daily": "node scripts/agent/run-scheduler-cycle.mjs daily",
"scheduler:weekly": "node scripts/agent/run-scheduler-cycle.mjs weekly",
"report:lock-reliability": "node scripts/agent/summarize-lock-reliability.mjs --since-days 30",
"test:integration:e2e": "node --test test/scheduler-preflight-lock.e2e.test.mjs",
"validate:scheduler-failure-schema": "node --test test/scheduler-lock-failure-schema.contract.test.mjs",
"test:ci-resilience": "node --test test/lock-acquisition-resilience.integration.test.mjs",
"test:extended-main": "npm run test:integration:e2e && npm run test:ci-resilience",
"test:playwright": "playwright test",
"test:playwright:coverage": "rm -rf coverage/playwright/tmp && mkdir -p coverage/playwright/tmp && CI=true playwright test && npx c8 report --temp-directory coverage/playwright/tmp --report-dir coverage/playwright --reporter text --reporter json --all --src src",
"test:playwright:ui": "playwright test --ui",
"torch:memory:list": "node bin/torch-lock.mjs list-memories",
"torch:memory:inspect": "node bin/torch-lock.mjs inspect-memory",
"torch:memory:add": "node bin/torch-lock.mjs memory add",
"torch:memory:build": "node bin/torch-lock.mjs memory build",
"torch:memory:verify": "node bin/torch-lock.mjs memory verify",
"torch:remove": "node bin/torch-lock.mjs remove"
},
"dependencies": {
"nostr-tools": "2.23.1",
"ws": "8.19.0"
"test": "node --test --test-timeout=30000 test/fleet/*.test.mjs",
"torch": "node bin/torch.mjs",
"check": "node --check bin/torch.mjs && node --check src/cli.mjs",
"format": "eslint src/kernel src/cli.mjs bin/torch.mjs test/fleet --fix",
"lint": "eslint src/kernel src/cli.mjs bin/torch.mjs test/fleet"
},
"dependencies": {},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.58.2",
@@ -0,0 +1,41 @@
test_integrity_note:
change_type:
- new_tests
scenarios:
- id: SCN-init-read-only
given: a normal Git repository with committed project files
when: TORCH inspects and analyzes it or produces an installation plan
then: no project or machine-local TORCH state is created and Git status is unchanged
- id: SCN-install-doctor-purge
given: a reviewed fresh installation into a Git repository
when: TORCH installs, diagnoses, and purges its unchanged state
then: doctor reports healthy, all owned TORCH state is removed, and project source remains unchanged
- id: SCN-purge-protects-user-change
given: an installed TORCH project whose managed configuration was edited by its owner
when: a purge is requested
then: TORCH refuses destructive cleanup and preserves the modified file
- id: SCN-cli-install-approval
given: a Git repository without TORCH
when: install is invoked without a reviewed approval flag
then: the command refuses and creates no TORCH state
observable_outcomes:
- Git status before and after read-only analysis
- presence or absence of .torch and XDG-local project state
- doctor health and finding codes
- CLI exit status and structured error code
- preservation of project source and modified owner configuration
determinism_controls:
- isolated temporary Git repositories
- fixed test Git identity
- fixed installation timestamp where content is asserted
- per-fixture XDG_DATA_HOME
anti_cheat_rationale:
prevents:
- init that silently writes files
- installer that overwrites existing state
- doctor that reports healthy without required state
- uninstall that deletes owner-modified configuration
- CLI that mutates without explicit approval
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
+68
View File
@@ -0,0 +1,68 @@
import { analyzeRepository } from './kernel/analyze.mjs';
import { diagnoseProject } from './kernel/doctor.mjs';
import { asErrorRecord, TorchError } from './kernel/errors.mjs';
import { inspectRepository } from './kernel/git.mjs';
import { installProject, planInstall, uninstallProject } from './kernel/install.mjs';
const HELP = `TORCH — portable agent fleet
Usage:
torch init [--json]
torch analyze [--json]
torch install [--dry-run] [--yes] [--json]
torch doctor [--json]
torch uninstall [--dry-run] [--purge] [--json]
`;
function print(value, { json = false } = {}) {
if (json || typeof value !== 'string') process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
else process.stdout.write(`${value}\n`);
}
export async function runCli(argv = process.argv.slice(2), { cwd = process.cwd(), env = process.env } = {}) {
const command = argv.find((arg) => !arg.startsWith('-')) ?? 'help';
const json = argv.includes('--json');
try {
if (command === 'help' || argv.includes('--help') || argv.includes('-h')) {
process.stdout.write(HELP);
return 0;
}
const repository = inspectRepository(cwd);
if (command === 'init' || command === 'analyze') {
const analysis = analyzeRepository(repository);
print({ command, repository, analysis, next: 'Review analysis, then run torch install --dry-run.' }, { json });
return 0;
}
if (command === 'install') {
if (argv.includes('--dry-run')) {
print(planInstall({ repository, env }), { json });
return 0;
}
if (!argv.includes('--yes')) {
throw new TorchError('Installation requires reviewed approval. Re-run with --dry-run, then --yes.', {
code: 'APPROVAL_REQUIRED',
});
}
print(installProject({ repository, env }), { json });
return 0;
}
if (command === 'doctor') {
const diagnosis = diagnoseProject({ repository, env });
print(diagnosis, { json });
return diagnosis.healthy ? 0 : 1;
}
if (command === 'uninstall') {
const result = uninstallProject({
repository,
purge: argv.includes('--purge'),
dryRun: argv.includes('--dry-run'),
});
print(result, { json });
return result.canProceed === false ? 1 : 0;
}
throw new TorchError(`Unknown command: ${command}`, { code: 'UNKNOWN_COMMAND' });
} catch (error) {
print(asErrorRecord(error), { json: true });
return error instanceof TorchError ? 2 : 1;
}
}
+87
View File
@@ -0,0 +1,87 @@
import { existsSync, readFileSync } from 'node:fs';
import { extname } from 'node:path';
import { trackedFiles } from './git.mjs';
const LANGUAGE_BY_EXTENSION = new Map([
['.js', 'JavaScript'], ['.mjs', 'JavaScript'], ['.cjs', 'JavaScript'],
['.ts', 'TypeScript'], ['.tsx', 'TypeScript'], ['.jsx', 'JavaScript'],
['.py', 'Python'], ['.rs', 'Rust'], ['.go', 'Go'], ['.java', 'Java'],
['.c', 'C'], ['.h', 'C/C++'], ['.cc', 'C++'], ['.cpp', 'C++'],
['.cs', 'C#'], ['.rb', 'Ruby'], ['.php', 'PHP'], ['.swift', 'Swift'],
['.kt', 'Kotlin'], ['.sh', 'Shell'], ['.bash', 'Shell'], ['.ps1', 'PowerShell'],
['.html', 'HTML'], ['.css', 'CSS'], ['.scss', 'CSS'], ['.vue', 'Vue'],
['.svelte', 'Svelte'], ['.sol', 'Solidity'],
]);
const MANIFESTS = [
'package.json', 'pyproject.toml', 'requirements.txt', 'Cargo.toml', 'go.mod',
'pom.xml', 'build.gradle', 'Makefile', 'CMakeLists.txt', 'Dockerfile',
];
export function analyzeRepository(repository) {
const files = trackedFiles(repository.root);
const languageCounts = new Map();
const topLevel = new Set();
for (const file of files) {
const language = LANGUAGE_BY_EXTENSION.get(extname(file).toLowerCase());
if (language) languageCounts.set(language, (languageCounts.get(language) ?? 0) + 1);
const first = file.split('/')[0];
if (file.includes('/')) topLevel.add(first);
}
const languages = [...languageCounts.entries()]
.map(([name, files]) => ({ name, files }))
.sort((a, b) => b.files - a.files || a.name.localeCompare(b.name));
const manifests = MANIFESTS.filter((file) => files.includes(file));
const operations = files.filter((file) =>
/(^|\/)(\.github\/workflows|Dockerfile|docker-compose|vercel\.json|systemd|deploy|release|cron)/i.test(file));
const testFiles = files.filter((file) => /(^|\/)(test|tests|spec|e2e)(\/|\.|$)/i.test(file));
const docs = files.filter((file) => /(^|\/)(docs?|README)(\/|\.|$)/i.test(file));
const sharedCandidates = files.filter((file) =>
/(^|\/)(package(-lock)?\.json|[^/]*config[^/]*|schema|schemas|types|shared)(\/|\.|$)/i.test(file));
const checks = [];
const packagePath = `${repository.root}/package.json`;
if (existsSync(packagePath)) {
try {
const pkg = JSON.parse(readFileSync(packagePath, 'utf8'));
for (const [name, command] of Object.entries(pkg.scripts ?? {})) {
if (/test|check|lint|type|build|verify|audit/i.test(name)) checks.push({ name, command });
}
} catch {
// The analyzer reports observable repository shape. Invalid manifests are
// surfaced later by doctor and should not make read-only analysis mutate.
}
}
const scarceResources = [];
const joined = files.join('\n').toLowerCase();
if (/playwright|puppeteer|cypress|browser/.test(joined)) scarceResources.push('browser');
if (/cuda|gpu|webgl|three\.js|threejs/.test(joined)) scarceResources.push('gpu');
if (/postgres|mysql|sqlite|database/.test(joined)) scarceResources.push('database');
return {
generatedAt: new Date().toISOString(),
repository: {
root: repository.root,
name: repository.name,
branch: repository.branch,
head: repository.head,
hasRemote: repository.remotes.length > 0,
},
inventory: {
trackedFileCount: files.length,
languages,
manifests,
topLevelDirectories: [...topLevel].sort(),
testFileCount: testFiles.length,
documentationFileCount: docs.length,
operations: operations.slice(0, 100),
sharedSurfaceCandidates: sharedCandidates.slice(0, 100),
checks,
scarceResources,
},
mutationPerformed: false,
};
}
+55
View File
@@ -0,0 +1,55 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileHash } from './files.mjs';
import { projectStatePath } from './paths.mjs';
function parseJsonYaml(path) {
return JSON.parse(readFileSync(path, 'utf8'));
}
export function diagnoseProject({ repository, env = process.env }) {
const trackedRoot = join(repository.root, '.torch');
const configPath = join(trackedRoot, 'torch.yaml');
const manifestPath = join(trackedRoot, 'install-manifest.json');
const findings = [];
if (!existsSync(configPath)) {
return { status: 'not-installed', healthy: false, findings: [{ severity: 'error', code: 'CONFIG_MISSING', path: configPath }] };
}
if (!existsSync(manifestPath)) {
return { status: 'broken', healthy: false, findings: [{ severity: 'error', code: 'MANIFEST_MISSING', path: manifestPath }] };
}
let config;
let manifest;
try { config = parseJsonYaml(configPath); } catch (error) {
findings.push({ severity: 'error', code: 'CONFIG_INVALID', message: error.message });
}
try { manifest = parseJsonYaml(manifestPath); } catch (error) {
findings.push({ severity: 'error', code: 'MANIFEST_INVALID', message: error.message });
}
if (manifest) {
for (const record of manifest.created ?? []) {
const path = join(repository.root, record.path);
if (!existsSync(path)) findings.push({ severity: 'error', code: 'OWNED_FILE_MISSING', path: record.path });
else if (fileHash(path) !== record.sha256) findings.push({ severity: 'info', code: 'OWNED_FILE_MODIFIED', path: record.path });
}
const stateRoot = projectStatePath(manifest.projectId, env);
if (!existsSync(stateRoot)) findings.push({ severity: 'error', code: 'LOCAL_STATE_MISSING', path: stateRoot });
}
if (config?.project?.id && manifest?.projectId && config.project.id !== manifest.projectId) {
findings.push({ severity: 'error', code: 'PROJECT_ID_MISMATCH' });
}
if (repository.dirtyEntries.length) {
findings.push({ severity: 'info', code: 'WORKTREE_DIRTY', count: repository.dirtyEntries.length });
}
return {
status: findings.some((finding) => finding.severity === 'error') ? 'broken' : 'ok',
healthy: !findings.some((finding) => finding.severity === 'error'),
projectId: config?.project?.id ?? null,
findings,
};
}
+16
View File
@@ -0,0 +1,16 @@
export class TorchError extends Error {
constructor(message, { code = 'TORCH_ERROR', details = undefined } = {}) {
super(message);
this.name = 'TorchError';
this.code = code;
this.details = details;
}
}
export function asErrorRecord(error) {
return {
error: error?.code ?? 'UNEXPECTED_ERROR',
message: error instanceof Error ? error.message : String(error),
...(error?.details === undefined ? {} : { details: error.details }),
};
}
+24
View File
@@ -0,0 +1,24 @@
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { TorchError } from './errors.mjs';
export function sha256(content) {
return createHash('sha256').update(content).digest('hex');
}
export function writeNewFile(path, content) {
if (existsSync(path)) {
throw new TorchError(`Refusing to overwrite existing path: ${path}`, {
code: 'PATH_ALREADY_EXISTS',
details: { path },
});
}
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
return { path, sha256: sha256(content) };
}
export function fileHash(path) {
return sha256(readFileSync(path));
}
+57
View File
@@ -0,0 +1,57 @@
import { execFileSync } from 'node:child_process';
import { realpathSync } from 'node:fs';
import { basename } from 'node:path';
import { TorchError } from './errors.mjs';
function git(cwd, args, { optional = false } = {}) {
try {
return execFileSync('git', ['-C', cwd, ...args], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
} catch (error) {
if (optional) return '';
throw new TorchError(`Git command failed: git ${args.join(' ')}`, {
code: 'GIT_COMMAND_FAILED',
details: error.stderr?.toString().trim() || undefined,
});
}
}
export function inspectRepository(cwd = process.cwd()) {
const rootText = git(cwd, ['rev-parse', '--show-toplevel'], { optional: true });
if (!rootText) {
throw new TorchError(`Not a Git repository: ${cwd}`, { code: 'NOT_GIT_REPOSITORY' });
}
const root = realpathSync(rootText);
const branch = git(root, ['branch', '--show-current'], { optional: true }) || null;
const head = git(root, ['rev-parse', 'HEAD'], { optional: true }) || null;
const initialCommit = head
? git(root, ['rev-list', '--max-parents=0', 'HEAD'], { optional: true }).split('\n')[0] || null
: null;
const status = git(root, ['status', '--porcelain=v1', '--untracked-files=all'], { optional: true });
const remotesText = git(root, ['remote', '-v'], { optional: true });
const remotes = remotesText
? remotesText.split('\n').map((line) => {
const [name, url, kindText = ''] = line.split(/\s+/);
return { name, url, kind: kindText.replace(/[()]/g, '') || 'unknown' };
})
: [];
return {
root,
name: basename(root),
branch,
head,
initialCommit,
dirtyEntries: status ? status.split('\n') : [],
remotes,
canonicalRemote: remotes.find((remote) => remote.name === 'origin' && remote.kind === 'fetch') ?? null,
};
}
export function trackedFiles(root) {
const output = git(root, ['ls-files', '-z'], { optional: true });
return output ? output.split('\0').filter(Boolean) : [];
}
+203
View File
@@ -0,0 +1,203 @@
import { randomUUID } from 'node:crypto';
import {
existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync,
} from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { TorchError } from './errors.mjs';
import { fileHash, writeNewFile } from './files.mjs';
import { projectStatePath } from './paths.mjs';
const TRACKED_DIR = '.torch';
function jsonYaml(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
function initialFiles({ repository, projectId, createdAt }) {
const config = {
schema: 'torch.dev/v1alpha1',
project: {
id: projectId,
name: repository.name,
main_branch: repository.branch || 'main',
},
paths: { tracked_state: TRACKED_DIR, worktree_parent: '~/TORCHWorktrees' },
repository: repository.canonicalRemote
? { canonical: { type: 'remote', remote: repository.canonicalRemote.name } }
: { canonical: { type: 'unconfigured' } },
forge: { provider: 'none' },
git: {
branch_prefix: 'torch/', convergence: 'merge', allow_rebase: false,
allow_force_push: false, allow_bare_stash: false,
},
synchronization: { strategy: 'dispatcher-managed', auto_merge_worktrees: false },
session_manager: { id: 'session-manager', start_last: true },
domains: [],
};
const roster = {
schema: 'torch.dev/roster/v1alpha1',
areas: [{
id: 'session-manager', title: 'TORCH Session Manager',
scope: ['routing', 'priority', 'ownership rulings', 'fleet health'],
not_scope: ['project feature implementation by default'],
neighbours: ['all'],
}],
};
return new Map([
['torch.yaml', jsonYaml(config)],
['roster.yaml', jsonYaml(roster)],
['decisions.md', '# TORCH decisions\n\nNo project decisions recorded yet.\n'],
['RESUME-BRIEF.md', '# TORCH resume brief\n\nFleet not started yet.\n'],
['prompts/COMMON.md', '# Common fleet rules\n\nRepository state outranks conversation memory. Query ownership before crossing a domain boundary.\n'],
['prompts/session-manager.md', '# TORCH Session Manager\n\nRoute owner requests, establish ownership and priority, and keep routine coordination inside the fleet.\n'],
['backlog/.gitkeep', ''],
['INSTALLATION.md', `# TORCH installation\n\nInstalled ${createdAt}. Run \`torch doctor\` before starting the fleet.\n`],
]);
}
function manifestPath(root) {
return join(root, TRACKED_DIR, 'install-manifest.json');
}
function readManifest(root) {
const path = manifestPath(root);
if (!existsSync(path)) {
throw new TorchError(`No TORCH ownership manifest at ${path}`, { code: 'NOT_INSTALLED' });
}
try {
return JSON.parse(readFileSync(path, 'utf8'));
} catch (error) {
throw new TorchError(`Invalid TORCH ownership manifest: ${path}`, {
code: 'INVALID_INSTALL_MANIFEST', details: error.message,
});
}
}
function safeProjectPath(root, relativePath) {
const target = resolve(root, relativePath);
const prefix = `${resolve(root)}${sep}`;
if (!target.startsWith(prefix)) {
throw new TorchError(`Manifest path escapes project root: ${relativePath}`, {
code: 'UNSAFE_MANIFEST_PATH', details: { relativePath },
});
}
return target;
}
export function planInstall({ repository, env = process.env }) {
const trackedRoot = join(repository.root, TRACKED_DIR);
return {
action: 'install',
projectRoot: repository.root,
trackedRoot,
stateParent: join(projectStatePath('<new-project-id>', env), '..'),
wouldCreateTrackedState: !existsSync(trackedRoot),
mutationPerformed: false,
};
}
export function installProject({
repository,
env = process.env,
projectId = randomUUID(),
now = () => new Date(),
} = {}) {
const trackedRoot = join(repository.root, TRACKED_DIR);
if (existsSync(trackedRoot)) {
throw new TorchError(`Refusing to replace existing ${trackedRoot}`, {
code: 'ALREADY_INSTALLED', details: { trackedRoot },
});
}
const createdAt = now().toISOString();
const stateRoot = projectStatePath(projectId, env);
if (existsSync(stateRoot)) {
throw new TorchError(`Refusing to replace existing TORCH state: ${stateRoot}`, {
code: 'STATE_ALREADY_EXISTS', details: { stateRoot },
});
}
const created = [];
try {
for (const [path, content] of initialFiles({ repository, projectId, createdAt })) {
const absolute = join(trackedRoot, path);
const record = writeNewFile(absolute, content);
created.push({ path: relative(repository.root, record.path), sha256: record.sha256 });
}
mkdirSync(dirname(stateRoot), { recursive: true });
mkdirSync(stateRoot, { recursive: false });
for (const dir of ['sessions', 'messages', 'locks', 'leases', 'checks', 'integration', 'logs']) {
mkdirSync(join(stateRoot, dir));
}
writeFileSync(join(stateRoot, 'project.json'), jsonYaml({
schema: 'torch.dev/local-project/v1alpha1', projectId,
root: repository.root, createdAt, detachedAt: null,
}), { encoding: 'utf8', mode: 0o600, flag: 'wx' });
const manifest = {
schema: 'torch.dev/install-manifest/v1alpha1',
installationId: randomUUID(),
projectId,
createdAt,
created,
patched: [],
external: [{ type: 'local-state', path: stateRoot }],
};
writeNewFile(manifestPath(repository.root), jsonYaml(manifest));
return { projectId, trackedRoot, stateRoot, manifest, mutationPerformed: true };
} catch (error) {
if (existsSync(trackedRoot)) rmSync(trackedRoot, { recursive: true, force: true });
if (existsSync(stateRoot)) rmSync(stateRoot, { recursive: true, force: true });
throw error;
}
}
function nonEmptyEntries(path) {
if (!existsSync(path)) return [];
return readdirSync(path, { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name !== 'project.json')
.map((entry) => entry.name);
}
export function planUninstall({ repository, purge = false }) {
const manifest = readManifest(repository.root);
const problems = [];
for (const record of manifest.created) {
const path = safeProjectPath(repository.root, record.path);
if (!existsSync(path)) problems.push({ type: 'missing', path: record.path });
else if (fileHash(path) !== record.sha256) problems.push({ type: 'modified', path: record.path });
}
for (const entry of manifest.external ?? []) {
if (entry.type === 'local-state') {
const extras = nonEmptyEntries(entry.path);
if (extras.length) problems.push({ type: 'local-state-not-empty', path: entry.path, entries: extras });
}
}
return {
action: purge ? 'purge' : 'detach', manifest, problems,
canProceed: !purge || problems.length === 0,
mutationPerformed: false,
};
}
export function uninstallProject({ repository, purge = false, dryRun = false }) {
const plan = planUninstall({ repository, purge });
if (dryRun || !purge) return plan;
if (!plan.canProceed) {
throw new TorchError('TORCH purge stopped because managed state changed', {
code: 'UNSAFE_TO_PURGE', details: plan.problems,
});
}
for (const record of [...plan.manifest.created].reverse()) {
rmSync(safeProjectPath(repository.root, record.path));
}
rmSync(manifestPath(repository.root));
rmSync(join(repository.root, TRACKED_DIR), { recursive: true });
for (const entry of plan.manifest.external ?? []) {
if (entry.type === 'local-state' && existsSync(entry.path)) rmSync(entry.path, { recursive: true });
}
return { ...plan, mutationPerformed: true };
}
+11
View File
@@ -0,0 +1,11 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
export function torchDataHome(env = process.env) {
const dataHome = env.XDG_DATA_HOME || join(homedir(), '.local', 'share');
return join(dataHome, 'torch');
}
export function projectStatePath(projectId, env = process.env) {
return join(torchDataHome(env), 'projects', projectId);
}
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
const CLI = new URL('../../bin/torch.mjs', import.meta.url).pathname;
function repo() {
const root = mkdtempSync(join(tmpdir(), 'torch-cli-'));
execFileSync('git', ['init', '-b', 'main', root]);
execFileSync('git', ['-C', root, 'config', 'user.email', '[email protected]']);
execFileSync('git', ['-C', root, 'config', 'user.name', 'TORCH Test']);
writeFileSync(join(root, 'README.md'), '# Fixture\n');
execFileSync('git', ['-C', root, 'add', '.']);
execFileSync('git', ['-C', root, 'commit', '-m', 'fixture']);
return root;
}
function run(root, args) {
return spawnSync(process.execPath, [CLI, ...args], {
cwd: root,
env: { ...process.env, XDG_DATA_HOME: join(root, '.data') },
encoding: 'utf8',
});
}
test('SCN-cli-init: init reports analysis without creating project state', () => {
const root = repo();
const result = run(root, ['init', '--json']);
assert.equal(result.status, 0, result.stderr || result.stdout);
const output = JSON.parse(result.stdout);
assert.equal(output.command, 'init');
assert.equal(output.analysis.mutationPerformed, false);
assert.equal(existsSync(join(root, '.torch')), false);
});
test('SCN-cli-install-approval: install requires an explicit reviewed approval flag', () => {
const root = repo();
const result = run(root, ['install', '--json']);
assert.equal(result.status, 2);
assert.equal(JSON.parse(result.stdout).error, 'APPROVAL_REQUIRED');
assert.equal(existsSync(join(root, '.torch')), false);
});
+79
View File
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { analyzeRepository } from '../../src/kernel/analyze.mjs';
import { diagnoseProject } from '../../src/kernel/doctor.mjs';
import { inspectRepository } from '../../src/kernel/git.mjs';
import { installProject, planInstall, uninstallProject } from '../../src/kernel/install.mjs';
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'torch-kernel-'));
execFileSync('git', ['init', '-b', 'main', root]);
execFileSync('git', ['-C', root, 'config', 'user.email', '[email protected]']);
execFileSync('git', ['-C', root, 'config', 'user.name', 'TORCH Test']);
writeFileSync(join(root, 'package.json'), JSON.stringify({
name: 'fixture-app', scripts: { test: 'node --test', build: 'node build.mjs' },
}));
writeFileSync(join(root, 'index.js'), 'export const ready = true;\n');
execFileSync('git', ['-C', root, 'add', '.']);
execFileSync('git', ['-C', root, 'commit', '-m', 'fixture']);
const stateHome = join(root, '.xdg-data');
return { root, env: { ...process.env, XDG_DATA_HOME: stateHome } };
}
test('SCN-init-read-only: inspection and analysis do not mutate the project', () => {
const { root, env } = fixture();
const before = execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' });
const repository = inspectRepository(root);
const analysis = analyzeRepository(repository);
const plan = planInstall({ repository, env });
const after = execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' });
assert.equal(analysis.mutationPerformed, false);
assert.equal(plan.mutationPerformed, false);
assert.equal(existsSync(join(root, '.torch')), false);
assert.equal(after, before);
assert.deepEqual(analysis.inventory.languages[0], { name: 'JavaScript', files: 1 });
assert.equal(analysis.inventory.checks.some((check) => check.name === 'test'), true);
});
test('SCN-install-doctor-purge: a fresh install is healthy and exactly reversible', () => {
const { root, env } = fixture();
const repository = inspectRepository(root);
const installed = installProject({
repository, env, projectId: 'fixture-project', now: () => new Date('2026-09-27T00:00:00Z'),
});
assert.equal(existsSync(join(root, '.torch', 'torch.yaml')), true);
assert.equal(existsSync(installed.stateRoot), true);
const config = JSON.parse(readFileSync(join(root, '.torch', 'torch.yaml'), 'utf8'));
assert.equal(config.project.id, 'fixture-project');
const diagnosis = diagnoseProject({ repository: inspectRepository(root), env });
assert.equal(diagnosis.healthy, true);
assert.equal(diagnosis.status, 'ok');
const result = uninstallProject({ repository: inspectRepository(root), purge: true });
assert.equal(result.mutationPerformed, true);
assert.equal(existsSync(join(root, '.torch')), false);
assert.equal(existsSync(installed.stateRoot), false);
assert.equal(readFileSync(join(root, 'index.js'), 'utf8'), 'export const ready = true;\n');
});
test('SCN-purge-protects-user-change: uninstall refuses to delete modified managed files', () => {
const { root, env } = fixture();
const repository = inspectRepository(root);
installProject({ repository, env, projectId: 'modified-project' });
const configPath = join(root, '.torch', 'torch.yaml');
writeFileSync(configPath, `${readFileSync(configPath, 'utf8')}\n# owner change\n`);
assert.throws(
() => uninstallProject({ repository: inspectRepository(root), purge: true }),
(error) => error.code === 'UNSAFE_TO_PURGE'
&& error.details.some((problem) => problem.type === 'modified' && problem.path === '.torch/torch.yaml'),
);
assert.equal(existsSync(configPath), true);
});