@really-knows-ai/foundry 3.3.9 → 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,151 +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
- }
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) : [];
35
37
  }
36
38
 
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;
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
+ }
42
52
  }
43
- return -1;
53
+ return entries;
44
54
  }
45
55
 
46
- function findTableSeparator(lines, afterIdx) {
47
- for (let i = afterIdx + 1; i < lines.length; i++) {
48
- if (isTableSeparator(lines[i].trim())) return i;
56
+ /**
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}>}
66
+ */
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' });
81
+ }
49
82
  }
50
- return -1;
51
- }
52
83
 
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 };
84
+ return changes;
59
85
  }
60
86
 
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;
87
+ /**
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}>}
94
+ */
95
+ function dedupeArtefactChanges(changes) {
96
+ const seen = new Map();
97
+ for (const { file, state } of changes) {
98
+ seen.set(file, state);
65
99
  }
66
- return lines.length;
67
- }
68
-
69
- function formatTableRow(cols) {
70
- return '| ' + cols.join(' | ') + ' |';
100
+ return Array.from(seen.entries()).map(([file, state]) => ({ file, state }));
71
101
  }
72
102
 
73
103
  /**
74
- * Parse the artefacts markdown table from text.
75
- * @param {string} text
76
- * @returns {Array<{file: string, type: string, cycle: string, status: string}>}
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
77
108
  */
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
- });
95
- }
109
+ export function resolveBranchBaseSha(io, baseBranch = 'main') {
110
+ if (!io.exec) {
111
+ throw new Error('io.exec is required for resolveBranchBaseSha');
96
112
  }
97
-
98
- return artefacts;
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}`);
116
+ }
117
+ return sha;
99
118
  }
100
119
 
101
120
  /**
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
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>}
106
125
  */
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');
113
- }
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');
126
+ function getFilePatterns(def) {
127
+ const fm = def.frontmatter;
128
+ return Array.isArray(fm && fm['file-patterns']) ? fm['file-patterns'] : [];
120
129
  }
121
130
 
122
131
  /**
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
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}>>}
128
145
  */
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}`);
137
- }
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
- }
148
- }
149
-
150
- throw new Error(`File not found in artefacts table: ${file}`);
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;
151
166
  }
@@ -7,7 +7,6 @@
7
7
  import path from 'node:path';
8
8
  import { load as loadYaml } from 'js-yaml';
9
9
  import { parseFrontmatter } from '../workfile.js';
10
- import { parseArtefactsTable } from '../artefacts.js';
11
10
  import { parseAllHistoryEntries } from '../history.js';
12
11
  import { sha256Buffer } from './hash.js';
13
12
  import { buildAttestationPayload } from './payload.js';
@@ -21,19 +20,11 @@ function readWorkFiles(cwd, io) {
21
20
 
22
21
  return {
23
22
  workText: io.readFile(workPath),
24
- historyText: io.fileExists(historyPath) ? io.readFile(historyPath) : '',
25
- feedbackText: io.fileExists(feedbackPath) ? io.readFile(feedbackPath) : '',
23
+ historyText: io.exists(historyPath) ? io.readFile(historyPath) : '',
24
+ feedbackText: io.exists(feedbackPath) ? io.readFile(feedbackPath) : '',
26
25
  };
27
26
  }
28
27
 
