aimship 0.1.0-preview.4 → 0.1.0-preview.8

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 (47) hide show
  1. package/CONTRIBUTING.md +59 -0
  2. package/README.md +126 -19
  3. package/README.zh-CN.md +127 -20
  4. package/docs/cli.md +68 -32
  5. package/docs/examples.md +5 -0
  6. package/docs/guide.md +181 -30
  7. package/docs/guide.zh-CN.md +210 -0
  8. package/docs/installation.md +19 -108
  9. package/docs/reference.md +78 -0
  10. package/docs/reference.zh-CN.md +78 -0
  11. package/lib/approvals.mjs +153 -0
  12. package/lib/cli.mjs +29 -16
  13. package/lib/documents.mjs +156 -0
  14. package/lib/migrate.mjs +77 -0
  15. package/lib/state.mjs +96 -24
  16. package/lib/view.mjs +75 -0
  17. package/package.json +3 -2
  18. package/skills/aimship/SKILL.md +2 -2
  19. package/skills/aimship/references/artifact-map.json +172 -0
  20. package/skills/aimship/references/core.md +12 -6
  21. package/skills/aimship/references/document-patterns.md +32 -10
  22. package/skills/aimship/references/document-review.md +28 -0
  23. package/skills/aimship/references/hosts.md +4 -2
  24. package/skills/aimship/references/recovery.md +10 -0
  25. package/skills/aimship/references/sources-and-changes.md +8 -0
  26. package/skills/aimship/references/stages/contract.md +10 -2
  27. package/skills/aimship/references/stages/diagnose.md +10 -0
  28. package/skills/aimship/references/stages/frame.md +4 -0
  29. package/skills/aimship/references/stages/handoff.md +4 -0
  30. package/skills/aimship/references/stages/implement.md +6 -0
  31. package/skills/aimship/references/stages/plan.md +10 -2
  32. package/skills/aimship/references/stages/prototype.md +8 -0
  33. package/skills/aimship/references/stages/requirements.md +8 -0
  34. package/skills/aimship/references/stages/review.md +4 -0
  35. package/skills/aimship/references/stages/scope.md +8 -0
  36. package/skills/aimship/references/stages/tech-design.md +11 -1
  37. package/skills/aimship/references/stages/test-design.md +9 -1
  38. package/skills/aimship/references/stages/verify.md +6 -0
  39. package/skills/aimship/references/templates/change-log.md +14 -0
  40. package/skills/aimship/references/templates/diagnosis.md +39 -0
  41. package/skills/aimship/references/templates/handoff.md +34 -0
  42. package/skills/aimship/references/templates/plan.md +35 -0
  43. package/skills/aimship/references/templates/prd.md +39 -0
  44. package/skills/aimship/references/templates/review.md +19 -0
  45. package/skills/aimship/references/templates/scope.md +39 -0
  46. package/skills/aimship/references/templates/tech-design.md +40 -0
  47. package/skills/aimship/references/templates/testing.md +35 -0
