@really-knows-ai/foundry 2.1.0 → 2.3.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,77 @@
1
+ # Changelog
2
+
3
+ ## 2.3.0 — 2026-04-20
4
+
5
+ ### Breaking
6
+
7
+ - **LLM orchestration replaced with deterministic `foundry_orchestrate` tool.** The `cycle` and `sort` skills are removed; replaced by a single thin `orchestrate` skill that drives a 3-line loop.
8
+ - **Six tools deregistered** from the plugin (still exist as internal imports for tests): `foundry_sort`, `foundry_history_append`, `foundry_stage_finalize`, `foundry_git_commit`, `foundry_workfile_configure_from_cycle`, `foundry_workfile_set`.
9
+ - Upgrade requires clean main + no in-flight workfile (see `upgrade-foundry` skill).
10
+
11
+ ### Added
12
+
13
+ - `foundry_orchestrate` — single tool that owns the sort → history → dispatch → finalize → history → commit loop. Atomic stage completion.
14
+ - `scripts/orchestrate.js` — deterministic orchestration logic, composes existing internal functions.
15
+ - Orphaned-stage detection: if orchestrate is called without `lastResult` but an active stage exists, returns `violation`. Fixes the ses_256c failure mode where an LLM skipped the post-dispatch history append and wedged the cycle.
16
+
17
+ ### Fixed
18
+
19
+ - Root cause of all deferred HARDEN.md bugs (B, C, D, E, G) and the ses_256c bug: LLM misfollowing a deterministic protocol. Protocol now lives inside the plugin tool.
20
+
21
+ ### Migration
22
+
23
+ See `skills/upgrade-foundry/SKILL.md` for v2.3.0 pre-flight checks. No automated state migration — complete or discard in-flight cycles on v2.2.x before upgrading.
24
+
25
+ ## 2.2.1 — 2026-04-20
26
+
27
+ Follow-up patch addressing the five bugs deferred from v2.2.0 (see `HARDEN.md` §Deferred).
28
+
29
+ ### Breaking changes
30
+
31
+ - **Cycle-definition deadlock config flattened.** The nested `human-appraise: {enabled, deadlock-threshold}` block is replaced by three flat keys:
32
+ - `human-appraise: <bool>` (default `false`) — include `human-appraise` in the stage loop every iteration
33
+ - `deadlock-appraise: <bool>` (default `true`) — route to `human-appraise` when LLM appraisers deadlock
34
+ - `deadlock-iterations: <number>` (default `5`) — deadlock threshold
35
+ Run the `upgrade-foundry` skill to migrate existing cycle defs — the old nested form is no longer read.
36
+
37
+ ### New
38
+
39
+ - **`foundry_workfile_configure_from_cycle({cycleId, stages})`** — populates WORK.md frontmatter from a cycle definition in one call. Replaces the prior 6–7 sequential `foundry_workfile_set` calls at cycle start. Defaults for `max-iterations`, `human-appraise`, `deadlock-appraise`, `deadlock-iterations`, and `models` now live in plugin code rather than skill prose.
40
+ - **`foundry_artefacts_list({cycle})`** — optional cycle filter. Callers should always pass the current cycle to avoid picking up stale rows from prior aborted sessions.
41
+
42
+ ### Fixed
43
+
44
+ - **Bug B — deadlock routing.** Sort now reads the flat deadlock keys from WORK.md frontmatter and routes to `human-appraise` on deadlock (either an existing `human-appraise:<cycle>` stage in `stages`, or a synthesized one). When `deadlock-appraise: false`, deadlock marks the cycle `blocked`.
45
+ - **Bug C — stale artefact validation.** `quench`, `appraise`, and `human-appraise` skills now pass the current cycle to `foundry_artefacts_list`, scoping validation to artefacts produced by the current cycle instead of every row that has ever landed in WORK.md.
46
+ - **Bug D — overwriting WORK.md.** The `flow` skill now calls `foundry_workfile_get` before `foundry_workfile_create` and prompts the user to resume, discard, or abort when an existing workfile is detected. Silent overwrite is not offered; resume requires matching `flow` and `cycle`.
47
+ - **Bug E — missing micro-commits.** `foundry_sort` now returns `{route: 'violation'}` when `WORK.md`, `WORK.history.yaml`, or anything under `.foundry/` has uncommitted changes at the start of a sort call and history is non-empty. Structurally enforces the one-commit-per-stage contract that previously lived only in skill prose. First sort of a cycle is exempt (empty history).
48
+ - **Bug G — workfile setup boilerplate.** See `foundry_workfile_configure_from_cycle` above.
49
+
50
+ ### Migration
51
+
52
+ Run the `upgrade-foundry` skill to migrate cycle definitions to the flat deadlock keys (Bug B). No other migration required — WORK.md, `.foundry/`, and feedback state are forward-compatible.
53
+
54
+ ## 2.2.0 — 2026-04-19
55
+
56
+ ### Breaking changes
57
+
58
+ - **`foundry_artefacts_add` removed.** Artefact registration now happens exclusively via `foundry_stage_finalize` after a forge stage closes.
59
+ - **`foundry_artefacts_set_status` no longer accepts `draft`.** Only `done` and `blocked` are valid. New artefacts are registered as `draft` automatically by `stage_finalize`.
60
+ - **Feedback / artefact / workfile mutation tools now enforce stage-lock preconditions.** Tools callable by subagents require an active stage matching their role; tools callable by the orchestrator require no active stage. Out-of-band calls return a structured error instead of mutating state.
61
+ - **Feedback state machine strictly enforced.** `approved` is terminal. `quench` cannot approve/reject `wont-fix` items. See `HARDEN.md` §4 for the full matrix.
62
+ - **`foundry_sort` dispatchable routes now return a `token` field.** Subagents must redeem the token via `foundry_stage_begin`; forged or replayed tokens are rejected.
63
+
64
+ ### New
65
+
66
+ - **`foundry_stage_begin(stage, cycle, token)`** — subagents open a work stage by consuming a single-use HMAC-signed token.
67
+ - **`foundry_stage_end(summary)`** — subagents close a stage; preserves `baseSha` for finalize.
68
+ - **`foundry_stage_finalize(cycle)`** — orchestrator verifies stage output against allowed file patterns, registers matching files as draft artefacts, rejects stray writes with `{error: "unexpected_files", files: [...]}`.
69
+ - **`.foundry/` state directory** (gitignored) — holds `.secret` (per-worktree HMAC key, mode 0600), `active-stage.json` (present only during an active stage), `last-stage.json` (for finalize lookup).
70
+
71
+ ### Fixed
72
+
73
+ - Normalized `maxIterations` → `max-iterations` across workfile read/write paths (previously inconsistent between flow and cycle skills, causing latent deadlock-detection issues).
74
+
75
+ ### Migration
76
+
77
+ Upgrade with the `upgrade-foundry` skill. `.foundry/` is created automatically on first plugin boot; `.secret` is generated idempotently. No data migration required — existing `WORK.md` and `foundry/*` configs are compatible.
package/docs/work-spec.md CHANGED
@@ -25,8 +25,8 @@ The `stages` list is the happy path. Sort follows it but loops back to `forge` w
25
25
 
