@really-knows-ai/foundry 2.0.1 → 2.2.1

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,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,33 @@
1
+ /**
2
+ * Slug utilities for generating shell-safe, git-ref-safe identifiers.
3
+ */
4
+
5
+ /**
6
+ * Convert an arbitrary string into a URL/git-branch-friendly slug.
7
+ *
8
+ * Rules:
9
+ * - Strips diacritics (e.g. "café" → "cafe")
10
+ * - Lowercases
11
+ * - Replaces any run of non-[a-z0-9] characters with a single dash
12
+ * - Trims leading/trailing dashes
13
+ *
14
+ * Throws if the input is not a string or if the resulting slug is empty.
15
+ */
16
+ export function slugify(input) {
17
+ if (typeof input !== 'string') {
18
+ throw new TypeError(`slugify: expected string, got ${typeof input}`);
19
+ }
20
+
21
+ const slug = input
22
+ .normalize('NFD')
23
+ .replace(/\p{Diacritic}/gu, '')
24
+ .toLowerCase()
25
+ .replace(/[^a-z0-9]+/g, '-')
26
+ .replace(/^-+|-+$/g, '');
27
+
28
+ if (slug.length === 0) {
29
+ throw new Error(`slugify: input produced empty slug (input: ${JSON.stringify(input)})`);
30
+ }
31
+
32
+ return slug;
33
+ }
@@ -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);