@@ -0,0 +1,156 @@
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, encoding = 'utf8') {
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, encoding);
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) ?? (run.stage === 'contract' && policy.interface === 'none' ? find('sql') : null);
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 === 'contract') {
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 (state.reviewPolicy === 1 && (policy.interface !== 'none' || policy.databases.length) && runs.some(r=>['test-design','plan','implement'].includes(r.stage) && r.progress !== 'planned') && !runs.some(r=>r.stage==='contract' && r.progress==='done')) issues.push('Complete the applicable interface/SQL contract before test design, plan or implementation.');
120
+ if (policy.interface === 'http') {
121
+ const doc=find('contract');
122
+ if (doc?.mode === 'new' && doc.path !== 'openapi.yaml') issues.push('New HTTP contract must be openapi.yaml.');
123
+ }
124
+ 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.');
125
+ const seen = new Set(), current = new Set(), nextIds = {};
126
+ for (const d of defs) {
127
+ if (seen.has(d.key)) issues.push(`Duplicate definition ${d.key}.`); seen.add(d.key);
128
+ const identity = `${d.namespace}:${d.id}`;
129
+ 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); }
130
+ const numeric=/^(.*)-(\d+)$/.exec(d.id);
131
+ if (numeric) {
132
+ const [,prefix,number]=numeric, group=`${d.namespace}:${prefix}`;
133
+ nextIds[group] = Math.max(nextIds[group] ?? 1, Number(number)+1);
134
+ }
135
+ }
136
+ const byKey=new Map(defs.map(d=>[d.key,d]));
137
+ for (const d of defs) {
138
+ for (const key of d.refs) if (!seen.has(key)) issues.push(`${d.key}: unresolved ${key}.`);
139
+ if (d.status !== 'current') continue;
140
+ const refersTo=kind=>d.refs.some(key=>byKey.has(key) && identityKind(byKey.get(key))===kind);
141
+ if (d.namespace === 'local' && identityKind(d)==='REQ' && !refersTo('AC')) issues.push(`${d.key}: missing acceptance reference.`);
142
+ if (identityKind(d)==='RUN' && !refersTo('TC')) issues.push(`${d.key}: missing versioned case reference.`);
143
+ if (identityKind(d)==='TC' && d.namespace === 'local' && !d.refs.length) issues.push(`${d.key}: missing acceptance or risk basis.`);
144
+ 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.`);
145
+ }
146
+ for (const key of state.identityHistory ?? []) if (!seen.has(key)) issues.push(`Preserve historical definition ${key}; retire it rather than deleting/reusing its identity.`);
147
+ return { retired:defs.filter(d=>d.status==='retired').map(d=>`${d.namespace}:${d.id}`), issues, unchecked, digests, identities: [...seen], nextIds };
148
+ }
149
+ export async function documentChanges(directory, state) {
150
+ const changes=[];
151
+ for (const [ref, digest] of Object.entries(state.documentDigests ?? {})) {
152
+ try { const text = await safeRead(directory,state,ref); if (text === null || hash(text) !== digest) changes.push(ref); }
153
+ catch { changes.push(ref); }
154
+ }
155
+ return changes;
156
+ }
@@ -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
+ }
package/lib/state.mjs CHANGED
@@ -1,10 +1,13 @@
1
1
  import fs from 'node:fs/promises';
2
+ import { validateReviewState, enforceReviews, reviewStatus } from './approvals.mjs';
2
3
  import path from 'node:path';
4
+ import { inspectDocuments, documentChanges, anchors, preservedLegacyRuns } from './documents.mjs';
5
+ import { savedResult, viewStatus, generateView } from './view.mjs';
3
6
  import { randomUUID } from 'node:crypto';
4
7
  import { atomicWrite, exists, fail, inside, json, noSymlinks, readJSON, withLock } from './io.mjs';
5
8
 
6
- export const SCHEMA_VERSION = 1;
7
- export const STAGES = ['frame', 'requirements', 'prototype', 'scope', 'diagnose', 'tech-design', 'contract', 'plan', 'test-design', 'implement', 'review', 'verify', 'handoff'];
9
+ export const SCHEMA_VERSION = 2;
10
+ export const STAGES = ['frame', 'requirements', 'prototype', 'scope', 'diagnose', 'tech-design', 'contract', 'test-design', 'plan', 'implement', 'review', 'verify', 'handoff'];
8
11
  const PROGRESS = ['planned', 'active', 'blocked', 'done'];
9
12
  const RESULTS = ['passed', 'failed', 'not-run', 'unknown', 'mixed'];
10
13
  const OUTCOME_REFS = ['goalConclusionRef', 'acceptanceRef', 'mergeRef', 'deploymentRef', 'handoffRef'];
@@ -24,7 +27,22 @@ function unique(items, label) { requireThat(items.every(object), `${label} entri
24
27
 
25
28
  export function validateState(state) {
26
29
  requireThat(object(state), 'State must be an object.');
27
- if (state.schemaVersion !== SCHEMA_VERSION) fail('UNKNOWN_SCHEMA', `Schema ${state.schemaVersion} is read-only for this tool.`, 'Preserve the file and use a compatible tool or an explicit, reviewed migration.');
30
+ if (![1, SCHEMA_VERSION].includes(state.schemaVersion)) fail('UNKNOWN_SCHEMA', `Schema ${state.schemaVersion} is read-only for this tool.`, 'Preserve the file and use a compatible tool or an explicit, reviewed migration.');
31
+ if (state.schemaVersion === 2) {
32
+ requireThat(object(state.documentPolicy) && ['authored','adopted','editable','legacy'].includes(state.documentPolicy.authority), 'documentPolicy.authority is required.');
33
+ if (state.documentPolicy.authority !== 'legacy') {
34
+ const p = state.documentPolicy;
35
+ requireThat(typeof p.interaction === 'boolean' && typeof p.prototypeCovered === 'boolean' && ['none','http','other'].includes(p.interface) && Array.isArray(p.databases) && p.databases.every(x => typeof x === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(x)) && new Set(p.databases).size === p.databases.length, 'Invalid documentPolicy conditions.');
36
+ requireThat(Array.isArray(state.documents), 'documents must be an array.');
37
+ const paths = new Set();
38
+ for (const d of state.documents) {
39
+ requireThat(object(d) && ['prd','scope','tech-design','plan','testing','review','diagnosis','handoff','change-log','prototype','contract','sql','source'].includes(d.kind) && ['new','reused'].includes(d.mode) && string(d.version), 'Invalid document registration.');
40
+ ref(d.path, 'documents.path'); requireThat(!d.path.includes('#') && !paths.has(d.path), 'Document paths must be unique and have no fragment.'); paths.add(d.path);
41
+ if (d.assetRefs !== undefined) refs(d.assetRefs, 'documents.assetRefs');
42
+ if (d.mode === 'reused') { ref(d.basisRef, 'documents.basisRef'); }
43
+ }
44
+ }
45
+ }
28
46
  requireThat(Number.isSafeInteger(state.revision) && state.revision >= 0, 'revision must be a nonnegative integer.');
29
47
  requireThat(string(state.deliveryId) && string(state.rulesVersion), 'deliveryId and rulesVersion are required.');
30
48
  requireThat(object(state.goal) && string(state.goal.summary) && string(state.goal.endpoint), 'goal.summary and goal.endpoint are required.');
@@ -57,19 +75,55 @@ export function validateState(state) {
57
75
  if (state.next.inputRefs !== undefined) refs(state.next.inputRefs, 'next.inputRefs');
58
76
  if (state.goalHistory !== undefined) requireThat(Array.isArray(state.goalHistory), 'goalHistory must be an array.');
59
77
  if (state.rulesHistory !== undefined) requireThat(Array.isArray(state.rulesHistory), 'rulesHistory must be an array.');
78
+ validateReviewState(state);
60
79
  return state;
61
80
  }
62
81
 
63
- export function statePaths(directory) {
64
- const dir = path.resolve(directory);
65
- return { dir, statePath: path.join(dir, 'state.json'), backup: path.join(dir, 'state.previous.json'), lock: path.join(dir, '.state.lock') };
82
+ export function statePaths(directory, legacy = false) {
83
+ const dir = path.resolve(directory), internal = legacy ? dir : path.join(dir,'.aimship');
84
+ return { dir, internal, statePath:path.join(internal,'state.json'), backup:path.join(internal,'state.previous.json'), lock:path.join(internal,'.state.lock'), legacy };
85
+ }
86
+ export async function locateState(directory, writing = false) {
87
+ const modern=statePaths(directory), legacy=statePaths(directory,true);
88
+ const hasNew=await exists(modern.statePath), hasOld=await exists(legacy.statePath);
89
+ const journalPath=path.join(modern.internal,'migration.json');
90
+ const journal=await exists(journalPath) ? await readJSON(journalPath) : null;
91
+ if (hasNew && hasOld) {
92
+ if (writing) fail('LAYOUT_CONFLICT','Both state layouts exist.','Inspect both copies; resume a recorded migration explicitly.');
93
+ return {...modern,conflict:true,candidates:[legacy.statePath,modern.statePath],migration:journal};
94
+ }
95
+ if (writing && journal && journal.status !== 'complete') fail('MIGRATION_PENDING','Migration is incomplete.','Resume migrate with the original revision and compatibilityRef.');
96
+ return {...(hasOld || (!hasNew && await exists(legacy.backup)) ? legacy : modern),migration:journal};
66
97
  }
67
98
  export async function readState(directory) {
68
- const paths = statePaths(directory); const state = await readJSON(paths.statePath);
69
- requireThat(object(state), 'State must be an object.');
70
- const readOnly = state.schemaVersion !== SCHEMA_VERSION;
99
+ const paths=await locateState(directory); const state=await readJSON(paths.statePath);
100
+ requireThat(object(state),'State must be an object.');
101
+ const layoutMismatch=(state.schemaVersion===1 && !paths.legacy)||(state.schemaVersion===2 && paths.legacy);
102
+ const readOnly=paths.conflict || layoutMismatch || ![1,SCHEMA_VERSION].includes(state.schemaVersion) || (state.reviewPolicy !== undefined && state.reviewPolicy !== 1);
71
103
  if (!readOnly) validateState(state);
72
- return { ...paths, state, readOnly };
104
+ return {...paths,state,readOnly,layoutMismatch,review:readOnly ? null : await reviewStatus(directory,state),view:await viewStatus(directory,state),documentChanges:await documentChanges(directory,state)};
105
+ }
106
+ async function inspectForWrite(directory,state,previous) {
107
+ const inspected=await inspectDocuments(directory,state);
108
+ if (inspected.issues.length) fail('DOCUMENT_CHECK','Document structure or traceability needs repair.','Repair the specific documents; file presence is not semantic completion.',inspected);
109
+ // Historical migrated runs retain their old formats. New completed runs need resolvable results.
110
+ const legacyRuns=await preservedLegacyRuns(directory,state);
111
+ for (const run of state.stageRuns.filter(r=>r.progress==='done' && !legacyRuns.has(r.id))) {
112
+ if (previous?.stageRuns.some(r=>r.id===run.id && JSON.stringify(r)===JSON.stringify(run))) continue;
113
+ const results=await checkReferences(directory,{...state,goalRef:run.resultRef,next:{inputRefs:[]},stageRuns:[],currentRun:null});
114
+ if (results.some(r=>!['exists','not-checked'].includes(r.status))) fail('DOCUMENT_CHECK',`Cannot locate result for ${run.id}.`,'Fix the result reference.',{references:results});
115
+ }
116
+ state.documentDigests=inspected.digests;
117
+ state.retiredIdentities=[...new Set([...(previous?.retiredIdentities??[]),...inspected.retired??[]])];
118
+ state.identityHistory=[...new Set([...(previous?.identityHistory??[]),...inspected.identities])];
119
+ }
120
+ export async function renderState(directory,revision,rulesVersion,runtime,preserveEdits=false) {
121
+ const p=await locateState(directory,true);
122
+ return withLock(p.lock,async()=>{
123
+ const {state}=await readState(directory); validateState(state); checkRevision(state,revision); assertRules(state,rulesVersion,runtime);
124
+ if (p.legacy) fail('MIGRATION_REQUIRED','Migrate before rendering.','Use migrate, then adopt.');
125
+ return {revision:state.revision,stateSaved:false,view:await generateView(directory,state,preserveEdits)};
126
+ });
73
127
  }
74
128
  function checkRevision(state, revision) {
75
129
  if (!Number.isSafeInteger(revision) || revision !== state.revision) fail('REVISION_CONFLICT', `Expected revision ${revision}; current revision is ${state.revision}.`, 'Read state again and merge your intended changes; never retry with a guessed revision.', { expected: revision, actual: state.revision });
@@ -84,14 +138,15 @@ export function assertInstallationComplete(runtime) {
84
138
  export async function initState(directory, input, rulesVersion, runtime) {
85
139
  assertInstallationComplete(runtime);
86
140
  if (rulesVersion !== runtime.version || runtime.activeVersion !== runtime.version) fail('VERSION_MISMATCH', 'New delivery requires the active tool and rules version.', 'Bind the current installation and use its returned version.');
87
- const p = statePaths(directory); await noSymlinks(p.dir); await fs.mkdir(p.dir, { recursive: true });
141
+ const p = statePaths(directory); await noSymlinks(p.internal); await fs.mkdir(p.internal, { recursive: true });
142
+ if (await exists(path.join(p.dir,'state.json')) || await exists(path.join(p.dir,'state.previous.json')) || await exists(p.backup) || await exists(path.join(p.internal,'migration.json'))) fail('ALREADY_EXISTS','Existing delivery or recovery material found.','Read/resume the existing delivery.');
88
143
  return withLock(p.lock, async () => {
89
144
  if (await exists(p.statePath)) fail('ALREADY_EXISTS', 'A delivery already exists here.', 'Read/resume this delivery or choose a different folder.');
90
- const forbidden = ['schemaVersion', 'revision', 'deliveryId', 'rulesVersion', 'createdAt', 'updatedAt', 'goalHistory', 'rulesHistory'];
145
+ const forbidden = ['schemaVersion', 'revision', 'deliveryId', 'rulesVersion', 'createdAt', 'updatedAt', 'goalHistory', 'rulesHistory', 'documentDigests', 'identityHistory', 'retiredIdentities', 'legacyRunIds', 'writeResult'];
91
146
  requireThat(object(input) && !forbidden.some(x => Object.hasOwn(input, x)), 'init input must not set tool-managed identity/version fields.');
92
147
  const now = new Date().toISOString();
93
- const state = { cycle: 1, closureRefs: [], stageRuns: [], currentRun: null, openChanges: [], repositories: [], outcome: { goal: 'in-progress', acceptance: 'not-recorded' }, next: { action: 'Continue the confirmed arrangement.' }, ...input, schemaVersion: SCHEMA_VERSION, revision: 0, deliveryId: randomUUID(), rulesVersion, createdAt: now, updatedAt: now };
94
- validateState(state); await atomicWrite(p.statePath, json(state)); return state;
148
+ const state = { reviewPolicy: 1, reviewHistory: [], cycle: 1, closureRefs: [], stageRuns: [], currentRun: null, openChanges: [], repositories: [], outcome: { goal: 'in-progress', acceptance: 'not-recorded' }, next: { action: 'Continue the confirmed arrangement.' }, ...input, schemaVersion: SCHEMA_VERSION, revision: 0, deliveryId: randomUUID(), rulesVersion, createdAt: now, updatedAt: now };
149
+ validateState(state); requireThat(state.documentPolicy.authority !== 'legacy', 'New deliveries cannot opt into legacy rules.'); await inspectForWrite(directory,state); await enforceReviews(directory,state); await atomicWrite(p.statePath, json(state)); return savedResult(directory,state);
95
150
  });
96
151
  }
97
152
  function merge(base, patch) {
@@ -120,9 +175,12 @@ function retainHistory(previous, next) {
120
175
  }
121
176
  export async function updateState(directory, patch, revision, rulesVersion, runtime, mode = 'update') {
122
177
  assertInstallationComplete(runtime);
123
- const p = statePaths(directory);
178
+ const p = await locateState(directory,true);
124
179
  return withLock(p.lock, async () => {
125
- const { state: previous } = await readState(directory); validateState(previous); checkRevision(previous, revision);
180
+ const read = await readState(directory);
181
+ if (read.layoutMismatch) fail('LAYOUT_CONFLICT','Schema and layout disagree.','Inspect the original layout; do not write through an inconsistent path.');
182
+ const previous = read.state; validateState(previous); checkRevision(previous, revision);
183
+ if (previous.schemaVersion === 1 && mode !== 'checkpoint') fail('MIGRATION_REQUIRED','Legacy layout/schema must migrate before rule adoption or updates.','Use migrate with a compatibility assessment, then adopt.');
126
184
  requireThat(object(patch), 'Update input must be an object.');
127
185
  if (mode === 'checkpoint') {
128
186
  if (rulesVersion !== previous.rulesVersion || runtime.version !== previous.rulesVersion) fail('VERSION_MISMATCH', 'Checkpoint must use the rules already bound to this state.', 'Use the compatible old tool or preserve facts outside state and resume with the active tool.');
@@ -138,7 +196,7 @@ export async function updateState(directory, patch, revision, rulesVersion, runt
138
196
  } else {
139
197
  assertRules(previous, rulesVersion, runtime);
140
198
  }
141
- const immutable = ['schemaVersion', 'revision', 'deliveryId', 'rulesVersion', 'createdAt', 'updatedAt', 'goalHistory', 'rulesHistory'];
199
+ const immutable = ['schemaVersion', 'revision', 'deliveryId', 'rulesVersion', 'createdAt', 'updatedAt', 'goalHistory', 'rulesHistory', 'documentDigests', 'identityHistory', 'retiredIdentities', 'legacyRunIds', 'writeResult'];
142
200
  requireThat(!immutable.some(x => Object.hasOwn(patch, x)), 'Identity, revision, and historical metadata are tool-managed.');
143
201
  let next;
144
202
  if (mode === 'adopt') {
@@ -150,12 +208,15 @@ export async function updateState(directory, patch, revision, rulesVersion, runt
150
208
  requireThat(string(next.goal.decisionRef) && next.goal.decisionRef !== previous.goal.decisionRef, 'Goal revision requires a new explicit decisionRef; viewing results does not revise a goal.');
151
209
  next.goalHistory = [...(previous.goalHistory ?? []), { goal: previous.goal, goalRef: previous.goalRef, outcome: previous.outcome, revision: previous.revision }];
152
210
  }
211
+ requireThat(previous.documentPolicy?.authority === 'legacy' || next.documentPolicy?.authority !== 'legacy', 'Cannot downgrade a new delivery to legacy document rules.');
212
+ if (mode === 'update' && next.documentPolicy?.authority === 'legacy') requireThat(Object.keys(patch).every(k=>['next','checkpoint','repositories'].includes(k)), 'Reconcile legacy documents and set the current documentPolicy before advancing stages or outcomes.');
213
+ if (mode === 'update') { await inspectForWrite(directory,next,previous); if (next.reviewPolicy === 1 || !Object.keys(patch).every(k=>['next','checkpoint','repositories'].includes(k))) await enforceReviews(directory,next,previous); }
153
214
  retainHistory(previous, next); next.revision = previous.revision + 1; next.updatedAt = new Date().toISOString();
154
- validateState(next); await atomicWrite(p.backup, json(previous)); await atomicWrite(p.statePath, json(next)); return next;
215
+ validateState(next); await atomicWrite(p.backup, json(previous)); await atomicWrite(p.statePath, json(next)); return next.schemaVersion === 2 ? savedResult(directory,next) : next;
155
216
  });
156
217
  }
157
218
  export async function recoverState(directory, revision, rulesVersion, runtime) {
158
- const p = statePaths(directory);
219
+ const p = await locateState(directory,true);
159
220
  return withLock(p.lock, async () => {
160
221
  const backup = await readJSON(p.backup); validateState(backup); checkRevision(backup, revision); assertRules(backup, rulesVersion, runtime);
161
222
  let corruptRevision = backup.revision + 1;
@@ -163,15 +224,16 @@ export async function recoverState(directory, revision, rulesVersion, runtime) {
163
224
  const raw = await fs.readFile(p.statePath, 'utf8');
164
225
  let parsed; try { parsed = JSON.parse(raw); } catch { /* Corruption is preserved below. */ }
165
226
  if (parsed) {
166
- if (parsed.schemaVersion !== SCHEMA_VERSION) fail('UNKNOWN_SCHEMA', 'Cannot recover over an unknown schema.', 'Use a compatible tool; preserve the newer state.');
227
+ if (parsed.reviewPolicy !== undefined && parsed.reviewPolicy !== 1) fail('UNKNOWN_SCHEMA','Cannot recover over an unknown review protocol.','Preserve the newer state and use a compatible tool.');
228
+ if (parsed.schemaVersion !== backup.schemaVersion) fail('UNKNOWN_SCHEMA', 'Cannot recover over an unknown schema.', 'Use a compatible tool; preserve the newer state.');
167
229
  try { validateState(parsed); fail('STATE_VALID', 'Current state is valid; refusing rollback.', 'Use a normal revision-checked update to reconcile facts.'); }
168
- catch (e) { if (e.code !== 'INVALID_STATE') throw e; }
230
+ catch (e) { if (!['INVALID_STATE','REVIEW_CHECK'].includes(e.code)) throw e; }
169
231
  if (Number.isSafeInteger(parsed.revision)) corruptRevision = Math.max(corruptRevision, parsed.revision);
170
232
  }
171
- await atomicWrite(path.join(p.dir, `state.corrupt.${randomUUID()}.json`), raw);
233
+ await atomicWrite(path.join(p.internal, `state.corrupt.${randomUUID()}.json`), raw);
172
234
  }
173
235
  const next = { ...backup, revision: corruptRevision + 1, updatedAt: new Date().toISOString(), recovery: { fromRevision: backup.revision, action: 'Inspect actual artifacts and unfinished effects before proceeding; nothing has been re-executed.' } };
174
- await atomicWrite(p.statePath, json(next)); return next;
236
+ await atomicWrite(p.statePath, json(next)); return next.schemaVersion === 2 ? savedResult(directory,next) : next;
175
237
  });
176
238
  }
177
239
  export async function checkReferences(directory, state, all = false) {
@@ -188,6 +250,8 @@ export async function checkReferences(directory, state, all = false) {
188
250
  }
189
251
  for (const history of state.rulesHistory ?? []) add(history?.ref);
190
252
  add(state.checkpoint?.ref);
253
+ for (const r of state.reviewHistory ?? []) add(r.sourceRef);
254
+ for (const d of state.documents ?? []) for (const r of d.assetRefs ?? []) add(r);
191
255
  }
192
256
  const results = [];
193
257
  for (const value of values) {
@@ -203,7 +267,15 @@ export async function checkReferences(directory, state, all = false) {
203
267
  try {
204
268
  const real = await fs.realpath(target); const realBase = await fs.realpath(base);
205
269
  if (!inside(realBase, real)) results.push({ ref: value, status: 'outside-root', path: target });
206
- else { await fs.access(real, fs.constants.R_OK); results.push({ ref: value, status: 'exists', path: target, fragmentChecked: false }); }
270
+ else {
271
+ await fs.access(real, fs.constants.R_OK);
272
+ const fragment=value.split('#')[1], doc=state.documents?.find(d=>d.path===value.split('#')[0]);
273
+ if (fragment && doc?.mode==='new' && /\.md$/.test(relative)) {
274
+ const ids=anchors(await fs.readFile(real,'utf8'));
275
+ results.push({ref:value,status:ids.filter(x=>x===fragment).length===1?'exists':'missing-fragment',path:target,fragmentChecked:true});
276
+ } else if (fragment) results.push({ref:value,status:'not-checked',path:target,fragmentChecked:false,reason:'Nonstandard/reused fragment requires host inspection and recorded basis.'});
277
+ else results.push({ref:value,status:'exists',path:target,fragmentChecked:false});
278
+ }
207
279
  } catch (e) { results.push({ ref: value, status: e.code === 'ENOENT' ? 'missing' : 'inaccessible', path: target, reason: e.code }); }
208
280
  }
209
281
  return results;
package/lib/view.mjs ADDED
@@ -0,0 +1,75 @@
1
+ import fs from 'node:fs/promises';
2
+ import { reviewStatus } from './approvals.mjs';
3
+ import path from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { atomicWrite, exists, hash, json, readJSON, noSymlinks } from './io.mjs';
6
+ import { resolveRef } from './documents.mjs';
7
+
8
+ const paths = dir => ({ view: path.join(dir,'status.md'), meta: path.join(dir,'.aimship/view.json') });
9
+ export async function viewStatus(directory, state) {
10
+ if (state.schemaVersion !== 2) return { status:'legacy', recovery:'Explicitly migrate this delivery before generating its view.' };
11
+ const p=paths(directory);
12
+ try {
13
+ if (!await exists(p.view)) return { status:'missing', recovery:'Run render at the current revision.' };
14
+ await noSymlinks(p.view); await noSymlinks(p.meta);
15
+ if (!await exists(p.meta)) return { status:'modified', recovery:'No trusted generation digest; preserve edits with render --preserve-edits.' };
16
+ const meta=await readJSON(p.meta), raw=await fs.readFile(p.view,'utf8');
17
+ if (hash(raw)!==meta.hash) return { status:'modified', recovery:'Inspect the edits, then render --preserve-edits.' };
18
+ const current=meta.deliveryId===state.deliveryId && meta.revision===state.revision;
19
+ return { status:current?'current':'stale', revision:meta.revision, recovery:current?null:'Run render; do not replay the saved business update.' };
20
+ } catch(error) { return {status:'error',reason:error.code ?? error.message,recovery:'Inspect view/metadata paths; preserve untrusted content before render.'}; }
21
+ }
22
+ const cell = x => String(x ?? 'not recorded').replaceAll('|','\\|').replaceAll('\n',' ');
23
+ const link = ref => `[${ref.replaceAll('[','\\[').replaceAll(']','\\]')}](${ref.replaceAll(' ','%20').replaceAll('(','%28').replaceAll(')','%29')})`;
24
+ async function available(directory,state,ref) {
25
+ if (/^https?:\/\//.test(ref)) return true;
26
+ const p=resolveRef(directory,state,ref); return p && await exists(p);
27
+ }
28
+ export async function renderText(directory,state) {
29
+ const artifactLink=ref=> {
30
+ if (!ref.startsWith('repo:')) return link(ref);
31
+ const target=resolveRef(directory,state,ref);
32
+ const fragment=ref.includes('#')?'#'+ref.split('#')[1]:'';
33
+ return target ? `[${cell(ref)}](${encodeURI(target).replaceAll('(','%28').replaceAll(')','%29')}${fragment})` : cell(ref);
34
+ };
35
+ const lines=['# Delivery status','','Generated by Aimship. Edit formal documents and structured state, not this page.','', '## Goal and basis','', cell(state.goal.summary), '',`Endpoint: ${cell(state.goal.endpoint)}`,''];
36
+ if (await available(directory,state,state.goalRef)) lines.push(artifactLink(state.goalRef),'');
37
+ lines.push('## Associated engineering objects','','| ID | Actual checkout/worktree | Role |','| --- | --- | --- |');
38
+ for (const r of state.repositories) lines.push(`| ${cell(r.id)} | ${cell(r.path)} | ${cell(r.role)} |`);
39
+ if (!state.repositories.length) lines.push('| — | No repository registered | — |');
40
+ lines.push('','## Stage progress','','| Run / scope | Stage | Progress | Result | Applicability |','| --- | --- | --- | --- | --- |');
41
+ for (const r of state.stageRuns) lines.push(`| ${cell(r.id)} / ${cell(r.scope)} | ${r.stage} | ${r.progress} | ${cell(r.result)} | ${cell(r.applicability)} |`);
42
+ lines.push('','## Current work, blockers and next action','',`Current: ${cell(state.currentRun)}`, '',`Next: ${cell(state.next.action)}`);
43
+ for (const r of state.stageRuns.filter(r=>r.progress==='blocked')) lines.push(`- Blocked: ${cell(r.id)} — ${cell(r.scope)}`);
44
+ for (const c of state.openChanges.filter(c=>c.status==='open')) lines.push(`- Open change/question: ${link(c.ref)}`);
45
+ if (state.checkpoint) lines.push('',`Unfinished facts: ${cell(state.checkpoint.summary)}`);
46
+ const review = await reviewStatus(directory,state);
47
+ lines.push('', '## Document review', '', `Review protocol: ${review.compatibility}`, `Next stage: ${cell(review.nextStage)}`, '', '| Document | Version | Review status | Approval record |', '| --- | --- | --- | --- |');
48
+ for (const d of review.documents) lines.push(`| ${artifactLink(d.path)} | ${cell(d.version)} | ${d.status} | ${cell(d.approval)} |`);
49
+ for (const p of review.pending) lines.push(`- Awaiting review: ${artifactLink(p.document)} (${cell(p.run)}; ${p.status})`);
50
+ lines.push('', 'Approval records bind content and user decision evidence; the tool does not authenticate the user.', '');
51
+ lines.push('','## Artifacts and plan','');
52
+ const refs=new Set([...(state.documents??[]).map(d=>d.path),...state.stageRuns.flatMap(r=>[...r.outputRefs,r.resultRef].filter(Boolean)),...(state.next.inputRefs??[])]);
53
+ for (const ref of refs) if (await available(directory,state,ref)) lines.push(`- ${artifactLink(ref)}`);
54
+ lines.push('','## Independent outcomes and generation','',`Goal: ${state.outcome.goal}`,`Acceptance: ${state.outcome.acceptance}`,`Merge: ${state.outcome.mergeRef?link(state.outcome.mergeRef):'not recorded'}`,`Deployment: ${state.outcome.deploymentRef?link(state.outcome.deploymentRef):'not recorded'}`,'',`Delivery: ${state.deliveryId}`,`State revision: ${state.revision}`,`Rules: ${state.rulesVersion}`,`State saved: ${state.updatedAt}`,'','Matching revisions do not establish document semantics or authorization.','');
55
+ return lines.join('\n');
56
+ }
57
+ export async function generateView(directory,state,preserveEdits=false) {
58
+ const p=paths(directory); const diagnostic=await viewStatus(directory,state);
59
+ let preservedPath;
60
+ try {
61
+ if (['modified','error'].includes(diagnostic.status) && await exists(p.view)) {
62
+ if (!preserveEdits) return {...diagnostic,status:'protected'};
63
+ await noSymlinks(p.view);
64
+ preservedPath=path.join(directory,'.aimship',`status-edits-${randomUUID()}.md`);
65
+ await atomicWrite(preservedPath,await fs.readFile(p.view));
66
+ }
67
+ const text=await renderText(directory,state);
68
+ await atomicWrite(p.view,text);
69
+ await atomicWrite(p.meta,json({deliveryId:state.deliveryId,revision:state.revision,hash:hash(text)}));
70
+ return {status:'current',revision:state.revision,...(preservedPath?{preservedPath}:{})};
71
+ } catch(error) {return {status:'error',reason:error.code??error.message,...(preservedPath?{preservedPath}:{}),recovery:'State was saved. Repair the view paths, then render at the returned revision; do not replay update.'};}
72
+ }
73
+ export async function savedResult(directory,state) {
74
+ return {...state,writeResult:{stateSaved:true,revision:state.revision,view:await generateView(directory,state)}};
75
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aimship",
3
- "version": "0.1.0-preview.4",
3
+ "version": "0.1.0-preview.8",
4
4
  "description": "Explicit, goal-driven delivery workflow for coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,7 +23,8 @@
23
23
  "scripts": {
24
24
  "test": "node --test --test-concurrency=1",
25
25
  "check:package": "node scripts/check-package.mjs",
26
- "prepack": "node scripts/check-package.mjs"
26
+ "prepack": "node scripts/check-package.mjs",
27
+ "check:docs": "node scripts/check-docs.mjs"
27
28
  },
28
29
  "publishConfig": {
29
30
  "registry": "https://registry.npmjs.org",
@@ -11,9 +11,9 @@ Activate only when the user explicitly asks to start or resume Aimship. Ordinary
11
11
  1. Run `"{{AIMSHIP_NODE}}" "{{AIMSHIP_LAUNCHER}}" bind` to resolve the installed version. Read the returned fixed-version core and CLI help. Use that fixed version's tools, carrying its rules version and the current state revision. If this is a source checkout rather than an installed entry, use `node bin/aimship.mjs bind` from the package root.
12
12
  2. Read the core at the path returned by `bind`. Resolve all supporting links below against that same fixed release, even if a cached entry mentions an older path. [The shared core](references/core.md) is the source-checkout reference. For an existing delivery, read its state and the current goal/Scope plus only the next step's required references. Do not create a replacement delivery, reset its plan, or infer a new goal from “continue”.
13
13
  3. For new work, use the binding’s configured `dataRoot` unless the user supplied a delivery location; do not infer it from the code root. Preserve the original request, identify the authorized endpoint and code locations, and propose the necessary ordered stages. After associating code scope, follow the core’s recorded-overlap check for independent deliveries. Existing explicit authorization and agreed arrangements remain valid. No answer is not acceptance. Create records only for an actual request.
14
- 4. Read the selected [stage reference](references/core.md#stage-references) when reaching it. Explain design choices and unresolved decisions, then continue authorized work. Only material missing facts or choices block their dependent work.
14
+ 4. Read the selected [stage reference](references/core.md#stage-references) when reaching it. Explain design choices and unresolved decisions. Follow [document review](references/document-review.md): generate, self-check, give the document entry/short summary/next stage and pause. Explicit approval releases the next authorized stage without another start question; questions, silence and viewing results do not approve. Authorized implementation/review/repair/retest can then continue.
15
15
  5. At stable results and before a manual session switch, save the state and give a short continuation prompt. Keep a compact binding: delivery ID, absolute delivery directory, state path, current run, rules version, and fixed core/tool paths. After compaction, reload these references if absent; do not assume the full skill survived.
16
16
 
17
- Read [recovery and updates](references/recovery.md) on interruption, version mismatch or missing references; [sources and change flow](references/sources-and-changes.md) when adopting external material or revising reviewed work; [host access](references/hosts.md) only when access or host setup needs attention. Read [document patterns](references/document-patterns.md) only for an artifact being produced.
17
+ Read [recovery and updates](references/recovery.md) on interruption, version mismatch or missing references; [sources and change flow](references/sources-and-changes.md) when adopting external material or revising reviewed work; [host access](references/hosts.md) only when access or host setup needs attention. Read [document patterns](references/document-patterns.md) when establishing the artifact arrangement or producing a document. New deliveries keep formal documents at the root, source/request.md under source/, and structured state under .aimship/; status.md is the generated reading entry. On resume use the actual statePath returned by read, inspect view/document-change warnings, and explicitly repair or migrate as described in recovery. Do not treat a generated view as editable authority.
18
18
 
19
19
  The agent handles meaning, engineering and evidence. The local tools protect structure and writes; a successful command or a file's existence cannot establish goal completion. This skill does not schedule agents, open conversations, merge, deploy, or execute database changes by default.