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.
- package/CONTRIBUTING.md +59 -0
- package/README.md +126 -19
- package/README.zh-CN.md +127 -20
- package/docs/cli.md +68 -32
- package/docs/examples.md +5 -0
- package/docs/guide.md +181 -30
- package/docs/guide.zh-CN.md +210 -0
- package/docs/installation.md +19 -108
- package/docs/reference.md +78 -0
- package/docs/reference.zh-CN.md +78 -0
- package/lib/approvals.mjs +153 -0
- package/lib/cli.mjs +29 -16
- package/lib/documents.mjs +156 -0
- package/lib/migrate.mjs +77 -0
- package/lib/state.mjs +96 -24
- package/lib/view.mjs +75 -0
- package/package.json +3 -2
- package/skills/aimship/SKILL.md +2 -2
- package/skills/aimship/references/artifact-map.json +172 -0
- package/skills/aimship/references/core.md +12 -6
- package/skills/aimship/references/document-patterns.md +32 -10
- package/skills/aimship/references/document-review.md +28 -0
- package/skills/aimship/references/hosts.md +4 -2
- package/skills/aimship/references/recovery.md +10 -0
- package/skills/aimship/references/sources-and-changes.md +8 -0
- package/skills/aimship/references/stages/contract.md +10 -2
- package/skills/aimship/references/stages/diagnose.md +10 -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 +10 -2
- package/skills/aimship/references/stages/prototype.md +8 -0
- package/skills/aimship/references/stages/requirements.md +8 -0
- package/skills/aimship/references/stages/review.md +4 -0
- package/skills/aimship/references/stages/scope.md +8 -0
- package/skills/aimship/references/stages/tech-design.md +11 -1
- package/skills/aimship/references/stages/test-design.md +9 -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 +35 -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 +40 -0
- 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
|
+
}
|
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
|
+
}
|
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 =
|
|
7
|
-
export const STAGES = ['frame', 'requirements', 'prototype', 'scope', 'diagnose', 'tech-design', 'contract', '
|
|
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
|
|
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:
|
|
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
|
|
69
|
-
requireThat(object(state),
|
|
70
|
-
const
|
|
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 {
|
|
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.
|
|
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 =
|
|
178
|
+
const p = await locateState(directory,true);
|
|
124
179
|
return withLock(p.lock, async () => {
|
|
125
|
-
const
|
|
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 =
|
|
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.
|
|
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
|
|
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.
|
|
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 {
|
|
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.
|
|
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",
|
package/skills/aimship/SKILL.md
CHANGED
|
@@ -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,
|
|
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)
|
|
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.
|