aimship 0.1.0-preview.4 → 0.1.0-preview.7
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/CONTRIBUTING.md +22 -0
- package/README.md +68 -23
- package/README.zh-CN.md +69 -24
- package/docs/cli.md +52 -32
- package/docs/examples.md +5 -0
- package/docs/guide.md +248 -30
- package/docs/guide.zh-CN.md +277 -0
- package/docs/installation.md +17 -110
- package/lib/cli.mjs +28 -16
- package/lib/documents.mjs +155 -0
- package/lib/migrate.mjs +77 -0
- package/lib/state.mjs +87 -21
- package/lib/view.mjs +69 -0
- package/package.json +3 -2
- package/skills/aimship/SKILL.md +1 -1
- package/skills/aimship/references/artifact-map.json +163 -0
- package/skills/aimship/references/core.md +8 -4
- package/skills/aimship/references/document-patterns.md +30 -10
- package/skills/aimship/references/hosts.md +4 -2
- package/skills/aimship/references/recovery.md +8 -0
- package/skills/aimship/references/sources-and-changes.md +6 -0
- package/skills/aimship/references/stages/contract.md +4 -0
- package/skills/aimship/references/stages/diagnose.md +6 -0
- package/skills/aimship/references/stages/frame.md +4 -0
- package/skills/aimship/references/stages/handoff.md +4 -0
- package/skills/aimship/references/stages/implement.md +6 -0
- package/skills/aimship/references/stages/plan.md +5 -1
- package/skills/aimship/references/stages/prototype.md +4 -0
- package/skills/aimship/references/stages/requirements.md +4 -0
- package/skills/aimship/references/stages/review.md +4 -0
- package/skills/aimship/references/stages/scope.md +4 -0
- package/skills/aimship/references/stages/tech-design.md +6 -0
- package/skills/aimship/references/stages/test-design.md +5 -1
- package/skills/aimship/references/stages/verify.md +6 -0
- package/skills/aimship/references/templates/change-log.md +14 -0
- package/skills/aimship/references/templates/diagnosis.md +39 -0
- package/skills/aimship/references/templates/handoff.md +34 -0
- package/skills/aimship/references/templates/plan.md +34 -0
- package/skills/aimship/references/templates/prd.md +39 -0
- package/skills/aimship/references/templates/review.md +19 -0
- package/skills/aimship/references/templates/scope.md +39 -0
- package/skills/aimship/references/templates/tech-design.md +39 -0
- package/skills/aimship/references/templates/testing.md +34 -0
package/docs/installation.md
CHANGED
|
@@ -1,117 +1,24 @@
|
|
|
1
|
-
# Installation
|
|
1
|
+
# Installation guide moved
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Installation and lifecycle instructions now live in the complete user guide. This page preserves the old entry point.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- [English guide](guide.md#install)
|
|
6
|
+
- [中文使用指南](guide.zh-CN.md#install)
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
<a id="installation-layout"></a>
|
|
9
|
+
<a id="source-and-local-packages"></a>
|
|
10
|
+
<a id="isolated-trial"></a>
|
|
11
|
+
<a id="registry-installation-after-publication"></a>
|
|
12
|
+
Installation layout, source/local packages and isolated trials: [English](guide.md#install) · [中文](guide.zh-CN.md#install).
|
|
8
13
|
|
|
9
|
-
|
|
14
|
+
<a id="host-entry-and-reload"></a>
|
|
15
|
+
Host entry and reload: [English](guide.md#hosts) · [中文](guide.zh-CN.md#hosts).
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
<a id="add-a-host-inspect-and-update"></a>
|
|
18
|
+
Add a host and inspect the installation: [English](guide.md#hosts) · [中文](guide.zh-CN.md#hosts). Update software: [English](guide.md#update) · [中文](guide.zh-CN.md#update).
|
|
12
19
|
|
|
13
|
-
|
|
20
|
+
<a id="disconnect-or-uninstall"></a>
|
|
21
|
+
Disconnect or uninstall: [English](guide.md#uninstall) · [中文](guide.zh-CN.md#uninstall).
|
|
14
22
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
From the directory containing a supplied tarball:
|
|
20
|
-
|
|
21
|
-
```sh
|
|
22
|
-
npx --offline file:./aimship-0.1.0-preview.4.tgz install --host codex
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
Run these against your real home only when you intend to install. Repeat `--host` to connect multiple hosts in that operation. No login to npm is needed to consume a local tarball; npm still needs a writable cache. The installer reports the installation home, previous/target version, action/result, registered hosts and entry locations. A zero exit status is not proof that a host has loaded the skill.
|
|
26
|
-
|
|
27
|
-
### Isolated trial
|
|
28
|
-
|
|
29
|
-
These commands use one disposable installation and npm cache, not your real configuration. Put the candidate tarball in your current directory first:
|
|
30
|
-
|
|
31
|
-
```sh
|
|
32
|
-
trial=$(mktemp -d)
|
|
33
|
-
trial=$(cd "$trial" && pwd -P)
|
|
34
|
-
npx --yes --offline --cache "$trial/npm-cache" file:./aimship-0.1.0-preview.4.tgz install --home "$trial/home" --host codex
|
|
35
|
-
node "$trial/home/.aimship/bin/aimship.mjs" --version
|
|
36
|
-
node "$trial/home/.aimship/bin/aimship.mjs" status
|
|
37
|
-
node "$trial/home/.aimship/bin/aimship.mjs" install --host cursor
|
|
38
|
-
node "$trial/home/.aimship/bin/aimship.mjs" uninstall --host cursor
|
|
39
|
-
node "$trial/home/.aimship/bin/aimship.mjs" uninstall
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
`--home` redirects only Aimship's managed installation. It does not make the running host discover this home, log in, or inherit permissions. The installed launcher and fixed release CLI default later management operations to their own installation home. An explicit `--home` overrides that target. After inspection, the disposable directory can be removed by its owner; do not substitute a real home for this temporary variable.
|
|
43
|
-
|
|
44
|
-
## Registry installation after publication
|
|
45
|
-
|
|
46
|
-
This candidate has not been published. After the maintainer confirms the package identity and publishes this exact preview, the proposed initial command is:
|
|
47
|
-
|
|
48
|
-
```sh
|
|
49
|
-
npx aimship@preview install --host codex
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
The `preview` tag selects the preview channel. To select an exact version, replace `@preview` with its version number. Confirm the package installation when npm prompts. npm obtains the installer; it copies a complete local release, so subsequent use does not depend on that npm cache. Do not use an unversioned command and assume it selects the preview channel. A missing package/version is a distribution failure, not a successful installation.
|
|
53
|
-
|
|
54
|
-
## Host entry and reload
|
|
55
|
-
|
|
56
|
-
| Host | Managed entry | Explicit use | After install, update or disconnect |
|
|
57
|
-
| --- | --- | --- | --- |
|
|
58
|
-
| Codex | `~/.agents/skills/aimship/` with `agents/openai.yaml` | Select Aimship; CLI `$aimship` | Start a new conversation or restart the CLI, then inspect its actual skill selector. If stale, restart the app. |
|
|
59
|
-
| Cursor | Same `.agents/skills/aimship/` entry | `/aimship` | Start a new agent session; inspect Customize → Skills in the editor. Restart the host if the list remains stale. |
|
|
60
|
-
| Claude Code | `~/.claude/skills/aimship/` | `/aimship` | Start a new session and inspect slash-command discovery; restart the host if stale. |
|
|
61
|
-
|
|
62
|
-
These host integrations are experimental; end-to-end host behavior and cross-host continuation have not yet been verified. Check skill discovery and directory access in your host before starting a delivery. Each host needs its own authentication, model availability and permission to access materials and code. Do not disable permission controls or grant unrelated directories. No cloud synchronization is installed or enabled.
|
|
63
|
-
|
|
64
|
-
Codex and Cursor share the managed entry. Cursor also documents compatibility discovery from Claude/Codex directories. Aimship registers only selected hosts; it cannot guarantee that an unselected compatible host will not display an entry. If two same-name entries appear, inspect their paths and installation version rather than deleting unrelated files. Shared entry presence is distinct from registration, host discovery, login and permission. See the official [Cursor directories](https://cursor.com/docs/skills) and [Codex skill locations](https://learn.chatgpt.com/docs/build-skills).
|
|
65
|
-
|
|
66
|
-
After an update, a running conversation may retain old rules. Save current facts, reload the bound core and current stage at a stable point, and use the existing compatibility/adoption process; start a new conversation if context cannot reliably refresh. Version mismatch stops new workflow writes. Never infer that installation instantly changed an active conversation.
|
|
67
|
-
|
|
68
|
-
## Add a host, inspect and update
|
|
69
|
-
|
|
70
|
-
Reuse the installed core without fetching another package:
|
|
71
|
-
|
|
72
|
-
```sh
|
|
73
|
-
node "$HOME/.aimship/bin/aimship.mjs" install --host cursor
|
|
74
|
-
node "$HOME/.aimship/bin/aimship.mjs" --version
|
|
75
|
-
node "$HOME/.aimship/bin/aimship.mjs" status
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
Repeating the same install is safe for unchanged managed files. Adding a host preserves existing registrations. `status` reads Aimship's known installation files and managed entries; it does not scan projects or query host accounts. It separates registered hosts, missing/modified files, shared entries, pending installation and active version from unverified host capabilities. It is diagnostic, not a semantic delivery validator.
|
|
79
|
-
|
|
80
|
-
To update from a new local release, run that release's `install` command with the same installation home. No `--host` is necessary for an existing installation: registered hosts are retained, and a core whose last host was disconnected remains a core-only installation. After publication, the explicit preview-channel update is:
|
|
81
|
-
|
|
82
|
-
```sh
|
|
83
|
-
npx aimship@preview install
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
There is one installation/update operation: `install`. `update` in the low-level CLI modifies delivery state and is **not** a software updater. The installer reports old and target versions and the result, updates the shared core and selected entries, and preserves configuration, custom content and every delivery. Completed deliveries are not traversed or rewritten. No automatic update runs in the background.
|
|
87
|
-
|
|
88
|
-
## Disconnect or uninstall
|
|
89
|
-
|
|
90
|
-
Disconnect one host, leaving the core and other registrations usable:
|
|
91
|
-
|
|
92
|
-
```sh
|
|
93
|
-
node "$HOME/.aimship/bin/aimship.mjs" uninstall --host cursor
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
If another registered host needs a shared entry, that file is retained and reported. Disconnecting the last host retains the core so version/status and later host connection remain available. A compatible host may still discover an entry used by another host; this command does not change that host's settings.
|
|
97
|
-
|
|
98
|
-
Remove all managed programs and entries:
|
|
99
|
-
|
|
100
|
-
```sh
|
|
101
|
-
node "$HOME/.aimship/bin/aimship.mjs" uninstall
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
Full uninstall retains all delivery materials, user code, custom files and necessary configuration. Modified/unowned files are preserved and listed. No option purges delivery history. The launcher is removed on complete removal; inspect the returned result. Restart existing host sessions to discard cached rules.
|
|
105
|
-
|
|
106
|
-
## Troubleshooting and recovery
|
|
107
|
-
|
|
108
|
-
- **Foreign or modified entry/release:** read the reported path, keep its contents and resolve ownership or merge customizations explicitly. Do not force-overwrite it to obtain a successful exit status.
|
|
109
|
-
- **Partial installation:** `status` can identify pending work. Rerun the exact pending package against the same home after correcting the reported cause. Binding/new workflow writes remain suspended until recovery completes. Do not choose a different version mid-recovery.
|
|
110
|
-
- **Launcher unavailable:** run `node bin/aimship.mjs status --home <installation-home>` from the exact source/package installer. A missing launcher or damaged diagnostic module cannot diagnose itself. `status` reports facts without repairing them; only after inspecting the cause should you rerun the exact installer or reconcile retained files.
|
|
111
|
-
- **Unexpected version:** use the stable launcher for the installed active version. A CLI inside an older release can report a different tool version; reload the current binding before continuing. A source checkout is not automatically an installed release.
|
|
112
|
-
- **Package not found/authentication error:** confirm the exact package/version and public registry. Installation of a public release does not itself prove publish rights. Do not paste credentials into a bug report.
|
|
113
|
-
- **Skill absent or duplicated:** inspect the actual host selector and returned entry paths after reloading. A file check alone cannot diagnose host discovery; retain this as unverified until observed.
|
|
114
|
-
- **Directory denied:** authorize only the needed delivery/code roots in that host. Shared material storage does not share permissions. Report the exact denied operation rather than relocating user materials or changing security settings.
|
|
115
|
-
- **Different host/session:** explicitly resume the same absolute delivery directory and inspect state/current inputs. Do not copy materials, reset the goal or run two writers. See [the user guide](guide.md).
|
|
116
|
-
|
|
117
|
-
The initial data root can be selected with `--data-root` on installation. Existing configuration is retained on later installs; no language setting or second installation mode is introduced. Low-level state/version/recovery contracts are in [the CLI protocol](cli.md).
|
|
23
|
+
<a id="troubleshooting-and-recovery"></a>
|
|
24
|
+
Troubleshooting and recovery: [English](guide.md#troubleshoot) · [中文](guide.zh-CN.md#troubleshoot).
|
package/lib/cli.mjs
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
|
+
import { migrateState, scanDeliveries } from './migrate.mjs';
|
|
2
|
+
import { inspectDocuments } from './documents.mjs';
|
|
1
3
|
import fs from 'node:fs/promises';
|
|
2
4
|
import path from 'node:path';
|
|
3
5
|
import os from 'node:os';
|
|
4
6
|
import { exists, fail, noSymlinks, readJSON } from './io.mjs';
|
|
5
7
|
import { install, installPaths, installationStatus, installationVersion, readConfig, uninstall } from './install.mjs';
|
|
6
|
-
import { SCHEMA_VERSION, STAGES, assertInstallationComplete, assertRules, checkReferences, initState, readState, recoverState, updateState, validateState } from './state.mjs';
|
|
8
|
+
import { SCHEMA_VERSION, STAGES, renderState, assertInstallationComplete, assertRules, checkReferences, initState, readState, recoverState, updateState, validateState } from './state.mjs';
|
|
7
9
|
|
|
8
10
|
const HELP = {
|
|
9
11
|
usage: 'node <aimship.mjs> <command> [delivery-directory] [options]',
|
|
10
12
|
commands: {
|
|
13
|
+
render: 'render <dir> --revision <N> --rules-version <version> [--preserve-edits]. Explicitly rebuild status.md; preserve manual edits before replacement. Does not replay a state update.',
|
|
14
|
+
migrate: 'migrate <dir> --revision <old-N> --rules-version <active-version> --input <json-file|->. Input {compatibilityRef}; preserve legacy state then move to schema 2. Adopt rules separately; documents require reviewed reconciliation.',
|
|
15
|
+
scan: 'scan <material-root>. Read immediate delivery directories in both layouts, deduplicate IDs and report conflicts/unknown records. No global search or write.',
|
|
11
16
|
status: 'status [--home <dir>]. Read only Aimship-managed paths and fixed entries. Report registration, shared entry presence/integrity, active version, pending work and config problems. Host loading, login and access permissions remain unverified; no directories are created.',
|
|
12
17
|
bind: 'Resolve active installation once. Returns immutable release/core/stage/CLI paths plus configured dataRoot, configPath and customPath. Source mode returns the default data root with null config/custom paths. No directories are created. Optional --home is for isolated testing.',
|
|
13
18
|
init: 'init <dir> --input <json-file|-> --rules-version <version>. Create state once; input requires goal {summary, endpoint} and goalRef. Does not create templates.',
|
|
14
19
|
read: 'read <dir> [--rules-version <version>]. Read-only JSON access; unknown schemas remain readable.',
|
|
15
20
|
update: 'update <dir> --revision <N> --rules-version <version> --input <json-file|->. Recursively merge objects; arrays replace in full. Identity/history fields are managed. null is a literal value, not deletion.',
|
|
16
|
-
check: 'check <dir> [--rules-version <version>] [--all]. Check structure and current input references (or every registered reference). Does not prove semantics,
|
|
21
|
+
check: 'check <dir> [--rules-version <version>] [--all]. Check structure and current input references (or every registered reference). Checks explicit anchors/identity metadata in new local Markdown; reused and unsupported fragments are explicitly not checked. Does not prove semantics, remote access or validation results.',
|
|
17
22
|
resume: 'resume <dir> [--rules-version <version>]. Read state and return fixed rule paths, required inputs, warnings, and continuation text; never execute work or rewrite state.',
|
|
18
23
|
checkpoint: 'checkpoint <dir> --revision <N> --rules-version <old-version> --input <json-file|->. Save only checkpoint {summary, ref?} and next when a version mismatch interrupts ongoing work. Cannot complete work.',
|
|
19
24
|
adopt: 'adopt <dir> --revision <N> --rules-version <active-version> --input <json-file|->. Input is {compatibilityRef}; records the assessed stable-point rule switch without changing business history.',
|
|
@@ -29,11 +34,11 @@ const HELP = {
|
|
|
29
34
|
result: ['passed', 'failed', 'not-run', 'unknown', 'mixed'],
|
|
30
35
|
applicability: ['current', 'needs-review', 'unknown'],
|
|
31
36
|
outcome: { goal: ['in-progress', 'met', 'not-met', 'unknown'], acceptance: ['not-recorded', 'accepted', 'declined', 'unknown'] },
|
|
32
|
-
stageInstance: { id: 'stable-instance-id', stage: '<stage>', scope: 'batch or whole-delivery scope', progress: 'planned', inputRefs: [], outputRefs: [], resultRef: '
|
|
37
|
+
stageInstance: { id: 'stable-instance-id', stage: '<stage>', scope: 'batch or whole-delivery scope', progress: 'planned', inputRefs: [], outputRefs: [], resultRef: 'review.md#rev-001-v1 (required when done)' },
|
|
33
38
|
references: 'delivery-relative/path#optional-fragment, repo:<registered-id>/<repo-relative-path>#fragment, or HTTP(S) URL. Local paths cannot escape their root. Repository registration: {id, path: absolute-path, role}.',
|
|
34
39
|
topLevel: {
|
|
35
|
-
managed: 'schemaVersion, revision, deliveryId, rulesVersion, createdAt, updatedAt, goalHistory, rulesHistory. Do not supply these in init/update.',
|
|
36
|
-
requiredInit: 'goal: {summary, endpoint}; goalRef: current requirements or Scope reference. Save the
|
|
40
|
+
managed: 'schemaVersion, revision, deliveryId, rulesVersion, createdAt, updatedAt, goalHistory, rulesHistory, documentDigests, identityHistory, retiredIdentities, legacyRunIds, writeResult. Do not supply these in init/update.',
|
|
41
|
+
requiredInit: 'goal: {summary, endpoint}; goalRef: current requirements or Scope reference. Save source/request.md and the registered PRD/Scope draft before init; documentPolicy and documents are required.',
|
|
37
42
|
stageRuns: 'Ordered array of stageInstance objects, default []. Every ID is unique; currentRun must match one of these IDs or be null. Stage types may repeat with distinct IDs.',
|
|
38
43
|
currentRun: 'Instance ID or null (default). Do not invent an ID without registering its instance in stageRuns.',
|
|
39
44
|
repositories: 'Array of {id, path: absolute-path, role, ...optional-version-facts}, default []. No repository is required for a design-only goal.',
|
|
@@ -45,10 +50,12 @@ const HELP = {
|
|
|
45
50
|
},
|
|
46
51
|
minimalInit: {
|
|
47
52
|
goal: { summary: 'Repair the search empty-state message', endpoint: 'Implement, review, and verify the agreed repair' },
|
|
48
|
-
goalRef: '
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
goalRef: 'prd.md',
|
|
54
|
+
documentPolicy: {authority:'authored',interaction:false,prototypeCovered:false,interface:'none',databases:[]},
|
|
55
|
+
documents: [{kind:'prd',path:'prd.md',mode:'new',version:'1'}],
|
|
56
|
+
stageRuns: [{ id: 'requirements-1', stage: 'requirements', scope: 'Search empty-state repair', progress: 'planned', inputRefs: ['prd.md'], outputRefs: [] }],
|
|
57
|
+
currentRun: 'requirements-1',
|
|
58
|
+
next: { action: 'Draft the confirmed requirements', inputRefs: ['prd.md'] }
|
|
52
59
|
},
|
|
53
60
|
safety: 'Done is execution progress only. Results, applicability, goal attainment, acceptance, merge, and deployment are independent. Preserve old result references with priorResultRef/historyResultRefs. Goal revision needs a new goal.decisionRef; prior goals are retained automatically.'
|
|
54
61
|
}
|
|
@@ -59,8 +66,8 @@ function parse(argv) {
|
|
|
59
66
|
const item = argv[i];
|
|
60
67
|
if (!item.startsWith('--')) { positional.push(item); continue; }
|
|
61
68
|
const name = item.slice(2);
|
|
62
|
-
if (!['help', 'version', 'all', 'input', 'rules-version', 'revision', 'host', 'home', 'data-root'].includes(name)) fail('USAGE', `Unknown option: ${item}`, 'Run help for command usage.');
|
|
63
|
-
const value = ['help', 'version', 'all'].includes(name) ? true : argv[++i];
|
|
69
|
+
if (!['help', 'version', 'all', 'preserve-edits', 'input', 'rules-version', 'revision', 'host', 'home', 'data-root'].includes(name)) fail('USAGE', `Unknown option: ${item}`, 'Run help for command usage.');
|
|
70
|
+
const value = ['help', 'version', 'all', 'preserve-edits'].includes(name) ? true : argv[++i];
|
|
64
71
|
if (value === undefined || (typeof value === 'string' && value.startsWith('--'))) fail('USAGE', `Missing value for ${item}`, 'Provide the option value.');
|
|
65
72
|
if (name === 'host') (options.hosts ??= []).push(value);
|
|
66
73
|
else { if (Object.hasOwn(options, name)) fail('USAGE', `Repeated option: ${item}`, 'Provide one value; only --host is repeatable.'); options[name] = value; }
|
|
@@ -111,7 +118,7 @@ async function binding(runtime) {
|
|
|
111
118
|
}
|
|
112
119
|
function allowed(command, options) {
|
|
113
120
|
const common = ['help', 'version'];
|
|
114
|
-
const perCommand = { status: ['home'], bind: ['home'], init: ['input', 'rules-version', 'home'], read: ['rules-version', 'home'], update: ['input', 'rules-version', 'revision', 'home'], check: ['all', 'rules-version', 'home'], resume: ['rules-version', 'home'], checkpoint: ['input', 'rules-version', 'revision', 'home'], adopt: ['input', 'rules-version', 'revision', 'home'], recover: ['rules-version', 'revision', 'home'], install: ['hosts', 'home', 'data-root'], uninstall: ['hosts', 'home'], help: [] };
|
|
121
|
+
const perCommand = { render: ['revision','rules-version','preserve-edits','home'], migrate:['revision','rules-version','input','home'], scan:['home'], status: ['home'], bind: ['home'], init: ['input', 'rules-version', 'home'], read: ['rules-version', 'home'], update: ['input', 'rules-version', 'revision', 'home'], check: ['all', 'rules-version', 'home'], resume: ['rules-version', 'home'], checkpoint: ['input', 'rules-version', 'revision', 'home'], adopt: ['input', 'rules-version', 'revision', 'home'], recover: ['rules-version', 'revision', 'home'], install: ['hosts', 'home', 'data-root'], uninstall: ['hosts', 'home'], help: [] };
|
|
115
122
|
for (const key of Object.keys(options)) if (![...common, ...(perCommand[command] ?? [])].includes(key)) fail('USAGE', `Option --${key} is not valid for ${command}.`, `Run help ${command}.`);
|
|
116
123
|
}
|
|
117
124
|
export async function run(argv, context) {
|
|
@@ -135,25 +142,30 @@ export async function run(argv, context) {
|
|
|
135
142
|
if (command === 'status') return { command, data: { toolVersion: pkg.version, ...await installationStatus({ home: installHome }) } };
|
|
136
143
|
if (command === 'install') return { command, data: await install(context.sourceRoot, { home: installHome, hosts: options.hosts, dataRoot: options['data-root'] }) };
|
|
137
144
|
if (command === 'uninstall') return { command, data: await uninstall({ home: installHome, hosts: options.hosts }) };
|
|
145
|
+
if (command === 'scan') return {command,data:await scanDeliveries(path.resolve(positional[1]))};
|
|
138
146
|
const runtime = await runtimeFor(context.sourceRoot, options, context.installRoot);
|
|
139
147
|
if (command === 'bind') return { command, data: await binding(runtime) };
|
|
140
148
|
const directory = path.resolve(positional[1]); const rulesVersion = options['rules-version'];
|
|
149
|
+
if (command === 'render') return {command,data:await renderState(directory,revisionOf(options.revision),rulesVersion,runtime,options['preserve-edits'])};
|
|
150
|
+
if (command === 'migrate') return {command,data:await migrateState(directory,revisionOf(options.revision),rulesVersion,runtime,await inputJSON(options.input))};
|
|
141
151
|
if (command === 'init') return { command, data: await initState(directory, await inputJSON(options.input), rulesVersion, runtime) };
|
|
142
152
|
if (['update', 'checkpoint', 'adopt'].includes(command)) return { command, data: await updateState(directory, await inputJSON(options.input), revisionOf(options.revision), rulesVersion, runtime, command) };
|
|
143
153
|
if (command === 'recover') return { command, data: await recoverState(directory, revisionOf(options.revision), rulesVersion, runtime) };
|
|
144
154
|
const read = await readState(directory);
|
|
145
155
|
let mismatch = null;
|
|
146
156
|
if (!read.readOnly) { try { assertRules(read.state, rulesVersion, runtime); } catch (error) { mismatch = { code: error.code, ...error.details, recovery: error.recovery }; } }
|
|
147
|
-
if (command === 'read') return { command, data: { state: read.state, readOnly: read.readOnly, versionMismatch: mismatch, statePath:
|
|
148
|
-
if (read.readOnly) return { command, data: { state: read.state, readOnly: true, canProceed: false, reason: 'Unknown schema: no structure checking, mutation, or automatic work is permitted.' } };
|
|
157
|
+
if (command === 'read') return { command, data: { state: read.state, readOnly: read.readOnly, versionMismatch: mismatch, statePath: read.statePath, view: read.view, documentChanges: read.documentChanges, layoutConflict:read.conflict??false, layoutMismatch:read.layoutMismatch??false, candidates:read.candidates, migration:read.migration?.status??null } };
|
|
158
|
+
if (read.readOnly) return { command, data: { state: read.state, readOnly: true, canProceed: false, reason: read.conflict?'Conflicting state layouts require explicit reconciliation.':'Unknown schema: no structure checking, mutation, or automatic work is permitted.', candidates:read.candidates } };
|
|
149
159
|
validateState(read.state); const references = await checkReferences(directory, read.state, options.all);
|
|
160
|
+
const documents=await inspectDocuments(directory,read.state);
|
|
150
161
|
const missing = references.filter(x => !['exists', 'not-checked'].includes(x.status));
|
|
151
162
|
if (command === 'check') {
|
|
152
163
|
if (missing.length) fail('REFERENCE_CHECK', 'Required references are missing or inaccessible.', 'Repair the specific registered paths; do not scan the disk or overwrite source materials.', { references, versionMismatch: mismatch });
|
|
153
164
|
if (mismatch) fail('VERSION_MISMATCH', 'State is structurally valid, but rules are not bound consistently.', mismatch.recovery, mismatch);
|
|
154
|
-
|
|
165
|
+
if (documents.issues.length) fail('DOCUMENT_CHECK','Document mapping or identities need repair.','Inspect listed issues.',documents);
|
|
166
|
+
return { command, data: { structure: 'valid', references, documents, view:read.view, documentChanges:read.documentChanges, semantics: 'not-checked' } };
|
|
155
167
|
}
|
|
156
168
|
const currentRun = read.state.stageRuns.find(x => x.id === read.state.currentRun) ?? null;
|
|
157
169
|
const blocked = currentRun?.progress === 'blocked';
|
|
158
|
-
return { command, data: { state: read.state, binding: await binding(runtime), readOnly: false, canProceed: !mismatch && missing.length === 0 && !blocked, versionMismatch: mismatch, references, currentRun, continuation: `Use Aimship to continue ${directory}; first read
|
|
170
|
+
return { command, data: { state: read.state, binding: await binding(runtime), readOnly: false, canProceed: read.state.documentPolicy?.authority !== 'legacy' && !mismatch && missing.length === 0 && !blocked && !documents.issues.length && !read.documentChanges.length && read.view.status === 'current' && (!read.migration || read.migration.status === 'complete'), statePath:read.statePath, view:read.view, documentChanges:read.documentChanges, documents, versionMismatch: mismatch, references, currentRun, continuation: `Use Aimship to continue ${directory}; first read ${read.statePath} and the current inputs it references, then follow the confirmed arrangement. Inspect unfinished effects and related artifacts before repeating any action.`, warnings: ['Resume does not rerun work, tests, migrations, or other side effects.', 'Structural readiness does not establish authorization, business readiness, or goal completion.', ...(blocked ? [`Current run ${currentRun.id} is recorded as blocked. Resolve its documented blocker before progressing this work.`] : []), ...(missing.length ? ['Some required paths must be repaired.'] : []), ...(read.state.checkpoint ? ['Inspect the saved checkpoint and current artifacts.'] : [])] } };
|
|
159
171
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
4
|
+
import { exists, hash, inside, fail, readJSON, json } from './io.mjs';
|
|
5
|
+
|
|
6
|
+
export async function artifactMap() {
|
|
7
|
+
return JSON.parse(await fs.readFile(new URL('../skills/aimship/references/artifact-map.json', import.meta.url), 'utf8'));
|
|
8
|
+
}
|
|
9
|
+
export function resolveRef(directory, state, ref) {
|
|
10
|
+
if (/^https?:\/\//.test(ref)) return null;
|
|
11
|
+
let base = path.resolve(directory), relative = ref.split('#')[0];
|
|
12
|
+
if (relative.startsWith('repo:')) {
|
|
13
|
+
const [, id, rest] = /^repo:([^/]+)\/(.*)$/.exec(relative) ?? [];
|
|
14
|
+
base = state.repositories.find(r => r.id === id)?.path;
|
|
15
|
+
if (!base) return null;
|
|
16
|
+
relative = rest;
|
|
17
|
+
}
|
|
18
|
+
const file = path.resolve(base, relative);
|
|
19
|
+
if (!inside(base, file)) fail('INVALID_REFERENCE', 'Reference escapes its root.', 'Repair the registered reference.');
|
|
20
|
+
return file;
|
|
21
|
+
}
|
|
22
|
+
export async function safeRead(directory, state, ref) {
|
|
23
|
+
const file = resolveRef(directory, state, ref);
|
|
24
|
+
if (!file) return null;
|
|
25
|
+
const real = await fs.realpath(file);
|
|
26
|
+
const base = ref.startsWith('repo:') ? state.repositories.find(r => r.id === /^repo:([^/]+)/.exec(ref)[1]).path : directory;
|
|
27
|
+
if (!inside(await fs.realpath(base), real)) fail('INVALID_REFERENCE', 'Reference resolves outside its root.', 'Repair the symbolic link or reference.');
|
|
28
|
+
return fs.readFile(real, 'utf8');
|
|
29
|
+
}
|
|
30
|
+
export const anchors = text => [...text.matchAll(/<a\s+id=["']([^"']+)["']\s*><\/a>/g)].map(m => m[1]);
|
|
31
|
+
const identityKinds = ['REQ','AC','B','TASK','TC','REV','RUN','CHG','Q','PAGE'];
|
|
32
|
+
const identityKind = d => d.kind ?? d.id.split('-')[0];
|
|
33
|
+
// Migration exempts preserved execution facts, never future work under an old ID.
|
|
34
|
+
export async function preservedLegacyRuns(directory,state) {
|
|
35
|
+
if (!state.legacyRunIds?.length) return new Set();
|
|
36
|
+
const journal=await readJSON(path.join(directory,'.aimship/migration.json'));
|
|
37
|
+
if (journal.protocol!==1 || hash(json(journal.target))!==journal.targetHash || journal.target.deliveryId!==state.deliveryId) fail('MIGRATION_CONFLICT','Cannot verify legacy run provenance.','Inspect the migration journal and original state.');
|
|
38
|
+
const basis=run=>{const {applicability,...facts}=run;return facts;};
|
|
39
|
+
return new Set(state.stageRuns.filter(run=>state.legacyRunIds.includes(run.id) && journal.target.stageRuns.some(old=>old.id===run.id && old.progress==='done' && isDeepStrictEqual(basis(old),basis(run)))).map(run=>run.id));
|
|
40
|
+
}
|
|
41
|
+
export function definitions(text, file) {
|
|
42
|
+
const markers=[...text.matchAll(/<!--\s*aimship:def\b/g)].length;
|
|
43
|
+
const validMarkers=[...text.matchAll(/<!--\s*aimship:def\s+(\{[^\n]*\})\s*-->/g)].length;
|
|
44
|
+
if (markers !== validMarkers) fail('DOCUMENT_METADATA', `Malformed definition marker in ${file}.`, 'Use a complete single-line aimship:def JSON comment.');
|
|
45
|
+
return [...text.matchAll(/<!--\s*aimship:def\s+(\{[^\n]*\})\s*-->/g)].map(m => {
|
|
46
|
+
try {
|
|
47
|
+
const d = JSON.parse(m[1]);
|
|
48
|
+
const namespace = d.namespace ?? 'local';
|
|
49
|
+
const validId=typeof d.id==='string' && (namespace==='local' ? /^[A-Za-z][A-Za-z0-9_-]*-\d+$/.test(d.id) : d.id.trim().length>0 && !/[:@\r\n]/.test(d.id));
|
|
50
|
+
if (!validId || !Number.isSafeInteger(d.version) || d.version < 1 || !['current','historical','retired'].includes(d.status) || !Array.isArray(d.refs) || !d.refs.every(x => typeof x === 'string' && /^[\w-]+:[^:@\r\n]+@[1-9]\d*$/.test(x)) || !/^[\w-]+$/.test(namespace) || (d.kind!==undefined && (!identityKinds.includes(d.kind) || (namespace==='local' && d.kind!==d.id.split('-')[0])))) throw new Error('Invalid identity metadata');
|
|
51
|
+
return { ...d, namespace, key: `${namespace}:${d.id}@${d.version}`, file };
|
|
52
|
+
} catch { fail('DOCUMENT_METADATA', `Invalid definition in ${file}.`, 'Use the documented aimship:def JSON syntax.'); }
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
export async function inspectDocuments(directory, state) {
|
|
56
|
+
if (state.schemaVersion !== 2 || !state.documentPolicy || state.documentPolicy.authority === 'legacy') return { issues: [], unchecked: ['Legacy documents require recorded compatibility review.'], digests: {}, identities: [], nextIds: {} };
|
|
57
|
+
const map = await artifactMap(), issues = [], unchecked = [], digests = {}, defs = [], documentAnchors = new Map();
|
|
58
|
+
const docs = state.documents ?? [];
|
|
59
|
+
const policy = state.documentPolicy;
|
|
60
|
+
for (const kind of new Set(docs.map(d=>d.kind))) if (!['sql','source'].includes(kind) && docs.filter(d=>d.kind===kind).length>1) issues.push(`Register one current artifact for ${kind}; keep history through references.`);
|
|
61
|
+
if (docs.some(d=>d.kind==='prd'&&d.mode==='new') && docs.some(d=>d.kind==='scope'&&d.mode==='new')) issues.push('Do not create competing new PRD and Scope entries.');
|
|
62
|
+
const requirementKind = policy.authority === 'adopted' ? 'scope' : 'prd';
|
|
63
|
+
const find = kind => docs.find(d => d.kind === (kind === 'requirement' ? requirementKind : kind));
|
|
64
|
+
if (!await exists(path.join(directory, 'source/request.md'))) issues.push('Missing source/request.md.');
|
|
65
|
+
if (!find('requirement')) issues.push(`Register ${requirementKind} as the requirement entry.`);
|
|
66
|
+
if (policy.authority !== 'editable' && state.goalRef.split('#')[0] !== `${requirementKind}.md`) issues.push('goalRef must point to the selected root PRD/Scope.');
|
|
67
|
+
if (find('requirement') && state.goalRef.split('#')[0]!==find('requirement').path) issues.push('goalRef must point to the registered requirement authority.');
|
|
68
|
+
for (const doc of docs) {
|
|
69
|
+
const spec = map.documents[doc.kind];
|
|
70
|
+
// Location rules apply even when content is remote or cannot be read.
|
|
71
|
+
if (doc.mode==='new') {
|
|
72
|
+
if (spec && doc.path!==spec.path) issues.push(`${doc.kind} must use ${spec.path}, not ${doc.path}.`);
|
|
73
|
+
if (doc.kind==='prototype' && (!doc.path.startsWith('prototype/') || (/\.html$/.test(doc.path) && doc.path!=='prototype/index.html'))) issues.push('New prototypes must stay in prototype/; HTML uses prototype/index.html.');
|
|
74
|
+
if (doc.kind==='source' && !doc.path.startsWith('source/')) issues.push('New source material must stay in source/.');
|
|
75
|
+
}
|
|
76
|
+
let text;
|
|
77
|
+
try { text = await safeRead(directory, state, doc.path); }
|
|
78
|
+
catch (error) { issues.push(`Cannot read ${doc.path}: ${error.code}`); continue; }
|
|
79
|
+
if (text === null) { unchecked.push(`${doc.path}: remote or unavailable repository; inspect adopted version using host tools (${doc.basisRef ?? 'missing basisRef'}).`); continue; }
|
|
80
|
+
digests[doc.path] = hash(text);
|
|
81
|
+
if (doc.mode === 'reused') { unchecked.push(`${doc.path}: reused format/content; human/Agent basis ${doc.basisRef}.`); continue; }
|
|
82
|
+
if (spec) {
|
|
83
|
+
const ids = anchors(text); let previous = -1;
|
|
84
|
+
documentAnchors.set(doc.path,ids);
|
|
85
|
+
for (const id of spec.sections) {
|
|
86
|
+
const i = ids.indexOf(id);
|
|
87
|
+
if (i < 0 || i <= previous || ids.filter(x => x === id).length !== 1) issues.push(`${doc.path}: missing, duplicate or unordered section #${id}.`);
|
|
88
|
+
previous = i;
|
|
89
|
+
}
|
|
90
|
+
defs.push(...definitions(text, doc.path));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const legacyRuns=await preservedLegacyRuns(directory,state);
|
|
94
|
+
const runs = state.stageRuns.filter(r => !legacyRuns.has(r.id));
|
|
95
|
+
for (const [i, run] of state.stageRuns.entries()) {
|
|
96
|
+
if (!runs.includes(run) || run.progress === 'planned') continue;
|
|
97
|
+
const contract = map.stages[run.stage], doc = find(contract.kind);
|
|
98
|
+
if (!doc) { issues.push(`${run.id}: missing registered ${contract.kind} artifact.`); continue; }
|
|
99
|
+
if (run.stage === 'implement' && !state.stageRuns.slice(0,i).some(r => r.stage === 'plan' && r.progress === 'done')) issues.push(`${run.id}: complete plan before implementation.`);
|
|
100
|
+
if (run.progress === 'done') {
|
|
101
|
+
if (run.resultRef?.split('#')[0] !== doc.path) issues.push(`${run.id}: result must locate its mapped artifact ${doc.path}.`);
|
|
102
|
+
const section = contract.sectionByKind?.[doc.kind] ?? contract.section;
|
|
103
|
+
if (doc.mode==='new' && section && !['review','verify'].includes(run.stage) && run.resultRef !== `${doc.path}#${section}`) issues.push(`${run.id}: use the mapped #${section} result section.`);
|
|
104
|
+
if (map.documents[doc.kind] && doc.mode === 'new' && !run.resultRef?.includes('#')) issues.push(`${run.id}: result needs a precise section/record anchor.`);
|
|
105
|
+
if (map.documents[doc.kind] && doc.mode==='new' && (documentAnchors.get(doc.path)??[]).filter(id=>id===run.resultRef?.split('#')[1]).length!==1) issues.push(`${run.id}: missing or duplicate result anchor ${run.resultRef}.`);
|
|
106
|
+
if (!run.outputRefs.some(r => r.split('#')[0] === doc.path)) issues.push(`${run.id}: outputRefs must register ${doc.path}.`);
|
|
107
|
+
if (['review','verify'].includes(run.stage) && doc.mode==='new') {
|
|
108
|
+
const prefix=run.stage==='review'?'REV-':'RUN-';
|
|
109
|
+
if (!defs.some(d=>d.file===doc.path && d.namespace==='local' && d.id.startsWith(prefix) && d.run===run.id && `${doc.path}#${d.id.toLowerCase()}-v${d.version}`===run.resultRef)) issues.push(`${run.id}: result must locate its own versioned REV/RUN definition with matching run field.`);
|
|
110
|
+
}
|
|
111
|
+
if (run.stage === 'tech-design') {
|
|
112
|
+
for (const db of policy.databases) {
|
|
113
|
+
const file = policy.databases.length === 1 ? 'database.sql' : `database-${db}.sql`;
|
|
114
|
+
if (!docs.some(d => d.kind === 'sql' && d.path === file) || !await exists(path.join(directory,file))) issues.push(`${run.id}: missing independent ${file}.`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (policy.interface === 'http') {
|
|
120
|
+
const doc=find('contract');
|
|
121
|
+
if (doc?.mode === 'new' && doc.path !== 'openapi.yaml') issues.push('New HTTP contract must be openapi.yaml.');
|
|
122
|
+
}
|
|
123
|
+
if (policy.interaction && !policy.prototypeCovered && runs.some(r => r.stage === 'implement' && r.progress !== 'planned') && !state.stageRuns.some(r => r.stage === 'prototype' && r.progress === 'done')) issues.push('Uncovered interaction requires completed prototype before implementation.');
|
|
124
|
+
const seen = new Set(), current = new Set(), nextIds = {};
|
|
125
|
+
for (const d of defs) {
|
|
126
|
+
if (seen.has(d.key)) issues.push(`Duplicate definition ${d.key}.`); seen.add(d.key);
|
|
127
|
+
const identity = `${d.namespace}:${d.id}`;
|
|
128
|
+
if (d.status === 'current') { if (state.retiredIdentities?.includes(identity)) issues.push(`Retired identity cannot be reused: ${identity}.`); if (current.has(identity)) issues.push(`Multiple current definitions ${identity}.`); current.add(identity); }
|
|
129
|
+
const numeric=/^(.*)-(\d+)$/.exec(d.id);
|
|
130
|
+
if (numeric) {
|
|
131
|
+
const [,prefix,number]=numeric, group=`${d.namespace}:${prefix}`;
|
|
132
|
+
nextIds[group] = Math.max(nextIds[group] ?? 1, Number(number)+1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const byKey=new Map(defs.map(d=>[d.key,d]));
|
|
136
|
+
for (const d of defs) {
|
|
137
|
+
for (const key of d.refs) if (!seen.has(key)) issues.push(`${d.key}: unresolved ${key}.`);
|
|
138
|
+
if (d.status !== 'current') continue;
|
|
139
|
+
const refersTo=kind=>d.refs.some(key=>byKey.has(key) && identityKind(byKey.get(key))===kind);
|
|
140
|
+
if (d.namespace === 'local' && identityKind(d)==='REQ' && !refersTo('AC')) issues.push(`${d.key}: missing acceptance reference.`);
|
|
141
|
+
if (identityKind(d)==='RUN' && !refersTo('TC')) issues.push(`${d.key}: missing versioned case reference.`);
|
|
142
|
+
if (identityKind(d)==='TC' && d.namespace === 'local' && !d.refs.length) issues.push(`${d.key}: missing acceptance or risk basis.`);
|
|
143
|
+
if (identityKind(d)==='AC' && runs.some(r => ['test-design','verify'].includes(r.stage) && r.progress === 'done') && !defs.some(t => t.status === 'current' && identityKind(t)==='TC' && t.refs.includes(d.key))) issues.push(`${d.key}: no current case coverage.`);
|
|
144
|
+
}
|
|
145
|
+
for (const key of state.identityHistory ?? []) if (!seen.has(key)) issues.push(`Preserve historical definition ${key}; retire it rather than deleting/reusing its identity.`);
|
|
146
|
+
return { retired:defs.filter(d=>d.status==='retired').map(d=>`${d.namespace}:${d.id}`), issues, unchecked, digests, identities: [...seen], nextIds };
|
|
147
|
+
}
|
|
148
|
+
export async function documentChanges(directory, state) {
|
|
149
|
+
const changes=[];
|
|
150
|
+
for (const [ref, digest] of Object.entries(state.documentDigests ?? {})) {
|
|
151
|
+
try { const text = await safeRead(directory,state,ref); if (text === null || hash(text) !== digest) changes.push(ref); }
|
|
152
|
+
catch { changes.push(ref); }
|
|
153
|
+
}
|
|
154
|
+
return changes;
|
|
155
|
+
}
|
package/lib/migrate.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { atomicWrite, exists, fail, hash, json, noSymlinks, readJSON, withLock } from './io.mjs';
|
|
4
|
+
import { statePaths, validateState, assertInstallationComplete, readState } from './state.mjs';
|
|
5
|
+
import { savedResult } from './view.mjs';
|
|
6
|
+
|
|
7
|
+
export async function migrateState(directory, revision, rulesVersion, runtime, input) {
|
|
8
|
+
assertInstallationComplete(runtime);
|
|
9
|
+
if (rulesVersion !== runtime.version || runtime.version !== runtime.activeVersion) fail('VERSION_MISMATCH','Migration requires the active tool.','Bind the active version.');
|
|
10
|
+
if (!input || Object.keys(input).length !== 1 || typeof input.compatibilityRef !== 'string' || !input.compatibilityRef.trim()) fail('USAGE','Migration input is {compatibilityRef}.','Record the assessment and old/new document disposition first.');
|
|
11
|
+
const old=statePaths(directory,true), modern=statePaths(directory);
|
|
12
|
+
await noSymlinks(modern.internal); await fs.mkdir(modern.internal,{recursive:true});
|
|
13
|
+
return withLock(old.lock,()=>withLock(modern.lock,async()=>{
|
|
14
|
+
const journalPath=path.join(modern.internal,'migration.json');
|
|
15
|
+
for (const file of [journalPath,old.statePath,old.backup,modern.statePath,path.join(modern.internal,'legacy/state.json'),path.join(modern.internal,'legacy/state.previous.json')]) await noSymlinks(file);
|
|
16
|
+
let journal;
|
|
17
|
+
if (await exists(journalPath)) {
|
|
18
|
+
journal=await readJSON(journalPath);
|
|
19
|
+
if (journal.protocol !== 1 || journal.revision !== revision || journal.compatibilityRef !== input.compatibilityRef) fail('MIGRATION_CONFLICT','Migration parameters or journal differ.','Inspect recorded migration; never overwrite the copies.');
|
|
20
|
+
if (journal.status==='complete') {
|
|
21
|
+
if (await exists(old.statePath)) fail('LAYOUT_CONFLICT','A root state reappeared after migration.','Inspect both states before proceeding.');
|
|
22
|
+
return {state:(await readState(directory)).state,migration:'already-complete'};
|
|
23
|
+
}
|
|
24
|
+
} else {
|
|
25
|
+
if (await exists(modern.statePath) || await exists(modern.backup)) fail('LAYOUT_CONFLICT','Target state/recovery already exists.','Inspect both layouts; do not merge by timestamp.');
|
|
26
|
+
await noSymlinks(old.statePath); await noSymlinks(old.backup);
|
|
27
|
+
const raw=await fs.readFile(old.statePath,'utf8'), state=JSON.parse(raw);
|
|
28
|
+
validateState(state);
|
|
29
|
+
if (state.schemaVersion!==1) fail('UNKNOWN_SCHEMA','Only schema 1 root state can migrate.','Keep unknown formats read-only.');
|
|
30
|
+
if (state.revision!==revision) fail('REVISION_CONFLICT','Legacy revision changed.','Read and reassess before migration.');
|
|
31
|
+
const next={...state,schemaVersion:2,revision:state.revision+1,updatedAt:new Date().toISOString(),documentPolicy:{authority:'legacy'},documents:[],legacyRunIds:state.stageRuns.map(r=>r.id),migration:{fromSchema:1,compatibilityRef:input.compatibilityRef}};
|
|
32
|
+
// validate the assessment reference with the ordinary state reference protocol.
|
|
33
|
+
validateState({...next,next:{...next.next,inputRefs:[input.compatibilityRef]}});
|
|
34
|
+
const backupRaw=await exists(old.backup)?await fs.readFile(old.backup,'utf8'):null;
|
|
35
|
+
journal={protocol:1,status:'prepared',revision,compatibilityRef:input.compatibilityRef,sourceHash:hash(raw),backupHash:backupRaw===null?null:hash(backupRaw),targetHash:hash(json(next)),target:next};
|
|
36
|
+
const legacyDir=path.join(modern.internal,'legacy'); await noSymlinks(legacyDir); await fs.mkdir(legacyDir,{recursive:true});
|
|
37
|
+
for (const [name,bytes] of [['state.json',raw],['state.previous.json',backupRaw]]) {
|
|
38
|
+
if(bytes===null) continue;
|
|
39
|
+
const file=path.join(legacyDir,name);
|
|
40
|
+
if(await exists(file) && hash(await fs.readFile(file))!==hash(bytes)) fail('MIGRATION_CONFLICT','Legacy backup differs.','Preserve all copies and resolve manually.');
|
|
41
|
+
if(!await exists(file)) await atomicWrite(file,bytes);
|
|
42
|
+
}
|
|
43
|
+
await atomicWrite(journalPath,json(journal));
|
|
44
|
+
}
|
|
45
|
+
validateState(journal.target);
|
|
46
|
+
if (hash(json(journal.target))!==journal.targetHash) fail('MIGRATION_CONFLICT','Migration target checksum changed.','Inspect journal and preserved source.');
|
|
47
|
+
const legacyCopy=path.join(modern.internal,'legacy/state.json');
|
|
48
|
+
if(hash(await fs.readFile(legacyCopy))!==journal.sourceHash) fail('MIGRATION_CONFLICT','Preserved source changed.','Recover the original source before resuming.');
|
|
49
|
+
const recoveryCopy=path.join(modern.internal,'legacy/state.previous.json');
|
|
50
|
+
if (journal.backupHash !== null && (!await exists(recoveryCopy) || hash(await fs.readFile(recoveryCopy))!==journal.backupHash)) fail('MIGRATION_CONFLICT','Preserved recovery copy is missing or changed.','Repair the preserved copy from the original recovery material before resuming.');
|
|
51
|
+
if(await exists(old.statePath) && hash(await fs.readFile(old.statePath))!==journal.sourceHash) fail('MIGRATION_CONFLICT','Legacy state changed during migration.','Reconcile facts; do not overwrite either copy.');
|
|
52
|
+
if(await exists(modern.statePath) && hash(await fs.readFile(modern.statePath))!==journal.targetHash) fail('MIGRATION_CONFLICT','Target state changed during migration.','Reconcile both states.');
|
|
53
|
+
if(await exists(old.backup) && hash(await fs.readFile(old.backup))!==journal.backupHash) fail('MIGRATION_CONFLICT','Legacy recovery copy changed.','Preserve both recovery copies.');
|
|
54
|
+
if(!await exists(modern.statePath)) await atomicWrite(modern.statePath,json(journal.target));
|
|
55
|
+
await fs.rm(old.statePath,{force:true}); await fs.rm(old.backup,{force:true});
|
|
56
|
+
await atomicWrite(journalPath,json({...journal,status:'complete'}));
|
|
57
|
+
return savedResult(directory,journal.target);
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Discovery is intentionally bounded to named material roots, never the home or code tree.
|
|
62
|
+
export async function scanDeliveries(root) {
|
|
63
|
+
const records=[],issues=[],seen=new Map();
|
|
64
|
+
for(const entry of await fs.readdir(root,{withFileTypes:true})) {
|
|
65
|
+
if(!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
66
|
+
const directory=path.join(root,entry.name);
|
|
67
|
+
if(!await exists(path.join(directory,'state.json')) && !await exists(path.join(directory,'.aimship/state.json'))) continue;
|
|
68
|
+
try {
|
|
69
|
+
const read=await readState(directory), id=read.state.deliveryId;
|
|
70
|
+
if(read.conflict || read.readOnly) {issues.push({directory,reason:read.conflict?'layout-conflict':'unknown-schema',candidates:read.candidates}); continue;}
|
|
71
|
+
if(seen.has(id)) {issues.push({directory,reason:'duplicate-delivery-id',other:seen.get(id)}); continue;}
|
|
72
|
+
seen.set(id,directory);
|
|
73
|
+
records.push({directory,statePath:read.statePath,deliveryId:id,rulesVersion:read.state.rulesVersion,repositories:read.state.repositories,currentRun:read.state.currentRun,stageRuns:read.state.stageRuns,outcome:read.state.outcome,next:read.state.next,updatedAt:read.state.updatedAt,migration:read.migration?.status??null});
|
|
74
|
+
} catch(error) {issues.push({directory,reason:error.code??error.message});}
|
|
75
|
+
}
|
|
76
|
+
return {root:path.resolve(root),records,issues,limits:'Named root immediate children only. Records may be stale; unknown roots and non-Aimship work are outside this check. Agent must assess actual writing intent and checkout/worktree identity.'};
|
|
77
|
+
}
|