26
26
  - `flow` — set by the foundry flow skill at foundry flow start, never changes
27
27
  - `cycle` — set by the foundry flow skill when starting each foundry cycle
28
- - `stages` — set by the foundry cycle skill when starting each foundry cycle (reads artefact type to determine if quench is needed)
29
- - `max-iterations` — set by the foundry cycle skill (default 3, could be overridden in foundry cycle definition)
28
+ - `stages` — set by the orchestrate skill when starting each foundry cycle (reads artefact type to determine if quench is needed)
29
+ - `max-iterations` — set by the orchestrate skill (default 3, could be overridden in foundry cycle definition)
30
30
 
31
31
  ## Sections
32
32
 
@@ -96,9 +96,9 @@ Grouped by artefact file path. Each item is a checklist entry with a tag indicat
96
96
  | Section | Written by | Updated by |
97
97
  |---------|-----------|------------|
98
98
  | Frontmatter (`flow`) | `foundry_workfile_create` (flow skill) | nobody |
99
- | Frontmatter (`cycle`, `stages`, `max-iterations`) | `foundry_workfile_set` (cycle skill) | `foundry_workfile_set` (reset on each new cycle) |
99
+ | Frontmatter (`cycle`, `stages`, `max-iterations`) | `foundry_workfile_set` (orchestrate skill) | `foundry_workfile_set` (reset on each new cycle) |
100
100
  | Goal | `foundry_workfile_create` (flow skill) | nobody |
