excuse-ops 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ExcuseOps contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,214 @@
1
- # Temporary Holding Version
1
+ # ExcuseOps
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Local-first version control for your personal mythology.**
4
+
5
+ ExcuseOps is a terminal app for checking whether accounts of a day can coexist. Record who was told what, find timing and travel contradictions, preview a permitted repair, and replay the original history. A thin npm launcher runs the real Python/Z3 engine; SQLite keeps the data on your machine.
6
+
7
+ This is a fictional-scenario consistency tool, not a truth detector. Results depend on the times, routes and rules you enter. It does not send messages or verify real-world events.
8
+
9
+ ## What it does
10
+
11
+ - Checks each audience's story and the combined schedule, including uncertainty windows, travel, shared events, ordering and explicitly allowed overlaps.
12
+ - Explains clashes with source statements, travel gaps, a text Lie Meter and a template snitch question. Nothing is actually sent.
13
+ - Separates logical consistency from communication exposure: disconnected audiences do not make contradictory accounts consistent.
14
+ - Suggests bounded start-time translations, minimizing changed statements before total minutes moved. Applying a repair is explicit.
15
+ - Locks versions marked sent. Corrections and retractions preserve the originals rather than rewriting history.
16
+ - Stores revisions and reports locally, with read-only replay and first-break analysis.
17
+
18
+ There is no browser, web server, cloud account or JavaScript solver fallback.
19
+
20
+ ## Current release and requirements
21
+
22
+ Version **0.1.0** is the initial early-access CLI release. Registry releases use the **`next`** tag rather than `latest`; installation support is intentionally narrow.
23
+
24
+ This release is limited to the measured environment below. The launcher enforces the Node version, OS family and CPU architecture; setup enforces the CPython version. Other combinations are not verified or supported:
25
+
26
+ | Component | Supported combination |
27
+ | --- | --- |
28
+ | Platform | macOS on Apple Silicon; verified on macOS 26.5.1 arm64 |
29
+ | Node.js | 26.7.0 |
30
+ | Python | Existing CPython 3.14.6 |
31
+ | npm | Verified with 11.19.0 |
32
+ | Solver | `z3-solver` 4.16.0.0, installed by explicit setup |
33
+
34
+ Python is **not** downloaded for you. Set `EXCUSE_OPS_PYTHON` to its executable path if automatic discovery cannot find the supported interpreter.
35
+
36
+ ## Installation
37
+
38
+ ### Install the current local artifact
39
+
40
+ Obtain `excuse-ops-0.1.0.tgz` from a local build, then install it into a directory you control:
41
+
42
+ ```sh
43
+ mkdir excuseops-demo
44
+ cd excuseops-demo
45
+ npm install --ignore-scripts /absolute/path/to/excuse-ops-0.1.0.tgz
46
+ npx --no-install excuse setup
47
+ npx --no-install excuse --help
48
+ ```
49
+
50
+ `setup` explains the dependency download and asks for consent. It creates a release-specific isolated Python environment and installs the bundled application wheel plus the hash-pinned Z3 wheel. It does not modify system Python. Installing the npm package alone does not run setup: there is no postinstall hook or implicit installation on ordinary commands.
51
+
52
+ Network access is needed for initial dependency setup. Once setup succeeds, scenario commands work offline without remote services or a repository checkout. Repeating setup verifies and reuses a healthy runtime; an update prepares its own runtime without replacing project databases.
53
+
54
+ ### Registry installation (early access)
55
+
56
+ Install the early-access channel:
57
+
58
+ ```sh
59
+ npm install excuse-ops@next
60
+ npx --no-install excuse setup
61
+ ```
62
+
63
+ Use `next` explicitly; it does not imply a stable `latest` release. If the registry package is not yet available, use the local tarball installation above. No global installation is required.
64
+
65
+ ## Quick start: the Tuesday contradiction
66
+
67
+ The commands below use `npx --no-install excuse` from the directory where you installed the package. Prompts are interactive; confirmation defaults to **no**.
68
+
69
+ ### 1. Create and configure a project
70
+
71
+ ```sh
72
+ npx --no-install excuse init
73
+ ```
74
+
75
+ Enter a project name, such as `Tuesday`, and choose a blank project. Run `config` once for each item below, review the proposed change, and confirm saving it:
76
+
77
+ ```sh
78
+ npx --no-install excuse config
79
+ ```
80
+
81
+ | Category | Operation | Values to enter |
82
+ | --- | --- | --- |
83
+ | `people` | `add` | `Boss`, then `Friend` in a separate invocation |
84
+ | `places` | `add` | `Clinic`, then `Arcade` in a separate invocation |
85
+ | `activities` | `add` | `Dentist`, then `Gaming` in a separate invocation |
86
+ | `travel` | `set` | `Clinic`, `Arcade`, `25` minutes, `30` kilometers |
87
+
88
+ Travel is bidirectional. Do not add a people-link yet.
89
+
90
+ ### 2. Record the accounts
91
+
92
+ ```sh
93
+ npx --no-install excuse add "Dentist at Clinic, 10:00-11:00, told Boss"
94
+ npx --no-install excuse mark-sent <dentist-id>
95
+ npx --no-install excuse merge
96
+ npx --no-install excuse add "Gaming at Arcade, 11:05-12:30, told Friend; may move 11:00-12:30"
97
+ npx --no-install excuse status
98
+ npx --no-install excuse diff
99
+ npx --no-install excuse merge
100
+ ```
101
+
102
+ Replace `<dentist-id>` with the actual ID returned by `add`; do not type angle-bracket placeholders into your shell. Confirm `mark-sent` only to record that the account was communicated—this action sends nothing.
103
+
104
+ Dentist alone is consistent. Adding Gaming leaves a five-minute gap for a 25-minute trip: the combined merge is **blocked**, even though each audience's story is individually consistent. The entered distance implies 360 km/h over that gap. Without a communication path, exposure is **safe-for-now**, not logically consistent. A blocked merge deliberately returns exit **1** and saves its report; do not use a success-only `&&` chain for the whole example.
105
+
106
+ ### 3. Inspect exposure, preview and apply a repair
107
+
108
+ ```sh
109
+ npx --no-install excuse link Boss Friend --label "group chat"
110
+ npx --no-install excuse merge
111
+ npx --no-install excuse resolve
112
+ npx --no-install excuse resolve --apply <plan-id>
113
+ npx --no-install excuse merge
114
+ npx --no-install excuse log
115
+ npx --no-install excuse log --at <action-id>
116
+ ```
117
+
118
+ Use the plan ID printed by `resolve` and an action ID shown by `log`. Linking changes exposure to **danger** without fixing the clash. The optimal permitted repair moves Gaming by +20 minutes to **11:25–12:50**, preserving its 85-minute duration and the sent Dentist version.
119
+
120
+ A preview does not change account content or the domain revision. Applying a current plan appends new versions; stale or already-consumed plans are rejected. Replay is read-only: viewing an older blocked/disconnected state does not replace today's repaired workspace.
121
+
122
+ ## Statement syntax
123
+
124
+ Entities must be configured first. Input is a small, explicit language—not arbitrary natural-language interpretation.
125
+
126
+ ```text
127
+ Dentist at Clinic, 10:00-11:00, told Boss
128
+ Gaming at Arcade, 11:05-12:30, told Friend; may move 11:00-12:30
129
+ Groceries at Market, 45 min, starts 09:00-09:30, told Parent
130
+ Lunch at Cafe, 12:00-13:00, told Boss; event team-lunch; after s1
131
+ ```
132
+
133
+ The last two examples require their own configured names; `s1` must be an actual active statement ID. Start windows express uncertainty, while `may move` bounds permitted replacement starts. Repairs preserve window width, duration, activity, place, audience, shared-event identity and ordering references.
134
+
135
+ Times use one day at integer-minute resolution. Intervals are half-open; `24:00` is allowed only as an exclusive end. No overnight or multiple-date scheduling. Configured names cannot contain commas, semicolons, newlines, ` at ` or ` told `.
136
+
137
+ ## Commands
138
+
139
+ Prefix these commands with `npx --no-install excuse`, or use `excuse` directly when its installed bin directory is on your PATH.
140
+
141
+ | Command | Purpose |
142
+ | --- | --- |
143
+ | `setup` | Explicitly prepare or verify the isolated runtime |
144
+ | `init` | Create a blank or guided project without overwriting an existing one |
145
+ | `config` | Inspect/edit entities, travel, communication links and overlap permissions |
146
+ | `add "<statement>"` | Save an immutable draft version |
147
+ | `edit <id>` | Replace an active draft with a new version |
148
+ | `status`, `diff` | Inspect current accounts, pending changes and report staleness |
149
+ | `merge` | Check stories/global consistency, save a report and explain clashes |
150
+ | `link <person> <person> --label "<label>"` | Record a communication connection; send nothing |
151
+ | `resolve` | Preview permitted repairs without applying them |
152
+ | `resolve --apply <plan-id>` | Revalidate and atomically apply a current plan |
153
+ | `mark-sent <id>` | Confirm a communicated version's lock |
154
+ | `correct <id>`, `retract <id>` | Explicitly supersede or deactivate an account while retaining history |
155
+ | `log`, `log --at <action-id>` | Inspect history or replay an original snapshot read-only |
156
+ | `--help`, `--version` | Show usage or the installed application version |
157
+
158
+ `merge`, `resolve` and `log` accept `--budget-seconds`. A timeout is unknown/incomplete, never proof of consistency or impossibility. Incomplete repairs are labeled best-found rather than optimal.
159
+
160
+ ## Data, safety and exit codes
161
+
162
+ Projects live in **`.excuseops/project.sqlite`** beneath the directory where you run `init`. Commands find the nearest initialized ancestor, so child directories use the same project. Back up this database to retain the scenario and its history; runtimes are separate and are not a backup of project data.
163
+
164
+ Managed runtimes live under **`~/Library/Application Support/ExcuseOps`**, overridden by `EXCUSE_OPS_HOME`. Never delete an unfamiliar runtime directory or setup lock as an automatic recovery step. See the detailed guide for ownership/retry behavior.
165
+
166
+ | Exit | Meaning |
167
+ | --- | --- |
168
+ | `0` | Successful command/cancellation, consistent merge or completed valid repair search |
169
+ | `1` | Proven contradiction or proven absence of a permitted repair |
170
+ | `2` | Invalid input/usage, missing project/prerequisite or unsupported runtime |
171
+ | `3` | Unknown/timed-out/incomplete solving, including a validated incomplete candidate |
172
+ | `4` | Storage/runtime failure or rejected stale/concurrent mutation |
173
+ | `130` | User interruption |
174
+
175
+ Sent versions cannot be moved by ordinary edits or repairs. Explicit corrections/retractions retain originals. No command delivers a message, reads live maps or synchronizes a cloud account.
176
+
177
+ ## Verification and known limits
178
+
179
+ The CLI is checked with real Z3 regressions, a separately authored specification-only AI exhaustive oracle, seeded comparisons and installation of the actual npm tarball outside the checkout. The independent oracle is bounded; its results are not extrapolated to exhaustive 30-account proofs.
180
+
181
+ Recorded verification includes 138 Python tests, 157 oracle cases, 100 seeded agreements, seven Node launcher tests and an installed-consumer acceptance run covering lifecycle, repair, replay, interruption, quoting, rollback, concurrent writers and upgrades.
182
+
183
+ Performance is a measured limitation, not a blanket guarantee: at a one-second budget, 14 of 100 measured 30-account repair searches returned valid best-found candidates without completing optimality proofs. The detailed guide records timings, workload scope and provenance.
184
+
185
+ ## Build and development
186
+
187
+ From a checkout with the already-installed pinned development tools (setuptools 80.9.0, pytest 9.0.2 and z3-solver 4.16.0.0):
188
+
189
+ ```sh
190
+ PYTHONPATH=src .venv/bin/python -m pytest tests -q
191
+ .venv/bin/python scripts/build_npm.py
192
+ npm pack ./npm
193
+ ```
194
+
195
+ The builder copies this root README into the npm payload; do not maintain a second handwritten package README. The tarball bundles the application wheel, launcher, explicit bootstrap and dependency lock—not a prebuilt environment or project data.
196
+
197
+ To repeat the disposable installed-artifact checks, including setup downloads and a real upgrade, only in an environment where these installations are authorized:
198
+
199
+ ```sh
200
+ .venv/bin/python scripts/verify_artifact.py --allow-install
201
+ ```
202
+
203
+ ## Documentation
204
+
205
+ - [Full CLI guide, grammar, setup and measured limitations](https://github.com/danilocasim/excuse-ops/blob/main/docs/CLI_MVP.md)
206
+ - [Independent checker provenance and supported bounds](https://github.com/danilocasim/excuse-ops/blob/main/verification/ORACLE_PROVENANCE.md)
207
+ - [Implementation and acceptance evidence](https://github.com/danilocasim/excuse-ops/blob/main/.plan/excuse-mvp/TODO.md)
208
+ - [Broader CLI/web product plan](https://github.com/danilocasim/excuse-ops/blob/main/docs/CLI_WEB_IMPLEMENTATION_PLAN.md)
209
+
210
+ This package delivers the CLI-only milestone. A web UI, multiple dates, actual messaging, cloud collaboration and AI interpretation of arbitrary prose are outside this release.
211
+
212
+ ## License
213
+
214
+ MIT. See [LICENSE](https://github.com/danilocasim/excuse-ops/blob/main/LICENSE). The bundled application is MIT-licensed; separately installed dependencies retain their own licenses.
package/bin/excuse.mjs ADDED
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { readFileSync, statSync, realpathSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join, resolve, delimiter } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { isDeepStrictEqual } from 'node:util';
8
+
9
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
+
11
+ class PrerequisiteError extends Error {}
12
+
13
+ function exists(path) {
14
+ try { statSync(path); return true; }
15
+ catch (error) {
16
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
17
+ throw error;
18
+ }
19
+ }
20
+
21
+ function findPython(release) {
22
+ const override = process.env.EXCUSE_OPS_PYTHON;
23
+ const candidates = override ? [resolve(override)] :
24
+ ['python3.14', 'python3', 'python'].flatMap(name =>
25
+ (process.env.PATH ?? '').split(delimiter).filter(Boolean).map(directory => join(directory, name)));
26
+ for (const candidate of [...new Set(candidates)]) {
27
+ const probe = spawnSync(candidate, ['-I', '-c',
28
+ 'import json,sys; print(json.dumps({"version":list(sys.version_info[:3]),"implementation":sys.implementation.name,"executable":sys.executable}))'],
29
+ { encoding:'utf8', timeout:5000, shell:false });
30
+ if (probe.error) {
31
+ if (probe.error.code === 'ENOENT' || probe.error.code === 'ENOTDIR') continue;
32
+ throw probe.error; // Permission failures must never trigger another route.
33
+ }
34
+ if (probe.status !== 0) continue;
35
+ let info;
36
+ try { info = JSON.parse(probe.stdout); } catch { continue; }
37
+ if (info.implementation === 'cpython' && isDeepStrictEqual(info.version, release.python)) return info.executable;
38
+ }
39
+ throw new PrerequisiteError(`CPython ${release.python.join('.')} is required. Install it yourself, or set EXCUSE_OPS_PYTHON to its executable path; Python is never downloaded.`);
40
+ }
41
+
42
+ function run(command, args) {
43
+ return new Promise(resolveExit => {
44
+ // One isolated POSIX process group owns Python and its setup descendants.
45
+ // The terminal signals Node's group; Node forwards once to this other group.
46
+ const child = spawn(command, args, { stdio:'inherit', shell:false, detached:true });
47
+ let signalled = null;
48
+ let failed = false;
49
+ function forward(signal) {
50
+ if (signalled || !child.pid) return;
51
+ signalled = signal;
52
+ try { process.kill(-child.pid, signal); }
53
+ catch (error) { if (error.code !== 'ESRCH') console.error(`Cannot signal runtime: ${error.message}`); }
54
+ }
55
+ const interrupt = () => forward('SIGINT');
56
+ const terminate = () => forward('SIGTERM');
57
+ process.on('SIGINT', interrupt);
58
+ process.on('SIGTERM', terminate);
59
+ child.on('error', error => { failed = true; console.error(`Cannot launch runtime: ${error.message}`); });
60
+ child.on('close', (code, signal) => {
61
+ process.off('SIGINT', interrupt);
62
+ process.off('SIGTERM', terminate);
63
+ resolveExit(failed ? 4 : signalled === 'SIGINT' || signal === 'SIGINT' ? 130 :
64
+ signalled === 'SIGTERM' || signal === 'SIGTERM' ? 143 : code ?? 4);
65
+ });
66
+ });
67
+ }
68
+
69
+ async function main() {
70
+ const release = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8'));
71
+ if (process.platform !== release.platform || process.arch !== release.arch || process.versions.node !== release.node) {
72
+ throw new PrerequisiteError(`This artifact is verified only for ${release.platform}/${release.arch}, Node ${release.node}.`);
73
+ }
74
+ const args = process.argv.slice(2);
75
+ if (args[0] === 'setup') {
76
+ return run(findPython(release), ['-I', join(root, 'bootstrap.py'), '--package-root', root, ...args.slice(1)]);
77
+ }
78
+ const override = process.env.EXCUSE_OPS_HOME;
79
+ const expanded = override === '~' ? homedir() :
80
+ override?.startsWith('~/') ? join(homedir(), override.slice(2)) : override;
81
+ const homePath = expanded ? resolve(expanded) : join(homedir(), 'Library', 'Application Support', 'ExcuseOps');
82
+ const home = exists(homePath) ? realpathSync(homePath) : homePath;
83
+ const runtime = join(home, 'runtimes', release.runtime_tag);
84
+ const lock = join(home, 'runtimes', `.${release.runtime_tag}.setup.lock`);
85
+ const marker = join(runtime, 'ready.json');
86
+ const python = join(runtime, 'bin', 'python');
87
+ if (exists(lock) || !exists(marker) || !exists(python)) {
88
+ throw new PrerequisiteError('No ready runtime for this release. Run excuse setup explicitly.');
89
+ }
90
+ let ready;
91
+ try { ready = JSON.parse(readFileSync(marker, 'utf8')); }
92
+ catch (error) {
93
+ if (error.code === 'EACCES' || error.code === 'EPERM') throw error;
94
+ throw new PrerequisiteError('Runtime readiness metadata is invalid. Run excuse setup to repair it.');
95
+ }
96
+ const expected = Object.fromEntries(['version','runtime_tag','files','python','platform','arch'].map(key => [key,release[key]]));
97
+ if (!isDeepStrictEqual(ready, expected) || realpathSync(runtime) !== resolve(runtime)) {
98
+ throw new PrerequisiteError('Runtime identity does not match this release. Run excuse setup.');
99
+ }
100
+ return run(python, ['-I', '-m', 'excuse_ops', ...args]);
101
+ }
102
+
103
+ try { process.exitCode = await main(); }
104
+ catch (error) {
105
+ console.error(error.message);
106
+ process.exitCode = error instanceof PrerequisiteError ? 2 : 4;
107
+ }
package/bootstrap.py ADDED
@@ -0,0 +1,184 @@
1
+ """Explicit, stdlib-only installation of an npm release's isolated runtime."""
2
+ import argparse
3
+ from contextlib import contextmanager
4
+ import hashlib
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import platform
9
+ import subprocess
10
+ import sys
11
+ from typing import Iterator, Sequence
12
+ import venv
13
+
14
+
15
+ class SetupError(RuntimeError):
16
+ pass
17
+
18
+
19
+ class PrerequisiteError(SetupError):
20
+ pass
21
+
22
+
23
+ def digest(path: Path) -> str:
24
+ return hashlib.sha256(path.read_bytes()).hexdigest()
25
+
26
+
27
+ def release(package: Path) -> dict:
28
+ try:
29
+ data = json.loads((package / 'release.json').read_text())
30
+ if data['schema'] != 1:
31
+ raise PrerequisiteError('Unsupported release manifest.')
32
+ for name, expected in data['files'].items():
33
+ target = (package / name).resolve()
34
+ if not target.is_relative_to(package.resolve()) or digest(target) != expected:
35
+ raise SetupError(f'Packaged artifact integrity failure: {name}')
36
+ if not data['runtime_tag'].startswith(data['version'] + '-') or '/' in data['runtime_tag']:
37
+ raise SetupError('Invalid runtime identity.')
38
+ return data
39
+ except (KeyError, TypeError, ValueError) as error:
40
+ raise SetupError('Invalid release metadata.') from error
41
+ except FileNotFoundError as error:
42
+ raise PrerequisiteError('An intact npm artifact is required; use excuse setup or pass --package-root PATH.') from error
43
+
44
+
45
+ def home() -> Path:
46
+ override = os.environ.get('EXCUSE_OPS_HOME')
47
+ return Path(override).expanduser().resolve() if override else Path.home() / 'Library' / 'Application Support' / 'ExcuseOps'
48
+
49
+
50
+ def runtime_path(data: dict) -> Path:
51
+ return home() / 'runtimes' / data['runtime_tag']
52
+
53
+
54
+ def identity(data: dict) -> dict:
55
+ return {key: data[key] for key in ('version', 'runtime_tag', 'files', 'python', 'platform', 'arch')}
56
+
57
+
58
+ def _atomic_json(path: Path, value: dict) -> None:
59
+ temporary = path.with_name(path.name + f'.{os.getpid()}.tmp')
60
+ try:
61
+ with temporary.open('x') as stream:
62
+ json.dump(value, stream, sort_keys=True)
63
+ stream.flush()
64
+ os.fsync(stream.fileno())
65
+ temporary.replace(path)
66
+ finally:
67
+ temporary.unlink(missing_ok=True)
68
+
69
+
70
+ @contextmanager
71
+ def _ownership(lock: Path, data: dict) -> Iterator[None]:
72
+ try:
73
+ stream = lock.open('x')
74
+ except FileExistsError as error:
75
+ raise SetupError(f'Setup ownership already exists: {lock}. Verify its PID and recover explicitly; no automatic lock removal.') from error
76
+ try:
77
+ with stream:
78
+ json.dump({'pid': os.getpid(), 'runtime_tag': data['runtime_tag']}, stream)
79
+ yield
80
+ finally:
81
+ lock.unlink()
82
+
83
+
84
+ def _verify(directory: Path, data: dict) -> bool:
85
+ python = directory / 'bin' / 'python'
86
+ if not python.exists():
87
+ return False
88
+ script = '''import importlib,importlib.metadata as m,json,sys,z3
89
+ expected=json.loads(sys.argv[1])
90
+ assert list(sys.version_info[:3]) == expected['python']
91
+ assert sys.prefix != sys.base_prefix
92
+ assert m.version('excuse-ops') == expected['version']
93
+ assert m.version('z3-solver') == expected['solver_version']
94
+ for name in expected['modules']: importlib.import_module('excuse_ops.'+name)
95
+ x=z3.Int('setup_probe'); solver=z3.Solver(); solver.add(x==7)
96
+ assert solver.check()==z3.sat and solver.model().eval(x).as_long()==7
97
+ '''
98
+ result = subprocess.run([str(python), '-I', '-c', script, json.dumps(data)], capture_output=True, text=True, timeout=30)
99
+ return result.returncode == 0
100
+
101
+
102
+ def _install(directory: Path, package: Path, data: dict) -> None:
103
+ venv.EnvBuilder(with_pip=True).create(directory)
104
+ requirements = directory / 'release-requirements.txt'
105
+ wheel = package / data['wheel']
106
+ requirements.write_text((package / 'requirements.lock').read_text() + '\n' + wheel.as_uri() + ' --hash=sha256:' + data['files'][data['wheel']] + '\n')
107
+ # An explicitly approved retry must repair files even if installed metadata
108
+ # still claims the correct version after an interrupted/corrupt installation.
109
+ subprocess.run([
110
+ str(directory / 'bin' / 'python'), '-I', '-m', 'pip', '--isolated', 'install',
111
+ '--disable-pip-version-check', '--no-input', '--only-binary=:all:',
112
+ '--require-hashes', '--force-reinstall', '-r', str(requirements),
113
+ ], check=True)
114
+
115
+
116
+ def setup(package: Path) -> int:
117
+ package = package.resolve()
118
+ data = release(package)
119
+ if (sys.implementation.name != 'cpython' or list(sys.version_info[:3]) != data['python']
120
+ or sys.platform != data['platform'] or platform.machine() != data['arch']):
121
+ raise PrerequisiteError(f"This release requires CPython {'.'.join(map(str,data['python']))} on {data['platform']}/{data['arch']}.")
122
+ directory = runtime_path(data)
123
+ lock = directory.parent / ('.' + data['runtime_tag'] + '.setup.lock')
124
+ ready = directory / 'ready.json'
125
+ owner = directory / 'owner.json'
126
+ expected = identity(data)
127
+ if lock.exists():
128
+ raise SetupError(f'Setup ownership exists: {lock}; verify its PID before explicit recovery.')
129
+ if directory.exists():
130
+ if directory.is_symlink() or not owner.is_file() or json.loads(owner.read_text()) != expected:
131
+ raise SetupError(f'Refusing to modify an unknown/incompatible directory: {directory}')
132
+ if ready.is_file() and json.loads(ready.read_text()) == expected and _verify(directory, data):
133
+ print(f'Runtime verified and reused: {directory}')
134
+ return 0
135
+ print(f'Runtime: {directory}')
136
+ print(f"Installs bundled ExcuseOps {data['version']} and downloads hash-pinned z3-solver {data['solver_version']}. System Python and project data are not changed.")
137
+ try:
138
+ answer = input('Install/repair this isolated runtime? [y/N]: ').strip().lower()
139
+ except EOFError:
140
+ answer = ''
141
+ if answer not in ('y', 'yes'):
142
+ print('Cancelled. Nothing installed.')
143
+ return 0
144
+ directory.parent.mkdir(parents=True, exist_ok=True)
145
+ with _ownership(lock, data):
146
+ # Recheck after acquiring ownership: another setup may have completed
147
+ # while this process was waiting for consent.
148
+ if directory.exists():
149
+ if directory.is_symlink() or not owner.is_file() or json.loads(owner.read_text()) != expected:
150
+ raise SetupError(f'Refusing to modify an unknown/incompatible directory: {directory}')
151
+ if ready.is_file() and json.loads(ready.read_text()) == expected and _verify(directory, data):
152
+ print(f'Runtime verified and reused: {directory}')
153
+ return 0
154
+ else:
155
+ directory.mkdir()
156
+ _atomic_json(owner, expected)
157
+ ready.unlink(missing_ok=True)
158
+ _install(directory, package, data)
159
+ if not _verify(directory, data):
160
+ raise SetupError('Installed runtime failed application/Z3 verification; not marked ready. Run setup to retry.')
161
+ _atomic_json(ready, expected)
162
+ print(f'Runtime ready: {directory}')
163
+ return 0
164
+
165
+
166
+ def main(argv: Sequence[str] | None = None) -> int:
167
+ parser = argparse.ArgumentParser(prog='excuse setup', allow_abbrev=False)
168
+ parser.add_argument('--package-root', type=Path, default=Path(__file__).resolve().parent)
169
+ args = parser.parse_args(argv)
170
+ try:
171
+ return setup(args.package_root)
172
+ except PrerequisiteError as error:
173
+ print(str(error), file=sys.stderr)
174
+ return 2
175
+ except (SetupError, OSError, ValueError, subprocess.SubprocessError) as error:
176
+ print(f'Setup failed: {error}', file=sys.stderr)
177
+ return 4
178
+ except KeyboardInterrupt:
179
+ print('Setup interrupted; no incomplete runtime was marked ready.', file=sys.stderr)
180
+ return 130
181
+
182
+
183
+ if __name__ == '__main__':
184
+ raise SystemExit(main())
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
+ "bin": {
3
+ "excuse": "bin/excuse.mjs"
4
+ },
5
+ "bugs": {
6
+ "url": "https://github.com/danilocasim/excuse-ops/issues"
7
+ },
8
+ "cpu": [
9
+ "arm64"
10
+ ],
11
+ "description": "Local-first version control for your personal mythology",
12
+ "engines": {
13
+ "node": "26.7.0"
14
+ },
15
+ "files": [
16
+ "README.md",
17
+ "LICENSE",
18
+ "bin/excuse.mjs",
19
+ "bootstrap.py",
20
+ "release.json",
21
+ "requirements.lock",
22
+ "artifacts/excuse_ops-0.1.0-py3-none-any.whl"
23
+ ],
24
+ "homepage": "https://github.com/danilocasim/excuse-ops#readme",
25
+ "license": "MIT",
2
26
  "name": "excuse-ops",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
27
+ "os": [
28
+ "darwin"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "registry": "https://registry.npmjs.org/",
33
+ "tag": "next"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/danilocasim/excuse-ops.git"
38
+ },
39
+ "type": "module",
40
+ "version": "0.1.0"
41
+ }
package/release.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "arch": "arm64",
3
+ "files": {
4
+ "artifacts/excuse_ops-0.1.0-py3-none-any.whl": "aff2ab6c74530a04f5d88fbc071d4adeb05eaa5222041f83aa078482a8781ddc",
5
+ "bootstrap.py": "44ec6e6711f53bfb62b306a0317e73f1c2db9454b8e7963045e564dba2733f6d",
6
+ "requirements.lock": "7f30566ba72b3ed4e8d24fa601d7b972273215312c3ef7604bebfba7c25995d3"
7
+ },
8
+ "modules": [
9
+ "__init__",
10
+ "application",
11
+ "bootstrap",
12
+ "checker",
13
+ "cli",
14
+ "domain",
15
+ "history",
16
+ "parser",
17
+ "repair",
18
+ "reports",
19
+ "storage"
20
+ ],
21
+ "node": "26.7.0",
22
+ "platform": "darwin",
23
+ "python": [
24
+ 3,
25
+ 14,
26
+ 6
27
+ ],
28
+ "runtime_tag": "0.1.0-darwin-arm64-cpython-3146-aff2ab6c74530a04",
29
+ "schema": 1,
30
+ "solver_version": "4.16.0.0",
31
+ "version": "0.1.0",
32
+ "wheel": "artifacts/excuse_ops-0.1.0-py3-none-any.whl"
33
+ }
@@ -0,0 +1 @@
1
+ z3-solver==4.16.0.0 --hash=sha256:cc52843cfdd3d3f2cd24bedc62e71c18af8c8b7b23fb05e639ab60b01b5f8f2f