aimship 0.1.0-preview.7 → 0.1.0-preview.9
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 +52 -1
- package/README.md +120 -58
- package/README.zh-CN.md +120 -58
- package/docs/cli.md +32 -2
- package/docs/guide.md +187 -187
- package/docs/guide.zh-CN.md +187 -187
- package/docs/installation.md +12 -8
- package/lib/approvals.mjs +153 -0
- package/lib/cli.mjs +11 -7
- package/lib/delivery.mjs +76 -0
- package/lib/documents.mjs +5 -4
- package/lib/state.mjs +39 -9
- package/lib/view.mjs +13 -2
- package/package.json +1 -1
- package/skills/aimship/SKILL.md +3 -3
- package/skills/aimship/references/artifact-map.json +21 -12
- package/skills/aimship/references/core.md +25 -5
- package/skills/aimship/references/document-patterns.md +4 -2
- package/skills/aimship/references/document-review.md +28 -0
- package/skills/aimship/references/recovery.md +8 -0
- package/skills/aimship/references/sources-and-changes.md +2 -0
- package/skills/aimship/references/stages/contract.md +7 -3
- package/skills/aimship/references/stages/diagnose.md +4 -0
- package/skills/aimship/references/stages/handoff.md +2 -0
- package/skills/aimship/references/stages/plan.md +6 -2
- package/skills/aimship/references/stages/prototype.md +4 -0
- package/skills/aimship/references/stages/requirements.md +4 -0
- package/skills/aimship/references/stages/review.md +2 -0
- package/skills/aimship/references/stages/scope.md +4 -0
- package/skills/aimship/references/stages/tech-design.md +6 -2
- package/skills/aimship/references/stages/test-design.md +5 -1
- package/skills/aimship/references/templates/plan.md +1 -0
- package/skills/aimship/references/templates/prd.md +2 -0
- package/skills/aimship/references/templates/scope.md +2 -0
- package/skills/aimship/references/templates/tech-design.md +1 -0
- package/skills/aimship/references/templates/testing.md +1 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { artifactMap, safeRead, anchors } from './documents.mjs';
|
|
3
|
+
import { fail, hash } from './io.mjs';
|
|
4
|
+
|
|
5
|
+
const text = value => typeof value === 'string' && value.trim().length > 0;
|
|
6
|
+
const reject = message => fail('REVIEW_CHECK', message, 'Read review status. Preserve history, self-check the current document, and record only an actual explicit user decision.');
|
|
7
|
+
const kinds = ['self-check', 'approved', 'changes-requested', 'editorial'];
|
|
8
|
+
export function validateReviewState(state) {
|
|
9
|
+
if (state.reviewPolicy === undefined) return;
|
|
10
|
+
if (state.reviewPolicy !== 1 || !Array.isArray(state.reviewHistory)) reject('Unsupported review policy or missing reviewHistory.');
|
|
11
|
+
const ids = new Set();
|
|
12
|
+
for (const r of state.reviewHistory) {
|
|
13
|
+
if (!r || !text(r.id) || ids.has(r.id) || !kinds.includes(r.kind) || !text(r.document) || !text(r.version) || !/^[a-f0-9]{64}$/.test(r.digest ?? '') || !text(r.sourceRef) || !text(r.summary)) reject('Review records need unique ID, kind, document, version, digest, sourceRef and summary.');
|
|
14
|
+
ids.add(r.id);
|
|
15
|
+
if (r.kind === 'self-check' && !['passed','failed'].includes(r.result)) reject('Self-check records must distinguish passed from failed.');
|
|
16
|
+
if (r.kind === 'approved' && r.decision !== 'explicit-approval') reject('Approval requires an explicit user approval decision; questions, silence and requests to see results are not approval.');
|
|
17
|
+
if (r.kind === 'editorial' && !/^[a-f0-9]{64}$/.test(r.previousDigest ?? '')) reject('Editorial reconciliation needs the previous approved digest.');
|
|
18
|
+
}
|
|
19
|
+
for (const [index, run] of state.stageRuns.entries()) {
|
|
20
|
+
if (run.dependsOn !== undefined && (!Array.isArray(run.dependsOn) || new Set(run.dependsOn).size !== run.dependsOn.length || !run.dependsOn.every(id => state.stageRuns.slice(0, index).some(r => r.id === id)))) reject(`${run.id}: dependencies must be unique preceding run IDs.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// Shared execution/result sections are outside the approved design surface.
|
|
24
|
+
// Reused formats have no inferred boundary: the entire pinned file is reviewed.
|
|
25
|
+
export async function reviewFingerprint(directory, state, doc) {
|
|
26
|
+
const stop = doc.mode === 'new' ? { plan: 'execution', testing: 'runs' }[doc.kind] : null;
|
|
27
|
+
let body = await safeRead(directory, state, doc.path, stop ? 'utf8' : null);
|
|
28
|
+
if (body === null) return null;
|
|
29
|
+
if (stop) {
|
|
30
|
+
const at = body.search(new RegExp(`<a\\s+id=["']${stop}["']\\s*><\\/a>`));
|
|
31
|
+
if (at < 0) return null;
|
|
32
|
+
body = body.slice(0, at);
|
|
33
|
+
}
|
|
34
|
+
const assets = [];
|
|
35
|
+
for (const ref of doc.assetRefs ?? []) { const bytes = await safeRead(directory,state,ref,null); if (bytes === null) return null; assets.push([ref,hash(bytes)]); }
|
|
36
|
+
return hash(assets.length ? JSON.stringify([hash(body),assets]) : body);
|
|
37
|
+
}
|
|
38
|
+
function decisionFor(state, doc, digest) {
|
|
39
|
+
let approved = null, checked = null, latest = null;
|
|
40
|
+
for (const r of state.reviewHistory ?? []) {
|
|
41
|
+
if (r.document !== doc.path) continue;
|
|
42
|
+
if (r.kind === 'changes-requested') { approved = null; checked = null; }
|
|
43
|
+
if (['approved','editorial'].includes(r.kind)) approved = r;
|
|
44
|
+
if (r.version === doc.version && r.digest === digest) {
|
|
45
|
+
latest = r;
|
|
46
|
+
if (r.kind === 'self-check') { checked = r.result === 'passed' ? r : null; if (r.result === 'failed') approved = null; }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (approved?.version !== doc.version || approved?.digest !== digest) approved = null;
|
|
50
|
+
return { approved, checked, latest };
|
|
51
|
+
}
|
|
52
|
+
export async function reviewStatus(directory, state) {
|
|
53
|
+
if (state.reviewPolicy !== 1) return { compatibility: 'review-adoption-required', canProceed: false, pending: [], runs: [], documents: [], instruction: 'Preserve old results. Enable reviewPolicy: 1 with an empty reviewHistory after compatibility assessment; no historical approval is inferred.' };
|
|
54
|
+
const map = await artifactMap(), documents = [], evidenceIssues = [], evidenceCache = new Map();
|
|
55
|
+
for (const r of state.reviewHistory ?? []) {
|
|
56
|
+
if (!evidenceCache.has(r.sourceRef)) { try { evidenceCache.set(r.sourceRef,await evidence(directory,state,r.sourceRef)); } catch { evidenceCache.set(r.sourceRef,null); } }
|
|
57
|
+
if (!r.sourceDigest || evidenceCache.get(r.sourceRef) !== r.sourceDigest) evidenceIssues.push(r.id);
|
|
58
|
+
}
|
|
59
|
+
for (const doc of state.documents ?? []) {
|
|
60
|
+
if (['source','change-log','review','handoff'].includes(doc.kind)) continue;
|
|
61
|
+
let digest = null;
|
|
62
|
+
try { digest = await reviewFingerprint(directory, state, doc); } catch { /* Missing input remains blocked. */ }
|
|
63
|
+
const { approved, checked, latest } = decisionFor(state, doc, digest);
|
|
64
|
+
documents.push({ path: doc.path, version: doc.version, digest, selfCheck: checked?.id ?? null, approval: approved?.id ?? null, status: !digest ? 'unavailable' : approved && (evidenceIssues.includes(approved.id) || (checked && evidenceIssues.includes(checked.id))) ? 'evidence-changed' : approved ? 'approved' : latest?.kind === 'changes-requested' ? 'changes-requested' : latest?.kind === 'self-check' && latest.result === 'failed' ? 'self-check-failed' : checked ? 'pending' : 'self-check-required' });
|
|
65
|
+
}
|
|
66
|
+
const runs = [], requirement = state.documentPolicy?.authority === 'adopted' ? 'scope' : 'prd';
|
|
67
|
+
for (const [i, run] of state.stageRuns.entries()) {
|
|
68
|
+
const spec = map.stages[run.stage];
|
|
69
|
+
let required = [];
|
|
70
|
+
if (spec?.humanReview) {
|
|
71
|
+
const kind = spec.kind === 'requirement' ? requirement : spec.kind;
|
|
72
|
+
required = (state.documents ?? []).filter(d => d.kind === kind || (run.stage === 'contract' && d.kind === 'sql')).map(d => d.path);
|
|
73
|
+
}
|
|
74
|
+
const declared = run.dependsOn ?? (i ? [state.stageRuns[i - 1].id] : []);
|
|
75
|
+
const producers = state.stageRuns.slice(0, i).filter(r => r.outputRefs.some(out => run.inputRefs.some(input => out.split('#')[0] === input.split('#')[0]))).map(r => r.id);
|
|
76
|
+
const development = ['requirements','scope','tech-design','contract','test-design','plan','implement'];
|
|
77
|
+
const prerequisites = development.includes(run.stage) ? state.stageRuns.slice(0,i).filter(r=>development.includes(r.stage) && development.indexOf(r.stage)<development.indexOf(run.stage)).map(r=>r.id) : [];
|
|
78
|
+
const dependsOn = [...new Set([...declared, ...producers, ...prerequisites])];
|
|
79
|
+
const blockedBy = dependsOn.filter(id => !runs.find(r => r.id === id)?.ready);
|
|
80
|
+
const waiting = required.filter(file => documents.find(d => d.path === file)?.status !== 'approved');
|
|
81
|
+
const ready = run.progress === 'done' && !['needs-review','unknown'].includes(run.applicability) && !blockedBy.length && !waiting.length && (!spec?.humanReview || required.length > 0);
|
|
82
|
+
runs.push({ id: run.id, stage: run.stage, progress: run.progress, applicability: run.applicability, documents: required, waiting, dependsOn, blockedBy, ready });
|
|
83
|
+
}
|
|
84
|
+
const current = runs.find(r => r.id === state.currentRun);
|
|
85
|
+
const pending = runs.filter(r => r.progress !== 'planned').flatMap(r => r.waiting.map(document => ({ run: r.id, document, status: documents.find(d => d.path === document)?.status })));
|
|
86
|
+
const nextRun = runs.find(r => !r.ready);
|
|
87
|
+
const nextStage = current ? runs[runs.indexOf(current) + 1]?.stage ?? null : nextRun?.stage ?? null;
|
|
88
|
+
return { compatibility: 'current', documents, runs, pending, evidenceIssues, currentRun: current?.id ?? null, nextRun: nextRun?.id ?? null, nextStage, canProceed: current ? !current.blockedBy.length && !(current.progress === 'done' && !current.ready) && current.progress !== 'blocked' && !['needs-review','unknown'].includes(current.applicability) : !pending.length && !runs.some(r=>r.progress==='blocked' || ['needs-review','unknown'].includes(r.applicability)), identity: 'Record validation only; user identity, intent and source authenticity are not authenticated.' };
|
|
89
|
+
}
|
|
90
|
+
async function evidence(directory, state, ref) {
|
|
91
|
+
if (!text(ref) || ref.includes('\\') || ref.split('#')[0].split('/').includes('..') || ref.startsWith('/') || (/^[a-zA-Z]:/.test(ref) && !ref.startsWith('repo:'))) reject('Review evidence must stay inside a registered root.');
|
|
92
|
+
let body;
|
|
93
|
+
try { body = await safeRead(directory, state, ref); } catch { reject(`Cannot read review evidence ${ref}.`); }
|
|
94
|
+
if (!text(body)) reject(`Preserve locally readable review evidence for ${ref}.`);
|
|
95
|
+
const fragment = ref.split('#')[1];
|
|
96
|
+
if (fragment && !anchors(body).includes(fragment)) reject(`Review evidence anchor not found: ${ref}.`);
|
|
97
|
+
return hash(body);
|
|
98
|
+
}
|
|
99
|
+
export async function enforceReviews(directory, state, previous) {
|
|
100
|
+
validateReviewState(state);
|
|
101
|
+
if (state.reviewPolicy !== 1) reject('Adopt the review protocol before workflow updates; old results remain readable but are not approvals.');
|
|
102
|
+
const oldHistory = previous?.reviewHistory ?? [];
|
|
103
|
+
for (const old of previous?.documents ?? []) if (oldHistory.some(r=>r.document===old.path) && !state.documents.some(d=>d.path===old.path && d.kind===old.kind)) reject(`Preserve reviewed document registration ${old.path}.`);
|
|
104
|
+
if (state.reviewHistory.length < oldHistory.length || !oldHistory.every((r, i) => isDeepStrictEqual(r, state.reviewHistory[i]))) reject('Preserve review history unchanged and in order.');
|
|
105
|
+
const prefix = { ...state, reviewHistory: [...oldHistory] };
|
|
106
|
+
for (const r of state.reviewHistory.slice(oldHistory.length)) {
|
|
107
|
+
const doc = state.documents.find(d => d.path === r.document);
|
|
108
|
+
if (!doc || r.version !== doc.version || r.digest !== await reviewFingerprint(directory, state, doc)) reject(`${r.id}: review must bind the current registered document version and content.`);
|
|
109
|
+
const prior = decisionFor(prefix, doc, r.digest);
|
|
110
|
+
if (r.kind === 'approved' && !prior.checked) reject(`${r.id}: self-check the same content before recording approval.`);
|
|
111
|
+
if (r.kind === 'editorial' && (!prior.checked || !decisionFor(prefix, doc, r.previousDigest).approved)) reject(`${r.id}: editorial reconciliation needs a self-check and the previous approval at the same version.`);
|
|
112
|
+
r.sourceDigest = await evidence(directory, state, r.sourceRef);
|
|
113
|
+
prefix.reviewHistory.push(r);
|
|
114
|
+
}
|
|
115
|
+
// Only the initial selected development sequence is ordered; later rework can recur.
|
|
116
|
+
const order = ['requirements','scope','tech-design','contract','test-design','plan','implement'];
|
|
117
|
+
const first = stage => state.stageRuns.findIndex(r => r.stage === stage);
|
|
118
|
+
for (let i = 0; i < order.length; i++) for (let j = i + 1; j < order.length; j++) {
|
|
119
|
+
const a = first(order[i]), b = first(order[j]);
|
|
120
|
+
if (order[i] === 'requirements' && order[j] === 'scope') continue;
|
|
121
|
+
if (a >= 0 && b >= 0 && a > b && !previous?.stageRuns.some(r => r.id === state.stageRuns[b].id && r.progress !== 'planned')) reject(`${order[i]} must precede ${order[j]} in the initial development arrangement.`);
|
|
122
|
+
}
|
|
123
|
+
const restoring = new Set(state.stageRuns.filter(r=>r.applicability === 'current' && previous?.stageRuns.some(old=>old.id===r.id && old.applicability==='needs-review')).map(r=>r.id));
|
|
124
|
+
let status = await reviewStatus(directory, state);
|
|
125
|
+
// Invalidate only registered producer/dependency paths. Independent branches retain their facts.
|
|
126
|
+
const affected = new Set();
|
|
127
|
+
for (const doc of state.documents) {
|
|
128
|
+
const oldDoc = previous?.documents?.find(d => d.path === doc.path);
|
|
129
|
+
const latest = oldHistory.filter(r => r.document === doc.path && ['approved','editorial'].includes(r.kind)).at(-1);
|
|
130
|
+
const now = status.documents.find(d => d.path === doc.path);
|
|
131
|
+
if (!oldDoc || !latest || !now) continue;
|
|
132
|
+
const changed = latest.digest !== now.digest || latest.version !== doc.version;
|
|
133
|
+
const editorial = state.reviewHistory.slice(oldHistory.length).some(r=>r.document===doc.path && r.kind==='editorial' && r.digest===now.digest);
|
|
134
|
+
const requested = state.reviewHistory.slice(oldHistory.length).some(r=>r.document===doc.path && (r.kind==='changes-requested' || (r.kind==='self-check' && r.result==='failed')));
|
|
135
|
+
if ((!changed && !requested) || editorial) continue;
|
|
136
|
+
if (changed && !state.openChanges.some(c => c.status === 'open')) reject(`${doc.path}: record the material change and impact in an open CHG, or self-check an editorial reconciliation.`);
|
|
137
|
+
if (changed && now.status === 'approved' && doc.version === latest.version) reject(`${doc.path}: a material revision needs a new document version; use editorial only for unchanged meaning.`);
|
|
138
|
+
for (const r of status.runs.filter(r => r.documents.includes(doc.path) && r.progress !== 'planned')) affected.add(r.id);
|
|
139
|
+
}
|
|
140
|
+
for (const r of status.runs) if (r.dependsOn.some(id => affected.has(id))) affected.add(r.id);
|
|
141
|
+
for (const r of state.stageRuns) if (affected.has(r.id) && r.progress !== 'planned') r.applicability = 'needs-review';
|
|
142
|
+
status = await reviewStatus(directory, state);
|
|
143
|
+
const previousStatus = previous?.reviewPolicy === 1 ? await reviewStatus(directory, previous) : null;
|
|
144
|
+
for (const run of state.stageRuns) {
|
|
145
|
+
const old = previous?.stageRuns.find(r => r.id === run.id);
|
|
146
|
+
const progressing = ['active','done'].includes(run.progress) && (!old || old.progress !== run.progress || old.resultRef !== run.resultRef || restoring.has(run.id));
|
|
147
|
+
if (restoring.has(run.id) && run.applicability === 'needs-review') reject(`${run.id}: current document or dependency still needs review.`);
|
|
148
|
+
if (progressing && status.runs.find(r => r.id === run.id).blockedBy.length) reject(`${run.id}: prerequisite completion, approval or affected dependency is unresolved.`);
|
|
149
|
+
if (old && old.progress !== 'planned' && previousStatus && !previousStatus.runs.find(r=>r.id===old.id).dependsOn.every(id=>status.runs.find(r=>r.id===run.id).dependsOn.includes(id))) reject(`${run.id}: preserve started dependencies; record revised work as a new run.`);
|
|
150
|
+
}
|
|
151
|
+
if (state.outcome.goal === 'met' && previous?.outcome.goal !== 'met' && status.runs.some(r => !r.ready)) reject('Resolve selected stages and document approvals before declaring the goal met.');
|
|
152
|
+
return status;
|
|
153
|
+
}
|
package/lib/cli.mjs
CHANGED
|
@@ -16,10 +16,10 @@ const HELP = {
|
|
|
16
16
|
status: 'status [--home <dir>]. Read only Aimship-managed paths and fixed entries. Report registration, shared entry presence/integrity, active version, pending work and config problems. Host loading, login and access permissions remain unverified; no directories are created.',
|
|
17
17
|
bind: 'Resolve active installation once. Returns immutable release/core/stage/CLI paths plus configured dataRoot, configPath and customPath. Source mode returns the default data root with null config/custom paths. No directories are created. Optional --home is for isolated testing.',
|
|
18
18
|
init: 'init <dir> --input <json-file|-> --rules-version <version>. Create state once; input requires goal {summary, endpoint} and goalRef. Does not create templates.',
|
|
19
|
-
read: 'read <dir> [--rules-version <version>]. Read-only JSON access; unknown schemas remain readable.',
|
|
19
|
+
read: 'read <dir> [--rules-version <version>] [--delivery-id <known-id>]. Read-only JSON access; unknown schemas remain readable.',
|
|
20
20
|
update: 'update <dir> --revision <N> --rules-version <version> --input <json-file|->. Recursively merge objects; arrays replace in full. Identity/history fields are managed. null is a literal value, not deletion.',
|
|
21
21
|
check: 'check <dir> [--rules-version <version>] [--all]. Check structure and current input references (or every registered reference). Checks explicit anchors/identity metadata in new local Markdown; reused and unsupported fragments are explicitly not checked. Does not prove semantics, remote access or validation results.',
|
|
22
|
-
resume: 'resume <dir> [--rules-version <version>]. Read state and return fixed rule paths, required inputs, warnings, and continuation text; never execute work or rewrite state.',
|
|
22
|
+
resume: 'resume <dir> [--rules-version <version>] [--delivery-id <known-id>]. Read state and return fixed rule paths, required inputs, warnings, and continuation text; never execute work or rewrite state.',
|
|
23
23
|
checkpoint: 'checkpoint <dir> --revision <N> --rules-version <old-version> --input <json-file|->. Save only checkpoint {summary, ref?} and next when a version mismatch interrupts ongoing work. Cannot complete work.',
|
|
24
24
|
adopt: 'adopt <dir> --revision <N> --rules-version <active-version> --input <json-file|->. Input is {compatibilityRef}; records the assessed stable-point rule switch without changing business history.',
|
|
25
25
|
recover: 'recover <dir> --revision <backup-revision> --rules-version <version>. Restore state.previous.json only over missing/corrupt state; preserve corrupted bytes. Inspect actual artifacts afterward; nothing reruns.',
|
|
@@ -29,6 +29,9 @@ const HELP = {
|
|
|
29
29
|
},
|
|
30
30
|
stateProtocol: {
|
|
31
31
|
schemaVersion: SCHEMA_VERSION,
|
|
32
|
+
documentReview: {reviewPolicy:1, reviewHistory:'Append-only {id,kind,document,version,digest,sourceRef,summary}. self-check requires result: passed|failed; approved requires decision: explicit-approval; editorial requires previousDigest. Read review.documents for current digests. Tools validate records, not user identity. See docs/cli.md#document-review-protocol.'},
|
|
33
|
+
deliveryPolicy: 'New states default to {version:1}; partialRef identifies an explicitly limited scope, null restores standard scope with a new goal decision. Legacy absence remains historical-unassessed; explicit adoption requires compatibilityRef. Completion is derived separately from goal/acceptance.',
|
|
34
|
+
sessions: 'run.sessionGroup is an advisory context group, auto-filled for new runs; reviewOf names implementation runs and overall:true identifies overall review. read/check/resume expose session/completion; writeResult.session is withheld on failed view saving. No session gate or automatic execution.',
|
|
32
35
|
stages: STAGES,
|
|
33
36
|
progress: ['planned', 'active', 'blocked', 'done'],
|
|
34
37
|
result: ['passed', 'failed', 'not-run', 'unknown', 'mixed'],
|
|
@@ -66,7 +69,7 @@ function parse(argv) {
|
|
|
66
69
|
const item = argv[i];
|
|
67
70
|
if (!item.startsWith('--')) { positional.push(item); continue; }
|
|
68
71
|
const name = item.slice(2);
|
|
69
|
-
if (!['help', 'version', 'all', 'preserve-edits', 'input', 'rules-version', 'revision', 'host', 'home', 'data-root'].includes(name)) fail('USAGE', `Unknown option: ${item}`, 'Run help for command usage.');
|
|
72
|
+
if (!['help', 'version', 'all', 'preserve-edits', 'input', 'rules-version', 'delivery-id', 'revision', 'host', 'home', 'data-root'].includes(name)) fail('USAGE', `Unknown option: ${item}`, 'Run help for command usage.');
|
|
70
73
|
const value = ['help', 'version', 'all', 'preserve-edits'].includes(name) ? true : argv[++i];
|
|
71
74
|
if (value === undefined || (typeof value === 'string' && value.startsWith('--'))) fail('USAGE', `Missing value for ${item}`, 'Provide the option value.');
|
|
72
75
|
if (name === 'host') (options.hosts ??= []).push(value);
|
|
@@ -118,7 +121,7 @@ async function binding(runtime) {
|
|
|
118
121
|
}
|
|
119
122
|
function allowed(command, options) {
|
|
120
123
|
const common = ['help', 'version'];
|
|
121
|
-
const perCommand = { render: ['revision','rules-version','preserve-edits','home'], migrate:['revision','rules-version','input','home'], scan:['home'], status: ['home'], bind: ['home'], init: ['input', 'rules-version', 'home'], read: ['rules-version', 'home'], update: ['input', 'rules-version', 'revision', 'home'], check: ['all', 'rules-version', 'home'], resume: ['rules-version', 'home'], checkpoint: ['input', 'rules-version', 'revision', 'home'], adopt: ['input', 'rules-version', 'revision', 'home'], recover: ['rules-version', 'revision', 'home'], install: ['hosts', 'home', 'data-root'], uninstall: ['hosts', 'home'], help: [] };
|
|
124
|
+
const perCommand = { render: ['revision','rules-version','preserve-edits','home'], migrate:['revision','rules-version','input','home'], scan:['home'], status: ['home'], bind: ['home'], init: ['input', 'rules-version', 'home'], read: ['rules-version', 'delivery-id', 'home'], update: ['input', 'rules-version', 'revision', 'home'], check: ['all', 'rules-version', 'home'], resume: ['rules-version', 'delivery-id', 'home'], checkpoint: ['input', 'rules-version', 'revision', 'home'], adopt: ['input', 'rules-version', 'revision', 'home'], recover: ['rules-version', 'revision', 'home'], install: ['hosts', 'home', 'data-root'], uninstall: ['hosts', 'home'], help: [] };
|
|
122
125
|
for (const key of Object.keys(options)) if (![...common, ...(perCommand[command] ?? [])].includes(key)) fail('USAGE', `Option --${key} is not valid for ${command}.`, `Run help ${command}.`);
|
|
123
126
|
}
|
|
124
127
|
export async function run(argv, context) {
|
|
@@ -152,9 +155,10 @@ export async function run(argv, context) {
|
|
|
152
155
|
if (['update', 'checkpoint', 'adopt'].includes(command)) return { command, data: await updateState(directory, await inputJSON(options.input), revisionOf(options.revision), rulesVersion, runtime, command) };
|
|
153
156
|
if (command === 'recover') return { command, data: await recoverState(directory, revisionOf(options.revision), rulesVersion, runtime) };
|
|
154
157
|
const read = await readState(directory);
|
|
158
|
+
if (options['delivery-id'] && options['delivery-id'] !== read.state.deliveryId) fail('DELIVERY_ID_MISMATCH','This directory is not the bound delivery.','Locate the intended delivery; do not guess the latest one.');
|
|
155
159
|
let mismatch = null;
|
|
156
160
|
if (!read.readOnly) { try { assertRules(read.state, rulesVersion, runtime); } catch (error) { mismatch = { code: error.code, ...error.details, recovery: error.recovery }; } }
|
|
157
|
-
if (command === 'read') return { command, data: { state: read.state, readOnly: read.readOnly, versionMismatch: mismatch, statePath: read.statePath, view: read.view, documentChanges: read.documentChanges, layoutConflict:read.conflict??false, layoutMismatch:read.layoutMismatch??false, candidates:read.candidates, migration:read.migration?.status??null } };
|
|
161
|
+
if (command === 'read') return { command, data: { state: read.state, readOnly: read.readOnly, versionMismatch: mismatch, statePath: read.statePath, review:read.review, completion:read.completion, session:read.session, view: read.view, documentChanges: read.documentChanges, layoutConflict:read.conflict??false, layoutMismatch:read.layoutMismatch??false, candidates:read.candidates, migration:read.migration?.status??null } };
|
|
158
162
|
if (read.readOnly) return { command, data: { state: read.state, readOnly: true, canProceed: false, reason: read.conflict?'Conflicting state layouts require explicit reconciliation.':'Unknown schema: no structure checking, mutation, or automatic work is permitted.', candidates:read.candidates } };
|
|
159
163
|
validateState(read.state); const references = await checkReferences(directory, read.state, options.all);
|
|
160
164
|
const documents=await inspectDocuments(directory,read.state);
|
|
@@ -163,9 +167,9 @@ export async function run(argv, context) {
|
|
|
163
167
|
if (missing.length) fail('REFERENCE_CHECK', 'Required references are missing or inaccessible.', 'Repair the specific registered paths; do not scan the disk or overwrite source materials.', { references, versionMismatch: mismatch });
|
|
164
168
|
if (mismatch) fail('VERSION_MISMATCH', 'State is structurally valid, but rules are not bound consistently.', mismatch.recovery, mismatch);
|
|
165
169
|
if (documents.issues.length) fail('DOCUMENT_CHECK','Document mapping or identities need repair.','Inspect listed issues.',documents);
|
|
166
|
-
return { command, data: { structure: 'valid', references, documents, view:read.view, documentChanges:read.documentChanges, semantics: 'not-checked' } };
|
|
170
|
+
return { command, data: { structure: 'valid', review:read.review, completion:read.completion, session:read.session, references, documents, view:read.view, documentChanges:read.documentChanges, semantics: 'not-checked' } };
|
|
167
171
|
}
|
|
168
172
|
const currentRun = read.state.stageRuns.find(x => x.id === read.state.currentRun) ?? null;
|
|
169
173
|
const blocked = currentRun?.progress === 'blocked';
|
|
170
|
-
return { command, data: { state: read.state, binding: await binding(runtime), readOnly: false, canProceed: read.state.documentPolicy?.authority !== 'legacy' && !mismatch && missing.length === 0 && !blocked && !documents.issues.length && !read.documentChanges.length && read.view.status === 'current' && (!read.migration || read.migration.status === 'complete'), statePath:read.statePath, view:read.view, documentChanges:read.documentChanges, documents, versionMismatch: mismatch, references, currentRun, continuation:
|
|
174
|
+
return { command, data: { state: read.state, binding: await binding(runtime), readOnly: false, review:read.review, completion:read.completion, session:read.session, canProceed: read.review.canProceed && read.state.documentPolicy?.authority !== 'legacy' && !mismatch && missing.length === 0 && !blocked && !documents.issues.length && !read.documentChanges.length && read.view.status === 'current' && (!read.migration || read.migration.status === 'complete'), statePath:read.statePath, view:read.view, documentChanges:read.documentChanges, documents, versionMismatch: mismatch, references, currentRun, continuation: !mismatch && !missing.length && !documents.issues.length ? read.session?.continuation ?? null : null, warnings: ['Resume does not rerun work, tests, migrations, or other side effects.', 'Structural readiness does not establish authorization, business readiness, or goal completion.', ...(blocked ? [`Current run ${currentRun.id} is recorded as blocked. Resolve its documented blocker before progressing this work.`] : []), ...(missing.length ? ['Some required paths must be repaired.'] : []), ...(read.state.checkpoint ? ['Inspect the saved checkpoint and current artifacts.'] : [])] } };
|
|
171
175
|
}
|
package/lib/delivery.mjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fail } from './io.mjs';
|
|
3
|
+
|
|
4
|
+
// Additive policy: absent means historical, never inferred complete or rewritten on read.
|
|
5
|
+
export function validateDelivery(state) {
|
|
6
|
+
const p = state.deliveryPolicy;
|
|
7
|
+
if (p === undefined) return;
|
|
8
|
+
if (!p || p.version !== 1) fail('INVALID_STATE', 'Unsupported delivery policy.', 'Preserve the state and use a compatible tool.');
|
|
9
|
+
if (p.partialRef != null && (typeof p.partialRef !== 'string' || !p.partialRef.trim())) fail('INVALID_STATE', 'partialRef must identify the explicit scope decision.', 'Record the actual decision in the existing requirement or change record.');
|
|
10
|
+
for (const r of state.stageRuns) {
|
|
11
|
+
if (r.overall !== undefined && (r.stage !== 'review' || typeof r.overall !== 'boolean')) fail('INVALID_STATE', 'overall is a review scope flag.', 'Identify the actual overall review.');
|
|
12
|
+
if (r.sessionGroup !== undefined && (typeof r.sessionGroup !== 'string' || !r.sessionGroup.trim())) fail('INVALID_STATE', 'Invalid sessionGroup.', 'Use a nonempty advisory group ID.');
|
|
13
|
+
if (r.reviewOf !== undefined && (r.stage !== 'review' || !Array.isArray(r.reviewOf) || !r.reviewOf.length || new Set(r.reviewOf).size !== r.reviewOf.length || r.reviewOf.some(id => !state.stageRuns.some(x => x.id === id && x.stage === 'implement')))) fail('INVALID_STATE', 'reviewOf must identify implementation runs.', 'Record the actual reviewed batches.');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function arrangeSessions(state) {
|
|
17
|
+
if (state.deliveryPolicy?.version !== 1) return;
|
|
18
|
+
for (const r of state.stageRuns) {
|
|
19
|
+
if (r.sessionGroup) continue;
|
|
20
|
+
const category = ['frame','requirements','scope'].includes(r.stage) ? 'requirements' : ['tech-design','contract','prototype'].includes(r.stage) ? 'technical' : r.stage;
|
|
21
|
+
const batch = r.stage === 'review' && !r.overall && r.reviewOf?.length === 1 ? state.stageRuns.find(x=>x.id===r.reviewOf[0]) : null;
|
|
22
|
+
r.sessionGroup = batch ? (batch.sessionGroup ?? `implement:${batch.id}`) : ['requirements','technical','test-design','plan'].includes(category) ? category : `${category}:${r.id}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function completionStatus(state, review, {changed=[]}={}) {
|
|
26
|
+
if (state.deliveryPolicy?.version !== 1) return {status:'historical-unassessed', standardComplete:false, readyForAcceptance:false, gaps:['Historical outcomes are preserved; explicitly adopt the delivery policy after assessing evidence.']};
|
|
27
|
+
const runs=state.stageRuns, gaps=[];
|
|
28
|
+
if(changed.length) gaps.push('Changed or missing registered documents require reconciliation.');
|
|
29
|
+
const ready=r=>review.runs.find(x=>x.id===r.id)?.ready;
|
|
30
|
+
const has=stage=>runs.some(r=>r.stage===stage && ready(r));
|
|
31
|
+
for (const stage of ['tech-design','test-design','plan','implement','verify','handoff']) if (!has(stage)) gaps.push(`Missing current completed ${stage}.`);
|
|
32
|
+
if (!runs.some(r=>['requirements','scope'].includes(r.stage) && ready(r))) gaps.push('Missing reviewed requirements / Scope.');
|
|
33
|
+
if ((state.documentPolicy?.interface!=='none' || state.documentPolicy?.databases?.length) && !has('contract')) gaps.push('Missing applicable interface/SQL contract.');
|
|
34
|
+
if (state.documentPolicy?.interaction && !state.documentPolicy.prototypeCovered && !has('prototype')) gaps.push('Missing applicable interaction prototype.');
|
|
35
|
+
if (review.runs.some(r=>!r.ready)) gaps.push('Selected work or document approvals remain unresolved.');
|
|
36
|
+
const implementations=runs.filter(r=>r.stage==='implement');
|
|
37
|
+
if (implementations.some(r=>['failed','mixed','unknown','not-run'].includes(r.result))) gaps.push('Implementation results remain unresolved.');
|
|
38
|
+
const allReviews=runs.filter(r=>r.stage==='review');
|
|
39
|
+
const effectiveReviews=allReviews.filter((r,i)=>!allReviews.slice(i+1).some(later=>ready(later) && later.result==='passed' && later.historyResultRefs?.includes(r.resultRef)));
|
|
40
|
+
if (effectiveReviews.some(r=>!ready(r) || r.result!=='passed')) gaps.push('Review results remain unresolved.');
|
|
41
|
+
const reviews=allReviews.filter(r=>ready(r) && r.result==='passed');
|
|
42
|
+
for (const [i, batch] of implementations.entries()) {
|
|
43
|
+
if (!reviews.some(r=>r.reviewOf?.includes(batch.id) && runs.indexOf(r)>runs.indexOf(batch) && (!implementations[i+1] || runs.indexOf(r)<runs.indexOf(implementations[i+1])))) gaps.push(`Missing passing batch review for ${batch.id}.`);
|
|
44
|
+
}
|
|
45
|
+
const overall=reviews.findLast(r=>r.overall === true && implementations.length && implementations.every(b=>r.reviewOf?.includes(b.id) && runs.indexOf(r)>runs.indexOf(b)));
|
|
46
|
+
if (!overall) gaps.push('Missing passing overall implementation review.');
|
|
47
|
+
const allChecks=runs.filter(r=>r.stage==='verify');
|
|
48
|
+
const checks=allChecks.filter((r,i)=>!allChecks.slice(i+1).some(later=>ready(later) && later.result==='passed' && later.historyResultRefs?.includes(r.resultRef)));
|
|
49
|
+
if (!checks.length || checks.some(r=>!ready(r) || r.result!=='passed' || !overall || runs.indexOf(r)<runs.indexOf(overall))) gaps.push('Independent verification is not fully recorded as passed after overall review.');
|
|
50
|
+
const handoff=runs.findLast(r=>r.stage==='handoff' && ready(r));
|
|
51
|
+
if (handoff && checks.some(r=>runs.indexOf(r)>runs.indexOf(handoff))) gaps.push('Handoff precedes verification.');
|
|
52
|
+
if (state.openChanges.some(c=>c.status==='open')) gaps.push('Unresolved changes remain.');
|
|
53
|
+
const partial=!!state.deliveryPolicy.partialRef;
|
|
54
|
+
const readyForAcceptance=!partial && !gaps.length;
|
|
55
|
+
return {status:partial?'partial-scope':readyForAcceptance && state.outcome.goal==='met' && state.outcome.acceptance==='accepted'?'standard-complete':readyForAcceptance?'ready-for-acceptance':'incomplete',standardComplete:readyForAcceptance && state.outcome.goal==='met' && state.outcome.acceptance==='accepted',readyForAcceptance,gaps,partialRef:state.deliveryPolicy.partialRef??null,scope:'Recorded structure, approvals and results only; not independent proof of test execution or semantic quality.'};
|
|
56
|
+
}
|
|
57
|
+
export function sessionAdvice(directory,state,review,{saved=true}={}) {
|
|
58
|
+
const groups=[];
|
|
59
|
+
for (const r of state.stageRuns) {
|
|
60
|
+
const id=r.sessionGroup??'unrecorded';let g=groups.find(x=>x.id===id);if(!g)groups.push(g={id,runs:[]});g.runs.push(r.id);
|
|
61
|
+
}
|
|
62
|
+
const current=state.stageRuns.find(r=>r.id===state.currentRun);
|
|
63
|
+
const status=review.runs.find(r=>r.id===state.currentRun);
|
|
64
|
+
const pending=review.pending.some(p=>state.stageRuns.find(r=>r.id===p.run)?.progress==='done' || p.status==='changes-requested' || p.status==='self-check-failed');
|
|
65
|
+
const next=current?state.stageRuns[state.stageRuns.indexOf(current)+1]:state.stageRuns.find(r=>r.progress!=='done');
|
|
66
|
+
let action='continue-current';
|
|
67
|
+
if (!saved) action='reconcile-save';
|
|
68
|
+
else if(state.deliveryPolicy?.version !== 1) action='assess-compatibility';
|
|
69
|
+
else if(pending) action='await-review';
|
|
70
|
+
else if(!current && state.stageRuns.length && review.runs.every(r=>r.ready)) action='finished';
|
|
71
|
+
else if(current && (['failed','mixed','unknown','not-run'].includes(current.result) || current.progress==='blocked' || ['needs-review','unknown'].includes(current.applicability))) action='resolve-current';
|
|
72
|
+
else if(status?.ready) action=!next?'finished':next.progress!=='planned'?'inspect-existing-work':current.sessionGroup && current.sessionGroup===next.sessionGroup?'continue-group':'suggest-new-session';
|
|
73
|
+
if (saved && !pending && state.deliveryPolicy?.version === 1 && status?.ready && next && review.runs.find(r=>r.id===next.id)?.blockedBy.length) action='resolve-current';
|
|
74
|
+
const continuation=['assess-compatibility','reconcile-save','await-review','finished'].includes(action)?null:`Use Aimship to continue delivery ${JSON.stringify(path.resolve(directory))}; first read its current state and relevant formal documents, then continue the recorded work.`;
|
|
75
|
+
return {action,currentRun:current?.id??null,nextRun:next?.id??null,groups,continuation,advisory:true,note:'A session boundary is not a blocker or approval. Continue here when requested. Resolve the delivery identity, current approvals and unfinished effects on every resume; this text grants no approval.'};
|
|
76
|
+
}
|
package/lib/documents.mjs
CHANGED
|
@@ -19,13 +19,13 @@ export function resolveRef(directory, state, ref) {
|
|
|
19
19
|
if (!inside(base, file)) fail('INVALID_REFERENCE', 'Reference escapes its root.', 'Repair the registered reference.');
|
|
20
20
|
return file;
|
|
21
21
|
}
|
|
22
|
-
export async function safeRead(directory, state, ref) {
|
|
22
|
+
export async function safeRead(directory, state, ref, encoding = 'utf8') {
|
|
23
23
|
const file = resolveRef(directory, state, ref);
|
|
24
24
|
if (!file) return null;
|
|
25
25
|
const real = await fs.realpath(file);
|
|
26
26
|
const base = ref.startsWith('repo:') ? state.repositories.find(r => r.id === /^repo:([^/]+)/.exec(ref)[1]).path : directory;
|
|
27
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,
|
|
28
|
+
return fs.readFile(real, encoding);
|
|
29
29
|
}
|
|
30
30
|
export const anchors = text => [...text.matchAll(/<a\s+id=["']([^"']+)["']\s*><\/a>/g)].map(m => m[1]);
|
|
31
31
|
const identityKinds = ['REQ','AC','B','TASK','TC','REV','RUN','CHG','Q','PAGE'];
|
|
@@ -94,7 +94,7 @@ export async function inspectDocuments(directory, state) {
|
|
|
94
94
|
const runs = state.stageRuns.filter(r => !legacyRuns.has(r.id));
|
|
95
95
|
for (const [i, run] of state.stageRuns.entries()) {
|
|
96
96
|
if (!runs.includes(run) || run.progress === 'planned') continue;
|
|
97
|
-
const contract = map.stages[run.stage], doc = find(contract.kind);
|
|
97
|
+
const contract = map.stages[run.stage], doc = find(contract.kind) ?? (run.stage === 'contract' && policy.interface === 'none' ? find('sql') : null);
|
|
98
98
|
if (!doc) { issues.push(`${run.id}: missing registered ${contract.kind} artifact.`); continue; }
|
|
99
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
100
|
if (run.progress === 'done') {
|
|
@@ -108,7 +108,7 @@ export async function inspectDocuments(directory, state) {
|
|
|
108
108
|
const prefix=run.stage==='review'?'REV-':'RUN-';
|
|
109
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
110
|
}
|
|
111
|
-
if (run.stage === '
|
|
111
|
+
if (run.stage === 'contract') {
|
|
112
112
|
for (const db of policy.databases) {
|
|
113
113
|
const file = policy.databases.length === 1 ? 'database.sql' : `database-${db}.sql`;
|
|
114
114
|
if (!docs.some(d => d.kind === 'sql' && d.path === file) || !await exists(path.join(directory,file))) issues.push(`${run.id}: missing independent ${file}.`);
|
|
@@ -116,6 +116,7 @@ export async function inspectDocuments(directory, state) {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
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.');
|
|
119
120
|
if (policy.interface === 'http') {
|
|
120
121
|
const doc=find('contract');
|
|
121
122
|
if (doc?.mode === 'new' && doc.path !== 'openapi.yaml') issues.push('New HTTP contract must be openapi.yaml.');
|
package/lib/state.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { validateDelivery, arrangeSessions, completionStatus, sessionAdvice } from './delivery.mjs';
|
|
1
2
|
import fs from 'node:fs/promises';
|
|
3
|
+
import { validateReviewState, enforceReviews, reviewStatus } from './approvals.mjs';
|
|
2
4
|
import path from 'node:path';
|
|
3
5
|
import { inspectDocuments, documentChanges, anchors, preservedLegacyRuns } from './documents.mjs';
|
|
4
6
|
import { savedResult, viewStatus, generateView } from './view.mjs';
|
|
@@ -6,7 +8,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
6
8
|
import { atomicWrite, exists, fail, inside, json, noSymlinks, readJSON, withLock } from './io.mjs';
|
|
7
9
|
|
|
8
10
|
export const SCHEMA_VERSION = 2;
|
|
9
|
-
export const STAGES = ['frame', 'requirements', 'prototype', 'scope', 'diagnose', 'tech-design', 'contract', '
|
|
11
|
+
export const STAGES = ['frame', 'requirements', 'prototype', 'scope', 'diagnose', 'tech-design', 'contract', 'test-design', 'plan', 'implement', 'review', 'verify', 'handoff'];
|
|
10
12
|
const PROGRESS = ['planned', 'active', 'blocked', 'done'];
|
|
11
13
|
const RESULTS = ['passed', 'failed', 'not-run', 'unknown', 'mixed'];
|
|
12
14
|
const OUTCOME_REFS = ['goalConclusionRef', 'acceptanceRef', 'mergeRef', 'deploymentRef', 'handoffRef'];
|
|
@@ -37,6 +39,7 @@ export function validateState(state) {
|
|
|
37
39
|
for (const d of state.documents) {
|
|
38
40
|
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.');
|
|
39
41
|
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);
|
|
42
|
+
if (d.assetRefs !== undefined) refs(d.assetRefs, 'documents.assetRefs');
|
|
40
43
|
if (d.mode === 'reused') { ref(d.basisRef, 'documents.basisRef'); }
|
|
41
44
|
}
|
|
42
45
|
}
|
|
@@ -45,6 +48,7 @@ export function validateState(state) {
|
|
|
45
48
|
requireThat(string(state.deliveryId) && string(state.rulesVersion), 'deliveryId and rulesVersion are required.');
|
|
46
49
|
requireThat(object(state.goal) && string(state.goal.summary) && string(state.goal.endpoint), 'goal.summary and goal.endpoint are required.');
|
|
47
50
|
ref(state.goalRef, 'goalRef');
|
|
51
|
+
if (state.compatibilityRef !== undefined) ref(state.compatibilityRef, 'compatibilityRef');
|
|
48
52
|
if (state.goal.decisionRef !== undefined) ref(state.goal.decisionRef, 'goal.decisionRef');
|
|
49
53
|
requireThat(Number.isSafeInteger(state.cycle) && state.cycle >= 1, 'cycle must be a positive integer.');
|
|
50
54
|
refs(state.closureRefs, 'closureRefs');
|
|
@@ -73,6 +77,9 @@ export function validateState(state) {
|
|
|
73
77
|
if (state.next.inputRefs !== undefined) refs(state.next.inputRefs, 'next.inputRefs');
|
|
74
78
|
if (state.goalHistory !== undefined) requireThat(Array.isArray(state.goalHistory), 'goalHistory must be an array.');
|
|
75
79
|
if (state.rulesHistory !== undefined) requireThat(Array.isArray(state.rulesHistory), 'rulesHistory must be an array.');
|
|
80
|
+
validateDelivery(state);
|
|
81
|
+
if (state.deliveryPolicy?.partialRef) ref(state.deliveryPolicy.partialRef, 'deliveryPolicy.partialRef');
|
|
82
|
+
validateReviewState(state);
|
|
76
83
|
return state;
|
|
77
84
|
}
|
|
78
85
|
|
|
@@ -96,9 +103,11 @@ export async function readState(directory) {
|
|
|
96
103
|
const paths=await locateState(directory); const state=await readJSON(paths.statePath);
|
|
97
104
|
requireThat(object(state),'State must be an object.');
|
|
98
105
|
const layoutMismatch=(state.schemaVersion===1 && !paths.legacy)||(state.schemaVersion===2 && paths.legacy);
|
|
99
|
-
const readOnly=paths.conflict || layoutMismatch || ![1,SCHEMA_VERSION].includes(state.schemaVersion);
|
|
106
|
+
const readOnly=paths.conflict || layoutMismatch || ![1,SCHEMA_VERSION].includes(state.schemaVersion) || (state.reviewPolicy !== undefined && state.reviewPolicy !== 1) || (state.deliveryPolicy !== undefined && state.deliveryPolicy?.version !== 1);
|
|
100
107
|
if (!readOnly) validateState(state);
|
|
101
|
-
|
|
108
|
+
const review=readOnly ? null : await reviewStatus(directory,state);
|
|
109
|
+
const view=await viewStatus(directory,state), changes=await documentChanges(directory,state);
|
|
110
|
+
return {...paths,state,readOnly,layoutMismatch,review,completion:review ? completionStatus(state,review,{changed:changes}) : null,session:review ? sessionAdvice(directory,state,review,{saved:view.status==='current' && !changes.length}) : null,view,documentChanges:changes};
|
|
102
111
|
}
|
|
103
112
|
async function inspectForWrite(directory,state,previous) {
|
|
104
113
|
const inspected=await inspectDocuments(directory,state);
|
|
@@ -142,8 +151,8 @@ export async function initState(directory, input, rulesVersion, runtime) {
|
|
|
142
151
|
const forbidden = ['schemaVersion', 'revision', 'deliveryId', 'rulesVersion', 'createdAt', 'updatedAt', 'goalHistory', 'rulesHistory', 'documentDigests', 'identityHistory', 'retiredIdentities', 'legacyRunIds', 'writeResult'];
|
|
143
152
|
requireThat(object(input) && !forbidden.some(x => Object.hasOwn(input, x)), 'init input must not set tool-managed identity/version fields.');
|
|
144
153
|
const now = new Date().toISOString();
|
|
145
|
-
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 };
|
|
146
|
-
validateState(state); requireThat(state.documentPolicy.authority !== 'legacy', 'New deliveries cannot opt into legacy rules.'); await inspectForWrite(directory,state); await atomicWrite(p.statePath, json(state)); return savedResult(directory,state);
|
|
154
|
+
const state = { deliveryPolicy: {version:1}, 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 };
|
|
155
|
+
arrangeSessions(state); validateState(state); requireThat(state.deliveryPolicy?.version === 1, 'New deliveries require deliveryPolicy version 1.'); requireThat(state.documentPolicy.authority !== 'legacy', 'New deliveries cannot opt into legacy rules.'); await inspectForWrite(directory,state); await enforceReviews(directory,state); await enforceCompletion(directory,state); await atomicWrite(p.statePath, json(state)); return savedResult(directory,state);
|
|
147
156
|
});
|
|
148
157
|
}
|
|
149
158
|
function merge(base, patch) {
|
|
@@ -200,14 +209,22 @@ export async function updateState(directory, patch, revision, rulesVersion, runt
|
|
|
200
209
|
next = { ...previous, rulesVersion, rulesHistory: [...(previous.rulesHistory ?? []), { from: previous.rulesVersion, to: rulesVersion, ref: patch.compatibilityRef, at: new Date().toISOString() }] };
|
|
201
210
|
} else next = merge(previous, patch);
|
|
202
211
|
if (mode === 'checkpoint') next.checkpoint.activeVersion = runtime.activeVersion;
|
|
203
|
-
|
|
212
|
+
requireThat(previous.deliveryPolicy === undefined || next.deliveryPolicy?.version === previous.deliveryPolicy.version, 'Cannot remove or downgrade deliveryPolicy.');
|
|
213
|
+
if (previous.deliveryPolicy === undefined && next.deliveryPolicy !== undefined) {
|
|
214
|
+
requireThat(string(next.compatibilityRef) && patch.compatibilityRef !== undefined, 'Explicit delivery-policy adoption needs compatibilityRef; preserve historical outcomes.');
|
|
215
|
+
const basis = await checkReferences(directory,{...next,goalRef:next.compatibilityRef,next:{inputRefs:[]},stageRuns:[],currentRun:null});
|
|
216
|
+
requireThat(basis.every(r=>['exists','not-checked'].includes(r.status)), 'Compatibility assessment must be accessible before policy adoption.');
|
|
217
|
+
}
|
|
218
|
+
arrangeSessions(next); validateState(next);
|
|
204
219
|
if (JSON.stringify(previous.goal) !== JSON.stringify(next.goal) || previous.goalRef !== next.goalRef) {
|
|
205
220
|
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.');
|
|
206
|
-
next.goalHistory = [...(previous.goalHistory ?? []), { goal: previous.goal, goalRef: previous.goalRef, outcome: previous.outcome, revision: previous.revision }];
|
|
221
|
+
next.goalHistory = [...(previous.goalHistory ?? []), { goal: previous.goal, goalRef: previous.goalRef, deliveryPolicy: previous.deliveryPolicy, outcome: previous.outcome, revision: previous.revision }];
|
|
207
222
|
}
|
|
208
223
|
requireThat(previous.documentPolicy?.authority === 'legacy' || next.documentPolicy?.authority !== 'legacy', 'Cannot downgrade a new delivery to legacy document rules.');
|
|
209
224
|
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.');
|
|
210
|
-
if (mode === 'update') await inspectForWrite(directory,next,previous);
|
|
225
|
+
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); }
|
|
226
|
+
if (previous.deliveryPolicy?.partialRef !== next.deliveryPolicy?.partialRef && previous.deliveryPolicy !== undefined) requireThat(string(next.goal.decisionRef) && next.goal.decisionRef !== previous.goal.decisionRef, 'Scope changes need a new explicit goal decision.');
|
|
227
|
+
if (mode === 'update' && next.deliveryPolicy && previous.deliveryPolicy && JSON.stringify(previous.outcome)!==JSON.stringify(next.outcome)) await enforceCompletion(directory,next);
|
|
211
228
|
retainHistory(previous, next); next.revision = previous.revision + 1; next.updatedAt = new Date().toISOString();
|
|
212
229
|
validateState(next); await atomicWrite(p.backup, json(previous)); await atomicWrite(p.statePath, json(next)); return next.schemaVersion === 2 ? savedResult(directory,next) : next;
|
|
213
230
|
});
|
|
@@ -221,9 +238,10 @@ export async function recoverState(directory, revision, rulesVersion, runtime) {
|
|
|
221
238
|
const raw = await fs.readFile(p.statePath, 'utf8');
|
|
222
239
|
let parsed; try { parsed = JSON.parse(raw); } catch { /* Corruption is preserved below. */ }
|
|
223
240
|
if (parsed) {
|
|
241
|
+
if ((parsed.deliveryPolicy !== undefined && parsed.deliveryPolicy?.version !== 1) || (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.');
|
|
224
242
|
if (parsed.schemaVersion !== backup.schemaVersion) fail('UNKNOWN_SCHEMA', 'Cannot recover over an unknown schema.', 'Use a compatible tool; preserve the newer state.');
|
|
225
243
|
try { validateState(parsed); fail('STATE_VALID', 'Current state is valid; refusing rollback.', 'Use a normal revision-checked update to reconcile facts.'); }
|
|
226
|
-
catch (e) { if (e.code
|
|
244
|
+
catch (e) { if (!['INVALID_STATE','REVIEW_CHECK'].includes(e.code)) throw e; }
|
|
227
245
|
if (Number.isSafeInteger(parsed.revision)) corruptRevision = Math.max(corruptRevision, parsed.revision);
|
|
228
246
|
}
|
|
229
247
|
await atomicWrite(path.join(p.internal, `state.corrupt.${randomUUID()}.json`), raw);
|
|
@@ -240,12 +258,16 @@ export async function checkReferences(directory, state, all = false) {
|
|
|
240
258
|
state.openChanges.forEach(x => values.add(x.ref)); state.closureRefs.forEach(x => values.add(x));
|
|
241
259
|
const add = value => { if (value !== undefined) { ref(value, 'registered reference'); values.add(value); } };
|
|
242
260
|
const addOutcome = outcome => { for (const key of OUTCOME_REFS) add(outcome?.[key]); };
|
|
261
|
+
if (state.deliveryPolicy?.partialRef != null) add(state.deliveryPolicy.partialRef);
|
|
262
|
+
add(state.compatibilityRef);
|
|
243
263
|
add(state.goal.decisionRef); addOutcome(state.outcome);
|
|
244
264
|
for (const history of state.goalHistory ?? []) {
|
|
245
265
|
add(history?.goalRef); add(history?.goal?.decisionRef); addOutcome(history?.outcome);
|
|
246
266
|
}
|
|
247
267
|
for (const history of state.rulesHistory ?? []) add(history?.ref);
|
|
248
268
|
add(state.checkpoint?.ref);
|
|
269
|
+
for (const r of state.reviewHistory ?? []) add(r.sourceRef);
|
|
270
|
+
for (const d of state.documents ?? []) for (const r of d.assetRefs ?? []) add(r);
|
|
249
271
|
}
|
|
250
272
|
const results = [];
|
|
251
273
|
for (const value of values) {
|
|
@@ -274,3 +296,11 @@ export async function checkReferences(directory, state, all = false) {
|
|
|
274
296
|
}
|
|
275
297
|
return results;
|
|
276
298
|
}
|
|
299
|
+
|
|
300
|
+
async function enforceCompletion(directory,state) {
|
|
301
|
+
if (state.deliveryPolicy?.version !== 1 || state.outcome.goal !== 'met') return;
|
|
302
|
+
const completion=completionStatus(state,await reviewStatus(directory,state));
|
|
303
|
+
if (!state.deliveryPolicy.partialRef && !completion.readyForAcceptance) fail('DELIVERY_INCOMPLETE','Standard delivery evidence is incomplete.','Keep the goal in progress, or record the explicitly limited scope without claiming standard completion.',completion);
|
|
304
|
+
const refs=await checkReferences(directory,state,true);
|
|
305
|
+
if (refs.some(r=>!['exists','not-checked'].includes(r.status))) fail('DELIVERY_INCOMPLETE','Completion references are missing.','Repair evidence references without inventing results.',{references:refs});
|
|
306
|
+
}
|
package/lib/view.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { completionStatus, sessionAdvice } from './delivery.mjs';
|
|
1
2
|
import fs from 'node:fs/promises';
|
|
3
|
+
import { reviewStatus } from './approvals.mjs';
|
|
2
4
|
import path from 'node:path';
|
|
3
5
|
import { randomUUID } from 'node:crypto';
|
|
4
6
|
import { atomicWrite, exists, hash, json, readJSON, noSymlinks } from './io.mjs';
|
|
5
|
-
import { resolveRef } from './documents.mjs';
|
|
7
|
+
import { resolveRef, documentChanges } from './documents.mjs';
|
|
6
8
|
|
|
7
9
|
const paths = dir => ({ view: path.join(dir,'status.md'), meta: path.join(dir,'.aimship/view.json') });
|
|
8
10
|
export async function viewStatus(directory, state) {
|
|
@@ -42,6 +44,14 @@ export async function renderText(directory,state) {
|
|
|
42
44
|
for (const r of state.stageRuns.filter(r=>r.progress==='blocked')) lines.push(`- Blocked: ${cell(r.id)} — ${cell(r.scope)}`);
|
|
43
45
|
for (const c of state.openChanges.filter(c=>c.status==='open')) lines.push(`- Open change/question: ${link(c.ref)}`);
|
|
44
46
|
if (state.checkpoint) lines.push('',`Unfinished facts: ${cell(state.checkpoint.summary)}`);
|
|
47
|
+
const review = await reviewStatus(directory,state);
|
|
48
|
+
lines.push('', '## Document review', '', `Review protocol: ${review.compatibility}`, `Next stage: ${cell(review.nextStage)}`, '', '| Document | Version | Review status | Approval record |', '| --- | --- | --- | --- |');
|
|
49
|
+
for (const d of review.documents) lines.push(`| ${artifactLink(d.path)} | ${cell(d.version)} | ${d.status} | ${cell(d.approval)} |`);
|
|
50
|
+
for (const p of review.pending) lines.push(`- Awaiting review: ${artifactLink(p.document)} (${cell(p.run)}; ${p.status})`);
|
|
51
|
+
lines.push('', 'Approval records bind content and user decision evidence; the tool does not authenticate the user.', '');
|
|
52
|
+
const completion=completionStatus(state,review,{changed:await documentChanges(directory,state)}), session=sessionAdvice(directory,state,review);
|
|
53
|
+
lines.push('', '## Delivery completeness', '', `Assessment: ${completion.status}`, `Standard complete: ${completion.standardComplete}`, ...completion.gaps.map(g=>`- ${cell(g)}`), '', 'Historical goal/acceptance values are separate from this assessment.', '', '## Session suggestions', '', 'Requirements → technical/interface/SQL → test cases → plan → each implementation batch with its review → overall review → each verification batch. Only applicable work is grouped; these are context suggestions, not gates.', '', ...session.groups.map(g=>`- ${cell(g.id)}: ${g.runs.map(cell).join(', ')}`), '', `Next interaction: ${session.action}`);
|
|
54
|
+
if (session.continuation) lines.push('',cell(session.continuation));
|
|
45
55
|
lines.push('','## Artifacts and plan','');
|
|
46
56
|
const refs=new Set([...(state.documents??[]).map(d=>d.path),...state.stageRuns.flatMap(r=>[...r.outputRefs,r.resultRef].filter(Boolean)),...(state.next.inputRefs??[])]);
|
|
47
57
|
for (const ref of refs) if (await available(directory,state,ref)) lines.push(`- ${artifactLink(ref)}`);
|
|
@@ -65,5 +75,6 @@ export async function generateView(directory,state,preserveEdits=false) {
|
|
|
65
75
|
} 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.'};}
|
|
66
76
|
}
|
|
67
77
|
export async function savedResult(directory,state) {
|
|
68
|
-
|
|
78
|
+
const view=await generateView(directory,state);
|
|
79
|
+
return {...state,writeResult:{stateSaved:true,revision:state.revision,view,session:sessionAdvice(directory,state,await reviewStatus(directory,state),{saved:view.status==='current'})}};
|
|
69
80
|
}
|
package/package.json
CHANGED
package/skills/aimship/SKILL.md
CHANGED
|
@@ -10,9 +10,9 @@ Activate only when the user explicitly asks to start or resume Aimship. Ordinary
|
|
|
10
10
|
|
|
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
|
-
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
|
|
14
|
-
4. Read the selected [stage reference](references/core.md#stage-references) when reaching it. Explain design choices and unresolved decisions, then continue
|
|
15
|
-
5. At
|
|
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 establish the standard development sequence and applicable artifacts; respect explicitly limited scope and record its basis without calling it standard complete delivery. 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. Follow [document review](references/document-review.md): generate, self-check, give the current document entry/status/review focus and wait; do not send a next-stage continuation prompt while review is pending. After explicit approval save and verify the record, then follow the core session interaction: continue within a group, suggest a new session at its boundary without executing the next group in that response; continuing here needs no new approval; questions, silence and viewing results do not approve. Authorized implementation/review/repair/retest can then continue.
|
|
15
|
+
5. Use the core’s default session groups in existing state. At confirmed saved group boundaries give current results, next suggestion and one continuation line with the actual delivery directory; no extra prompt document. A requested full prompt is optional. On resume, uniquely verify the delivery and actual saved review/unfinished-work stop; never infer approval from the copied entry. 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
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
|
|