@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/.opencode/plugins/foundry.js +273 -132
- package/CHANGELOG.md +77 -0
- package/docs/work-spec.md +4 -4
- package/package.json +3 -2
- package/scripts/lib/artefacts.js +6 -0
- package/scripts/lib/feedback-transitions.js +25 -0
- package/scripts/lib/feedback.js +146 -9
- package/scripts/lib/finalize.js +41 -0
- package/scripts/lib/history.js +15 -3
- package/scripts/lib/pending.js +18 -0
- package/scripts/lib/secret.js +23 -0
- package/scripts/lib/stage-guard.js +25 -0
- package/scripts/lib/state.js +31 -0
- package/scripts/lib/token.js +26 -0
- package/scripts/lib/workfile.js +12 -1
- package/scripts/orchestrate.js +418 -0
- package/scripts/sort.js +89 -14
- package/skills/add-cycle/SKILL.md +11 -6
- package/skills/appraise/SKILL.md +33 -17
- package/skills/flow/SKILL.md +13 -6
- package/skills/forge/SKILL.md +38 -26
- package/skills/human-appraise/SKILL.md +41 -17
- package/skills/orchestrate/SKILL.md +69 -0
- package/skills/quench/SKILL.md +31 -15
- package/skills/upgrade-foundry/SKILL.md +64 -1
- package/skills/cycle/SKILL.md +0 -81
- package/skills/sort/SKILL.md +0 -79
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
|
|
29
|
-
- `max-iterations` — set by the
|
|
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` (
|
|
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` (
|
|
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.
|
|
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
|
}
|
package/scripts/lib/artefacts.js
CHANGED
|
@@ -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
|
+
}
|
package/scripts/lib/feedback.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/scripts/lib/history.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/scripts/lib/workfile.js
CHANGED
|
@@ -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
|
-
|
|
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);
|