101
- | Artefacts | `foundry_artefacts_add` (forge skill) | `foundry_artefacts_set_status` (cycle skill) |
101
+ | Artefacts | `foundry_artefacts_add` (forge skill) | `foundry_artefacts_set_status` (orchestrate skill) |
102
102
  | Feedback | `foundry_feedback_add` (quench/appraise/hitl) | `foundry_feedback_action`/`foundry_feedback_wontfix` (forge), `foundry_feedback_resolve` (quench/appraise/hitl) |
103
103
 
104
104
  ## WORK.history.yaml
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@really-knows-ai/foundry",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "description": "A structured framework for AI-driven artefact creation with deterministic routing, quality gates, and iterative refinement cycles.",
5
5
  "type": "module",
6
6
  "main": ".opencode/plugins/foundry.js",
@@ -40,6 +40,7 @@
40
40
  "docs/concepts.md",
41
41
  "docs/getting-started.md",
42
42
  "README.md",
43
- "LICENSE"
43
+ "LICENSE",
44
+ "CHANGELOG.md"
44
45
  ]
45
46
  }
@@ -86,6 +86,12 @@ export function addArtefactRow(text, { file, type, cycle, status }) {
86
86
  * @returns {string} Updated text
87
87
  */
88
88
  export function setArtefactStatus(text, file, newStatus) {
89
+ if (newStatus === 'draft') {
90
+ throw new Error('status draft not permitted; use stage_finalize for registration');
91
+ }
92
+ if (!['done', 'blocked'].includes(newStatus)) {
93
+ throw new Error(`invalid status: ${newStatus}`);
94
+ }
89
95
  const lines = text.split('\n');
90
96
  let inTable = false;
91
97
  let found = false;
@@ -0,0 +1,25 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ // Matrix: [current][target] => set of allowed stageBases
4
+ const MATRIX = {
5
+ open: { actioned: ['forge'], 'wont-fix': ['forge'] },
6
+ actioned: { approved: ['quench', 'appraise', 'human-appraise'], rejected: ['quench', 'appraise', 'human-appraise'] },
7
+ 'wont-fix': { approved: ['appraise', 'human-appraise'], rejected: ['appraise', 'human-appraise'] },
8
+ rejected: { actioned: ['forge'], 'wont-fix': ['forge'] },
9
+ approved: {}, // terminal
10
+ };
11
+
12
+ export function validateTransition(current, target, stageBase) {
13
+ const row = MATRIX[current];
14
+ if (!row) return { ok: false, reason: `unknown state: ${current}` };
15
+ const allowedStages = row[target];
16
+ if (!allowedStages) return { ok: false, reason: `invalid transition ${current} → ${target}` };
17
+ if (!allowedStages.includes(stageBase)) {
18
+ return { ok: false, reason: `stage ${stageBase} cannot transition ${current} → ${target}` };
19
+ }
20
+ return { ok: true };
21
+ }
22
+
23
+ export function hashText(text) {
24
+ return createHash('sha256').update(text).digest('hex').slice(0, 16);
25
+ }
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { extractAllTags } from './tags.js';
6
+ import { validateTransition, hashText } from './feedback-transitions.js';
6
7
 
7
8
  // ---------------------------------------------------------------------------
8
9
  // Parsing
@@ -85,6 +86,16 @@ export function parseFeedback(text, cycle, artefacts) {
85
86
  // ---------------------------------------------------------------------------
86
87
 
87
88
  export function addFeedbackItem(text, file, itemText, tag) {
89
+ // Dedup by (file, tag, text hash): if any existing item under this file
90
+ // heading has the same tag and the same itemText, return without mutating.
91
+ const existing = collectItemsForFile(text, file);
92
+ const h = hashText(itemText);
93
+ for (const ex of existing) {
94
+ if (ex.tags.includes(`#${tag}`) && hashText(ex.coreText) === h) {
95
+ return { text, deduped: true };
96
+ }
97
+ }
98
+
88
99
  const newItem = `- [ ] ${itemText} #${tag}`;
89
100
  const lines = text.split('\n');
90
101
 
@@ -111,7 +122,7 @@ export function addFeedbackItem(text, file, itemText, tag) {
111
122
  if (feedbackIdx === -1) {
112
123
  // No Feedback section — append one
113
124
  lines.push('', '## Feedback', '', `### ${file}`, newItem);
114
- return lines.join('\n');
125
+ return { text: lines.join('\n'), deduped: false };
115
126
  }
116
127
 
117
128
  // Find the file heading within the feedback section
@@ -135,7 +146,7 @@ export function addFeedbackItem(text, file, itemText, tag) {
135
146
  if (fileIdx === -1) {
136
147
  // File heading doesn't exist — add it before section end
137
148
  lines.splice(sectionEnd, 0, '', fileHeading, newItem);
138
- return lines.join('\n');
149
+ return { text: lines.join('\n'), deduped: false };
139
150
  }
140
151
 
141
152
  // Find last item under this file heading
@@ -149,23 +160,23 @@ export function addFeedbackItem(text, file, itemText, tag) {
149
160
  }
150
161
 
151
162
  lines.splice(insertIdx, 0, newItem);
152
- return lines.join('\n');
163
+ return { text: lines.join('\n'), deduped: false };
153
164
  }
154
165
 
155
- export function actionFeedbackItem(text, file, index) {
156
- return transformFeedbackItem(text, file, index, (line) =>
166
+ export function actionFeedbackItem(text, file, index, stageBase) {
167
+ return transformFeedbackItemWithValidation(text, file, index, 'actioned', stageBase, (line) =>
157
168
  line.replace('- [ ]', '- [x]')
158
169
  );
159
170
  }
160
171
 
161
- export function wontfixFeedbackItem(text, file, index, reason) {
162
- return transformFeedbackItem(text, file, index, (line) =>
172
+ export function wontfixFeedbackItem(text, file, index, reason, stageBase) {
173
+ return transformFeedbackItemWithValidation(text, file, index, 'wont-fix', stageBase, (line) =>
163
174
  line.replace('- [ ]', '- [~]') + ` | wont-fix: ${reason}`
164
175
  );
165
176
  }
166
177
 
167
- export function resolveFeedbackItem(text, file, index, resolution, reason) {
168
- return transformFeedbackItem(text, file, index, (line) => {
178
+ export function resolveFeedbackItem(text, file, index, resolution, reason, stageBase) {
179
+ return transformFeedbackItemWithValidation(text, file, index, resolution, stageBase, (line) => {
169
180
  if (resolution === 'approved') {
170
181
  return line + ' | approved';
171
182
  }
@@ -257,6 +268,132 @@ export function detectDeadlocks(feedback, history, threshold = 3) {
257
268
  // Internal helpers
258
269
  // ---------------------------------------------------------------------------
259
270
 
271
+ /**
272
+ * Collect feedback items under a specific file heading, returning the parsed
273
+ * representation plus the "core text" (item body with tag and trailing resolution
274
+ * stripped) for dedup hashing.
275
+ */
276
+ function collectItemsForFile(text, file) {
277
+ const items = [];
278
+ const lines = text.split('\n');
279
+ let inFeedback = false;
280
+ let feedbackLevel = 0;
281
+ let currentFile = null;
282
+
283
+ for (const line of lines) {
284
+ const stripped = line.trim();
285
+
286
+ if (stripped === '# Feedback' || stripped === '## Feedback') {
287
+ inFeedback = true;
288
+ feedbackLevel = stripped.startsWith('## ') ? 2 : 1;
289
+ continue;
290
+ }
291
+
292
+ if (inFeedback && /^#{1,2} /.test(stripped)) {
293
+ const level = stripped.startsWith('## ') ? 2 : 1;
294
+ if (level <= feedbackLevel && stripped !== '# Feedback' && stripped !== '## Feedback') {
295
+ inFeedback = false;
296
+ continue;
297
+ }
298
+ }
299
+
300
+ if (!inFeedback) continue;
301
+
302
+ const fileHeadingPrefix = feedbackLevel === 1 ? '## ' : '### ';
303
+ if (stripped.startsWith(fileHeadingPrefix)) {
304
+ currentFile = stripped.slice(fileHeadingPrefix.length).trim();
305
+ continue;
306
+ }
307
+
308
+ if (currentFile === file && /^- \[/.test(stripped)) {
309
+ const parsed = parseFeedbackItem(stripped);
310
+ // Strip checkbox, tags, and trailing `| approved` / `| rejected: ...` /
311
+ // `| wont-fix: ...` to get the core author-supplied text for dedup.
312
+ let core = stripped.replace(/^- \[[ x~]\]\s*/, '');
313
+ core = core.replace(/\s*\|\s*(approved|rejected[^|]*|wont-fix[^|]*)\s*$/, '');
314
+ for (const t of parsed.tags) {
315
+ core = core.replace(t, '');
316
+ }
317
+ core = core.trim();
318
+ items.push({ line: stripped, state: parsed.state, tags: parsed.tags, coreText: core });
319
+ }
320
+ }
321
+
322
+ return items;
323
+ }
324
+
325
+ /**
326
+ * Read the line at (file, index) and return its current feedback state
327
+ * (or null if not found).
328
+ */
329
+ function readItemState(text, file, index) {
330
+ const lines = text.split('\n');
331
+ let inFeedback = false;
332
+ let feedbackLevel = 0;
333
+ let currentFile = null;
334
+ let fileIndex = 0;
335
+
336
+ for (let i = 0; i < lines.length; i++) {
337
+ const stripped = lines[i].trim();
338
+
339
+ if (stripped === '# Feedback' || stripped === '## Feedback') {
340
+ inFeedback = true;
341
+ feedbackLevel = stripped.startsWith('## ') ? 2 : 1;
342
+ continue;
343
+ }
344
+
345
+ if (inFeedback && /^#{1,2} /.test(stripped)) {
346
+ const level = stripped.startsWith('## ') ? 2 : 1;
347
+ if (level <= feedbackLevel && stripped !== '# Feedback' && stripped !== '## Feedback') {
348
+ inFeedback = false;
349
+ continue;
350
+ }
351
+ }
352
+
353
+ if (!inFeedback) continue;
354
+
355
+ const fileHeadingPrefix = feedbackLevel === 1 ? '## ' : '### ';
356
+ if (stripped.startsWith(fileHeadingPrefix)) {
357
+ currentFile = stripped.slice(fileHeadingPrefix.length).trim();
358
+ fileIndex = 0;
359
+ continue;
360
+ }
361
+
362
+ if (currentFile === file && /^- \[/.test(stripped)) {
363
+ if (fileIndex === index) {
364
+ const parsed = parseFeedbackItem(stripped);
365
+ // Map parseFeedbackItem's (state, resolved) pair onto state-machine states:
366
+ // - `| approved` → terminal "approved"
367
+ // - `| rejected` → "rejected" (parseFeedbackItem already sets this)
368
+ // - bare `[x]` → "actioned"
369
+ // - bare `[~]` → "wont-fix"
370
+ // - bare `[ ]` → "open"
371
+ if (parsed.resolved) return 'approved';
372
+ return parsed.state;
373
+ }
374
+ fileIndex++;
375
+ }
376
+ }
377
+ return null;
378
+ }
379
+
380
+ function transformFeedbackItemWithValidation(text, file, index, target, stageBase, transform) {
381
+ if (stageBase !== undefined) {
382
+ const current = readItemState(text, file, index);
383
+ if (!current) {
384
+ return { ok: false, error: `feedback item not found: file=${file} index=${index}` };
385
+ }
386
+ const v = validateTransition(current, target, stageBase);
387
+ if (!v.ok) {
388
+ return { ok: false, error: v.reason };
389
+ }
390
+ const updated = transformFeedbackItem(text, file, index, transform);
391
+ return { ok: true, text: updated };
392
+ }
393
+ // Backward-compatible path: return plain string.
394
+ return transformFeedbackItem(text, file, index, transform);
395
+ }
396
+
260
397
  function transformFeedbackItem(text, file, index, transform) {
261
398
  const lines = text.split('\n');
262
399
  let inFeedback = false;
@@ -0,0 +1,41 @@
1
+ // scripts/lib/finalize.js
2
+ import { execSync } from 'node:child_process';
3
+ import { minimatch } from 'minimatch';
4
+
5
+ const TOOL_MANAGED = [
6
+ 'WORK.md',
7
+ 'WORK.history.yaml',
8
+ ];
9
+ const TOOL_MANAGED_PREFIX = ['.foundry/'];
10
+
11
+ function changedFiles(cwd, baseSha) {
12
+ const tracked = execSync(`git diff --name-only ${baseSha} HEAD`, { cwd }).toString().split('\n').filter(Boolean);
13
+ const diffUnstaged = execSync('git diff --name-only', { cwd }).toString().split('\n').filter(Boolean);
14
+ const untracked = execSync('git ls-files --others --exclude-standard', { cwd }).toString().split('\n').filter(Boolean);
15
+ return [...new Set([...tracked, ...diffUnstaged, ...untracked])];
16
+ }
17
+
18
+ function isToolManaged(f) {
19
+ if (TOOL_MANAGED.includes(f)) return true;
20
+ return TOOL_MANAGED_PREFIX.some(p => f.startsWith(p));
21
+ }
22
+
23
+ export function finalizeStage({ cwd, baseSha, stageBase, cycleDef, artefactTypes, registerArtefact }) {
24
+ const files = changedFiles(cwd, baseSha).filter(f => !isToolManaged(f));
25
+ const allowedPatterns = stageBase === 'forge'
26
+ ? (artefactTypes[cycleDef.outputArtefactType]?.filePatterns ?? [])
27
+ : [];
28
+ const unexpected = [];
29
+ const matched = [];
30
+ for (const f of files) {
31
+ const hit = allowedPatterns.find(p => minimatch(f, p));
32
+ if (hit) matched.push(f);
33
+ else unexpected.push(f);
34
+ }
35
+ if (unexpected.length) return { ok: false, error: 'unexpected_files', files: unexpected };
36
+ const artefacts = matched.map(file => {
37
+ registerArtefact({ file, type: cycleDef.outputArtefactType, status: 'draft' });
38
+ return { file, type: cycleDef.outputArtefactType, status: 'draft' };
39
+ });
40
+ return { ok: true, artefacts };
41
+ }
@@ -18,7 +18,7 @@ export function loadHistory(historyPath, cycle, io) {
18
18
  /**
19
19
  * Append a history entry with auto-generated ISO timestamp.
20
20
  */
21
- export function appendEntry(historyPath, { cycle, stage, iteration, comment }, io) {
21
+ export function appendEntry(historyPath, { cycle, stage, iteration, comment, route }, io) {
22
22
  if (iteration == null) throw new Error('iteration is required');
23
23
  if (!comment) throw new Error('comment is required');
24
24
 
@@ -27,13 +27,15 @@ export function appendEntry(historyPath, { cycle, stage, iteration, comment }, i
27
27
  existing = yaml.load(io.readFile(historyPath)) || [];
28
28
  }
29
29
 
30
- existing.push({
30
+ const entry = {
31
31
  cycle,
32
32
  stage,
33
33
  iteration,
34
34
  comment,
35
35
  timestamp: new Date().toISOString(),
36
- });
36
+ };
37
+ if (route !== undefined) entry.route = route;
38
+ existing.push(entry);
37
39
 
38
40
  io.writeFile(historyPath, yaml.dump(existing));
39
41
  }
@@ -45,3 +47,13 @@ export function getIteration(historyPath, cycle, io) {
45
47
  const history = loadHistory(historyPath, cycle, io);
46
48
  return history.filter(e => (e.stage || '').split(':')[0] === 'forge').length;
47
49
  }
50
+
51
+ /**
52
+ * Return the `route` field from the most recent `sort` history entry for a
53
+ * given cycle, or null if none exists.
54
+ */
55
+ export function readLastSortRoute(historyPath, cycle, io) {
56
+ const entries = loadHistory(historyPath, cycle, io).filter(e => e.stage === 'sort');
57
+ if (!entries.length) return null;
58
+ return entries[entries.length - 1].route ?? null;
59
+ }
@@ -0,0 +1,18 @@
1
+ export function createPendingStore() {
2
+ const map = new Map();
3
+ return {
4
+ add(nonce, meta) { map.set(nonce, meta); },
5
+ consume(nonce) {
6
+ const meta = map.get(nonce);
7
+ if (!meta) return null;
8
+ map.delete(nonce);
9
+ if (meta.exp < Date.now()) return null;
10
+ return meta;
11
+ },
12
+ size() {
13
+ const now = Date.now();
14
+ for (const [k, v] of map) if (v.exp < now) map.delete(k);
15
+ return map.size;
16
+ },
17
+ };
18
+ }
@@ -0,0 +1,23 @@
1
+ import { existsSync, readFileSync, mkdirSync, openSync, writeSync, closeSync } from 'node:fs';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+
5
+ export function readOrCreateSecret(directory) {
6
+ const dir = join(directory, '.foundry');
7
+ const file = join(dir, '.secret');
8
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
9
+ const bytes = randomBytes(32);
10
+ let fd;
11
+ try {
12
+ fd = openSync(file, 'wx', 0o600);
13
+ } catch (err) {
14
+ if (err.code === 'EEXIST') return readFileSync(file);
15
+ throw err;
16
+ }
17
+ try {
18
+ writeSync(fd, bytes);
19
+ } finally {
20
+ closeSync(fd);
21
+ }
22
+ return bytes;
23
+ }
@@ -0,0 +1,25 @@
1
+ // scripts/lib/stage-guard.js
2
+ import { readActiveStage } from './state.js';
3
+
4
+ export function stageBaseOf(stage) {
5
+ const i = stage.indexOf(':');
6
+ return i === -1 ? stage : stage.slice(0, i);
7
+ }
8
+
9
+ export function requireNoActiveStage(io) {
10
+ const a = readActiveStage(io);
11
+ if (!a) return { ok: true };
12
+ return { ok: false, error: `tool requires no active stage; current: ${a.stage}` };
13
+ }
14
+
15
+ export function requireActiveStage(io, { stageBase, cycle } = {}) {
16
+ const a = readActiveStage(io);
17
+ if (!a) return { ok: false, error: `tool requires active stage; current: none` };
18
+ if (stageBase && stageBaseOf(a.stage) !== stageBase) {
19
+ return { ok: false, error: `tool requires active ${stageBase} stage; current: ${a.stage}` };
20
+ }
21
+ if (cycle && a.cycle !== cycle) {
22
+ return { ok: false, error: `tool requires active stage in cycle ${cycle}; current cycle: ${a.cycle}` };
23
+ }
24
+ return { ok: true, active: a };
25
+ }
@@ -0,0 +1,31 @@
1
+ const ACTIVE = '.foundry/active-stage.json';
2
+ const LAST = '.foundry/last-stage.json';
3
+ const DIR = '.foundry';
4
+
5
+ export function ensureFoundryDir(io) {
6
+ if (!io.exists(DIR)) io.mkdir(DIR);
7
+ }
8
+
9
+ export function readActiveStage(io) {
10
+ if (!io.exists(ACTIVE)) return null;
11
+ return JSON.parse(io.readFile(ACTIVE));
12
+ }
13
+
14
+ export function writeActiveStage(io, payload) {
15
+ ensureFoundryDir(io);
16
+ io.writeFile(ACTIVE, JSON.stringify(payload, null, 2));
17
+ }
18
+
19
+ export function clearActiveStage(io) {
20
+ io.unlink(ACTIVE);
21
+ }
22
+
23
+ export function readLastStage(io) {
24
+ if (!io.exists(LAST)) return null;
25
+ return JSON.parse(io.readFile(LAST));
26
+ }
27
+
28
+ export function writeLastStage(io, payload) {
29
+ ensureFoundryDir(io);
30
+ io.writeFile(LAST, JSON.stringify(payload, null, 2));
31
+ }
@@ -0,0 +1,26 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+
3
+ export function signToken(payload, secret) {
4
+ const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
5
+ const mac = createHmac('sha256', secret).update(body).digest('base64url');
6
+ return `${body}.${mac}`;
7
+ }
8
+
9
+ export function verifyToken(token, secret) {
10
+ if (typeof token !== 'string' || !token.includes('.')) return { ok: false, reason: 'malformed' };
11
+ const [body, mac] = token.split('.');
12
+ if (!body || !mac) return { ok: false, reason: 'malformed' };
13
+ const expected = createHmac('sha256', secret).update(body).digest();
14
+ let given;
15
+ try { given = Buffer.from(mac, 'base64url'); } catch { return { ok: false, reason: 'malformed' }; }
16
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
17
+ return { ok: false, reason: 'bad_signature' };
18
+ }
19
+ let payload;
20
+ try { payload = JSON.parse(Buffer.from(body, 'base64url').toString()); }
21
+ catch { return { ok: false, reason: 'malformed' }; }
22
+ if (typeof payload.exp !== 'number' || payload.exp < Date.now()) {
23
+ return { ok: false, reason: 'expired' };
24
+ }
25
+ return { ok: true, payload };
26
+ }
@@ -11,7 +11,16 @@ import yaml from 'js-yaml';
11
11
  export function parseFrontmatter(text) {
12
12
  const match = text.match(/^---\n(.+?)\n---/s);
13
13
  if (!match) return {};
14
- return yaml.load(match[1]) || {};
14
+ const fm = yaml.load(match[1]) || {};
15
+ // Normalize: on-disk canonical key is `max-iterations` (kebab).
16
+ // Tolerate legacy `maxIterations` (camel) by rewriting on read.
17
+ if (fm.maxIterations !== undefined) {
18
+ if (fm['max-iterations'] === undefined) {
19
+ fm['max-iterations'] = fm.maxIterations;
20
+ }
21
+ delete fm.maxIterations;
22
+ }
23
+ return fm;
15
24
  }
16
25
 
17
26
  export function writeFrontmatter(fields) {
@@ -25,6 +34,8 @@ export function getFrontmatterField(text, key) {
25
34
  }
26
35
 
27
36
  export function setFrontmatterField(text, key, value) {
37
+ // Coerce legacy camelCase key to canonical kebab form on write.
38
+ if (key === 'maxIterations') key = 'max-iterations';
28
39
  const fm = parseFrontmatter(text);
29
40
  fm[key] = value;
30
41
  const fmBlock = writeFrontmatter(fm);