@really-knows-ai/foundry 3.4.0 → 3.5.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.
@@ -1,58 +1,47 @@
1
1
  import path from 'path';
2
- import { readFileSync, writeFileSync, existsSync } from 'fs';
3
- import { requireNoActiveStage } from '../../../scripts/lib/stage-guard.js';
4
- import { guarded, notFailedGuard } from '../../../scripts/lib/guards.js';
5
- import { parseArtefactsTable, setArtefactStatus } from '../../../scripts/lib/artefacts.js';
6
- import { makeIO, branchIoFactory, asyncIoFactory, flowBranchGuard } from './helpers.js';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { getArtefactFiles } from '../../../scripts/lib/artefacts.js';
4
+ import { getCycleDefinition } from '../../../scripts/lib/config.js';
5
+ import { parseFrontmatter } from '../../../scripts/lib/workfile.js';
6
+ import { makeIO } from './helpers.js';
7
7
 
8
- const gateNotFailed = notFailedGuard(makeIO);
8
+ function readWorkCycleId(worktree) {
9
+ const workPath = path.join(worktree, 'WORK.md');
10
+ if (!existsSync(workPath)) throw new Error('WORK.md not found');
11
+ const frontmatter = parseFrontmatter(readFileSync(workPath, 'utf-8'));
12
+ if (!frontmatter.cycle) throw new Error('current cycle not found in WORK.md frontmatter');
13
+ return frontmatter.cycle;
14
+ }
15
+
16
+ async function readOutputType(foundryDir, cycleId, io) {
17
+ let cd;
18
+ try {
19
+ cd = await getCycleDefinition(foundryDir, cycleId, io);
20
+ } catch { return null; }
21
+ return cd.frontmatter && cd.frontmatter['output-type'];
22
+ }
9
23
 