29
- function checkBlockedArtefacts(artefacts) {
30
- const blocked = artefacts.filter(a => a.status === 'blocked');
31
- if (blocked.length > 0) {
32
- return `foundry_attest: cycle has blocked artefact(s): ${blocked.map(a => a.file).join(', ')}`;
33
- }
34
- return null;
35
- }
36
-
37
28
  function checkMissingStages(frontmatter, historyText) {
38
29
  const entries = parseAllHistoryEntries(historyText);
39
30
  const completed = new Set(entries.map(e => e.stage));
@@ -54,10 +45,7 @@ function checkUnresolvedFeedback(feedbackText) {
54
45
  return null;
55
46
  }
56
47
 
57
- function findCycleError(frontmatter, artefacts, historyText, feedbackText) {
58
- const blockedError = checkBlockedArtefacts(artefacts);
59
- if (blockedError) return blockedError;
60
-
48
+ function findCycleError(frontmatter, historyText, feedbackText) {
61
49
  const missingError = checkMissingStages(frontmatter, historyText);
62
50
  if (missingError) return missingError;
63
51
 
@@ -73,7 +61,9 @@ function computeDiffSha(execGit, baseBranch) {
73
61
 
74
62
  export async function buildAttestation({
75
63
  cwd,
64
+ foundryDir,
76
65
  baseBranch,
66
+ branchBaseSha,
77
67
  goalText,
78
68
  archiveBranch,
79
69
  archiveTipSha,
@@ -82,20 +72,22 @@ export async function buildAttestation({
82
72
  }) {
83
73
  const { workText, historyText, feedbackText } = readWorkFiles(cwd, io);
84
74
  const frontmatter = parseFrontmatter(workText);
85
- const artefacts = parseArtefactsTable(workText);
86
75
 
87
- const cycleError = findCycleError(frontmatter, artefacts, historyText, feedbackText);
76
+ const cycleError = findCycleError(frontmatter, historyText, feedbackText);
88
77
  if (cycleError) {
89
78
  return { ok: false, error: cycleError };
90
79
  }
91
80
 
92
81
  const diffSha = computeDiffSha(execGit, baseBranch);
93
82
 
94
- const payload = buildAttestationPayload({
83
+ const payload = await buildAttestationPayload({
95
84
  cwd,
85
+ foundryDir: foundryDir ?? 'foundry',
96
86
  goalText,
97
87
  archiveBranch,
98
88
  archiveTipSha,
89
+ baseBranch,
90
+ branchBaseSha,
99
91
  io,
100
92
  });
101
93
 
@@ -5,26 +5,29 @@
5
5
  */
6
6
 
7
7
  import { readFileSync, existsSync } from 'node:fs';
8
+ import { execFileSync } from 'node:child_process';
8
9
  import path from 'node:path';
9
10
  import { parseFrontmatter } from '../workfile.js';
10
- import { parseArtefactsTable } from '../artefacts.js';
11
+ import { getArtefactFiles } from '../artefacts.js';
12
+ import { getCycleDefinition } from '../config.js';
11
13
  import { parseAllHistoryEntries } from '../history.js';
12
14
  import { sha256Text, sortPaths } from './hash.js';
13
15
 
14
- function defaultIo() {
16
+ function defaultIo(cwd) {
15
17
  return {
16
18
  readFile: (filePath) => readFileSync(filePath, 'utf8'),
17
- fileExists: (filePath) => existsSync(filePath),
19
+ exists: (filePath) => existsSync(filePath),
20
+ exec: (args) => execFileSync(args[0], args.slice(1), { cwd, encoding: 'utf8' }),
18
21
  };
19
22
  }
20
23
 
21
24
  function readWorkFiles(cwd, io) {
22
- const { readFile, fileExists } = io ?? defaultIo();
25
+ const { readFile, exists } = io ?? defaultIo(cwd);
23
26
 
24
27
  return {
25
28
  workText: readFile(path.join(cwd, 'WORK.md')),
26
- historyText: fileExists(path.join(cwd, 'WORK.history.yaml')) ? readFile(path.join(cwd, 'WORK.history.yaml')) : '',
27
- feedbackText: fileExists(path.join(cwd, 'WORK.feedback.yaml')) ? readFile(path.join(cwd, 'WORK.feedback.yaml')) : '',
29
+ historyText: exists(path.join(cwd, 'WORK.history.yaml')) ? readFile(path.join(cwd, 'WORK.history.yaml')) : '',
30
+ feedbackText: exists(path.join(cwd, 'WORK.feedback.yaml')) ? readFile(path.join(cwd, 'WORK.feedback.yaml')) : '',
28
31
  };
29
32
  }
30
33
 
@@ -85,11 +88,34 @@ function buildGovernance(frontmatter, workText, historyText, feedbackText) {
85
88
  };
86
89
  }
87
90
 
88
- export function buildAttestationPayload({ cwd, goalText, archiveBranch, archiveTipSha, io }) {
89
- const { workText, historyText, feedbackText } = readWorkFiles(cwd, io);
91
+ async function discoverCycleOutputs(resolvedFd, cycleId, resolvedIo, options) {
92
+ try {
93
+ const cfm = (await getCycleDefinition(resolvedFd, cycleId, resolvedIo)).frontmatter;
94
+ const outputType = cfm && cfm['output-type'];
95
+ if (outputType) return getArtefactFiles(resolvedFd, outputType, resolvedIo, options);
96
+ } catch {
97
+ // If cycle definition is missing, outputs remain empty
98
+ }
99
+ return [];
100
+ }
101
+
102
+ export async function buildAttestationPayload(
103
+ { cwd, foundryDir: fd, goalText, archiveBranch, archiveTipSha, baseBranch, branchBaseSha, io },
104
+ ) {
105
+ const resolvedIo = io || defaultIo(cwd);
106
+ const resolvedFd = fd || 'foundry';
107
+ const { workText, historyText, feedbackText } = readWorkFiles(cwd, resolvedIo);
90
108
 
91
109
  const frontmatter = parseFrontmatter(workText);
92
- const artefacts = parseArtefactsTable(workText);
110
+
111
+ // Discover artefact outputs from branch changes
112
+ let outputs = [];
113
+ const cycleId = frontmatter.cycle;
114
+ if (cycleId) {
115
+ outputs = await discoverCycleOutputs(resolvedFd, cycleId, resolvedIo, { baseBranch, branchBaseSha });
116
+ }
117
+
118
+ const outputEntries = outputs.map(({ file, state }) => ({ path: file, state }));
93
119
 
94
120
  const sortedEntries = parseAndSortHistoryEntries(historyText);
95
121
  const stages = buildStagesFromEntries(sortedEntries);
@@ -97,7 +123,7 @@ export function buildAttestationPayload({ cwd, goalText, archiveBranch, archiveT
97
123
  return {
98
124
  contract: buildContract(frontmatter),
99
125
  governance: buildGovernance(frontmatter, workText, historyText, feedbackText),
100
- outputs: artefacts.map(row => ({ path: row.file, status: row.status })),
126
+ outputs: outputEntries,
101
127
  process: { stages },
102
128
  request: { goal_text: goalText },
103
129
  schema: 'foundry-attestation/v1',
@@ -49,7 +49,7 @@ function classifyFiles(files, allowedPatterns) {
49
49
  return { matched, unexpected };
50
50
  }
51
51
 
52
- export function finalizeStage({ cwd, baseSha, stageBase, cycleDef, artefactTypes, registerArtefact, io }) {
52
+ export function finalizeStage({ cwd, baseSha, stageBase, cycleDef, artefactTypes, io }) {
53
53
  if (!io?.exec) {
54
54
  throw new Error('finalizeStage: io.exec is required');
55
55
  }
@@ -62,9 +62,9 @@ export function finalizeStage({ cwd, baseSha, stageBase, cycleDef, artefactTypes
62
62
  // For non-forge stages, matched files are tool-managed side effects
63
63
  // (e.g. assay's memory writes) that should not become artefacts.
64
64
  if (stageBase !== 'forge') return { ok: true, artefacts: [], changedFiles: sortedFiles };
65
- const artefacts = sortedFiles.map(file => {
66
- registerArtefact({ file, type: cycleDef.outputArtefactType, status: 'draft' });
67
- return { file, type: cycleDef.outputArtefactType, status: 'draft' };
68
- });
65
+ const artefacts = sortedFiles.map(file => ({
66
+ file,
67
+ type: cycleDef.outputArtefactType,
68
+ }));
69
69
  return { ok: true, artefacts, changedFiles: sortedFiles };
70
70
  }