@really-knows-ai/foundry 2.3.1 → 3.0.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.
Files changed (170) hide show
  1. package/README.md +200 -198
  2. package/dist/.opencode/plugins/foundry-tools/appraiser-tools.js +28 -0
  3. package/dist/.opencode/plugins/foundry-tools/artefact-tools.js +58 -0
  4. package/dist/.opencode/plugins/foundry-tools/assay-tools.js +92 -0
  5. package/dist/.opencode/plugins/foundry-tools/attestation-tools.js +191 -0
  6. package/dist/.opencode/plugins/foundry-tools/config-create-tools.js +128 -0
  7. package/dist/.opencode/plugins/foundry-tools/config-law-tools.js +380 -0
  8. package/dist/.opencode/plugins/foundry-tools/config-tools.js +43 -0
  9. package/dist/.opencode/plugins/foundry-tools/feedback-tools.js +234 -0
  10. package/dist/.opencode/plugins/foundry-tools/git-helpers.js +354 -0
  11. package/dist/.opencode/plugins/foundry-tools/git-tools.js +181 -0
  12. package/dist/.opencode/plugins/foundry-tools/helpers.js +340 -0
  13. package/dist/.opencode/plugins/foundry-tools/history-tools.js +20 -0
  14. package/dist/.opencode/plugins/foundry-tools/memory-admin-tools.js +296 -0
  15. package/dist/.opencode/plugins/foundry-tools/memory-helpers.js +104 -0
  16. package/dist/.opencode/plugins/foundry-tools/memory-tools.js +286 -0
  17. package/dist/.opencode/plugins/foundry-tools/orchestrate-tool.js +159 -0
  18. package/dist/.opencode/plugins/foundry-tools/snapshot-tools.js +104 -0
  19. package/dist/.opencode/plugins/foundry-tools/stage-tools.js +186 -0
  20. package/dist/.opencode/plugins/foundry-tools/validate-tools.js +263 -0
  21. package/dist/.opencode/plugins/foundry-tools/workfile-tools.js +102 -0
  22. package/dist/.opencode/plugins/foundry.js +105 -0
  23. package/dist/CHANGELOG.md +490 -0
  24. package/dist/LICENSE +21 -0
  25. package/dist/README.md +278 -0
  26. package/dist/docs/README.md +59 -0
  27. package/dist/docs/architecture.md +434 -0
  28. package/dist/docs/concepts.md +396 -0
  29. package/dist/docs/getting-started.md +345 -0
  30. package/dist/docs/memory-maintenance.md +176 -0
  31. package/dist/docs/tools.md +1411 -0
  32. package/dist/docs/work-spec.md +283 -0
  33. package/dist/scripts/lib/artefacts.js +151 -0
  34. package/dist/scripts/lib/assay/loader.js +151 -0
  35. package/dist/scripts/lib/assay/parse-jsonl.js +102 -0
  36. package/dist/scripts/lib/assay/permissions.js +52 -0
  37. package/dist/scripts/lib/assay/run.js +219 -0
  38. package/dist/scripts/lib/assay/spawn-with-timeout.js +138 -0
  39. package/dist/scripts/lib/attestation/attest.js +111 -0
  40. package/dist/scripts/lib/attestation/canonical-json.js +109 -0
  41. package/dist/scripts/lib/attestation/hash.js +17 -0
  42. package/dist/scripts/lib/attestation/parse.js +14 -0
  43. package/dist/scripts/lib/attestation/payload.js +106 -0
  44. package/dist/scripts/lib/attestation/render.js +16 -0
  45. package/dist/scripts/lib/attestation/verify.js +15 -0
  46. package/dist/scripts/lib/branch-guard.js +72 -0
  47. package/dist/scripts/lib/config-creators/appraiser.js +9 -0
  48. package/dist/scripts/lib/config-creators/artefact-type.js +9 -0
  49. package/dist/scripts/lib/config-creators/cycle.js +11 -0
  50. package/dist/scripts/lib/config-creators/factory.js +49 -0
  51. package/dist/scripts/lib/config-creators/flow.js +11 -0
  52. package/dist/scripts/lib/config-validators/appraiser.js +49 -0
  53. package/dist/scripts/lib/config-validators/artefact-type.js +38 -0
  54. package/dist/scripts/lib/config-validators/cycle.js +131 -0
  55. package/dist/scripts/lib/config-validators/flow.js +57 -0
  56. package/dist/scripts/lib/config-validators/helpers.js +96 -0
  57. package/dist/scripts/lib/config-validators/law.js +96 -0
  58. package/dist/scripts/lib/config.js +393 -0
  59. package/dist/scripts/lib/failed-flow.js +131 -0
  60. package/dist/scripts/lib/feedback-store.js +249 -0
  61. package/dist/scripts/lib/feedback-transitions.js +105 -0
  62. package/dist/scripts/lib/finalize.js +70 -0
  63. package/dist/scripts/lib/foundational-guards.js +13 -0
  64. package/dist/scripts/lib/git-bridge.js +77 -0
  65. package/dist/scripts/lib/git-finish/work-finish.js +233 -0
  66. package/dist/scripts/lib/git-policy.js +101 -0
  67. package/dist/scripts/lib/guards.js +125 -0
  68. package/dist/scripts/lib/history.js +132 -0
  69. package/dist/scripts/lib/memory/admin/create-edge-type.js +91 -0
  70. package/dist/scripts/lib/memory/admin/create-entity-type.js +43 -0
  71. package/dist/scripts/lib/memory/admin/create-extractor.js +67 -0
  72. package/dist/scripts/lib/memory/admin/drop-edge-type.js +40 -0
  73. package/dist/scripts/lib/memory/admin/drop-entity-type.js +172 -0
  74. package/dist/scripts/lib/memory/admin/dump.js +47 -0
  75. package/dist/scripts/lib/memory/admin/helpers.js +31 -0
  76. package/dist/scripts/lib/memory/admin/init.js +170 -0
  77. package/dist/scripts/lib/memory/admin/live-store.js +76 -0
  78. package/dist/scripts/lib/memory/admin/reembed.js +285 -0
  79. package/dist/scripts/lib/memory/admin/rename-edge-type.js +54 -0
  80. package/dist/scripts/lib/memory/admin/rename-entity-type.js +151 -0
  81. package/dist/scripts/lib/memory/admin/reset.js +24 -0
  82. package/dist/scripts/lib/memory/admin/vacuum.js +9 -0
  83. package/dist/scripts/lib/memory/admin/validate.js +19 -0
  84. package/dist/scripts/lib/memory/config.js +149 -0
  85. package/dist/scripts/lib/memory/cozo.js +136 -0
  86. package/dist/scripts/lib/memory/drift.js +71 -0
  87. package/dist/scripts/lib/memory/embeddings.js +128 -0
  88. package/dist/scripts/lib/memory/frontmatter.js +75 -0
  89. package/dist/scripts/lib/memory/ndjson.js +84 -0
  90. package/dist/scripts/lib/memory/paths.js +25 -0
  91. package/dist/scripts/lib/memory/permissions.js +41 -0
  92. package/dist/scripts/lib/memory/prompt.js +109 -0
  93. package/dist/scripts/lib/memory/query.js +56 -0
  94. package/dist/scripts/lib/memory/reads.js +109 -0
  95. package/dist/scripts/lib/memory/schema.js +64 -0
  96. package/dist/scripts/lib/memory/search.js +73 -0
  97. package/dist/scripts/lib/memory/singleton.js +49 -0
  98. package/dist/scripts/lib/memory/store.js +162 -0
  99. package/dist/scripts/lib/memory/types.js +93 -0
  100. package/dist/scripts/lib/memory/validate.js +58 -0
  101. package/dist/scripts/lib/memory/writes.js +40 -0
  102. package/{scripts → dist/scripts}/lib/pending.js +7 -2
  103. package/dist/scripts/lib/secret.js +59 -0
  104. package/{scripts → dist/scripts}/lib/slug.js +3 -2
  105. package/dist/scripts/lib/snapshot/finish.js +103 -0
  106. package/dist/scripts/lib/snapshot/inspect.js +253 -0
  107. package/dist/scripts/lib/snapshot/render.js +55 -0
  108. package/dist/scripts/lib/sort-fs-check.js +121 -0
  109. package/dist/scripts/lib/sort-routing.js +101 -0
  110. package/{scripts → dist/scripts}/lib/stage-guard.js +12 -6
  111. package/{scripts → dist/scripts}/lib/state.js +4 -0
  112. package/dist/scripts/lib/token.js +57 -0
  113. package/dist/scripts/lib/tracing.js +59 -0
  114. package/dist/scripts/lib/ulid.js +100 -0
  115. package/dist/scripts/lib/validator-jsonl.js +162 -0
  116. package/{scripts → dist/scripts}/lib/workfile.js +38 -20
  117. package/dist/scripts/orchestrate-cycle.js +215 -0
  118. package/dist/scripts/orchestrate-phases.js +314 -0
  119. package/dist/scripts/orchestrate.js +163 -0
  120. package/dist/scripts/sort.js +278 -0
  121. package/{skills → dist/skills}/add-appraiser/SKILL.md +42 -6
  122. package/{skills → dist/skills}/add-artefact-type/SKILL.md +49 -21
  123. package/{skills → dist/skills}/add-cycle/SKILL.md +60 -14
  124. package/dist/skills/add-extractor/SKILL.md +133 -0
  125. package/{skills → dist/skills}/add-flow/SKILL.md +39 -7
  126. package/dist/skills/add-law/SKILL.md +191 -0
  127. package/dist/skills/add-memory-edge-type/SKILL.md +52 -0
  128. package/dist/skills/add-memory-entity-type/SKILL.md +74 -0
  129. package/{skills → dist/skills}/appraise/SKILL.md +62 -13
  130. package/dist/skills/assay/SKILL.md +72 -0
  131. package/dist/skills/change-embedding-model/SKILL.md +58 -0
  132. package/dist/skills/drop-memory-edge-type/SKILL.md +54 -0
  133. package/dist/skills/drop-memory-entity-type/SKILL.md +57 -0
  134. package/dist/skills/dry-run/SKILL.md +116 -0
  135. package/{skills → dist/skills}/flow/SKILL.md +15 -2
  136. package/dist/skills/forge/SKILL.md +121 -0
  137. package/dist/skills/human-appraise/SKILL.md +153 -0
  138. package/{skills → dist/skills}/init-foundry/SKILL.md +23 -4
  139. package/dist/skills/init-memory/SKILL.md +92 -0
  140. package/{skills → dist/skills}/orchestrate/SKILL.md +30 -4
  141. package/dist/skills/quench/SKILL.md +99 -0
  142. package/{skills → dist/skills}/refresh-agents/SKILL.md +1 -1
  143. package/dist/skills/rename-memory-edge-type/SKILL.md +50 -0
  144. package/dist/skills/rename-memory-entity-type/SKILL.md +51 -0
  145. package/dist/skills/reset-memory/SKILL.md +54 -0
  146. package/dist/skills/upgrade-foundry/SKILL.md +192 -0
  147. package/package.json +34 -17
  148. package/.opencode/plugins/foundry.js +0 -761
  149. package/CHANGELOG.md +0 -90
  150. package/docs/concepts.md +0 -59
  151. package/docs/getting-started.md +0 -78
  152. package/docs/work-spec.md +0 -193
  153. package/scripts/lib/artefacts.js +0 -124
  154. package/scripts/lib/config.js +0 -175
  155. package/scripts/lib/feedback-transitions.js +0 -25
  156. package/scripts/lib/feedback.js +0 -440
  157. package/scripts/lib/finalize.js +0 -41
  158. package/scripts/lib/history.js +0 -59
  159. package/scripts/lib/secret.js +0 -23
  160. package/scripts/lib/tags.js +0 -108
  161. package/scripts/lib/token.js +0 -26
  162. package/scripts/orchestrate.js +0 -418
  163. package/scripts/sort.js +0 -370
  164. package/scripts/validate-tags.js +0 -54
  165. package/skills/add-law/SKILL.md +0 -105
  166. package/skills/forge/SKILL.md +0 -88
  167. package/skills/human-appraise/SKILL.md +0 -82
  168. package/skills/quench/SKILL.md +0 -62
  169. package/skills/upgrade-foundry/SKILL.md +0 -216
  170. /package/{skills → dist/skills}/list-agents/SKILL.md +0 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Sort routing helpers — pure functions that decide the next stage given