10
24
  function makeListTool(tool) {
11
25
  return tool({
12
- description: 'List artefacts from the WORK.md table. Optionally filter by cycle — callers should always pass the current cycle to avoid picking up stale rows from prior sessions.',
13
- args: {
14
- cycle: tool.schema.string().optional().describe('Only return rows whose Cycle column matches this value'),
15
- },
16
- async execute(args, context) {
17
- const workPath = path.join(context.worktree, 'WORK.md');
18
- if (!existsSync(workPath)) {
19
- return JSON.stringify({ error: 'WORK.md not found' });
26
+ description: 'List artefact changes on the current work branch for the current cycle. Returns [{ file, state }] entries.',
27
+ args: {},
28
+ async execute(_args, context) {
29
+ try {
30
+ const cycleId = readWorkCycleId(context.worktree);
31
+ const io = makeIO(context.worktree);
32
+ const outputType = await readOutputType('foundry', cycleId, io);
33
+ if (!outputType) return JSON.stringify([]);
34
+ const artefacts = await getArtefactFiles('foundry', outputType, io, { baseBranch: 'main' });
35
+ return JSON.stringify(artefacts);
36
+ } catch (err) {
37
+ return JSON.stringify({ error: err.message });
20
38
  }
21
- const text = readFileSync(workPath, 'utf-8');
22
- const rows = parseArtefactsTable(text);
23
- const filtered = args.cycle ? rows.filter(r => r.cycle === args.cycle) : rows;
24
- return JSON.stringify(filtered);
25
39
  },
26
40
  });
27
41
  }
28
42
 
29
43
  export function createArtefactTools({ tool }) {
30
44
  return {
31
- // NOTE: `foundry_artefacts_add` was removed in v2.2.0. Artefacts are now
32
- // registered automatically by the orchestrator's internal finalize step as drafts,
33
- // then promoted to done|blocked via `foundry_artefacts_set_status`.
34
- foundry_artefacts_set_status: tool({
35
- description: 'Update the status of an artefact in WORK.md (done|blocked only)',
36
- args: {
37
- file: tool.schema.string().describe('Artefact file path'),
38
- status: tool.schema.string().describe('New status (done|blocked)'),
39
- },
40
- execute: guarded('foundry_artefacts_set_status', [flowBranchGuard, gateNotFailed], async (args, context) => {
41
- const io = makeIO(context.worktree);
42
- const guard = requireNoActiveStage(io);
43
- if (!guard.ok) return JSON.stringify({ error: `foundry_artefacts_set_status ${guard.error}` });
44
- const workPath = path.join(context.worktree, 'WORK.md');
45
- const text = readFileSync(workPath, 'utf-8');
46
- try {
47
- const updated = setArtefactStatus(text, args.file, args.status);
48
- writeFileSync(workPath, updated, 'utf-8');
49
- return JSON.stringify({ ok: true });
50
- } catch (e) {
51
- return JSON.stringify({ error: e.message });
52
- }
53
- }, { branchIo: branchIoFactory, io: asyncIoFactory }),
54
- }),
55
-
56
45
  foundry_artefacts_list: makeListTool(tool),
57
46
  };
58
47
  }
@@ -131,11 +131,18 @@ function commitAttestation(cwd, cycle, content, opts) {
131
131
  function buildAttestationInputs(opts) {
132
132
  return {
133
133
  cwd: opts.cwd,
134
+ foundryDir: 'foundry',
134
135
  baseBranch: opts.baseBranch,
136
+ branchBaseSha: opts.branchBaseSha,
135
137
  goalText: opts.goalText,
136
138
  archiveBranch: opts.archiveBranch,
137
139
  archiveTipSha: opts.archiveTipSha,
138
- io: { readFile: (p) => readFileSync(p, 'utf8'), fileExists: (p) => existsSync(p) },
140
+ io: {
141
+ readFile: (p) => readFileSync(p, 'utf8'),
142
+ fileExists: (p) => existsSync(p),
143
+ exists: (p) => existsSync(p),
144
+ exec: (args) => execFileSync(args[0], args.slice(1), { cwd: opts.cwd, encoding: 'utf8' }),
145
+ },
139
146
  execGit: opts.execGit,
140
147
  };
141
148
  }
@@ -151,7 +158,7 @@ function createAttestTool(tool) {
151
158
  return tool({
152
159
  description:
153
160
  'Verify the current work cycle is complete (all required stages ran, no unresolved ' +
154
- 'feedback, no blocked artefacts) and commit an ATTEST.md attestation file to the work branch. ' +
161
+ 'feedback) and commit an ATTEST.md attestation file to the work branch. ' +
155
162
  'foundry_git_finish will not merge without this commit at HEAD.',
156
163
  args: {
157
164
  baseBranch: tool.schema.string().optional()
@@ -1,11 +1,8 @@
1
- import path from 'path';
2
1
  import { execFileSync } from 'child_process';
3
2
  import { randomUUID } from 'node:crypto';
4
- import { readFileSync, writeFileSync } from 'fs';
5
3
  import { signToken } from '../../../scripts/lib/token.js';
6
4
  import { readOrCreateSecret } from '../../../scripts/lib/secret.js';
7
5
  import { getCycleDefinition, getArtefactType } from '../../../scripts/lib/config.js';
8
- import { addArtefactRow } from '../../../scripts/lib/artefacts.js';
9
6
  import { stageBaseOf } from '../../../scripts/lib/stage-guard.js';
10
7
  import { finalizeStage } from '../../../scripts/lib/finalize.js';
11
8
  import { commitWithPolicy } from '../../../scripts/lib/git-bridge.js';
@@ -40,15 +37,6 @@ function createGitBridge(cwd) {
40
37
  };
41
38
  }
42
39
 
43
- function makeRegisterArtefact(cwd, cycleId) {
44
- const workPath = path.join(cwd, 'WORK.md');
45
- return ({ file, type, status }) => {
46
- const text = readFileSync(workPath, 'utf-8');
47
- const updated = addArtefactRow(text, { file, type, cycle: cycleId, status });
48
- writeFileSync(workPath, updated, 'utf-8');
49
- };
50
- }
51
-
52
40
  async function createFinalize(cwd, io) {
53
41
  return async ({ cycleId, stage, baseSha }) => {
54
42
  let cycleDoc;
@@ -78,7 +66,6 @@ async function createFinalize(cwd, io) {
78
66
  cycleDef,
79
67
  artefactTypes,
80
68
  io,
81
- registerArtefact: makeRegisterArtefact(cwd, cycleId),
82
69
  });
83
70
  return result;
84
71
  };
package/dist/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.5.1] - 2026-05-22
4
+
5
+ ### Fixed
6
+
7
+ - Run CI against the active Node release lines: 22.x, 24.x, 25.x, and 26.x.
8
+ - Use Node 22-compatible `mock.module({ namedExports })` test mocks.
9
+
10
+ ## [3.5.0] - 2026-05-22
11
+
12
+ ### Changed
13
+
14
+ - Add branch-based artefact discovery with `getArtefactFiles` and git change-state tracking.
15
+ - Remove the `WORK.md` artefact table, artefact registration side effects, and per-artefact status updates.
16
+ - Update quench, appraise, orchestration, plugin tools, and attestation to use branch artefact discovery.
17
+ - Remove `foundry_artefacts_set_status` and update `foundry_artefacts_list` to return `{file, state}` entries.
18
+ - Remove artefact table generation from new `WORK.md` files.
19
+ - Update tests and docs for branch artefact discovery.
20
+
21
+ ### Fixed
22
+
23
+ - Include all non-deleted artefact files in missing-model violation payloads.
24
+ - Thread base-branch selection through artefact discovery contexts.
25
+
3
26
  ## [3.4.0] - 2026-05-20
4
27
 
5
28
  ### Changed
@@ -238,7 +238,7 @@ Input artefacts (files matching an input type's `file-patterns`) are read-only.
238
238
  When an unrecoverable error occurs (e.g. assay extractor abort, invalid JSONL, or memory-sync failure), the orchestrator marks `WORK.md` frontmatter with `status: failed` and a `reason`. The flow is then locked:
239
239
 
240
240
  - **Blocked tools.** All mutation tools refuse to run and return an error referencing the failure reason:
241
- - **Lifecycle:** `foundry_stage_begin`, `foundry_orchestrate`, `foundry_workfile_create`, `foundry_artefacts_set_status`
241
+ - **Lifecycle:** `foundry_stage_begin`, `foundry_orchestrate`, `foundry_workfile_create`
242
242
  - **Stage work:** `foundry_assay_run`, `foundry_validate_run`
243
243
  - **Feedback writes:** `foundry_feedback_add`, `foundry_feedback_action`, `foundry_feedback_wontfix`, `foundry_feedback_resolve` (`foundry_feedback_list` remains callable)
244
244
  - **Appraiser selection:** `foundry_appraisers_select`
@@ -59,7 +59,6 @@ state machine, see [`docs/concepts.md`](./concepts.md) and
59
59
 
60
60
  **Artefacts**
61
61
  - [`foundry_artefacts_list`](#foundry_artefacts_list)
62
- - [`foundry_artefacts_set_status`](#foundry_artefacts_set_status)
63
62
 
64
63
  **Feedback**
65
64
  - [`foundry_feedback_add`](#foundry_feedback_add)
@@ -326,41 +325,19 @@ flow** (escape hatch).
326
325
 
327
326
  ### `foundry_artefacts_list`
328
327
 
329
- > List artefacts from the WORK.md table. Optional `cycle` filter
330
- > callers should always pass the current cycle to avoid stale rows.
328
+ > List artefact changes on the current work branch for the current cycle.
331
329
 
332
330
  **Args:**
333
- - `cycle` (string, optional).
331
+ - none.
334
332
 
335
- **Returns:** array of `{file, type, cycle, status, ...}` rows. `{error:
336
- "WORK.md not found"}` if missing.
333
+ **Returns:** array of `{file, state}` entries. `{error: "WORK.md not found"}`
334
+ if `WORK.md` is missing, or `{error: "current cycle not found in WORK.md frontmatter"}`
335
+ if `WORK.md` has no current cycle.
337
336
 
338
337
  **Stage requirements:** none.
339
338
 
340
339
  **Side effects:** none.
341
340
 
342
- ### `foundry_artefacts_set_status`
343
-
344
- > Update the status of an artefact in `WORK.md` (`done` | `blocked`
345
- > only).
346
-
347
- **Args:**
348
- - `file` (string, required): Artefact file path.
349
- - `status` (string, required): New status (`done` | `blocked`).
350
-
351
- **Returns:** `{ ok: true }`. On invalid input: `{ error: <message> }`.
352
-
353
- **Stage requirements:** requires no active stage. Refuses on failed
354
- flow.
355
-
356
- **Failure modes:**
357
- - Invalid status, unknown file, malformed table → error from
358
- `setArtefactStatus`.
359
-
360
- **Side effects:** rewrites `WORK.md`.
361
-
362
- ---
363
-
364
341
  ## Feedback
365
342
 
366
343
  All feedback tools (except `foundry_feedback_list`) require an active
@@ -168,11 +168,11 @@ A crash between the two steps leaves the live file untouched.
168
168
  | Frontmatter (`flow`, `cycle`) | `foundry_workfile_create` (flow skill) | updated in place as the flow advances between cycles |
169
169
  | Frontmatter (`stages`, `max-iterations`, `human-appraise`, `deadlock-appraise`, `deadlock-iterations`, `models`) | `foundry_orchestrate` (first call of each cycle, internally) | reset on each new cycle |
170
170
  | Goal | `foundry_workfile_create` (flow skill) | nobody |
171
- | Artefacts | the orchestrator's internal finalize step (after forge closes) | `foundry_artefacts_set_status` (orchestrator `done`/`blocked`) |
171
+ | Artefact files | forge stage writes files on disk | git tracks file changes; cycle-level state records completion or failure |
172
172
  | `WORK.feedback.yaml` | `foundry_feedback_add` (`quench` / `appraise` / `human-appraise`) | `foundry_feedback_action` / `foundry_feedback_wontfix` (forge), `foundry_feedback_resolve` (source stage / human-appraise override); sort writes only deadlocked snapshots |
173
173
  | `WORK.history.yaml` | `foundry_orchestrate` | `foundry_orchestrate` |
174
174
 
175
- Note: `foundry_artefacts_add` no longer exists as a public tool — artefact registration is automatic via the orchestrator's internal finalize step, which scans the git diff and registers files matching the output type's `file-patterns` as `draft`.
175
+ Artefact files are discovered from branch changes matching the cycle output type's `file-patterns`.
176
176
 
177
177
  ## WORK.history.yaml
178
178
 
@@ -11,8 +11,8 @@
11
11
  * so the orchestrator can re-sort and determine the next action.
12
12
  */
13
13
 
14
- import { getArtefactsForCycle } from './lib/artefacts.js';
15
- import { selectAppraisers, getLaws } from './lib/config.js';
14
+ import { getArtefactFiles } from './lib/artefacts.js';
15
+ import { selectAppraisers, getLaws, getCycleDefinition } from './lib/config.js';
16
16
 
17
17
  // ---------------------------------------------------------------------------
18
18
  // Public API — gather
@@ -27,6 +27,8 @@ import { selectAppraisers, getLaws } from './lib/config.js';
27
27
  * @param {string} ctx.cycleId
28
28
  * @param {object} ctx.io
29
29
  * @param {string} ctx.foundryDir
30
+ * @param {string} [ctx.baseBranch] - Git base branch for diff comparison,
31
+ * defaults to 'main'.
30
32
  * @param {string} [ctx.defaultModel] - Fallback model when an appraiser has no
31
33
  * explicit model.
32
34
  * @returns {Promise<{action: string, tasks: Array, stage: string, cycle: string}>}
@@ -36,12 +38,19 @@ export async function gatherAppraiseContext(ctx) {
36
38
  return violation('cycleId is required', []);
37
39
  }
38
40
 
39
- const artefacts = getArtefactsForCycle(ctx.cycleId, ctx.io);
41
+ const cd = await getCycleDefinition(ctx.foundryDir, ctx.cycleId, ctx.io);
42
+ const outputType = cd.frontmatter['output-type'];
43
+ if (!outputType) {
44
+ return violation(`cycle ${ctx.cycleId} missing output-type field`, []);
45
+ }
46
+ const baseBranch = ctx.baseBranch || 'main';
47
+ const artefacts = await getArtefactFiles(ctx.foundryDir, outputType, ctx.io, { baseBranch });
40
48
  if (artefacts.length === 0) {
41
49
  return emptyDispatch(ctx.cycleId);
42
50
  }
43
51
 
44
- const tasks = await collectTasks(artefacts, ctx);
52
+ const typedArtefacts = artefacts.map(artefact => ({ ...artefact, type: outputType }));
53
+ const tasks = await collectTasks(typedArtefacts, ctx);
45
54
 
46
55
  return {
47
56
  action: 'dispatch_multi',
@@ -91,7 +100,10 @@ async function resolveTypeEntry(typeId, cache, ctx) {
91
100
  * Build and append appraiser tasks for a single artefact.
92
101
  */
93
102
  function addTasksForArtefact(tasks, artefact, entry, ctx) {
94
- const content = ctx.io.readFile(artefact.file);
103
+ let content = '';
104
+ if (artefact.state !== 'deleted') {
105
+ content = ctx.io.readFile(artefact.file);
106
+ }
95
107
 
96
108
  for (const appraiser of entry.appraisers) {
97
109
  const prompt = buildAppraiserPrompt({
@@ -1,163 +1,166 @@
1
1
  /**
2
- * Artefacts table utilities for WORK.md.
2
+ * Artefact discovery and branch change utilities.
3
3
  *
4
- * Parses, adds rows to, and updates status in the markdown artefacts table.
4
+ * Resolves the branch base SHA, collects changed files on the flow branch,
5
+ * filters by artefact type file-patterns, and returns the artefact change set.
5
6
  */
6
7
 
7
- // --- Table line classifiers ---
8
+ import { minimatch } from 'minimatch';
9
+ import { sortPaths } from './attestation/hash.js';
10
+ import { getArtefactType } from './config.js';
11
+
12
+ // --- Shared branch artefact discovery ---
13
+
14
+ const STATUS_HANDLERS = {
15
+ A: (parts) => [{ file: parts[1], state: 'new' }],
16
+ M: (parts) => [{ file: parts[1], state: 'modified' }],
17
+ T: (parts) => [{ file: parts[1], state: 'modified' }],
18
+ U: (parts) => [{ file: parts[1], state: 'modified' }],
19
+ D: (parts) => [{ file: parts[1], state: 'deleted' }],
20
+ R: (parts) => [
21
+ { file: parts[1], state: 'deleted' },
22
+ { file: parts[2], state: 'new' },
23
+ ],
24
+ C: (parts) => [{ file: parts[2], state: 'new' }],
25
+ };
8
26
 
9
- function isTableHeader(line) {
10
- return line.startsWith('| File');
11
- }
12
-
13
- function isTableSeparator(line) {
14
- return line.startsWith('|---');
15
- }
16
-
17
- function isTableRow(line) {
18
- return line.startsWith('|');
19
- }
20
-
21
- function parseTableRow(line) {
22
- const cols = line.split('|').slice(1, -1).map(c => c.trim());
23
- return cols.length >= 4 ? cols : null;
24
- }
25
-
26
- // --- Status validation ---
27
-
28
- function validateStatus(newStatus) {
29
- if (newStatus === 'draft') {
30
- throw new Error('status draft not permitted; artefacts are registered automatically during orchestration');
31
- }
32
- if (!['done', 'blocked'].includes(newStatus)) {
33
- throw new Error(`invalid status: ${newStatus}`);
34
- }
35
- }
36
-
37
- // --- Table boundary detection ---
38
-
39
- function findTableHeader(lines) {
40
- for (let i = 0; i < lines.length; i++) {
41
- if (isTableHeader(lines[i].trim())) return i;
42
- }
43
- return -1;
44
- }
45
-
46
- function findTableSeparator(lines, afterIdx) {
47
- for (let i = afterIdx + 1; i < lines.length; i++) {
48
- if (isTableSeparator(lines[i].trim())) return i;
49
- }
50
- return -1;
51
- }
52
-
53
- function getTableBounds(lines) {
54
- const headerIdx = findTableHeader(lines);
55
- if (headerIdx < 0) return null;
56
- const sepIdx = findTableSeparator(lines, headerIdx);
57
- if (sepIdx < 0) return null;
58
- return { headerIdx, sepIdx };
27
+ /**
28
+ * Parse a single git diff --name-status line into one or more { file, state } entries.
29
+ * Uses a lookup table to map status codes to handlers.
30
+ * @param {string} line - A line from git diff --name-status output
31
+ * @returns {Array<{file: string, state: string}>}
32
+ */
33
+ function parseDiffStatusLine(line) {
34
+ const parts = line.split('\t');
35
+ const handler = STATUS_HANDLERS[parts[0][0]];
36
+ return handler ? handler(parts) : [];
59
37
  }
60
38
 
61
- function findTableEnd(lines, startIdx) {
62
- for (let i = startIdx; i < lines.length; i++) {
63
- const stripped = lines[i].trim();
64
- if (!isTableRow(stripped)) return i;
39
+ /**
40
+ * Parse git diff --name-status output into an array of { file, state } entries.
41
+ * @param {string} output - Raw output from git diff --name-status
42
+ * @returns {Array<{file: string, state: string}>}
43
+ */
44
+ function parseDiffOutput(output) {
45
+ const entries = [];
46
+ if (!output) return entries;
47
+ for (const line of output.trim().split('\n')) {
48
+ if (!line) continue;
49
+ for (const entry of parseDiffStatusLine(line)) {
50
+ entries.push(entry);
51
+ }
65
52
  }
66
- return lines.length;
67
- }
68
-
69
- function formatTableRow(cols) {
70
- return '| ' + cols.join(' | ') + ' |';
53
+ return entries;
71
54
  }
72
55
 
73
56
  /**
74
- * Parse the artefacts markdown table from text.
75
- * @param {string} text
76
- * @returns {Array<{file: string, type: string, cycle: string, status: string}>}
57
+ * Collect changed files from the branch since a given base SHA.
58
+ * Combines committed, unstaged, staged, and untracked changes.
59
+ *
60
+ * Sources are ordered by increasing priority: committed, unstaged, staged, untracked.
61
+ * This function does not deduplicate per-file entries; call dedupeArtefactChanges for that.
62
+ *
63
+ * @param {string} branchBaseSha - The merge-base SHA for the branch
64
+ * @param {object} io - IO interface with exec
65
+ * @returns {Array<{file: string, state: string}>}
77
66
  */
78
- export function parseArtefactsTable(text) {
79
- const lines = text.split('\n');
80
- const bounds = getTableBounds(lines);
81
- if (!bounds) return [];
82
-
83
- const artefacts = [];
84
- const endIdx = findTableEnd(lines, bounds.sepIdx + 1);
85
-
86
- for (let i = bounds.sepIdx + 1; i < endIdx; i++) {
87
- const cols = parseTableRow(lines[i].trim());
88
- if (cols) {
89
- artefacts.push({
90
- file: cols[0],
91
- type: cols[1],
92
- cycle: cols[2],
93
- status: cols[3],
94
- });
67
+ function getBranchChangedFiles(branchBaseSha, io) {
68
+ const changes = [];
69
+
70
+ // Diff-based sources: committed, unstaged, staged
71
+ changes.push(...parseDiffOutput(io.exec(['git', 'diff', '--name-status', `${branchBaseSha}..HEAD`])));
72
+ changes.push(...parseDiffOutput(io.exec(['git', 'diff', '--name-status'])));
73
+ changes.push(...parseDiffOutput(io.exec(['git', 'diff', '--cached', '--name-status'])));
74
+
75
+ // Untracked files (not in git)
76
+ const untracked = io.exec(['git', 'ls-files', '--others', '--exclude-standard']);
77
+ if (untracked) {
78
+ for (const file of untracked.trim().split('\n')) {
79
+ if (!file) continue;
80
+ changes.push({ file, state: 'new' });
95
81
  }
96
82
  }
97
83
 
98
- return artefacts;
84
+ return changes;
99
85
  }
100
86
 
101
87
  /**
102
- * Add a row to the artefacts table.
103
- * @param {string} text - Full WORK.md text
104
- * @param {{file: string, type: string, cycle: string, status: string}} row
105
- * @returns {string} Updated text
88
+ * Deduplicate artefact changes, keeping the most recent state for each file.
89
+ * Because later git sources (staged, untracked) take precedence over earlier
90
+ * ones (committed), the last occurrence of a file wins.
91
+ *
92
+ * @param {Array<{file: string, state: string}>} changes
93
+ * @returns {Array<{file: string, state: string}>}
106
94
  */
107
- export function addArtefactRow(text, { file, type, cycle, status }) {
108
- const lines = text.split('\n');
109
- const bounds = getTableBounds(lines);
110
-
111
- if (!bounds) {
112
- throw new Error('Artefacts table not found');
95
+ function dedupeArtefactChanges(changes) {
96
+ const seen = new Map();
97
+ for (const { file, state } of changes) {
98
+ seen.set(file, state);
113
99
  }
114
-
115
- const endIdx = findTableEnd(lines, bounds.sepIdx + 1);
116
- const insertAt = endIdx > bounds.sepIdx + 1 ? endIdx - 1 : bounds.sepIdx;
117
- const newRow = `| ${file} | ${type} | ${cycle} | ${status} |`;
118
- lines.splice(insertAt + 1, 0, newRow);
119
- return lines.join('\n');
100
+ return Array.from(seen.entries()).map(([file, state]) => ({ file, state }));
120
101
  }
121
102
 
122
103
  /**
123
- * Update the status column for a specific file in the artefacts table.
124
- * @param {string} text - Full WORK.md text
125
- * @param {string} file - File name to match
126
- * @param {string} newStatus - New status value
127
- * @returns {string} Updated text
104
+ * Resolve the merge-base SHA between HEAD and a base branch.
105
+ * @param {object} io - IO interface with exec
106
+ * @param {string} [baseBranch='main'] - Base branch name
107
+ * @returns {string} The merge-base commit SHA
128
108
  */
129
- export function setArtefactStatus(text, file, newStatus) {
130
- validateStatus(newStatus);
131
-
132
- const lines = text.split('\n');
133
- const bounds = getTableBounds(lines);
134
-
135
- if (!bounds) {
136
- throw new Error(`File not found in artefacts table: ${file}`);
109
+ export function resolveBranchBaseSha(io, baseBranch = 'main') {
110
+ if (!io.exec) {
111
+ throw new Error('io.exec is required for resolveBranchBaseSha');
137
112
  }
138
-
139
- const endIdx = findTableEnd(lines, bounds.sepIdx + 1);
140
-
141
- for (let i = bounds.sepIdx + 1; i < endIdx; i++) {
142
- const cols = parseTableRow(lines[i].trim());
143
- if (cols && cols[0] === file) {
144
- cols[3] = newStatus;
145
- lines[i] = formatTableRow(cols);
146
- return lines.join('\n');
147
- }
113
+ const sha = io.exec(['git', 'merge-base', 'HEAD', baseBranch]).trim();
114
+ if (!sha) {
115
+ throw new Error(`Failed to resolve merge-base for HEAD and ${baseBranch}`);
148
116
  }
117
+ return sha;
118
+ }
149
119
 
150
- throw new Error(`File not found in artefacts table: ${file}`);
120
+ /**
121
+ * Extract file-patterns from an artefact type definition frontmatter.
122
+ * Returns an empty array when frontmatter is absent or contains no patterns.
123
+ * @param {object} def - Artefact type definition with frontmatter
124
+ * @returns {Array<string>}
125
+ */
126
+ function getFilePatterns(def) {
127
+ const fm = def.frontmatter;
128
+ return Array.isArray(fm && fm['file-patterns']) ? fm['file-patterns'] : [];
151
129
  }
152
130
 
153
131
  /**
154
- * Get draft artefacts for a specific cycle from the artefacts table.
155
- * @param {string} cycleId - Cycle ID to filter by
156
- * @param {object} io - IO interface
157
- * @returns {Array<{file: string, type: string, cycle: string, status: string}>}
132
+ * Get artefact files for a given artefact type using shared branch discovery.
133
+ *
134
+ * Reads the artefact type definition, resolves the branch base, collects changed
135
+ * files on the flow branch, filters by the type's file-patterns, and returns a
136
+ * deterministically sorted list of { file, state } entries.
137
+ *
138
+ * @param {string} foundryDir - Path to the foundry directory
139
+ * @param {string} typeId - Artefact type identifier
140
+ * @param {object} io - IO interface with exec, readFile, exists
141
+ * @param {object} [options={}] - Optional parameters
142
+ * @param {string} [options.baseBranch='main'] - Base branch for merge-base resolution
143
+ * @param {string} [options.branchBaseSha] - Pre-resolved merge-base SHA (takes precedence)
144
+ * @returns {Promise<Array<{file: string, state: string}>>}
158
145
  */
159
- export function getArtefactsForCycle(cycleId, io) {
160
- const text = io.readFile('WORK.md');
161
- const artefacts = parseArtefactsTable(text);
162
- return artefacts.filter(a => a.cycle === cycleId && a.status === 'draft');
146
+ export async function getArtefactFiles(foundryDir, typeId, io, options = {}) {
147
+ const def = await getArtefactType(foundryDir, typeId, io);
148
+ const patterns = getFilePatterns(def);
149
+
150
+ if (patterns.length === 0) return [];
151
+
152
+ const baseBranch = options.baseBranch || 'main';
153
+ const branchBaseSha = options.branchBaseSha || resolveBranchBaseSha(io, baseBranch);
154
+ const changedFiles = getBranchChangedFiles(branchBaseSha, io);
155
+ const changes = dedupeArtefactChanges(changedFiles);
156
+ const matching = changes.filter(({ file }) =>
157
+ patterns.some(pattern => minimatch(file, pattern))
158
+ );
159
+
160
+ // Sort deterministically by file path
161
+ const sorted = sortPaths(matching.map(({ file }) => file));
162
+ const order = new Map(sorted.map((file, idx) => [file, idx]));
163
+ const result = [...matching].sort((a, b) => order.get(a.file) - order.get(b.file));
164
+
165
+ return result;
163
166
  }