aimship 0.1.0-preview.11 → 0.1.0-preview.13

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 (46) hide show
  1. package/CONTRIBUTING.md +3 -3
  2. package/README.md +6 -10
  3. package/README.zh-CN.md +6 -10
  4. package/docs/cli.md +23 -25
  5. package/docs/guide.md +314 -148
  6. package/docs/guide.zh-CN.md +314 -148
  7. package/lib/approvals.mjs +77 -13
  8. package/lib/cli.mjs +27 -21
  9. package/lib/delivery.mjs +15 -12
  10. package/lib/discovery.mjs +25 -0
  11. package/lib/documents.mjs +159 -65
  12. package/lib/install.mjs +1 -1
  13. package/lib/language.mjs +41 -0
  14. package/lib/location.mjs +65 -0
  15. package/lib/state.mjs +54 -87
  16. package/lib/view.mjs +36 -34
  17. package/package.json +1 -1
  18. package/skills/aimship/SKILL.md +3 -3
  19. package/skills/aimship/references/artifact-map.json +165 -9
  20. package/skills/aimship/references/core.md +17 -13
  21. package/skills/aimship/references/document-patterns.md +17 -11
  22. package/skills/aimship/references/document-review.md +1 -3
  23. package/skills/aimship/references/hosts.md +11 -9
  24. package/skills/aimship/references/recovery.md +9 -13
  25. package/skills/aimship/references/sources-and-changes.md +11 -4
  26. package/skills/aimship/references/stages/diagnose.md +1 -1
  27. package/skills/aimship/references/stages/frame.md +1 -1
  28. package/skills/aimship/references/stages/handoff.md +1 -1
  29. package/skills/aimship/references/stages/implement.md +2 -2
  30. package/skills/aimship/references/stages/plan.md +1 -1
  31. package/skills/aimship/references/stages/requirements.md +1 -1
  32. package/skills/aimship/references/stages/review.md +1 -1
  33. package/skills/aimship/references/stages/scope.md +1 -1
  34. package/skills/aimship/references/stages/tech-design.md +1 -1
  35. package/skills/aimship/references/stages/test-design.md +1 -1
  36. package/skills/aimship/references/stages/verify.md +2 -2
  37. package/skills/aimship/references/templates/change-log.md +0 -2
  38. package/skills/aimship/references/templates/diagnosis.md +0 -7
  39. package/skills/aimship/references/templates/handoff.md +0 -6
  40. package/skills/aimship/references/templates/plan.md +2 -8
  41. package/skills/aimship/references/templates/prd.md +0 -7
  42. package/skills/aimship/references/templates/review.md +0 -3
  43. package/skills/aimship/references/templates/scope.md +0 -7
  44. package/skills/aimship/references/templates/tech-design.md +0 -7
  45. package/skills/aimship/references/templates/testing.md +2 -8
  46. package/lib/migrate.mjs +0 -77
package/lib/documents.mjs CHANGED
@@ -1,7 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { isDeepStrictEqual } from 'node:util';
4
- import { exists, hash, inside, fail, readJSON, json } from './io.mjs';
3
+ import { hash, inside, fail } from './io.mjs';
5
4
 
