aimship 0.0.0-stage → 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.
Files changed (48) hide show
  1. package/CONTRIBUTING.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +89 -2
  4. package/README.zh-CN.md +90 -0
  5. package/bin/aimship.mjs +17 -0
  6. package/docs/cli.md +82 -0
  7. package/docs/examples.md +5 -0
  8. package/docs/guide.md +277 -0
  9. package/docs/guide.zh-CN.md +277 -0
  10. package/docs/installation.md +24 -0
  11. package/lib/cli.mjs +171 -0
  12. package/lib/documents.mjs +155 -0
  13. package/lib/install.mjs +363 -0
  14. package/lib/io.mjs +55 -0
  15. package/lib/migrate.mjs +77 -0
  16. package/lib/state.mjs +276 -0
  17. package/lib/view.mjs +69 -0
  18. package/package.json +43 -4
  19. package/skills/aimship/SKILL.md +19 -0
  20. package/skills/aimship/agents/openai.yaml +6 -0
  21. package/skills/aimship/references/artifact-map.json +163 -0
  22. package/skills/aimship/references/core.md +88 -0
  23. package/skills/aimship/references/document-patterns.md +39 -0
  24. package/skills/aimship/references/hosts.md +17 -0
  25. package/skills/aimship/references/recovery.md +21 -0
  26. package/skills/aimship/references/sources-and-changes.md +34 -0
  27. package/skills/aimship/references/stages/contract.md +23 -0
  28. package/skills/aimship/references/stages/diagnose.md +25 -0
  29. package/skills/aimship/references/stages/frame.md +23 -0
  30. package/skills/aimship/references/stages/handoff.md +23 -0
  31. package/skills/aimship/references/stages/implement.md +25 -0
  32. package/skills/aimship/references/stages/plan.md +23 -0
  33. package/skills/aimship/references/stages/prototype.md +23 -0
  34. package/skills/aimship/references/stages/requirements.md +23 -0
  35. package/skills/aimship/references/stages/review.md +23 -0
  36. package/skills/aimship/references/stages/scope.md +23 -0
  37. package/skills/aimship/references/stages/tech-design.md +25 -0
  38. package/skills/aimship/references/stages/test-design.md +23 -0
  39. package/skills/aimship/references/stages/verify.md +25 -0
  40. package/skills/aimship/references/templates/change-log.md +14 -0
  41. package/skills/aimship/references/templates/diagnosis.md +39 -0
  42. package/skills/aimship/references/templates/handoff.md +34 -0
  43. package/skills/aimship/references/templates/plan.md +34 -0
  44. package/skills/aimship/references/templates/prd.md +39 -0
  45. package/skills/aimship/references/templates/review.md +19 -0
  46. package/skills/aimship/references/templates/scope.md +39 -0
  47. package/skills/aimship/references/templates/tech-design.md +39 -0
  48. package/skills/aimship/references/templates/testing.md +34 -0
