mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-06 21:01:54 +00:00
Begin portable TORCH fleet rewrite
This commit is contained in:
1 parent
a46832314f
commit
cce059fc3a
16 files changed
+2258
-613
No files matched your search
@@ -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
|
||||
@@ -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
Generated
+5
-134
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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) : [];
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user