6
5
  export async function artifactMap() {
7
6
  return JSON.parse(await fs.readFile(new URL('../skills/aimship/references/artifact-map.json', import.meta.url), 'utf8'));
@@ -27,51 +26,118 @@ export async function safeRead(directory, state, ref, encoding = 'utf8') {
27
26
  if (!inside(await fs.realpath(base), real)) fail('INVALID_REFERENCE', 'Reference resolves outside its root.', 'Repair the symbolic link or reference.');
28
27
  return fs.readFile(real, encoding);
29
28
  }
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));
29
+ // Read ordinary Markdown, ignoring examples and comments. Offsets are retained
30
+ // so the same parser defines document checks and approval boundaries.
31
+ export function markdownText(text) {
32
+ let fence = null;
33
+ return text.replace(/<!--[^]*?-->/g, m => m.replace(/[^\n]/g, ' ')).split('\n').map(line => {
34
+ const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line);
35
+ if (marker) {
36
+ if (!fence) fence = marker[1];
37
+ else if (marker[1][0] === fence[0] && marker[1].length >= fence.length) fence = null;
38
+ return ' '.repeat(line.length);
39
+ }
40
+ return fence || /^(?: {4}|\t| *>)/.test(line) ? ' '.repeat(line.length) : line;
41
+ }).join('\n');
40
42
  }
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.'); }
43
+ const normalizeHeading = value => value.replace(/^\d+(?:\.\d+)*[.)、]?\s*/, '').trim().toLowerCase();
44
+ export function headings(text) {
45
+ return [...markdownText(text).matchAll(/^ {0,3}(#{1,6})[ \t]+(.+?)\s*#*$/gm)].map(m => ({title:m[2], key:normalizeHeading(m[2]), level:m[1].length, start:m.index, end:m.index+m[0].length}));
46
+ }
47
+ export function sections(text, spec) {
48
+ const all=headings(text);
49
+ return spec.sections.map((id,i) => {
50
+ const names=Object.values(spec.headings).map(titles=>normalizeHeading(titles[i]));
51
+ const matches=all.filter(h=>h.level===2 && names.includes(h.key));
52
+ const h=matches.length===1?matches[0]:null;
53
+ const end=h ? (all.find(next=>next.start>h.start && next.level<=2)?.start ?? text.length) : null;
54
+ return {id, count:matches.length, start:h?.start, end, body:h?markdownText(text.slice(h.end,end)).trim():null};
55
+ });
56
+ }
57
+ const ID = '(?:REQ|AC|B|TASK|TC|REV|RUN|CHG|Q|PAGE)-[0-9]{3,}';
58
+ export const numberedIds = text => [...new Set(markdownText(text).match(new RegExp('\\b'+ID+'\\b','g'))??[])];
59
+ // A file-qualified Markdown citation names its own readable source. It does not
60
+ // create a second local definition or a namespace/index.
61
+ function qualifiedCitations(text) {
62
+ const references=[];
63
+ // A displayed link-syntax example is not a link. Keep ordinary `REQ-001`
64
+ // references visible to the number check.
65
+ const prose=markdownText(text).replace(/(`+)([^\n]*?)\1/g,(span,ticks,body)=>/\[[^\]]*\]\(/.test(body)?' '.repeat(span.length):span);
66
+ const local=prose.replace(/\[([^\]\n]*)\]\(([^()\s]+\.md)(?:#([^()\s]+))?\)/gi,(match,label,file,fragment)=>{
67
+ const locator=fragment??label.trim(),target=`${file}#${locator}`;
68
+ if (!new RegExp('^'+ID+'$').test(locator)) return match;
69
+ references.push({target,locator});
70
+ return label.replaceAll(locator,'');
53
71
  });
72
+ return {references,local};
73
+ }
74
+ const definitionSections={REQ:['requirements','adoption'],AC:['acceptance'],B:['batches'],TASK:['tasks'],TC:['cases']};
75
+ const referenceHeading=title=>/\b(?:coverage|summary|references|conclusion|notes)\b|覆盖|总结|汇总|引用|结论|备注/i.test(title);
76
+ export function definitions(text, file, spec) {
77
+ const lines=markdownText(text).split('\n'), result=[], context=[];
78
+ const areas=spec?sections(text,spec):[];
79
+ let offset=0;
80
+ for(let i=0;i<lines.length;i++) {
81
+ const line=lines[i], start=offset; offset+=line.length+1;
82
+ const heading=/^ {0,3}(#{1,6})[ \t]+(.+)$/.exec(line);
83
+ if(heading) {
84
+ while(context.length && context.at(-1).level>=heading[1].length)context.pop();
85
+ context.push({level:heading[1].length,title:heading[2]});
86
+ }
87
+ const structured=/^\s*(?:\||#{1,6}\s+|[-*+]\s+)/.test(line);
88
+ if(!structured && !new RegExp('^'+ID+'[::]').test(line)) continue;
89
+ const candidate=line.trim().replace(/^\|\s*/, '').replace(/^(?:#{1,6}\s+|[-*+]\s+)/,'').replace(/^[*`]+/,'');
90
+ const match=new RegExp('^('+ID+')(?![A-Za-z0-9_-])(?:[*`]*)(?:\\s*[|::—–-]\\s*|\\s+|$)(.*)$').exec(candidate);
91
+ if(!match) continue;
92
+ let body=match[2];
93
+ // A readable heading/list may put its explanation on following lines.
94
+ if(!/^\s*\|/.test(line)) {
95
+ for(let j=i+1;j<lines.length;j++) {
96
+ if(/^\s*(?:#{1,6}\s+|\||[-*+]\s+)/.test(lines[j]) || new RegExp('^'+ID+'[::]').test(lines[j])) break;
97
+ body+='\n'+lines[j];
98
+ }
99
+ }
100
+ const primary=areas.some(area=>definitionSections[match[1].split('-')[0]]?.includes(area.id) && start>=area.start && start<area.end);
101
+ result.push({id:match[1],file,body,refs:numberedIds(body).filter(id=>id!==match[1]),primary,reference:context.some(h=>referenceHeading(h.title))});
102
+ }
103
+ // Definitions remain unique in their existing owning sections. Repeating an
104
+ // established number in a summary, coverage table or execution log cites it.
105
+ // Unstructured sources use their ordinary summary/coverage headings; two
106
+ // substantive definitions elsewhere are still a conflict, never first-wins.
107
+ return result.filter(d=>!result.some(other=>other.id===d.id && other!==d &&
108
+ ((!d.primary && other.primary) || (d.primary===other.primary && d.reference && !other.reference))))
109
+ .map(({primary,reference,...record})=>record);
110
+ }
111
+ export function meaningful(text) {
112
+ const body=markdownText(text).replace(new RegExp(ID,'g'),'').replace(/[\s|:*`_#.—–-]/g,'');
113
+ return body.length>=3 && !/^(?:TBD|TODO|N\/A|待补充|待填写|未填写|\.{3})$/i.test(body)
114
+ && !/Replace guidance with actual content|<deliveryId from init>|<revision>/.test(text);
115
+ }
116
+ // A fragment is a search locator, not a renderer-dependent hyperlink anchor.
117
+ export function hasLocator(text, locator, spec) {
118
+ if(spec && spec.sections.includes(locator)) return sections(text,spec).find(s=>s.id===locator)?.count===1;
119
+ if(new RegExp('^'+ID+'$').test(locator)) return definitions(text,'',spec).filter(d=>d.id===locator).length===1;
120
+ return headings(text).filter(h=>h.title===locator || h.key===normalizeHeading(locator)).length===1;
54
121
  }
55
122
  export function unknownConditions(policy) {
56
123
  return ['interaction','interface','databases', ...(policy.interaction === false ? [] : ['prototypeCovered'])].filter(key=>policy[key] === null);
57
124
  }
58
125
  export async function inspectDocuments(directory, state) {
59
- if (![2,3].includes(state.schemaVersion) || !state.documentPolicy || state.documentPolicy.authority === 'legacy') return { issues: [], unchecked: ['Legacy documents require recorded compatibility review.'], digests: {}, identities: [], nextIds: {} };
60
- const map = await artifactMap(), issues = [], unchecked = [], digests = {}, defs = [], documentAnchors = new Map();
126
+ const map = await artifactMap(), issues = [], unchecked = [], digests = {}, defs = [], documentSections = new Map();
61
127
  const docs = state.documents ?? [];
62
128
  const policy = state.documentPolicy;
63
129
  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.`);
64
130
  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.');
65
131
  const requirementKind = policy.authority === 'adopted' ? 'scope' : 'prd';
66
132
  const find = kind => docs.find(d => d.kind === (kind === 'requirement' ? requirementKind : kind));
67
- if (state.schemaVersion === 3) {
133
+ if (state.schemaVersion === 4) {
68
134
  try {
69
135
  const request = await safeRead(directory,state,'source/request.md');
70
136
  if (!request?.trim()) issues.push('Missing or empty source/request.md.');
71
137
  else digests['source/request.md'] = hash(request);
72
138
  } catch { issues.push('Cannot read source/request.md inside this delivery.'); }
73
- } else if (!await exists(path.join(directory,'source/request.md'))) issues.push('Missing source/request.md.');
74
- const preparing = state.schemaVersion === 3 && !find('requirement');
139
+ }
140
+ const preparing = state.schemaVersion === 4 && !find('requirement');
75
141
  if (preparing && state.goalRef !== 'source/request.md') issues.push('Before requirements exist, goalRef must point to source/request.md.');
76
142
  if (!preparing && !find('requirement')) issues.push(`Register ${requirementKind} as the requirement entry.`);
77
143
  if (!preparing && policy.authority === null) issues.push('Select the requirement authority when registering PRD/Scope.');
@@ -90,22 +156,29 @@ export async function inspectDocuments(directory, state) {
90
156
  catch (error) { issues.push(`Cannot read ${doc.path}: ${error.code}`); continue; }
91
157
  if (text === null) { unchecked.push(`${doc.path}: remote or unavailable repository; inspect adopted version using host tools (${doc.basisRef ?? 'missing basisRef'}).`); continue; }
92
158
  digests[doc.path] = hash(text);
93
- if (doc.mode === 'reused') { unchecked.push(`${doc.path}: reused format/content; human/Agent basis ${doc.basisRef}.`); continue; }
159
+ if (!text.trim()) issues.push(`${doc.path}: empty document.`);
160
+ if (doc.mode === 'reused') {
161
+ // Reuse is a current input option, not a historical document protocol.
162
+ // Read ordinary IDs from the explicitly registered requirement authority;
163
+ // do not impose our outline or infer that arbitrary source formats passed.
164
+ if (doc === find('requirement') && /\.md$/i.test(doc.path)) defs.push(...definitions(text,doc.path).filter(d=>/^(REQ|AC|PAGE|Q)-/.test(d.id)));
165
+ unchecked.push(`${doc.path}: reused format/content; human/Agent basis ${doc.basisRef}.`); continue;
166
+ }
94
167
  if (spec) {
95
- const ids = anchors(text); let previous = -1;
96
- documentAnchors.set(doc.path,ids);
97
- for (const id of spec.sections) {
98
- const i = ids.indexOf(id);
99
- if (i < 0 || i <= previous || ids.filter(x => x === id).length !== 1) issues.push(`${doc.path}: missing, duplicate or unordered section #${id}.`);
100
- previous = i;
168
+ const records=sections(text,spec); let previous=-1;
169
+ documentSections.set(doc.path,records);
170
+ for(const record of records) {
171
+ if(record.count!==1 || record.start<=previous) issues.push(`${doc.path}: missing, duplicate or unordered section ${record.id}.`);
172
+ previous=record.start;
173
+ if(record.count===1 && !record.body) issues.push(`${doc.path}: empty section ${record.id}.`);
101
174
  }
102
- defs.push(...definitions(text, doc.path));
175
+ const owned={prd:['REQ','AC','PAGE'],scope:['REQ','AC','PAGE'],plan:['B','TASK'],testing:['TC','RUN'],review:['REV'],'change-log':['CHG']}[doc.kind]??[];
176
+ defs.push(...definitions(text, doc.path, spec).filter(d=>owned.includes(d.id.split('-')[0]) || d.id.startsWith('Q-')));
103
177
  }
104
178
  }
105
- const legacyRuns=await preservedLegacyRuns(directory,state);
106
- const runs = state.stageRuns.filter(r => !legacyRuns.has(r.id));
179
+ const runs = state.stageRuns;
107
180
  for (const [i, run] of state.stageRuns.entries()) {
108
- if (!runs.includes(run) || run.progress === 'planned') continue;
181
+ if (run.progress === 'planned') continue;
109
182
  const contract = map.stages[run.stage], doc = find(contract.kind) ?? (run.stage === 'contract' && policy.interface === 'none' ? find('sql') : null);
110
183
  if (!doc && preparing && ['frame','requirements','scope'].includes(run.stage) && run.progress !== 'done') continue;
111
184
  if (!doc) { issues.push(`${run.id}: missing registered ${contract.kind} artifact.`); continue; }
@@ -114,22 +187,28 @@ export async function inspectDocuments(directory, state) {
114
187
  if (run.resultRef?.split('#')[0] !== doc.path) issues.push(`${run.id}: result must locate its mapped artifact ${doc.path}.`);
115
188
  const section = contract.sectionByKind?.[doc.kind] ?? contract.section;
116
189
  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.`);
117
- if (map.documents[doc.kind] && doc.mode === 'new' && !run.resultRef?.includes('#')) issues.push(`${run.id}: result needs a precise section/record anchor.`);
118
- 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}.`);
190
+ if(map.documents[doc.kind] && doc.mode==='new') {
191
+ const body=await safeRead(directory,state,doc.path).catch(()=>'');
192
+ if(!run.resultRef?.includes('#') || !hasLocator(body,run.resultRef.split('#')[1],map.documents[doc.kind])) issues.push(`${run.id}: missing or ambiguous result locator ${run.resultRef}.`);
193
+ const used=documentSections.get(doc.path)?.find(s=>s.id===section);
194
+ if(used && !meaningful(used.body??'')) issues.push(`${run.id}: replace template guidance with substantive ${section} content.`);
195
+ }
119
196
  if (!run.outputRefs.some(r => r.split('#')[0] === doc.path)) issues.push(`${run.id}: outputRefs must register ${doc.path}.`);
120
197
  if (['review','verify'].includes(run.stage) && doc.mode==='new') {
121
198
  const prefix=run.stage==='review'?'REV-':'RUN-';
122
- 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.`);
199
+ const record=defs.find(d=>d.file===doc.path && d.id.startsWith(prefix) && `${doc.path}#${d.id}`===run.resultRef);
200
+ if(!record || !meaningful(record.body)) issues.push(`${run.id}: result must locate a substantive ${prefix} record.`);
201
+ if(state.stageRuns.some(other=>other.id!==run.id && other.progress==='done' && other.resultRef===run.resultRef)) issues.push(`${run.id}: use a distinct result record for each execution.`);
123
202
  }
124
203
  if (run.stage === 'contract') {
125
204
  for (const db of policy.databases ?? []) {
126
205
  const file = policy.databases.length === 1 ? 'database.sql' : `database-${db}.sql`;
127
- if (!docs.some(d => d.kind === 'sql' && d.path === file) || !await exists(path.join(directory,file))) issues.push(`${run.id}: missing independent ${file}.`);
206
+ if (!docs.some(d => d.kind === 'sql' && d.path === file) || !(await safeRead(directory,state,file).catch(()=>null))) issues.push(`${run.id}: missing independent ${file}.`);
128
207
  }
129
208
  }
130
209
  }
131
210
  }
132
- if (state.schemaVersion === 3) {
211
+ if (state.schemaVersion === 4) {
133
212
  const unknown = unknownConditions(policy);
134
213
  const dependent = runs.filter(r=>r.progress !== 'planned' && !['frame','requirements','scope','diagnose'].includes(r.stage));
135
214
  if (preparing && dependent.length) issues.push('Prepare and review formal requirements before dependent work.');
@@ -142,29 +221,44 @@ export async function inspectDocuments(directory, state) {
142
221
  if (doc?.mode === 'new' && doc.path !== 'openapi.yaml') issues.push('New HTTP contract must be openapi.yaml.');
143
222
  }
144
223
  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.');
145
- const seen = new Set(), current = new Set(), nextIds = {};
146
- for (const d of defs) {
147
- if (seen.has(d.key)) issues.push(`Duplicate definition ${d.key}.`); seen.add(d.key);
148
- const identity = `${d.namespace}:${d.id}`;
149
- 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); }
150
- const numeric=/^(.*)-(\d+)$/.exec(d.id);
151
- if (numeric) {
152
- const [,prefix,number]=numeric, group=`${d.namespace}:${prefix}`;
153
- nextIds[group] = Math.max(nextIds[group] ?? 1, Number(number)+1);
224
+ const seen=new Set();
225
+ for(const d of defs) {
226
+ if(seen.has(d.id)) issues.push(`Duplicate definition ${d.id}.`);
227
+ seen.add(d.id);
228
+ if(!meaningful(d.body)) issues.push(`${d.id}: empty or placeholder definition.`);
229
+ }
230
+ for(const doc of docs.filter(d=>d.mode==='new' && map.documents[d.kind])) {
231
+ const body=await safeRead(directory,state,doc.path).catch(()=>null);
232
+ if(!body) continue;
233
+ const citations=qualifiedCitations(body);
234
+ for(const {target,locator} of citations.references) {
235
+ try {
236
+ const source=await safeRead(directory,state,decodeURI(target));
237
+ const sourcePath=decodeURI(target).split('#')[0], registered=docs.find(d=>d.path===sourcePath && d.mode==='new');
238
+ const records=source===null?[]:definitions(source,sourcePath,map.documents[registered?.kind]).filter(d=>d.id===locator);
239
+ if(records.length!==1 || !meaningful(records[0].body)) issues.push(`${doc.path}: missing, ambiguous or empty source locator ${target}.`);
240
+ } catch { issues.push(`${doc.path}: unreadable source citation ${target}.`); }
154
241
  }
242
+ for(const id of numberedIds(citations.local)) if(!seen.has(id)) issues.push(`${doc.path}: undefined reference ${id}.`);
155
243
  }
156
- const byKey=new Map(defs.map(d=>[d.key,d]));
157
- for (const d of defs) {
158
- for (const key of d.refs) if (!seen.has(key)) issues.push(`${d.key}: unresolved ${key}.`);
159
- if (d.status !== 'current') continue;
160
- const refersTo=kind=>d.refs.some(key=>byKey.has(key) && identityKind(byKey.get(key))===kind);
161
- if (d.namespace === 'local' && identityKind(d)==='REQ' && !refersTo('AC')) issues.push(`${d.key}: missing acceptance reference.`);
162
- if (identityKind(d)==='RUN' && !refersTo('TC')) issues.push(`${d.key}: missing versioned case reference.`);
163
- if (identityKind(d)==='TC' && d.namespace === 'local' && !d.refs.length) issues.push(`${d.key}: missing acceptance or risk basis.`);
164
- 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.`);
244
+ for(const doc of docs.filter(d=>d.mode==='new' && map.documents[d.kind])) {
245
+ const ready=runs.some(r=>r.progress==='done' && (find(map.stages[r.stage].kind)?.path===doc.path))
246
+ || (state.reviewHistory??[]).some(r=>r.document===doc.path && r.version===doc.version && (r.kind==='approved' || (r.kind==='self-check' && r.result==='passed')));
247
+ const records=documentSections.get(doc.path)??[];
248
+ if(ready) {
249
+ for(const section of records) if(!meaningful(section.body??'')) issues.push(`${doc.path}: replace template guidance with substantive ${section.id} content.`);
250
+ for(const prefix of ({prd:['REQ','AC'],scope:['REQ','AC'],testing:['TC'],plan:['TASK']}[doc.kind]??[])) {
251
+ if(!defs.some(d=>d.file===doc.path && d.id.startsWith(prefix+'-'))) issues.push(`${doc.path}: include substantive ${prefix} records.`);
252
+ }
253
+ }
254
+ // Design obligations cannot hide in an unapproved execution surface.
255
+ const design=({plan:[['TASK','tasks'],['B','batches']],testing:[['TC','cases']]})[doc.kind]??[];
256
+ for(const [prefix,section] of design) {
257
+ const allowed=definitions(records.find(r=>r.id===section)?.body??'',doc.path).map(d=>d.id);
258
+ for(const d of defs.filter(d=>d.file===doc.path && d.id.startsWith(prefix+'-'))) if(!allowed.includes(d.id)) issues.push(`${doc.path}: define ${d.id} in ${section}, not execution records.`);
259
+ }
165
260
  }
166
- for (const key of state.identityHistory ?? []) if (!seen.has(key)) issues.push(`Preserve historical definition ${key}; retire it rather than deleting/reusing its identity.`);
167
- return { retired:defs.filter(d=>d.status==='retired').map(d=>`${d.namespace}:${d.id}`), issues, unchecked, digests, identities: [...seen], nextIds };
261
+ return {issues,unchecked,digests};
168
262
  }
169
263
  export async function documentChanges(directory, state) {
170
264
  const changes=[];
package/lib/install.mjs CHANGED
@@ -19,7 +19,7 @@ export function fileKey(relative, paths = path) {
19
19
  }
20
20
  function managedFile(file) {
21
21
  const normalized = file.replaceAll('\\', '/');
22
- return relativeFile(file) && (normalized === '.aimship/bin/aimship.mjs' || /^\.(agents|claude|cursor)\/skills\/aimship\/(SKILL\.md|agents\/.+)$/.test(normalized));
22
+ return relativeFile(file) && (normalized === '.aimship/bin/aimship.mjs' || /^\.(agents|claude)\/skills\/aimship\/(SKILL\.md|agents\/.+)$/.test(normalized));
23
23
  }
24
24
  function validateManifest(manifest) {
25
25
  if (!object(manifest) || manifest.schemaVersion !== 1 || manifest.product !== 'aimship' || !object(manifest.managed) || !object(manifest.releases) || !Array.isArray(manifest.hosts)) fail('INSTALL_FORMAT', 'Unknown or invalid installation manifest.', 'Preserve the installation and use a compatible installer.');
@@ -0,0 +1,41 @@
1
+ // Delivery display language only. Stable identifiers and existing user content stay unchanged.
2
+ export const languageOf = state => state.language;
3
+ export const text = (state, english, chinese) => languageOf(state) === 'zh-CN' ? chinese : english;
4
+ const labels = {
5
+ 'not recorded':'未记录', unknown:'未知', null:'未知', true:'是', false:'否', none:'不适用', http:'HTTP', other:'其他',
6
+ technical:'技术与契约', unrecorded:'未分组', unavailable:'不可用', 'assess-outcome':'核对交付结论与接受结果', frame:'目标梳理', requirements:'需求', scope:'范围确认', prototype:'原型', 'tech-design':'技术设计', contract:'接口与 SQL', 'test-design':'测试设计', plan:'实施计划', implement:'实施', review:'审查', verify:'验证', handoff:'交付验收', diagnose:'调查',
7
+ planned:'待开始', active:'进行中', blocked:'受阻', done:'已完成记录', passed:'通过', failed:'失败', 'not-run':'未执行', mixed:'部分通过', current:'当前有效', 'needs-review':'待复核',
8
+ 'in-progress':'进行中', met:'已达成', 'not-met':'未达成', 'not-recorded':'未记录', accepted:'已接受', declined:'未接受',
9
+ approved:'已批准', pending:'待审核', 'self-check-required':'待自检', 'self-check-failed':'自检失败', 'changes-requested':'需修改', 'evidence-changed':'审批依据已变化',
10
+ 'partial-scope':'明确限定范围', 'standard-complete':'标准交付已完成', 'ready-for-acceptance':'待验收', incomplete:'未完成',
11
+ 'continue-current':'继续当前工作', 'continue-group':'继续当前组工作', 'suggest-new-session':'建议新开会话', 'inspect-existing-work':'检查已有工作', 'resolve-current':'先处理当前问题', 'await-review':'等待审核', 'reconcile-save':'核对保存结果', finished:'当前约定目标已结束', 'arrange-next':'确认并记录下一项工作',
12
+ 'requirements preparation':'需求准备', supported:'已启用', enforced:'已启用',
13
+ interaction:'交互变化', prototypeCovered:'已有原型覆盖', interface:'接口变化', databases:'数据库变化'
14
+ };
15
+ export function displayValue(state,value) {
16
+ const raw=String(value ?? 'not recorded');
17
+ return languageOf(state)==='zh-CN' ? (labels[raw] ?? raw) : raw;
18
+ }
19
+ const explanations = {
20
+ 'Endpoint or artifact conditions remain unknown.':'交付终点或产物适用条件尚未明确。',
21
+ 'Changed or missing registered documents require reconciliation.':'已登记文档有变化或缺失,需要核对。',
22
+ 'Missing reviewed requirements / Scope.':'缺少已完成审核的需求或范围文档。',
23
+ 'Missing applicable interface/SQL contract.':'缺少本次适用的接口或 SQL 契约。',
24
+ 'Missing applicable interaction prototype.':'缺少本次适用的交互原型。',
25
+ 'Selected work or document approvals remain unresolved.':'已安排工作或文档审批仍有未完成项。',
26
+ 'Implementation results remain unresolved.':'实施结果仍有未解决项。',
27
+ 'Review results remain unresolved.':'审查结果仍有未解决项。',
28
+ 'Missing passing overall implementation review.':'缺少通过的整体实施审查。',
29
+ 'Independent verification is not fully recorded as passed after overall review.':'整体审查之后的独立验证尚未完整记录为通过。',
30
+ 'Handoff precedes verification.':'交付记录早于验证。',
31
+ 'Unresolved changes remain.':'仍有未处理的变更。'
32
+ };
33
+ export function completionMessage(state,message) {
34
+ if(languageOf(state)!=='zh-CN')return message;
35
+ if(explanations[message])return explanations[message];
36
+ const stage=/^Missing current completed (.+)\.$/.exec(message);
37
+ if(stage)return `缺少当前有效且已完成的${displayValue(state,stage[1])}记录。`;
38
+ const batch=/^Missing passing batch review for (.+)\.$/.exec(message);
39
+ if(batch)return `批次 ${batch[1]} 缺少通过的批内审查。`;
40
+ return message;
41
+ }
@@ -0,0 +1,65 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { fail, inside, noSymlinks } from './io.mjs';
5
+
6
+ const recovery = 'Use a delivery folder under the configured dataRoot, or preserve the user’s explicit location decision in a local source file. Do not fall back to the current workspace.';
7
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
8
+
9
+ // Resolve existing ancestors without creating a directory or following a managed
10
+ // symlink. Appended missing components retain their intended location.
11
+ async function location(target, writable = false) {
12
+ await noSymlinks(target);
13
+ let ancestor = target;
14
+ const missing = [];
15
+ while (true) {
16
+ let stat;
17
+ try { stat = await fs.stat(ancestor); }
18
+ catch (error) {
19
+ if (error.code !== 'ENOENT') fail('LOCATION_ACCESS', `Cannot inspect the delivery location: ${ancestor}`, recovery, { cause: error.code });
20
+ const parent = path.dirname(ancestor);
21
+ if (parent === ancestor) fail('LOCATION_ACCESS', `Cannot locate an existing parent: ${target}`, recovery);
22
+ missing.unshift(path.basename(ancestor)); ancestor = parent; continue;
23
+ }
24
+ if (!stat.isDirectory()) fail('LOCATION_PATH', `The location or its parent is not a directory: ${ancestor}`, recovery);
25
+ try {
26
+ if (writable) await fs.access(ancestor, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);
27
+ return path.join(await fs.realpath(ancestor), ...missing);
28
+ } catch (error) { fail('LOCATION_ACCESS', `Cannot access the delivery location: ${ancestor}`, recovery, { cause: error.code }); }
29
+ }
30
+ }
31
+
32
+ async function explicitDecision(directory, reference) {
33
+ if (typeof reference !== 'string' || !reference.trim() || reference.includes('\0') || reference.includes('\\') || reference.includes('#') || reference.includes(':') || path.isAbsolute(reference) || reference.split('/').includes('..')) {
34
+ fail('INVALID_STORAGE', 'An explicit location needs a delivery-relative local decisionRef.', recovery);
35
+ }
36
+ const file = path.resolve(directory, reference);
37
+ if (file === directory || !inside(directory, file)) fail('INVALID_STORAGE', 'The location decision must be a file inside this delivery.', recovery);
38
+ await noSymlinks(file);
39
+ try {
40
+ if (!(await fs.stat(file)).isFile() || !(await fs.readFile(file, 'utf8')).trim()) fail('INVALID_STORAGE', 'The location decision must be a nonempty readable file.', recovery);
41
+ } catch (error) {
42
+ if (error.code === 'INVALID_STORAGE') throw error;
43
+ fail('LOCATION_DECISION', `Cannot read the explicit location decision: ${reference}`, recovery, { cause: error.code });
44
+ }
45
+ }
46
+
47
+ /** New initialization only. Existing deliveries keep their saved identity/location. */
48
+ export async function validateInitLocation(directory, input, runtime = {}) {
49
+ const root = runtime.dataRoot === undefined ? path.join(os.homedir(), 'Aimship') : runtime.dataRoot;
50
+ if (typeof root !== 'string' || !root.trim() || root.includes('\0') || !path.isAbsolute(root)) fail('INVALID_STORAGE', 'The configured dataRoot must be an absolute path.', recovery);
51
+ if (typeof directory !== 'string' || !directory.trim() || directory.includes('\0')) fail('INVALID_STORAGE', 'A delivery location is required.', recovery);
52
+ const storage = input?.storage;
53
+ if (storage !== undefined && (!object(storage) || !['configured','explicit'].includes(storage.mode) || Object.keys(storage).some(key => !['mode','decisionRef'].includes(key)) || (storage.mode === 'configured' && storage.decisionRef !== undefined))) {
54
+ fail('INVALID_STORAGE', 'storage accepts only a location mode and an explicit decisionRef; dataRoot is tool-managed.', recovery);
55
+ }
56
+ const dataRoot = await location(path.resolve(root));
57
+ const target = await location(path.resolve(directory), true);
58
+ if (target === dataRoot) fail('INIT_LOCATION_ROOT', 'A delivery needs its own folder; dataRoot itself is not a delivery.', recovery);
59
+ if (storage?.mode === 'explicit') {
60
+ await explicitDecision(target, storage.decisionRef);
61
+ return { mode: 'explicit', dataRoot, decisionRef: storage.decisionRef };
62
+ }
63
+ if (!inside(dataRoot, target)) fail('INIT_LOCATION_REQUIRED', 'The initialization target is outside the configured dataRoot and has no explicit location decision.', recovery, { directory: target, dataRoot });
64
+ return { mode: 'configured', dataRoot };
65
+ }