3
+ * the current stage list, history, feedback, and iteration counters.
4
+ *
5
+ * Extracted from `src/scripts/sort.js` to keep that file under the
6
+ * configured `max-lines` limit and to lower per-function complexity.
7
+ */
8
+
9
+ // Spec §6.1: an item is "open" (still in flight) when its head state is
10
+ // 'open', 'actioned', 'rejected', or 'wont-fix' — equivalently, when the
11
+ // state is neither 'resolved' nor 'deadlocked'.
12
+ const isOpenItem = (f) => f.state !== 'resolved' && f.state !== 'deadlocked';
13
+
14
+ export function baseStage(stage) {
15
+ return stage.split(':')[0];
16
+ }
17
+
18
+ export function findFirst(stages, base) {
19
+ for (const s of stages) {
20
+ if (baseStage(s) === base) return s;
21
+ }
22
+ return null;
23
+ }
24
+
25
+ export function nextInRoute(stages, current) {
26
+ const idx = stages.indexOf(current);
27
+ if (idx !== -1 && idx + 1 < stages.length) {
28
+ return stages[idx + 1];
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function hasItemsNeedingForge(openItems) {
34
+ return openItems.some(f => f.state === 'open' || f.state === 'rejected');
35
+ }
36
+
37
+ function hasItemsPendingApproval(openItems) {
38
+ return openItems.some(f => f.state === 'actioned' || f.state === 'wont-fix');
39
+ }
40
+
41
+ function routeForgeIfNeeded(stages, forgeCount, maxIterations) {
42
+ if (forgeCount >= maxIterations) return 'blocked';
43
+ return findFirst(stages, 'forge') ?? 'blocked';
44
+ }
45
+
46
+ function appraiseForgeOrApproval(stages, openItems, forgeCount, maxIterations) {
47
+ if (hasItemsNeedingForge(openItems)) {
48
+ return routeForgeIfNeeded(stages, forgeCount, maxIterations);
49
+ }
50
+ if (hasItemsPendingApproval(openItems)) {
51
+ return findFirst(stages, 'appraise') ?? 'blocked';
52
+ }
53
+ return null;
54
+ }
55
+
56
+ export function nextAfterAppraise({ stages, current, feedback, forgeCount, maxIterations }) {
57
+ // Note: deadlock detection is handled by runDeadlockPass at the top of
58
+ // runSort (spec §6.1). This helper assumes routing has already been allowed
59
+ // to fall through (i.e., no item qualifies as deadlocked).
60
+ const openItems = feedback.filter(isOpenItem);
61
+ const decided = appraiseForgeOrApproval(stages, openItems, forgeCount, maxIterations);
62
+ if (decided !== null) return decided;
63
+ return nextInRoute(stages, current) ?? 'done';
64
+ }
65
+
66
+ export function nextAfterQuench(stages, current, feedback, forgeCount, maxIterations) {
67
+ const openItems = feedback.filter(isOpenItem);
68
+ const needsForge = openItems.some(f => f.state === 'open' || f.state === 'rejected');
69
+ if (needsForge) return routeForgeIfNeeded(stages, forgeCount, maxIterations);
70
+ return nextInRoute(stages, current) ?? 'done';
71
+ }
72
+
73
+ function lastNonSortStage(history) {
74
+ const nonSort = history.filter(e => baseStage(e.stage || '') !== 'sort');
75
+ if (nonSort.length === 0) return null;
76
+ return nonSort[nonSort.length - 1].stage;
77
+ }
78
+
79
+ function buildRouteHandlers({ stages, lastEntry, feedback, forgeCount, maxIterations }) {
80
+ const appraiseRoute = () => nextAfterAppraise({
81
+ stages, current: lastEntry, feedback, forgeCount, maxIterations,
82
+ });
83
+ return {
84
+ 'assay': () => findFirst(stages, 'forge') ?? 'blocked',
85
+ 'forge': () => nextInRoute(stages, lastEntry) ?? 'done',
86
+ 'quench': () => nextAfterQuench(stages, lastEntry, feedback, forgeCount, maxIterations),
87
+ 'appraise': appraiseRoute,
88
+ 'human-appraise': appraiseRoute,
89
+ };
90
+ }
91
+
92
+ export function determineRoute(stages, history, feedback, maxIterations) {
93
+ const forgeCount = history.filter(e => baseStage(e.stage || '') === 'forge').length;
94
+ const lastEntry = lastNonSortStage(history);
95
+ if (lastEntry === null) return stages[0];
96
+ const handlers = buildRouteHandlers({
97
+ stages, lastEntry, feedback, forgeCount, maxIterations,
98
+ });
99
+ const handler = handlers[baseStage(lastEntry)];
100
+ return handler ? handler() : 'blocked';
101
+ }
@@ -12,14 +12,20 @@ export function requireNoActiveStage(io) {
12
12
  return { ok: false, error: `tool requires no active stage; current: ${a.stage}` };
13
13
  }
14
14
 
15
+ function stageMismatchError(active, stageBase, cycle) {
16
+ if (stageBase && stageBaseOf(active.stage) !== stageBase) {
17
+ return `tool requires active ${stageBase} stage; current: ${active.stage}`;
18
+ }
19
+ if (cycle && active.cycle !== cycle) {
20
+ return `tool requires active stage in cycle ${cycle}; current cycle: ${active.cycle}`;
21
+ }
22
+ return null;
23
+ }
24
+
15
25
  export function requireActiveStage(io, { stageBase, cycle } = {}) {
16
26
  const a = readActiveStage(io);
17
27
  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
- }
28
+ const mismatch = stageMismatchError(a, stageBase, cycle);
29
+ if (mismatch) return { ok: false, error: mismatch };
24
30
  return { ok: true, active: a };
25
31
  }
@@ -29,3 +29,7 @@ export function writeLastStage(io, payload) {
29
29
  ensureFoundryDir(io);
30
30
  io.writeFile(LAST, JSON.stringify(payload, null, 2));
31
31
  }
32
+
33
+ export function clearLastStage(io) {
34
+ io.unlink(LAST);
35
+ }
@@ -0,0 +1,57 @@
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
+ function splitToken(token) {
10
+ if (typeof token !== 'string' || !token.includes('.')) return null;
11
+ const [body, mac] = token.split('.');
12
+ if (!body || !mac) return null;
13
+ return { body, mac };
14
+ }
15
+
16
+ function decodeMac(mac) {
17
+ try { return Buffer.from(mac, 'base64url'); }
18
+ catch { return null; }
19
+ }
20
+
21
+ function decodePayload(body) {
22
+ try { return JSON.parse(Buffer.from(body, 'base64url').toString()); }
23
+ catch { return null; }
24
+ }
25
+
26
+ function checkSignature(body, mac, secret) {
27
+ const expected = createHmac('sha256', secret).update(body).digest();
28
+ const given = decodeMac(mac);
29
+ if (!given) return { ok: false, reason: 'malformed' };
30
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
31
+ return { ok: false, reason: 'bad_signature' };
32
+ }
33
+ return { ok: true };
34
+ }
35
+
36
+ function checkExpiry(payload) {
37
+ if (typeof payload.exp !== 'number' || payload.exp < Date.now()) {
38
+ return { ok: false, reason: 'expired' };
39
+ }
40
+ return { ok: true };
41
+ }
42
+
43
+ export function verifyToken(token, secret) {
44
+ const parts = splitToken(token);
45
+ if (!parts) return { ok: false, reason: 'malformed' };
46
+
47
+ const sigCheck = checkSignature(parts.body, parts.mac, secret);
48
+ if (!sigCheck.ok) return sigCheck;
49
+
50
+ const payload = decodePayload(parts.body);
51
+ if (!payload) return { ok: false, reason: 'malformed' };
52
+
53
+ const expiryCheck = checkExpiry(payload);
54
+ if (!expiryCheck.ok) return expiryCheck;
55
+
56
+ return { ok: true, payload };
57
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * JSONL trace appender for dry-run branches.
3
+ *
4
+ * Trace files live under `.foundry/trace/<branchSlug>.jsonl`, one JSON
5
+ * record per line. The IO contract is async and minimal so the module
6
+ * stays testable with in-memory mocks.
7
+ */
8
+
9
+ const TRACE_DIR = '.foundry/trace';
10
+
11
+ /**
12
+ * Convert a git branch name to a filesystem-safe slug by replacing every
13
+ * `/` with `-`. Pure string transform — no validation, since callers pass
14
+ * already-validated branch names.
15
+ */
16
+ export function branchSlug(branch) {
17
+ return branch.replace(/\//g, '-');
18
+ }
19
+
20
+ function tracePath(branch) {
21
+ return `${TRACE_DIR}/${branchSlug(branch)}.jsonl`;
22
+ }
23
+
24
+ /**
25
+ * Append a single JSONL record to the trace file for `branch`.
26
+ *
27
+ * Ensures the trace directory exists before writing. Uses `io.appendFile`
28
+ * when present, otherwise falls back to read-then-write concatenation
29
+ * (treating ENOENT as empty).
30
+ */
31
+ export async function appendTraceRecord({ branch, record, io }) {
32
+ const path = tracePath(branch);
33
+ const line = JSON.stringify(record) + '\n';
34
+
35
+ await io.mkdirp(TRACE_DIR);
36
+
37
+ if (typeof io.appendFile === 'function') {
38
+ await io.appendFile(path, line);
39
+ return;
40
+ }
41
+
42
+ let existing = '';
43
+ try {
44
+ existing = await io.readFile(path);
45
+ } catch (err) {
46
+ if (err && err.code !== 'ENOENT') throw err;
47
+ }
48
+ await io.writeFile(path, existing + line);
49
+ }
50
+
51
+ /**
52
+ * Truncate the trace file for `branch` to empty. No-op when the file
53
+ * does not exist.
54
+ */
55
+ export async function truncateTrace({ branch, io }) {
56
+ const path = tracePath(branch);
57
+ if (!(await io.exists(path))) return;
58
+ await io.writeFile(path, '');
59
+ }
@@ -0,0 +1,100 @@
1
+ // scripts/lib/ulid.js
2
+ import { randomBytes } from 'node:crypto';
3
+
4
+ // Crockford's base32 alphabet (excludes I, L, O, U).
5
+ const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
6
+
7
+ // ULID spec: 10 chars of timestamp (48-bit ms since epoch) + 16 chars of randomness (80 bits).
8
+ // We make the randomness monotonic within the same millisecond by incrementing the previous
9
+ // random component by 1 whenever the timestamp hasn't advanced.
10
+
11
+ function encodeTime(ms) {
12
+ let out = '';
13
+ let remaining = ms;
14
+ for (let i = 9; i >= 0; i--) {
15
+ out = ALPHABET[remaining % 32] + out;
16
+ remaining = Math.floor(remaining / 32);
17
+ }
18
+ return out;
19
+ }
20
+
21
+ function randomIndexes() {
22
+ const bytes = randomBytes(10); // 80 bits
23
+ const out = new Array(16);
24
+ // Pack 80 bits into 16 5-bit groups.
25
+ let bitBuffer = 0;
26
+ let bits = 0;
27
+ let j = 0;
28
+ for (let i = 0; i < bytes.length; i++) {
29
+ bitBuffer = bitBuffer * 256 + bytes[i];
30
+ bits += 8;
31
+ while (bits >= 5) {
32
+ bits -= 5;
33
+ out[j++] = Math.floor(bitBuffer / Math.pow(2, bits)) % 32;
34
+ }
35
+ }
36
+ return out;
37
+ }
38
+
39
+ function incrementRandom(arr) {
40
+ // Increment as a base-32 little-endian-ish counter from the right.
41
+ const next = arr.slice();
42
+ for (let i = next.length - 1; i >= 0; i--) {
43
+ if (next[i] < 31) { next[i] += 1; return next; }
44
+ next[i] = 0;
45
+ }
46
+ // Overflow across all 80 bits: re-seed. Extraordinarily unlikely.
47
+ return randomIndexes();
48
+ }
49
+
50
+ /**
51
+ * Creates an independent ULID generator with its own monotonicity state.
52
+ *
53
+ * Monotonicity state (lastTime, lastRandom) is kept in closure, not module
54
+ * scope, so tests can instantiate isolated generators and production code
55
+ * can import a single shared instance without cross-test contamination.
56
+ *
57
+ * @returns {(now?: number) => string} generator function
58
+ */
59
+ export function createUlidGenerator() {
60
+ let lastTime = 0;
61
+ let lastRandom = null; // array of 16 base32 char indexes
62
+
63
+ return function ulid(now = Date.now()) {
64
+ let randArr;
65
+ if (now === lastTime && lastRandom) {
66
+ randArr = incrementRandom(lastRandom);
67
+ } else {
68
+ randArr = randomIndexes();
69
+ }
70
+ lastTime = now;
71
+ lastRandom = randArr;
72
+ const rand = randArr.map(i => ALPHABET[i]).join('');
73
+ return encodeTime(now) + rand;
74
+ };
75
+ }
76
+
77
+ // Default shared generator — preserves ergonomic `import { ulid }` usage.
78
+ // Tests that need deterministic, isolated state should call createUlidGenerator().
79
+ export const ulid = createUlidGenerator();
80
+
81
+ /**
82
+ * Reverses encodeTime — first 10 chars of a ULID encode 48-bit ms since epoch.
83
+ * Returns the integer ms. Throws if any of the first 10 chars is not in the
84
+ * Crockford alphabet.
85
+ *
86
+ * @param {string} id ULID string (only first 10 chars are inspected).
87
+ * @returns {number} milliseconds since epoch.
88
+ */
89
+ export function decodeUlidTime(id) {
90
+ let time = 0;
91
+ for (let i = 0; i < 10; i++) {
92
+ const ch = id[i];
93
+ const idx = ALPHABET.indexOf(ch);
94
+ if (idx === -1) {
95
+ throw new Error(`decodeUlidTime: invalid Crockford base32 char '${ch}' at position ${i}`);
96
+ }
97
+ time = time * 32 + idx;
98
+ }
99
+ return time;
100
+ }
@@ -0,0 +1,162 @@
1
+ import readline from 'readline';
2
+ import { minimatch } from 'minimatch';
3
+
4
+ /**
5
+ * Parse JSONL output from a validator, validating each line against patterns.
6
+ *
7
+ * Processes one JSON object per line. Each line must have:
8
+ * - file (REQUIRED): matches at least one pattern from patterns array
9
+ * - text (REQUIRED): feedback text
10
+ * - location (OPTIONAL): "line:col" format, prepended to text if present
11
+ * - severity (OPTIONAL): "error", "warning", etc., prepended to text if present
12
+ *
13
+ * If location and/or severity present, they are prepended to text as:
14
+ * [severity] file:location — <text>
15
+ * If only severity: [severity] file — <text>
16
+ * If only location: file:location — <text>
17
+ *
18
+ * Successfully parsed and pattern-matched lines flow into `items`.
19
+ * Errors are split into two categories so callers can distinguish them:
20
+ * - `parseErrors`: malformed JSON or missing required fields
21
+ * - `patternErrors`: file did not match any artefact-type file-pattern
22
+ *
23
+ * `ok` is true only when both error arrays are empty. Items are always
24
+ * returned regardless of `ok`, so a validator producing a mix of valid
25
+ * items and errors surfaces both.
26
+ *
27
+ * @param {Stream} stream - readable stream of JSONL lines
28
+ * @param {string[]} patterns - array of glob patterns for file matching
29
+ * @returns {Promise<{ok: boolean, items: object[], parseErrors: string[], patternErrors: string[]}>}
30
+ */
31
+ export async function parseValidatorJsonl(stream, patterns) {
32
+ const items = [];
33
+ const parseErrors = [];
34
+ const patternErrors = [];
35
+
36
+ return new Promise((resolve) => {
37
+ const rl = readline.createInterface({
38
+ input: stream,
39
+ crlfDelay: Infinity,
40
+ });
41
+
42
+ let lineNum = 0;
43
+
44
+ rl.on('line', (line) => {
45
+ lineNum++;
46
+ processLine(line, lineNum, patterns, items, { parseErrors, patternErrors });
47
+ });
48
+
49
+ rl.on('close', () => {
50
+ resolve(buildResult(items, parseErrors, patternErrors));
51
+ });
52
+
53
+ rl.on('error', (err) => {
54
+ parseErrors.push(`Stream error: ${err.message}`);
55
+ resolve(buildResult(items, parseErrors, patternErrors));
56
+ });
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Build the final parse result with `ok` reflecting whether any errors occurred.
62
+ */
63
+ function buildResult(items, parseErrors, patternErrors) {
64
+ const ok = parseErrors.length === 0 && patternErrors.length === 0;
65
+ return { ok, items, parseErrors, patternErrors };
66
+ }
67
+
68
+ /**
69
+ * Process a single JSONL line.
70
+ *
71
+ * `errors` bundles `parseErrors` and `patternErrors` so this function stays
72
+ * within the project's max-params lint budget.
73
+ */
74
+ function processLine(line, lineNum, patterns, items, errors) {
75
+ const { parseErrors, patternErrors } = errors;
76
+ const trimmed = line.trim();
77
+
78
+ // Skip empty lines
79
+ if (!trimmed) return;
80
+
81
+ // Parse JSON
82
+ let obj;
83
+ try {
84
+ obj = JSON.parse(trimmed);
85
+ } catch (err) {
86
+ parseErrors.push(`Line ${lineNum}: Invalid JSON: ${err.message}`);
87
+ return;
88
+ }
89
+
90
+ // Validate required fields
91
+ const validation = validateRequired(obj, lineNum);
92
+ if (validation.error) {
93
+ parseErrors.push(validation.error);
94
+ return;
95
+ }
96
+
97
+ // Validate file matches pattern
98
+ if (!fileMatchesPattern(obj.file, patterns)) {
99
+ patternErrors.push(`Line ${lineNum}: File '${obj.file}' does not match any pattern: ${patterns.join(', ')}`);
100
+ return;
101
+ }
102
+
103
+ // Build final item with location/severity prepended if present
104
+ const finalItem = buildFinalItem(obj);
105
+ items.push(finalItem);
106
+ }
107
+
108
+ /**
109
+ * Validate required fields in parsed line.
110
+ */
111
+ function validateRequired(obj, lineNum) {
112
+ if (typeof obj.file !== 'string' || obj.file.length === 0) {
113
+ return { error: `Line ${lineNum}: Missing required field 'file'` };
114
+ }
115
+ if (typeof obj.text !== 'string' || obj.text.length === 0) {
116
+ return { error: `Line ${lineNum}: Missing or empty required field 'text'` };
117
+ }
118
+ return { error: null };
119
+ }
120
+
121
+ /**
122
+ * Check if a file path matches at least one pattern.
123
+ */
124
+ function fileMatchesPattern(file, patterns) {
125
+ return patterns.some(pattern => minimatch(file, pattern));
126
+ }
127
+
128
+ /**
129
+ * Build final item with location/severity prepended to text if present.
130
+ */
131
+ function buildFinalItem(obj) {
132
+ const { file, text, location, severity, ...extra } = obj;
133
+ const finalText = prependLocationSeverity(text, file, location, severity);
134
+
135
+ // Return all fields including extra ones
136
+ const result = { file, text: finalText, ...extra };
137
+ if (location) result.location = location;
138
+ if (severity) result.severity = severity;
139
+
140
+ return result;
141
+ }
142
+
143
+ /**
144
+ * Prepend location and/or severity to text.
145
+ */
146
+ function prependLocationSeverity(text, file, location, severity) {
147
+ if (!severity && !location) {
148
+ return text;
149
+ }
150
+
151
+ let prefix = '';
152
+ if (severity) {
153
+ prefix += `[${severity}] `;
154
+ }
155
+ prefix += file;
156
+ if (location) {
157
+ prefix += `:${location}`;
158
+ }
159
+ prefix += ' — ';
160
+
161
+ return prefix + text;
162
+ }
@@ -8,8 +8,14 @@ import yaml from 'js-yaml';
8
8
  // Frontmatter parsing
9
9
  // ---------------------------------------------------------------------------
10
10
 
11
+ /**
12
+ * Parse YAML frontmatter from a markdown document.
13
+ * NOTE: Intentionally duplicates logic from memory/frontmatter.js for
14
+ * different use cases. See memory/frontmatter.js for the canonical version
15
+ * with full error handling and line-ending normalisation.
16
+ */
11
17
  export function parseFrontmatter(text) {
12
- const match = text.match(/^---\n(.+?)\n---/s);
18
+ const match = text.match(/^---\r?\n(.+?)\r?\n---/s);
13
19
  if (!match) return {};
14
20
  const fm = yaml.load(match[1]) || {};
15
21
  // Normalize: on-disk canonical key is `max-iterations` (kebab).
@@ -24,7 +30,7 @@ export function parseFrontmatter(text) {
24
30
  }
25
31
 
26
32
  export function writeFrontmatter(fields) {
27
- const body = yaml.dump(fields, { lineWidth: -1 }).trimEnd();
33
+ const body = yaml.dump(fields, { lineWidth: -1, sortKeys: false }).trimEnd();
28
34
  return `---\n${body}\n---`;
29
35
  }
30
36
 
@@ -33,15 +39,21 @@ export function getFrontmatterField(text, key) {
33
39
  return fm[key];
34
40
  }
35
41
 
42
+ /**
43
+ * Update a frontmatter field.
44
+ *
45
+ * Note: This function preserves key order but does not preserve YAML comments.
46
+ * If the frontmatter contains comments, they will be lost during rewrite.
47
+ */
36
48
  export function setFrontmatterField(text, key, value) {
37
49
  // Coerce legacy camelCase key to canonical kebab form on write.
38
- if (key === 'maxIterations') key = 'max-iterations';
50
+ const normalisedKey = key === 'maxIterations' ? 'max-iterations' : key;
39
51
  const fm = parseFrontmatter(text);
40
- fm[key] = value;
52
+ fm[normalisedKey] = value;
41
53
  const fmBlock = writeFrontmatter(fm);
42
54
 
43
55
  // Strip existing frontmatter (if any) and prepend new one
44
- const body = text.replace(/^---\n.+?\n---\n?/s, '');
56
+ const body = text.replace(/^---\r?\n.+?\r?\n---\r?\n?/s, '');
45
57
  return body ? `${fmBlock}\n${body}` : fmBlock;
46
58
  }
47
59
 
@@ -73,19 +85,7 @@ export function parseStagesValue(raw) {
73
85
  return raw.split(',').map(s => s.trim()).filter(Boolean);
74
86
  }
75
87
 
76
- /**
77
- * Parse a models value from tool input.
78
- * Accepts JSON object string or "key: value, key: value" string.
79
- * Always returns an object mapping stage base names to model IDs.
80
- */
81
- export function parseModelsValue(raw) {
82
- if (!raw || !raw.trim()) return {};
83
- // Try JSON first
84
- try {
85
- const parsed = JSON.parse(raw);
86
- if (typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
87
- } catch { /* not JSON */ }
88
- // Fall back to "key: value, key: value" format
88
+ function parseKvPairs(raw) {
89
89
  const result = {};
90
90
  for (const part of raw.split(',')) {
91
91
  const colonIdx = part.indexOf(':');
@@ -97,6 +97,26 @@ export function parseModelsValue(raw) {
97
97
  return result;
98
98
  }
99
99
 
100
+ function tryParseJsonObject(raw) {
101
+ try {
102
+ const parsed = JSON.parse(raw);
103
+ if (typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
104
+ } catch { /* not JSON */ }
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * Parse a models value from tool input.
110
+ * Accepts JSON object string or "key: value, key: value" string.
111
+ * Always returns an object mapping stage base names to model IDs.
112
+ */
113
+ export function parseModelsValue(raw) {
114
+ if (!raw || !raw.trim()) return {};
115
+ const jsonResult = tryParseJsonObject(raw);
116
+ if (jsonResult) return jsonResult;
117
+ return parseKvPairs(raw);
118
+ }
119
+
100
120
  // ---------------------------------------------------------------------------
101
121
  // Workfile creation
102
122
  // ---------------------------------------------------------------------------
@@ -110,7 +130,5 @@ ${goal}
110
130
 
111
131
  | File | Type | Cycle | Status |
112
132
  |------|------|-------|--------|
113
-
114
- ## Feedback
115
133
  `;
116
134
  }