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.
@@ -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