instar 1.3.198 → 1.3.199
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/dist/core/ApprenticeshipProgram.d.ts +211 -0
- package/dist/core/ApprenticeshipProgram.d.ts.map +1 -0
- package/dist/core/ApprenticeshipProgram.js +433 -0
- package/dist/core/ApprenticeshipProgram.js.map +1 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +19 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/retroHarvestValidator.d.ts +79 -0
- package/dist/core/retroHarvestValidator.d.ts.map +1 -0
- package/dist/core/retroHarvestValidator.js +256 -0
- package/dist/core/retroHarvestValidator.js.map +1 -0
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +7 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/AgentServer.d.ts +1 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +48 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/CapabilityIndex.d.ts.map +1 -1
- package/dist/server/CapabilityIndex.js +16 -0
- package/dist/server/CapabilityIndex.js.map +1 -1
- package/dist/server/routes.d.ts +5 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +99 -0
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/scripts/validate-retro-harvest.mjs +31 -237
- package/src/data/builtin-manifest.json +63 -63
- package/src/scaffold/templates.ts +7 -0
- package/upgrades/1.3.199.md +24 -0
- package/upgrades/side-effects/APPRENTICESHIP-STEP1-PROGRAM-SCAFFOLD-SPEC.md +82 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ApprenticeshipProgram — the standing program scaffold for apprenticeship /
|
|
3
|
+
* mentorship instances (Apprenticeship Step 1, APPRENTICESHIP-STEP1-PROGRAM-
|
|
4
|
+
* SCAFFOLD-SPEC.md).
|
|
5
|
+
*
|
|
6
|
+
* Each instance is a project under a standing program that crystallizes by
|
|
7
|
+
* bootstrap. This module is the instance-as-project REGISTRY plus the two
|
|
8
|
+
* lifecycle GATES that make "review before you start / capture before you
|
|
9
|
+
* close" unskippable at the state-mutating transition:
|
|
10
|
+
*
|
|
11
|
+
* - the RETRO-GATE (pending→active): refused unless the prior instance's
|
|
12
|
+
* retro-harvest exists at its CANONICAL CONFINED PATH and passes the
|
|
13
|
+
* Step 0 validator (the first instance is seeded by the Echo→Codey
|
|
14
|
+
* bootstrap harvest).
|
|
15
|
+
* - the DOC-AS-REQUIRED-ARTIFACT GATE (active→complete): refused until the
|
|
16
|
+
* instance's declared-required artifacts are VERIFIED PRESENT FROM LIVE
|
|
17
|
+
* STATE (never a stored flag).
|
|
18
|
+
*
|
|
19
|
+
* Signal vs Authority / The Body and the Mind: the gates are structural
|
|
20
|
+
* preconditions on OBJECTIVE artifacts ("does a validated harvest exist?",
|
|
21
|
+
* "is there ≥1 instance-scoped ledger entry?"). They are NOT quality
|
|
22
|
+
* judgments — whether the mentor truly internalized the lessons stays with
|
|
23
|
+
* the overseer (the mind), informed by the gate. Every verdict is appended
|
|
24
|
+
* to logs/apprenticeship-decisions.jsonl (the decision audit).
|
|
25
|
+
*
|
|
26
|
+
* Concurrency: file-backed store at `.instar/apprenticeship/instances.json`,
|
|
27
|
+
* atomic tmp-write+rename, with a single in-process serialized mutator and an
|
|
28
|
+
* optimistic `version` CAS (lost-update safe under the single-process-per-agent
|
|
29
|
+
* AgentServer model). A corrupt/unparseable store FAILS CLOSED — gates return
|
|
30
|
+
* `allow:false`, never "no prior instance → open the gate".
|
|
31
|
+
*/
|
|
32
|
+
import { type ValidateResult } from './retroHarvestValidator.js';
|
|
33
|
+
export type InstanceType = 'apprenticeship' | 'mentorship';
|
|
34
|
+
export type InstanceStatus = 'pending' | 'active' | 'complete' | 'blocked';
|
|
35
|
+
/** A tracked program-need with its target step + a resolvable honoredBy slot (§4). */
|
|
36
|
+
export interface ProgramNeed {
|
|
37
|
+
id: string;
|
|
38
|
+
targetStep: number;
|
|
39
|
+
/**
|
|
40
|
+
* NOT a self-certifying boolean — must reference a concrete, resolvable
|
|
41
|
+
* artifact (a merged spec slug `docs/specs/<slug>.md`, or `pr:<n>`). A
|
|
42
|
+
* later step's gate / review job validates the reference resolves before
|
|
43
|
+
* treating the need as honored. null = still open.
|
|
44
|
+
*/
|
|
45
|
+
honoredBy: string | null;
|
|
46
|
+
}
|
|
47
|
+
export interface ApprenticeshipInstance {
|
|
48
|
+
/** create-time clamp ^[a-z0-9-]+$ ; unique (dup-create rejected). */
|
|
49
|
+
id: string;
|
|
50
|
+
instanceType: InstanceType;
|
|
51
|
+
/** each ^[a-z0-9-]+$ */
|
|
52
|
+
overseer: string;
|
|
53
|
+
mentor: string;
|
|
54
|
+
mentee: string;
|
|
55
|
+
/** ^[a-z0-9-]+$ (flows into the ledger-count query). */
|
|
56
|
+
framework: string;
|
|
57
|
+
status: InstanceStatus;
|
|
58
|
+
/** for the retro-gate; must resolve to a `complete` instance (or null = bootstrap). */
|
|
59
|
+
priorInstanceId: string | null;
|
|
60
|
+
/** the CHECKLIST DEFINITION (what's required), not evidence. Immutable after create. */
|
|
61
|
+
requiredArtifacts: {
|
|
62
|
+
retroHarvest: boolean;
|
|
63
|
+
ledgerEntries: boolean;
|
|
64
|
+
detectorAudit: boolean;
|
|
65
|
+
};
|
|
66
|
+
/** tracked sequencing of the deferred needs (Close-the-Loop). */
|
|
67
|
+
programNeeds: ProgramNeed[];
|
|
68
|
+
/** canonical harvest identity, computed at create (§3.5). harvestFrom = mentor. */
|
|
69
|
+
harvestFrom: string;
|
|
70
|
+
/** harvestTo = mentee. */
|
|
71
|
+
harvestTo: string;
|
|
72
|
+
/** recorded for humans; NEVER the resolution source (§3.5). */
|
|
73
|
+
harvestRef: string | null;
|
|
74
|
+
/** optimistic-CAS counter for the serialized mutator (§3.1). */
|
|
75
|
+
version: number;
|
|
76
|
+
createdAt: string;
|
|
77
|
+
updatedAt: string;
|
|
78
|
+
}
|
|
79
|
+
export interface CreateInstanceInput {
|
|
80
|
+
id: string;
|
|
81
|
+
instanceType: InstanceType;
|
|
82
|
+
overseer?: string;
|
|
83
|
+
mentor: string;
|
|
84
|
+
mentee: string;
|
|
85
|
+
framework: string;
|
|
86
|
+
priorInstanceId?: string | null;
|
|
87
|
+
requiredArtifacts?: Partial<ApprenticeshipInstance['requiredArtifacts']>;
|
|
88
|
+
programNeeds?: ProgramNeed[];
|
|
89
|
+
}
|
|
90
|
+
/** Gate verdict shapes (§3.3). */
|
|
91
|
+
export interface StartGateVerdict {
|
|
92
|
+
allow: boolean;
|
|
93
|
+
reason: string;
|
|
94
|
+
}
|
|
95
|
+
export interface CompletionGateVerdict {
|
|
96
|
+
allow: boolean;
|
|
97
|
+
reason: string;
|
|
98
|
+
missing: string[];
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Injected dependencies for the gates (§3.3). Truth is re-derived from these
|
|
102
|
+
* LIVE deps, never from a stored requiredArtifacts boolean. Default impls read
|
|
103
|
+
* the real filesystem / validator; tests inject fakes and wiring-integrity
|
|
104
|
+
* tests assert the real ones are not no-ops.
|
|
105
|
+
*/
|
|
106
|
+
export interface GateDeps {
|
|
107
|
+
/** Read the canonical harvest text. Returns null when the file is absent. */
|
|
108
|
+
readHarvest: (relPath: string) => string | null;
|
|
109
|
+
/** Validate harvest text (the Step 0 structural validator). */
|
|
110
|
+
validate: (text: string, opts?: {
|
|
111
|
+
priorHarvestExists?: boolean;
|
|
112
|
+
}) => ValidateResult;
|
|
113
|
+
/** Count ledger entries scoped to THIS instance (framework + instance ref). ≥1 required. */
|
|
114
|
+
countInstanceLedgerEntries: (instance: ApprenticeshipInstance) => number;
|
|
115
|
+
/** Does the instance-scoped detector-audit artifact exist? */
|
|
116
|
+
detectorAuditExists: (instance: ApprenticeshipInstance) => boolean;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Step-4 differential-computation surface (§2). Declared here as a typed no-op
|
|
120
|
+
* interface so its LOCATION is resolved in Step 1; the real implementation
|
|
121
|
+
* lands in Step 4. The overseer (the mind) computes the differential between
|
|
122
|
+
* the mentee's raw streams and the mentor's reports.
|
|
123
|
+
*/
|
|
124
|
+
export interface ApprenticeshipOverseer {
|
|
125
|
+
/**
|
|
126
|
+
* Compute the differential read-channel for an instance (need-001). The
|
|
127
|
+
* Step-1 stub returns an explicit not-implemented marker — never silently
|
|
128
|
+
* "no differences". Implemented in Step 4.
|
|
129
|
+
*/
|
|
130
|
+
computeDifferential(instance: ApprenticeshipInstance): {
|
|
131
|
+
implemented: false;
|
|
132
|
+
reason: string;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/** The typed no-op overseer stub (§2). */
|
|
136
|
+
export declare const noopApprenticeshipOverseer: ApprenticeshipOverseer;
|
|
137
|
+
export interface ApprenticeshipProgramConfig {
|
|
138
|
+
/** Agent state dir (`.instar`). The store lives at `<stateDir>/apprenticeship/instances.json`. */
|
|
139
|
+
stateDir: string;
|
|
140
|
+
/** Project root — the canonical harvest path is resolved against this. */
|
|
141
|
+
projectDir: string;
|
|
142
|
+
/** Decision-audit log path. Default `<stateDir>/logs/apprenticeship-decisions.jsonl`. */
|
|
143
|
+
decisionLogPath?: string;
|
|
144
|
+
/** Override gate deps (tests). Defaults read the real fs + validator. */
|
|
145
|
+
deps?: Partial<GateDeps>;
|
|
146
|
+
}
|
|
147
|
+
export declare class ApprenticeshipProgram {
|
|
148
|
+
private storePath;
|
|
149
|
+
private decisionLogPath;
|
|
150
|
+
private projectDir;
|
|
151
|
+
private store;
|
|
152
|
+
/** True when the on-disk store was unparseable → gates fail closed. */
|
|
153
|
+
private corrupt;
|
|
154
|
+
private deps;
|
|
155
|
+
constructor(config: ApprenticeshipProgramConfig);
|
|
156
|
+
/** Default harvest reader — resolves the confined relative path under projectDir. */
|
|
157
|
+
private defaultReadHarvest;
|
|
158
|
+
private loadStore;
|
|
159
|
+
private saveStore;
|
|
160
|
+
/**
|
|
161
|
+
* The single in-process serialized mutator with optimistic `version` CAS.
|
|
162
|
+
* JS is single-threaded and `fn` is synchronous, so a straight read → apply
|
|
163
|
+
* → version++ → persist cannot interleave with another mutate() under the
|
|
164
|
+
* single-process model; the CAS guards against a same-tick stale read by
|
|
165
|
+
* re-reading the live record before commit. Returns the persisted snapshot.
|
|
166
|
+
*/
|
|
167
|
+
private mutate;
|
|
168
|
+
list(): ApprenticeshipInstance[];
|
|
169
|
+
get(id: string): ApprenticeshipInstance | null;
|
|
170
|
+
/** True when the on-disk store was present but unparseable (gates fail closed). */
|
|
171
|
+
isCorrupt(): boolean;
|
|
172
|
+
/**
|
|
173
|
+
* Create an instance. Charset-clamps id/overseer/mentor/mentee/framework to
|
|
174
|
+
* ^[a-z0-9-]+$, rejects a duplicate id, computes harvestFrom=mentor /
|
|
175
|
+
* harvestTo=mentee. requiredArtifacts defaults to all-required.
|
|
176
|
+
*/
|
|
177
|
+
createInstance(input: CreateInstanceInput): ApprenticeshipInstance;
|
|
178
|
+
/**
|
|
179
|
+
* The retro-gate (§3.3). priorInstanceId === null → the bootstrap seed (the
|
|
180
|
+
* Echo→Codey artifact at its canonical path) must exist + validate. Else the
|
|
181
|
+
* prior instance must be status:'complete' AND its canonical harvest must
|
|
182
|
+
* exist + validate. partial-accepted passes ONLY with acceptance metadata
|
|
183
|
+
* (acceptedBy + acceptedAt); complete always passes.
|
|
184
|
+
*
|
|
185
|
+
* The path is recomputed via safeArtifactPath(harvestFrom, harvestTo,
|
|
186
|
+
* instanceType) — never a stored harvestRef.
|
|
187
|
+
*/
|
|
188
|
+
evaluateStartGate(instance: ApprenticeshipInstance): StartGateVerdict;
|
|
189
|
+
/**
|
|
190
|
+
* The doc-as-required-artifact gate (§3.3). Each requiredArtifacts flag the
|
|
191
|
+
* instance DECLARES required is checked against LIVE state via injected deps —
|
|
192
|
+
* harvestExists+validates, an instance-scoped ledger count ≥1, detectorAudit
|
|
193
|
+
* present. A stored requiredArtifacts:true is NEVER treated as evidence of
|
|
194
|
+
* presence.
|
|
195
|
+
*/
|
|
196
|
+
evaluateCompletionGate(instance: ApprenticeshipInstance): CompletionGateVerdict;
|
|
197
|
+
/**
|
|
198
|
+
* The ONLY way status changes (§3.4). pending→active runs evaluateStartGate
|
|
199
|
+
* and refuses on !allow; active→complete runs evaluateCompletionGate;
|
|
200
|
+
* active→blocked / blocked→active (re-gate) allowed; complete is terminal.
|
|
201
|
+
* Any transition not in the table is rejected with a reason. Every gate
|
|
202
|
+
* verdict is appended to the decision audit.
|
|
203
|
+
*/
|
|
204
|
+
transition(id: string, to: InstanceStatus): {
|
|
205
|
+
ok: boolean;
|
|
206
|
+
reason: string;
|
|
207
|
+
instance?: ApprenticeshipInstance;
|
|
208
|
+
};
|
|
209
|
+
private recordDecision;
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=ApprenticeshipProgram.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ApprenticeshipProgram.d.ts","sourceRoot":"","sources":["../../src/core/ApprenticeshipProgram.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ApprenticeshipProgram — the standing program scaffold for apprenticeship /
|
|
3
|
+
* mentorship instances (Apprenticeship Step 1, APPRENTICESHIP-STEP1-PROGRAM-
|
|
4
|
+
* SCAFFOLD-SPEC.md).
|
|
5
|
+
*
|
|
6
|
+
* Each instance is a project under a standing program that crystallizes by
|
|
7
|
+
* bootstrap. This module is the instance-as-project REGISTRY plus the two
|
|
8
|
+
* lifecycle GATES that make "review before you start / capture before you
|
|
9
|
+
* close" unskippable at the state-mutating transition:
|
|
10
|
+
*
|
|
11
|
+
* - the RETRO-GATE (pending→active): refused unless the prior instance's
|
|
12
|
+
* retro-harvest exists at its CANONICAL CONFINED PATH and passes the
|
|
13
|
+
* Step 0 validator (the first instance is seeded by the Echo→Codey
|
|
14
|
+
* bootstrap harvest).
|
|
15
|
+
* - the DOC-AS-REQUIRED-ARTIFACT GATE (active→complete): refused until the
|
|
16
|
+
* instance's declared-required artifacts are VERIFIED PRESENT FROM LIVE
|
|
17
|
+
* STATE (never a stored flag).
|
|
18
|
+
*
|
|
19
|
+
* Signal vs Authority / The Body and the Mind: the gates are structural
|
|
20
|
+
* preconditions on OBJECTIVE artifacts ("does a validated harvest exist?",
|
|
21
|
+
* "is there ≥1 instance-scoped ledger entry?"). They are NOT quality
|
|
22
|
+
* judgments — whether the mentor truly internalized the lessons stays with
|
|
23
|
+
* the overseer (the mind), informed by the gate. Every verdict is appended
|
|
24
|
+
* to logs/apprenticeship-decisions.jsonl (the decision audit).
|
|
25
|
+
*
|
|
26
|
+
* Concurrency: file-backed store at `.instar/apprenticeship/instances.json`,
|
|
27
|
+
* atomic tmp-write+rename, with a single in-process serialized mutator and an
|
|
28
|
+
* optimistic `version` CAS (lost-update safe under the single-process-per-agent
|
|
29
|
+
* AgentServer model). A corrupt/unparseable store FAILS CLOSED — gates return
|
|
30
|
+
* `allow:false`, never "no prior instance → open the gate".
|
|
31
|
+
*/
|
|
32
|
+
import fs from 'node:fs';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import { validateRetroHarvest, parseArtifact, safeArtifactPath, } from './retroHarvestValidator.js';
|
|
35
|
+
/** The typed no-op overseer stub (§2). */
|
|
36
|
+
export const noopApprenticeshipOverseer = {
|
|
37
|
+
computeDifferential(_instance) {
|
|
38
|
+
return { implemented: false, reason: 'differential read-channel is implemented in Step 4 (need-001)' };
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const CHARSET_RE = /^[a-z0-9-]+$/;
|
|
42
|
+
/** Legal status transitions (§3.4). `complete` is terminal. */
|
|
43
|
+
const TRANSITIONS = {
|
|
44
|
+
pending: ['active'],
|
|
45
|
+
active: ['complete', 'blocked'],
|
|
46
|
+
blocked: ['active'],
|
|
47
|
+
complete: [], // terminal
|
|
48
|
+
};
|
|
49
|
+
// ── Implementation ───────────────────────────────────────────────────
|
|
50
|
+
export class ApprenticeshipProgram {
|
|
51
|
+
storePath;
|
|
52
|
+
decisionLogPath;
|
|
53
|
+
projectDir;
|
|
54
|
+
store;
|
|
55
|
+
/** True when the on-disk store was unparseable → gates fail closed. */
|
|
56
|
+
corrupt = false;
|
|
57
|
+
deps;
|
|
58
|
+
constructor(config) {
|
|
59
|
+
this.storePath = path.join(config.stateDir, 'apprenticeship', 'instances.json');
|
|
60
|
+
this.decisionLogPath =
|
|
61
|
+
config.decisionLogPath ?? path.join(config.stateDir, 'logs', 'apprenticeship-decisions.jsonl');
|
|
62
|
+
this.projectDir = config.projectDir;
|
|
63
|
+
this.deps = {
|
|
64
|
+
readHarvest: config.deps?.readHarvest ?? ((rel) => this.defaultReadHarvest(rel)),
|
|
65
|
+
validate: config.deps?.validate ?? validateRetroHarvest,
|
|
66
|
+
countInstanceLedgerEntries: config.deps?.countInstanceLedgerEntries ?? (() => 0),
|
|
67
|
+
detectorAuditExists: config.deps?.detectorAuditExists ?? (() => false),
|
|
68
|
+
};
|
|
69
|
+
this.store = this.loadStore();
|
|
70
|
+
}
|
|
71
|
+
/** Default harvest reader — resolves the confined relative path under projectDir. */
|
|
72
|
+
defaultReadHarvest(relPath) {
|
|
73
|
+
try {
|
|
74
|
+
const full = path.join(this.projectDir, relPath);
|
|
75
|
+
if (!fs.existsSync(full))
|
|
76
|
+
return null;
|
|
77
|
+
return fs.readFileSync(full, 'utf-8');
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// ── Persistence ────────────────────────────────────────────────────
|
|
84
|
+
loadStore() {
|
|
85
|
+
try {
|
|
86
|
+
if (fs.existsSync(this.storePath)) {
|
|
87
|
+
const raw = fs.readFileSync(this.storePath, 'utf-8');
|
|
88
|
+
const data = JSON.parse(raw);
|
|
89
|
+
if (data && data.version === 1 && Array.isArray(data.instances)) {
|
|
90
|
+
this.corrupt = false;
|
|
91
|
+
return data;
|
|
92
|
+
}
|
|
93
|
+
// Present but unparseable shape → fail closed (do NOT silently reset).
|
|
94
|
+
this.corrupt = true;
|
|
95
|
+
return { version: 1, instances: [], lastModified: new Date().toISOString() };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// A present-but-corrupt file fails closed; a truly-missing file is fresh.
|
|
100
|
+
if (fs.existsSync(this.storePath)) {
|
|
101
|
+
this.corrupt = true;
|
|
102
|
+
return { version: 1, instances: [], lastModified: new Date().toISOString() };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
this.corrupt = false;
|
|
106
|
+
return { version: 1, instances: [], lastModified: new Date().toISOString() };
|
|
107
|
+
}
|
|
108
|
+
saveStore() {
|
|
109
|
+
this.store.lastModified = new Date().toISOString();
|
|
110
|
+
const dir = path.dirname(this.storePath);
|
|
111
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
112
|
+
const tmpPath = `${this.storePath}.${process.pid}.tmp`;
|
|
113
|
+
fs.writeFileSync(tmpPath, JSON.stringify(this.store, null, 2) + '\n');
|
|
114
|
+
fs.renameSync(tmpPath, this.storePath);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The single in-process serialized mutator with optimistic `version` CAS.
|
|
118
|
+
* JS is single-threaded and `fn` is synchronous, so a straight read → apply
|
|
119
|
+
* → version++ → persist cannot interleave with another mutate() under the
|
|
120
|
+
* single-process model; the CAS guards against a same-tick stale read by
|
|
121
|
+
* re-reading the live record before commit. Returns the persisted snapshot.
|
|
122
|
+
*/
|
|
123
|
+
mutate(id, fn) {
|
|
124
|
+
const idx = this.store.instances.findIndex((i) => i.id === id);
|
|
125
|
+
if (idx === -1)
|
|
126
|
+
throw new Error(`ApprenticeshipProgram.mutate: unknown instance id ${id}`);
|
|
127
|
+
const current = this.store.instances[idx];
|
|
128
|
+
const observedVersion = current.version ?? 0;
|
|
129
|
+
const next = fn({ ...current });
|
|
130
|
+
// CAS re-read: under single-process this is belt-and-braces.
|
|
131
|
+
const latestIdx = this.store.instances.findIndex((i) => i.id === id);
|
|
132
|
+
if (latestIdx === -1)
|
|
133
|
+
throw new Error(`ApprenticeshipProgram.mutate: instance ${id} disappeared mid-apply`);
|
|
134
|
+
if ((this.store.instances[latestIdx].version ?? 0) !== observedVersion) {
|
|
135
|
+
throw new Error(`ApprenticeshipProgram.mutate: version drift on ${id} (CAS abort)`);
|
|
136
|
+
}
|
|
137
|
+
const committed = {
|
|
138
|
+
...next,
|
|
139
|
+
// requiredArtifacts is immutable after create — restore from the live record.
|
|
140
|
+
requiredArtifacts: current.requiredArtifacts,
|
|
141
|
+
version: observedVersion + 1,
|
|
142
|
+
updatedAt: new Date().toISOString(),
|
|
143
|
+
};
|
|
144
|
+
this.store.instances[latestIdx] = committed;
|
|
145
|
+
this.saveStore();
|
|
146
|
+
return committed;
|
|
147
|
+
}
|
|
148
|
+
// ── Read ───────────────────────────────────────────────────────────
|
|
149
|
+
list() {
|
|
150
|
+
return [...this.store.instances];
|
|
151
|
+
}
|
|
152
|
+
get(id) {
|
|
153
|
+
return this.store.instances.find((i) => i.id === id) ?? null;
|
|
154
|
+
}
|
|
155
|
+
/** True when the on-disk store was present but unparseable (gates fail closed). */
|
|
156
|
+
isCorrupt() {
|
|
157
|
+
return this.corrupt;
|
|
158
|
+
}
|
|
159
|
+
// ── Create ─────────────────────────────────────────────────────────
|
|
160
|
+
/**
|
|
161
|
+
* Create an instance. Charset-clamps id/overseer/mentor/mentee/framework to
|
|
162
|
+
* ^[a-z0-9-]+$, rejects a duplicate id, computes harvestFrom=mentor /
|
|
163
|
+
* harvestTo=mentee. requiredArtifacts defaults to all-required.
|
|
164
|
+
*/
|
|
165
|
+
createInstance(input) {
|
|
166
|
+
if (this.corrupt) {
|
|
167
|
+
throw new Error('ApprenticeshipProgram: store is corrupt — refusing to create (fail closed)');
|
|
168
|
+
}
|
|
169
|
+
const overseer = input.overseer ?? '';
|
|
170
|
+
const fields = {
|
|
171
|
+
id: input.id,
|
|
172
|
+
mentor: input.mentor,
|
|
173
|
+
mentee: input.mentee,
|
|
174
|
+
framework: input.framework,
|
|
175
|
+
};
|
|
176
|
+
// overseer is optional (apprenticeship has none); clamp only when present.
|
|
177
|
+
if (overseer)
|
|
178
|
+
fields.overseer = overseer;
|
|
179
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
180
|
+
if (typeof v !== 'string' || !CHARSET_RE.test(v)) {
|
|
181
|
+
throw new Error(`ApprenticeshipProgram.createInstance: ${k}="${v}" must match ^[a-z0-9-]+$`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (input.instanceType !== 'apprenticeship' && input.instanceType !== 'mentorship') {
|
|
185
|
+
throw new Error(`ApprenticeshipProgram.createInstance: invalid instanceType "${input.instanceType}"`);
|
|
186
|
+
}
|
|
187
|
+
if (this.store.instances.some((i) => i.id === input.id)) {
|
|
188
|
+
throw new Error(`ApprenticeshipProgram.createInstance: duplicate id "${input.id}"`);
|
|
189
|
+
}
|
|
190
|
+
const now = new Date().toISOString();
|
|
191
|
+
const harvestFrom = input.mentor; // a harvest is named for the mentoring edge (§3.1)
|
|
192
|
+
const harvestTo = input.mentee;
|
|
193
|
+
// Record the canonical path for humans only — NEVER the resolution source.
|
|
194
|
+
let harvestRef = null;
|
|
195
|
+
try {
|
|
196
|
+
harvestRef = safeArtifactPath(harvestFrom, harvestTo, input.instanceType);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
harvestRef = null;
|
|
200
|
+
}
|
|
201
|
+
const instance = {
|
|
202
|
+
id: input.id,
|
|
203
|
+
instanceType: input.instanceType,
|
|
204
|
+
overseer,
|
|
205
|
+
mentor: input.mentor,
|
|
206
|
+
mentee: input.mentee,
|
|
207
|
+
framework: input.framework,
|
|
208
|
+
status: 'pending',
|
|
209
|
+
priorInstanceId: input.priorInstanceId ?? null,
|
|
210
|
+
requiredArtifacts: {
|
|
211
|
+
retroHarvest: input.requiredArtifacts?.retroHarvest ?? true,
|
|
212
|
+
ledgerEntries: input.requiredArtifacts?.ledgerEntries ?? true,
|
|
213
|
+
detectorAudit: input.requiredArtifacts?.detectorAudit ?? true,
|
|
214
|
+
},
|
|
215
|
+
programNeeds: input.programNeeds ?? [],
|
|
216
|
+
harvestFrom,
|
|
217
|
+
harvestTo,
|
|
218
|
+
harvestRef,
|
|
219
|
+
version: 0,
|
|
220
|
+
createdAt: now,
|
|
221
|
+
updatedAt: now,
|
|
222
|
+
};
|
|
223
|
+
this.store.instances.push(instance);
|
|
224
|
+
this.saveStore();
|
|
225
|
+
return instance;
|
|
226
|
+
}
|
|
227
|
+
// ── Gates (pure; truth re-derived from injected deps) ───────────────
|
|
228
|
+
/**
|
|
229
|
+
* The retro-gate (§3.3). priorInstanceId === null → the bootstrap seed (the
|
|
230
|
+
* Echo→Codey artifact at its canonical path) must exist + validate. Else the
|
|
231
|
+
* prior instance must be status:'complete' AND its canonical harvest must
|
|
232
|
+
* exist + validate. partial-accepted passes ONLY with acceptance metadata
|
|
233
|
+
* (acceptedBy + acceptedAt); complete always passes.
|
|
234
|
+
*
|
|
235
|
+
* The path is recomputed via safeArtifactPath(harvestFrom, harvestTo,
|
|
236
|
+
* instanceType) — never a stored harvestRef.
|
|
237
|
+
*/
|
|
238
|
+
evaluateStartGate(instance) {
|
|
239
|
+
if (this.corrupt) {
|
|
240
|
+
return { allow: false, reason: 'instance store is corrupt — fail closed' };
|
|
241
|
+
}
|
|
242
|
+
let priorInstance = null;
|
|
243
|
+
let from;
|
|
244
|
+
let to;
|
|
245
|
+
let type;
|
|
246
|
+
let priorHarvestExists;
|
|
247
|
+
if (instance.priorInstanceId === null) {
|
|
248
|
+
// Bootstrap: validate THIS instance's own canonical harvest (the seed).
|
|
249
|
+
from = instance.harvestFrom;
|
|
250
|
+
to = instance.harvestTo;
|
|
251
|
+
type = instance.instanceType;
|
|
252
|
+
priorHarvestExists = false;
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
priorInstance = this.get(instance.priorInstanceId);
|
|
256
|
+
if (!priorInstance) {
|
|
257
|
+
return { allow: false, reason: `prior instance "${instance.priorInstanceId}" not found` };
|
|
258
|
+
}
|
|
259
|
+
if (priorInstance.status !== 'complete') {
|
|
260
|
+
return {
|
|
261
|
+
allow: false,
|
|
262
|
+
reason: `prior instance "${priorInstance.id}" is "${priorInstance.status}", must be "complete"`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
from = priorInstance.harvestFrom;
|
|
266
|
+
to = priorInstance.harvestTo;
|
|
267
|
+
type = priorInstance.instanceType;
|
|
268
|
+
priorHarvestExists = true;
|
|
269
|
+
}
|
|
270
|
+
// Recompute the confined path (NEVER the stored harvestRef).
|
|
271
|
+
let relPath;
|
|
272
|
+
try {
|
|
273
|
+
relPath = safeArtifactPath(from, to, type);
|
|
274
|
+
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
return { allow: false, reason: `cannot resolve canonical harvest path: ${e.message}` };
|
|
277
|
+
}
|
|
278
|
+
const text = this.deps.readHarvest(relPath);
|
|
279
|
+
if (text === null) {
|
|
280
|
+
return { allow: false, reason: `retro-harvest missing at canonical path ${relPath}` };
|
|
281
|
+
}
|
|
282
|
+
const result = this.deps.validate(text, { priorHarvestExists });
|
|
283
|
+
if (!result.valid) {
|
|
284
|
+
return { allow: false, reason: `retro-harvest at ${relPath} is invalid: ${result.errors.join('; ')}` };
|
|
285
|
+
}
|
|
286
|
+
// partial-accepted requires acceptance metadata (acceptedBy + acceptedAt).
|
|
287
|
+
let fm;
|
|
288
|
+
try {
|
|
289
|
+
fm = parseArtifact(text).frontmatter;
|
|
290
|
+
}
|
|
291
|
+
catch (e) {
|
|
292
|
+
return { allow: false, reason: `cannot parse harvest frontmatter: ${e.message}` };
|
|
293
|
+
}
|
|
294
|
+
const completeness = fm.completeness;
|
|
295
|
+
if (completeness === 'partial-accepted') {
|
|
296
|
+
const acceptedBy = fm.acceptedBy;
|
|
297
|
+
const acceptedAt = fm.acceptedAt;
|
|
298
|
+
if (!acceptedBy || !acceptedAt) {
|
|
299
|
+
return {
|
|
300
|
+
allow: false,
|
|
301
|
+
reason: `partial harvest at ${relPath} awaiting acceptance (acceptedBy + acceptedAt required)`,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
allow: true,
|
|
307
|
+
reason: instance.priorInstanceId === null
|
|
308
|
+
? `bootstrap seed ${relPath} present + valid (completeness=${String(completeness)})`
|
|
309
|
+
: `prior instance "${priorInstance.id}" complete; harvest ${relPath} present + valid`,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* The doc-as-required-artifact gate (§3.3). Each requiredArtifacts flag the
|
|
314
|
+
* instance DECLARES required is checked against LIVE state via injected deps —
|
|
315
|
+
* harvestExists+validates, an instance-scoped ledger count ≥1, detectorAudit
|
|
316
|
+
* present. A stored requiredArtifacts:true is NEVER treated as evidence of
|
|
317
|
+
* presence.
|
|
318
|
+
*/
|
|
319
|
+
evaluateCompletionGate(instance) {
|
|
320
|
+
if (this.corrupt) {
|
|
321
|
+
return { allow: false, reason: 'instance store is corrupt — fail closed', missing: ['store-corrupt'] };
|
|
322
|
+
}
|
|
323
|
+
const missing = [];
|
|
324
|
+
if (instance.requiredArtifacts.retroHarvest) {
|
|
325
|
+
// This instance's OWN harvest (mentor→mentee edge), at its canonical path.
|
|
326
|
+
let relPath = null;
|
|
327
|
+
try {
|
|
328
|
+
relPath = safeArtifactPath(instance.harvestFrom, instance.harvestTo, instance.instanceType);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
relPath = null;
|
|
332
|
+
}
|
|
333
|
+
const text = relPath ? this.deps.readHarvest(relPath) : null;
|
|
334
|
+
if (text === null) {
|
|
335
|
+
missing.push('retroHarvest:absent');
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
const result = this.deps.validate(text, { priorHarvestExists: instance.priorInstanceId !== null });
|
|
339
|
+
if (!result.valid)
|
|
340
|
+
missing.push('retroHarvest:invalid');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (instance.requiredArtifacts.ledgerEntries) {
|
|
344
|
+
const n = this.deps.countInstanceLedgerEntries(instance);
|
|
345
|
+
if (!(n >= 1))
|
|
346
|
+
missing.push('ledgerEntries:none');
|
|
347
|
+
}
|
|
348
|
+
if (instance.requiredArtifacts.detectorAudit) {
|
|
349
|
+
if (!this.deps.detectorAuditExists(instance))
|
|
350
|
+
missing.push('detectorAudit:absent');
|
|
351
|
+
}
|
|
352
|
+
if (missing.length > 0) {
|
|
353
|
+
return {
|
|
354
|
+
allow: false,
|
|
355
|
+
reason: `completion blocked — missing live artifacts: ${missing.join(', ')}`,
|
|
356
|
+
missing,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
return { allow: true, reason: 'all declared-required artifacts verified present from live state', missing: [] };
|
|
360
|
+
}
|
|
361
|
+
// ── Status transitions (the gates are not advisory) ─────────────────
|
|
362
|
+
/**
|
|
363
|
+
* The ONLY way status changes (§3.4). pending→active runs evaluateStartGate
|
|
364
|
+
* and refuses on !allow; active→complete runs evaluateCompletionGate;
|
|
365
|
+
* active→blocked / blocked→active (re-gate) allowed; complete is terminal.
|
|
366
|
+
* Any transition not in the table is rejected with a reason. Every gate
|
|
367
|
+
* verdict is appended to the decision audit.
|
|
368
|
+
*/
|
|
369
|
+
transition(id, to) {
|
|
370
|
+
if (this.corrupt) {
|
|
371
|
+
return { ok: false, reason: 'instance store is corrupt — fail closed' };
|
|
372
|
+
}
|
|
373
|
+
const instance = this.get(id);
|
|
374
|
+
if (!instance)
|
|
375
|
+
return { ok: false, reason: `instance "${id}" not found` };
|
|
376
|
+
const allowed = TRANSITIONS[instance.status] ?? [];
|
|
377
|
+
if (!allowed.includes(to)) {
|
|
378
|
+
return {
|
|
379
|
+
ok: false,
|
|
380
|
+
reason: `illegal transition ${instance.status}→${to} (allowed: ${allowed.join(', ') || 'none — terminal'})`,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
// Gate the guarded transitions.
|
|
384
|
+
if (instance.status === 'pending' && to === 'active') {
|
|
385
|
+
const verdict = this.evaluateStartGate(instance);
|
|
386
|
+
this.recordDecision({ gate: 'start', instanceId: id, allow: verdict.allow, reason: verdict.reason });
|
|
387
|
+
if (!verdict.allow)
|
|
388
|
+
return { ok: false, reason: `start gate refused: ${verdict.reason}` };
|
|
389
|
+
}
|
|
390
|
+
if (instance.status === 'blocked' && to === 'active') {
|
|
391
|
+
// Re-gate on resume from blocked.
|
|
392
|
+
const verdict = this.evaluateStartGate(instance);
|
|
393
|
+
this.recordDecision({ gate: 'start', instanceId: id, allow: verdict.allow, reason: verdict.reason });
|
|
394
|
+
if (!verdict.allow)
|
|
395
|
+
return { ok: false, reason: `start gate refused (re-gate): ${verdict.reason}` };
|
|
396
|
+
}
|
|
397
|
+
if (instance.status === 'active' && to === 'complete') {
|
|
398
|
+
const verdict = this.evaluateCompletionGate(instance);
|
|
399
|
+
this.recordDecision({
|
|
400
|
+
gate: 'completion',
|
|
401
|
+
instanceId: id,
|
|
402
|
+
allow: verdict.allow,
|
|
403
|
+
reason: verdict.reason,
|
|
404
|
+
missing: verdict.missing,
|
|
405
|
+
});
|
|
406
|
+
if (!verdict.allow)
|
|
407
|
+
return { ok: false, reason: `completion gate refused: ${verdict.reason}` };
|
|
408
|
+
}
|
|
409
|
+
const updated = this.mutate(id, (i) => ({ ...i, status: to }));
|
|
410
|
+
return { ok: true, reason: `transitioned ${instance.status}→${to}`, instance: updated };
|
|
411
|
+
}
|
|
412
|
+
// ── Decision audit (§3.6) ───────────────────────────────────────────
|
|
413
|
+
recordDecision(entry) {
|
|
414
|
+
try {
|
|
415
|
+
const dir = path.dirname(this.decisionLogPath);
|
|
416
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
417
|
+
const line = JSON.stringify({
|
|
418
|
+
ts: new Date().toISOString(),
|
|
419
|
+
gate: entry.gate,
|
|
420
|
+
instanceId: entry.instanceId,
|
|
421
|
+
allow: entry.allow,
|
|
422
|
+
reason: entry.reason,
|
|
423
|
+
...(entry.missing && entry.missing.length ? { missing: entry.missing } : {}),
|
|
424
|
+
}) + '\n';
|
|
425
|
+
fs.appendFileSync(this.decisionLogPath, line);
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
// @silent-fallback-ok — the audit is observability; a write failure must
|
|
429
|
+
// not block the (already-decided) transition.
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
//# sourceMappingURL=ApprenticeshipProgram.js.map
|