package/lib/cli.mjs ADDED
@@ -0,0 +1,171 @@
1
+ import { migrateState, scanDeliveries } from './migrate.mjs';
2
+ import { inspectDocuments } from './documents.mjs';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { exists, fail, noSymlinks, readJSON } from './io.mjs';
7
+ import { install, installPaths, installationStatus, installationVersion, readConfig, uninstall } from './install.mjs';
8
+ import { SCHEMA_VERSION, STAGES, renderState, assertInstallationComplete, assertRules, checkReferences, initState, readState, recoverState, updateState, validateState } from './state.mjs';
9
+
10
+ const HELP = {
11
+ usage: 'node <aimship.mjs> <command> [delivery-directory] [options]',
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.',
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.',
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.',
18
+ init: 'init <dir> --input <json-file|-> --rules-version <version>. Create state once; input requires goal {summary, endpoint} and goalRef. Does not create templates.',
19
+ read: 'read <dir> [--rules-version <version>]. Read-only JSON access; unknown schemas remain readable.',
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.',
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.',
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.',
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.',
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.',
25
+ recover: 'recover <dir> --revision <backup-revision> --rules-version <version>. Restore state.previous.json only over missing/corrupt state; preserve corrupted bytes. Inspect actual artifacts afterward; nothing reruns.',
26
+ install: 'install --host <codex|cursor|claude-code> [--host ...] [--data-root <dir>] [--home <dir>]. Explicitly copy the whole release into .aimship. Repeated installs retain registered hosts. Installed tools default to their owning home; explicit --home overrides it for isolated testing. Data root is configured, never created.',
27
+ uninstall: 'uninstall [--host <host> ...] [--home <dir>]. --host disconnects selected hosts, retaining shared core even when no hosts remain. Omit --host for full product uninstall. Delivery data, config, custom and modified/unowned files are retained. Installed tools default to their owning home; explicit --home overrides it.',
28
+ help: 'help [command]. Every command supports --help. --version reports the executing tool version and installed active version separately. install is the only installation/update entry; update modifies delivery state.'
29
+ },
30
+ stateProtocol: {
31
+ schemaVersion: SCHEMA_VERSION,
32
+ stages: STAGES,
33
+ progress: ['planned', 'active', 'blocked', 'done'],
34
+ result: ['passed', 'failed', 'not-run', 'unknown', 'mixed'],
35
+ applicability: ['current', 'needs-review', 'unknown'],
36
+ outcome: { goal: ['in-progress', 'met', 'not-met', 'unknown'], acceptance: ['not-recorded', 'accepted', 'declined', 'unknown'] },
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)' },
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}.',
39
+ topLevel: {
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.',
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.',
43
+ currentRun: 'Instance ID or null (default). Do not invent an ID without registering its instance in stageRuns.',
44
+ repositories: 'Array of {id, path: absolute-path, role, ...optional-version-facts}, default []. No repository is required for a design-only goal.',
45
+ openChanges: 'Array of {id, ref, status: open|closed}, default []; close records rather than deleting history.',
46
+ outcome: 'Defaults to {goal: in-progress, acceptance: not-recorded}. A met goal needs goalConclusionRef; recorded acceptance needs acceptanceRef. Optional handoffRef, mergeRef, deploymentRef retain independent facts.',
47
+ next: '{action, inputRefs?: []}; defaults to continuing the confirmed arrangement.',
48
+ cycle: 'Positive integer, default 1. A reopening increments by one, appends a closureRef and resets current acceptance.',
49
+ closureRefs: 'Historical closure references, default []. Preserve them across reopenings.'
50
+ },
51
+ minimalInit: {
52
+ goal: { summary: 'Repair the search empty-state message', endpoint: 'Implement, review, and verify the agreed repair' },
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'] }
59
+ },
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.'
61
+ }
62
+ };
63
+ function parse(argv) {
64
+ const positional = []; const options = {};
65
+ for (let i = 0; i < argv.length; i++) {
66
+ const item = argv[i];
67
+ if (!item.startsWith('--')) { positional.push(item); continue; }
68
+ const name = item.slice(2);
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];
71
+ if (value === undefined || (typeof value === 'string' && value.startsWith('--'))) fail('USAGE', `Missing value for ${item}`, 'Provide the option value.');
72
+ if (name === 'host') (options.hosts ??= []).push(value);
73
+ else { if (Object.hasOwn(options, name)) fail('USAGE', `Repeated option: ${item}`, 'Provide one value; only --host is repeatable.'); options[name] = value; }
74
+ }
75
+ return { positional, options };
76
+ }
77
+ async function inputJSON(file) {
78
+ if (!file) fail('USAGE', '--input is required.', 'Use a JSON file or --input - to read stdin.');
79
+ if (file !== '-') return readJSON(path.resolve(file));
80
+ try { let raw = ''; for await (const chunk of process.stdin) raw += chunk; return JSON.parse(raw); } catch (e) { fail('INVALID_JSON', 'Cannot parse JSON from stdin.', 'Provide one valid JSON object.', { cause: e.message }); }
81
+ }
82
+ function revisionOf(value) {
83
+ if (!/^(0|[1-9][0-9]*)$/.test(value ?? '') || !Number.isSafeInteger(Number(value))) fail('USAGE', '--revision requires a nonnegative integer.', 'Use the exact revision returned by read (backup revision for recover).');
84
+ return Number(value);
85
+ }
86
+ function installedRoot(sourceRoot) { return path.basename(path.dirname(sourceRoot)) === 'releases' ? path.dirname(path.dirname(sourceRoot)) : null; }
87
+ async function runtimeFor(sourceRoot, options, explicitRoot) {
88
+ const pkg = await readJSON(path.join(sourceRoot, 'package.json'));
89
+ const root = explicitRoot ?? installedRoot(sourceRoot) ?? (options.home ? installPaths(options.home).root : null);
90
+ let activeVersion = pkg.version;
91
+ let installPending = null;
92
+ if (root) {
93
+ const manifest = await readJSON(path.join(root, 'install-manifest.json'));
94
+ if (manifest.schemaVersion !== 1 || manifest.product !== 'aimship') fail('INSTALL_FORMAT', 'Unknown installation manifest.', 'Use a compatible installer.');
95
+ installPending = manifest.pending ?? null;
96
+ if (await exists(path.join(root, 'active.json'))) {
97
+ const active = await readJSON(path.join(root, 'active.json'));
98
+ if (active.launcherProtocol !== 1 || typeof active.version !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(active.version)) fail('INSTALL_FORMAT', 'Active pointer has an unsupported protocol or version.', 'Reinstall the exact complete package.');
99
+ activeVersion = active.version;
100
+ } else if (!installPending) fail('NOT_INSTALLED', 'No active installation exists.', 'Install the exact package explicitly.');
101
+ }
102
+ return { version: pkg.version, activeVersion, installPending, releaseRoot: sourceRoot, installRoot: root, launcher: root ? path.join(root, 'bin', 'aimship.mjs') : path.join(sourceRoot, 'bin', 'aimship.mjs') };
103
+ }
104
+ async function binding(runtime) {
105
+ assertInstallationComplete(runtime);
106
+ const root = runtime.installRoot ? path.join(runtime.installRoot, 'releases', runtime.activeVersion) : runtime.releaseRoot;
107
+ await noSymlinks(root);
108
+ const pkg = await readJSON(path.join(root, 'package.json'));
109
+ if (pkg.version !== runtime.activeVersion) fail('RELEASE_CONFLICT', 'Active pointer and release package version differ.', 'Repair installation before continuing.');
110
+ const corePath = path.join(root, 'skills', 'aimship', 'references', 'core.md');
111
+ if (!await exists(corePath)) fail('INCOMPLETE_RELEASE', 'The bound core is missing.', 'Repair the exact installation before continuing.');
112
+ const configPath = runtime.installRoot ? path.join(runtime.installRoot, 'config.json') : null;
113
+ let dataRoot = path.join(os.homedir(), 'Aimship');
114
+ if (configPath) {
115
+ dataRoot = (await readConfig(configPath)).dataRoot;
116
+ }
117
+ return { version: pkg.version, schemaVersion: SCHEMA_VERSION, launcherProtocol: 1, releaseRoot: root, corePath, stageDirectory: path.join(root, 'skills', 'aimship', 'references', 'stages'), cliPath: path.join(root, 'bin', 'aimship.mjs'), launcherPath: runtime.launcher, nodePath: process.execPath, dataRoot, configPath, customPath: runtime.installRoot ? path.join(runtime.installRoot, 'custom') : null, configurationStatus: runtime.installRoot ? 'installed' : 'source-default' };
118
+ }
119
+ function allowed(command, options) {
120
+ const common = ['help', 'version'];
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: [] };
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}.`);
123
+ }
124
+ export async function run(argv, context) {
125
+ if (Number(process.versions.node.split('.')[0]) < 22) fail('NODE_VERSION', 'Aimship requires Node.js 22 or later.', 'Install a maintained Node LTS release and rerun this exact package.');
126
+ const { positional, options } = parse(argv); const command = positional[0] ?? 'help';
127
+ const pkg = await readJSON(path.join(context.sourceRoot, 'package.json'));
128
+ const owningInstallRoot = context.installRoot ?? installedRoot(context.sourceRoot);
129
+ const installHome = options.home ?? (owningInstallRoot ? path.dirname(owningInstallRoot) : undefined);
130
+ if (options.version) {
131
+ const installed = await installationVersion({ home: installHome });
132
+ return { command: 'version', data: { version: pkg.version, toolVersion: pkg.version, installedActiveVersion: installed.installedActiveVersion, installation: installed.installation, pendingVersion: installed.pending?.version ?? null, node: process.version, integrity: 'Use status for managed-file and config integrity; host loading, login and permissions are not verified.', issues: [installed.manifest.error, installed.active.error].filter(Boolean) } };
133
+ }
134
+ if (command === 'help' || options.help) {
135
+ const subject = command === 'help' ? positional[1] : command;
136
+ if (subject && !HELP.commands[subject]) fail('USAGE', `Unknown command: ${subject}`, 'Run help.');
137
+ return { command: 'help', data: subject ? { usage: HELP.commands[subject], stateProtocol: HELP.stateProtocol } : HELP };
138
+ }
139
+ if (!HELP.commands[command]) fail('USAGE', `Unknown command: ${command}`, 'Run help.');
140
+ allowed(command, options);
141
+ if (['install', 'uninstall', 'bind', 'status'].includes(command) ? positional.length !== 1 : positional.length !== 2) fail('USAGE', 'Unexpected or missing positional arguments.', `Run help ${command}.`);
142
+ if (command === 'status') return { command, data: { toolVersion: pkg.version, ...await installationStatus({ home: installHome }) } };
143
+ if (command === 'install') return { command, data: await install(context.sourceRoot, { home: installHome, hosts: options.hosts, dataRoot: options['data-root'] }) };
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]))};
146
+ const runtime = await runtimeFor(context.sourceRoot, options, context.installRoot);
147
+ if (command === 'bind') return { command, data: await binding(runtime) };
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))};
151
+ if (command === 'init') return { command, data: await initState(directory, await inputJSON(options.input), rulesVersion, runtime) };
152
+ if (['update', 'checkpoint', 'adopt'].includes(command)) return { command, data: await updateState(directory, await inputJSON(options.input), revisionOf(options.revision), rulesVersion, runtime, command) };
153
+ if (command === 'recover') return { command, data: await recoverState(directory, revisionOf(options.revision), rulesVersion, runtime) };
154
+ const read = await readState(directory);
155
+ let mismatch = null;
156
+ if (!read.readOnly) { try { assertRules(read.state, rulesVersion, runtime); } catch (error) { mismatch = { code: error.code, ...error.details, recovery: error.recovery }; } }
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 } };
159
+ validateState(read.state); const references = await checkReferences(directory, read.state, options.all);
160
+ const documents=await inspectDocuments(directory,read.state);
161
+ const missing = references.filter(x => !['exists', 'not-checked'].includes(x.status));
162
+ if (command === 'check') {
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 });
164
+ if (mismatch) fail('VERSION_MISMATCH', 'State is structurally valid, but rules are not bound consistently.', mismatch.recovery, mismatch);
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' } };
167
+ }
168
+ const currentRun = read.state.stageRuns.find(x => x.id === read.state.currentRun) ?? null;
169
+ const blocked = currentRun?.progress === 'blocked';
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.'] : [])] } };
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
+ }