@really-knows-ai/foundry 2.3.2 → 3.0.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.
- package/README.md +180 -369
- package/dist/.opencode/plugins/foundry-tools/appraiser-tools.js +28 -0
- package/dist/.opencode/plugins/foundry-tools/artefact-tools.js +58 -0
- package/dist/.opencode/plugins/foundry-tools/assay-tools.js +92 -0
- package/dist/.opencode/plugins/foundry-tools/attestation-tools.js +191 -0
- package/dist/.opencode/plugins/foundry-tools/config-create-tools.js +128 -0
- package/dist/.opencode/plugins/foundry-tools/config-law-tools.js +380 -0
- package/dist/.opencode/plugins/foundry-tools/config-tools.js +43 -0
- package/dist/.opencode/plugins/foundry-tools/feedback-tools.js +234 -0
- package/dist/.opencode/plugins/foundry-tools/git-helpers.js +354 -0
- package/dist/.opencode/plugins/foundry-tools/git-tools.js +181 -0
- package/dist/.opencode/plugins/foundry-tools/helpers.js +340 -0
- package/dist/.opencode/plugins/foundry-tools/history-tools.js +20 -0
- package/dist/.opencode/plugins/foundry-tools/memory-admin-tools.js +296 -0
- package/dist/.opencode/plugins/foundry-tools/memory-helpers.js +104 -0
- package/dist/.opencode/plugins/foundry-tools/memory-tools.js +286 -0
- package/dist/.opencode/plugins/foundry-tools/orchestrate-tool.js +159 -0
- package/dist/.opencode/plugins/foundry-tools/snapshot-tools.js +104 -0
- package/dist/.opencode/plugins/foundry-tools/stage-tools.js +186 -0
- package/dist/.opencode/plugins/foundry-tools/validate-tools.js +263 -0
- package/dist/.opencode/plugins/foundry-tools/workfile-tools.js +102 -0
- package/dist/.opencode/plugins/foundry.js +105 -0
- package/dist/CHANGELOG.md +533 -0
- package/dist/LICENSE +21 -0
- package/dist/README.md +278 -0
- package/dist/docs/README.md +59 -0
- package/dist/docs/architecture.md +433 -0
- package/dist/docs/concepts.md +395 -0
- package/dist/docs/getting-started.md +344 -0
- package/dist/docs/memory-maintenance.md +176 -0
- package/dist/docs/tools.md +1411 -0
- package/dist/docs/work-spec.md +283 -0
- package/dist/scripts/lib/artefacts.js +151 -0
- package/dist/scripts/lib/assay/loader.js +151 -0
- package/dist/scripts/lib/assay/parse-jsonl.js +102 -0
- package/dist/scripts/lib/assay/permissions.js +52 -0
- package/dist/scripts/lib/assay/run.js +219 -0
- package/dist/scripts/lib/assay/spawn-with-timeout.js +138 -0
- package/dist/scripts/lib/attestation/attest.js +111 -0
- package/dist/scripts/lib/attestation/canonical-json.js +109 -0
- package/dist/scripts/lib/attestation/hash.js +17 -0
- package/dist/scripts/lib/attestation/parse.js +14 -0
- package/dist/scripts/lib/attestation/payload.js +106 -0
- package/dist/scripts/lib/attestation/render.js +16 -0
- package/dist/scripts/lib/attestation/verify.js +15 -0
- package/dist/scripts/lib/branch-guard.js +72 -0
- package/dist/scripts/lib/config-creators/appraiser.js +9 -0
- package/dist/scripts/lib/config-creators/artefact-type.js +9 -0
- package/dist/scripts/lib/config-creators/cycle.js +11 -0
- package/dist/scripts/lib/config-creators/factory.js +49 -0
- package/dist/scripts/lib/config-creators/flow.js +11 -0
- package/dist/scripts/lib/config-validators/appraiser.js +49 -0
- package/dist/scripts/lib/config-validators/artefact-type.js +38 -0
- package/dist/scripts/lib/config-validators/cycle.js +131 -0
- package/dist/scripts/lib/config-validators/flow.js +57 -0
- package/dist/scripts/lib/config-validators/helpers.js +96 -0
- package/dist/scripts/lib/config-validators/law.js +96 -0
- package/dist/scripts/lib/config.js +328 -0
- package/dist/scripts/lib/failed-flow.js +131 -0
- package/dist/scripts/lib/feedback-store.js +249 -0
- package/dist/scripts/lib/feedback-transitions.js +105 -0
- package/dist/scripts/lib/finalize.js +70 -0
- package/dist/scripts/lib/foundational-guards.js +13 -0
- package/dist/scripts/lib/git-bridge.js +77 -0
- package/dist/scripts/lib/git-finish/work-finish.js +233 -0
- package/dist/scripts/lib/git-policy.js +101 -0
- package/dist/scripts/lib/guards.js +125 -0
- package/dist/scripts/lib/history.js +132 -0
- package/dist/scripts/lib/memory/admin/create-edge-type.js +91 -0
- package/dist/scripts/lib/memory/admin/create-entity-type.js +43 -0
- package/dist/scripts/lib/memory/admin/create-extractor.js +67 -0
- package/dist/scripts/lib/memory/admin/drop-edge-type.js +40 -0
- package/dist/scripts/lib/memory/admin/drop-entity-type.js +172 -0
- package/dist/scripts/lib/memory/admin/dump.js +47 -0
- package/dist/scripts/lib/memory/admin/helpers.js +31 -0
- package/dist/scripts/lib/memory/admin/init.js +170 -0
- package/dist/scripts/lib/memory/admin/live-store.js +76 -0
- package/dist/scripts/lib/memory/admin/reembed.js +285 -0
- package/dist/scripts/lib/memory/admin/rename-edge-type.js +54 -0
- package/dist/scripts/lib/memory/admin/rename-entity-type.js +151 -0
- package/dist/scripts/lib/memory/admin/reset.js +24 -0
- package/dist/scripts/lib/memory/admin/vacuum.js +9 -0
- package/dist/scripts/lib/memory/admin/validate.js +19 -0
- package/dist/scripts/lib/memory/config.js +149 -0
- package/dist/scripts/lib/memory/cozo.js +136 -0
- package/dist/scripts/lib/memory/drift.js +71 -0
- package/dist/scripts/lib/memory/embeddings.js +128 -0
- package/dist/scripts/lib/memory/frontmatter.js +75 -0
- package/dist/scripts/lib/memory/ndjson.js +84 -0
- package/dist/scripts/lib/memory/paths.js +25 -0
- package/dist/scripts/lib/memory/permissions.js +41 -0
- package/dist/scripts/lib/memory/prompt.js +109 -0
- package/dist/scripts/lib/memory/query.js +56 -0
- package/dist/scripts/lib/memory/reads.js +109 -0
- package/dist/scripts/lib/memory/schema.js +64 -0
- package/dist/scripts/lib/memory/search.js +73 -0
- package/dist/scripts/lib/memory/singleton.js +49 -0
- package/dist/scripts/lib/memory/store.js +162 -0
- package/dist/scripts/lib/memory/types.js +93 -0
- package/dist/scripts/lib/memory/validate.js +58 -0
- package/dist/scripts/lib/memory/writes.js +40 -0
- package/{scripts → dist/scripts}/lib/pending.js +7 -2
- package/dist/scripts/lib/secret.js +59 -0
- package/{scripts → dist/scripts}/lib/slug.js +3 -2
- package/dist/scripts/lib/snapshot/finish.js +103 -0
- package/dist/scripts/lib/snapshot/inspect.js +253 -0
- package/dist/scripts/lib/snapshot/render.js +55 -0
- package/dist/scripts/lib/sort-fs-check.js +121 -0
- package/dist/scripts/lib/sort-routing.js +101 -0
- package/{scripts → dist/scripts}/lib/stage-guard.js +12 -6
- package/{scripts → dist/scripts}/lib/state.js +4 -0
- package/dist/scripts/lib/token.js +57 -0
- package/dist/scripts/lib/tracing.js +59 -0
- package/dist/scripts/lib/ulid.js +100 -0
- package/dist/scripts/lib/validator-jsonl.js +162 -0
- package/{scripts → dist/scripts}/lib/workfile.js +38 -20
- package/dist/scripts/orchestrate-cycle.js +215 -0
- package/dist/scripts/orchestrate-phases.js +314 -0
- package/dist/scripts/orchestrate.js +163 -0
- package/dist/scripts/sort.js +278 -0
- package/{skills → dist/skills}/add-appraiser/SKILL.md +39 -9
- package/{skills → dist/skills}/add-artefact-type/SKILL.md +62 -40
- package/{skills → dist/skills}/add-cycle/SKILL.md +57 -17
- package/dist/skills/add-extractor/SKILL.md +133 -0
- package/{skills → dist/skills}/add-flow/SKILL.md +36 -10
- package/dist/skills/add-law/SKILL.md +191 -0
- package/dist/skills/add-memory-edge-type/SKILL.md +52 -0
- package/dist/skills/add-memory-entity-type/SKILL.md +74 -0
- package/{skills → dist/skills}/appraise/SKILL.md +62 -13
- package/dist/skills/assay/SKILL.md +72 -0
- package/dist/skills/change-embedding-model/SKILL.md +58 -0
- package/dist/skills/drop-memory-edge-type/SKILL.md +54 -0
- package/dist/skills/drop-memory-entity-type/SKILL.md +57 -0
- package/dist/skills/dry-run/SKILL.md +116 -0
- package/{skills → dist/skills}/flow/SKILL.md +15 -2
- package/dist/skills/forge/SKILL.md +121 -0
- package/dist/skills/human-appraise/SKILL.md +153 -0
- package/{skills → dist/skills}/init-foundry/SKILL.md +23 -4
- package/dist/skills/init-memory/SKILL.md +92 -0
- package/{skills → dist/skills}/orchestrate/SKILL.md +30 -4
- package/dist/skills/quench/SKILL.md +99 -0
- package/{skills → dist/skills}/refresh-agents/SKILL.md +1 -1
- package/dist/skills/rename-memory-edge-type/SKILL.md +50 -0
- package/dist/skills/rename-memory-entity-type/SKILL.md +51 -0
- package/dist/skills/reset-memory/SKILL.md +54 -0
- package/dist/skills/upgrade-foundry/SKILL.md +191 -0
- package/package.json +34 -17
- package/.opencode/plugins/foundry.js +0 -761
- package/CHANGELOG.md +0 -100
- package/docs/concepts.md +0 -122
- package/docs/getting-started.md +0 -187
- package/docs/work-spec.md +0 -207
- package/scripts/lib/artefacts.js +0 -124
- package/scripts/lib/config.js +0 -175
- package/scripts/lib/feedback-transitions.js +0 -25
- package/scripts/lib/feedback.js +0 -440
- package/scripts/lib/finalize.js +0 -41
- package/scripts/lib/history.js +0 -59
- package/scripts/lib/secret.js +0 -23
- package/scripts/lib/tags.js +0 -108
- package/scripts/lib/token.js +0 -26
- package/scripts/orchestrate.js +0 -418
- package/scripts/sort.js +0 -370
- package/scripts/validate-tags.js +0 -54
- package/skills/add-law/SKILL.md +0 -111
- package/skills/forge/SKILL.md +0 -88
- package/skills/human-appraise/SKILL.md +0 -82
- package/skills/quench/SKILL.md +0 -62
- package/skills/upgrade-foundry/SKILL.md +0 -216
- /package/{skills → dist/skills}/list-agents/SKILL.md +0 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { entRelName, edgeRelName, cozoStringLit } from './cozo.js';
|
|
2
|
+
import { validateEntityWrite, validateEdgeWrite } from './validate.js';
|
|
3
|
+
|
|
4
|
+
const lit = cozoStringLit;
|
|
5
|
+
function vecLit(v) {
|
|
6
|
+
return `vec([${v.map((n) => Number(n).toString()).join(', ')}])`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function putEntity(store, { type, name, value }, vocabulary, { embedder } = {}) {
|
|
10
|
+
validateEntityWrite({ type, name, value }, vocabulary);
|
|
11
|
+
const rel = entRelName(type);
|
|
12
|
+
if (embedder) {
|
|
13
|
+
const vectors = await embedder([value]);
|
|
14
|
+
const vec = vectors && vectors[0];
|
|
15
|
+
if (!Array.isArray(vec)) throw new Error('embedder did not return a vector');
|
|
16
|
+
await store.db.run(
|
|
17
|
+
`?[name, value, embedding] <- [[${lit(name)}, ${lit(value)}, ${vecLit(vec)}]]\n:put ${rel} { name => value, embedding }`,
|
|
18
|
+
);
|
|
19
|
+
} else {
|
|
20
|
+
await store.db.run(
|
|
21
|
+
`?[name, value] <- [[${lit(name)}, ${lit(value)}]]\n:put ${rel} { name => value }`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function relate(store, { edge_type, from_type, from_name, to_type, to_name }, vocabulary) {
|
|
27
|
+
validateEdgeWrite({ edge_type, from_type, from_name, to_type, to_name }, vocabulary);
|
|
28
|
+
const rel = edgeRelName(edge_type);
|
|
29
|
+
await store.db.run(
|
|
30
|
+
`?[from_type, from_name, to_type, to_name] <- [[${lit(from_type)}, ${lit(from_name)}, ${lit(to_type)}, ${lit(to_name)}]]\n:put ${rel} { from_type, from_name, to_type, to_name }`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function unrelate(store, { edge_type, from_type, from_name, to_type, to_name }, vocabulary) {
|
|
35
|
+
validateEdgeWrite({ edge_type, from_type, from_name, to_type, to_name }, vocabulary);
|
|
36
|
+
const rel = edgeRelName(edge_type);
|
|
37
|
+
await store.db.run(
|
|
38
|
+
`?[from_type, from_name, to_type, to_name] <- [[${lit(from_type)}, ${lit(from_name)}, ${lit(to_type)}, ${lit(to_name)}]]\n:rm ${rel} { from_type, from_name, to_type, to_name }`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -5,13 +5,18 @@ export function createPendingStore() {
|
|
|
5
5
|
consume(nonce) {
|
|
6
6
|
const meta = map.get(nonce);
|
|
7
7
|
if (!meta) return null;
|
|
8
|
+
if (meta.exp < Date.now()) {
|
|
9
|
+
map.delete(nonce);
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
8
12
|
map.delete(nonce);
|
|
9
|
-
if (meta.exp < Date.now()) return null;
|
|
10
13
|
return meta;
|
|
11
14
|
},
|
|
12
|
-
|
|
15
|
+
gc() {
|
|
13
16
|
const now = Date.now();
|
|
14
17
|
for (const [k, v] of map) if (v.exp < now) map.delete(k);
|
|
18
|
+
},
|
|
19
|
+
size() {
|
|
15
20
|
return map.size;
|
|
16
21
|
},
|
|
17
22
|
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, openSync, writeSync, closeSync } from 'node:fs';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Lines that are considered equivalent to a `.foundry/` ignore rule.
|
|
6
|
+
// Git treats `.foundry` and `.foundry/` slightly differently (the former matches
|
|
7
|
+
// files too), but for our purpose any of these means the user is already
|
|
8
|
+
// ignoring the runtime directory.
|
|
9
|
+
const FOUNDRY_GITIGNORE_VARIANTS = new Set([
|
|
10
|
+
'.foundry',
|
|
11
|
+
'.foundry/',
|
|
12
|
+
'/.foundry',
|
|
13
|
+
'/.foundry/',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Idempotently ensure `.foundry/` is listed in the project's `.gitignore`.
|
|
18
|
+
* Creates the file if absent. Comments (`#`-prefixed lines) are ignored when
|
|
19
|
+
* checking for an existing entry.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} directory Project root.
|
|
22
|
+
* @returns {boolean} true if a line was appended, false if no change.
|
|
23
|
+
*/
|
|
24
|
+
export function ensureFoundryGitignored(directory) {
|
|
25
|
+
const path = join(directory, '.gitignore');
|
|
26
|
+
const exists = existsSync(path);
|
|
27
|
+
const current = exists ? readFileSync(path, 'utf-8') : '';
|
|
28
|
+
const present = current
|
|
29
|
+
.split(/\r?\n/)
|
|
30
|
+
.map((l) => l.trim())
|
|
31
|
+
.filter((l) => l.length > 0 && !l.startsWith('#'));
|
|
32
|
+
if (present.some((l) => FOUNDRY_GITIGNORE_VARIANTS.has(l))) return false;
|
|
33
|
+
const tail = current.length === 0 || current.endsWith('\n') ? '' : '\n';
|
|
34
|
+
writeFileSync(path, current + tail + '.foundry/\n', 'utf-8');
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function readOrCreateSecret(directory) {
|
|
39
|
+
const dir = join(directory, '.foundry');
|
|
40
|
+
const file = join(dir, '.secret');
|
|
41
|
+
// Ensure `.gitignore` lists `.foundry/` *before* the secret hits disk so the
|
|
42
|
+
// stage-token key is never momentarily visible as an untracked file.
|
|
43
|
+
ensureFoundryGitignored(directory);
|
|
44
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
45
|
+
const bytes = randomBytes(32);
|
|
46
|
+
let fd;
|
|
47
|
+
try {
|
|
48
|
+
fd = openSync(file, 'wx', 0o600);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (err.code === 'EEXIST') return readFileSync(file);
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
writeSync(fd, bytes);
|
|
55
|
+
} finally {
|
|
56
|
+
closeSync(fd);
|
|
57
|
+
}
|
|
58
|
+
return bytes;
|
|
59
|
+
}
|
|
@@ -22,8 +22,9 @@ export function slugify(input) {
|
|
|
22
22
|
.normalize('NFD')
|
|
23
23
|
.replace(/\p{Diacritic}/gu, '')
|
|
24
24
|
.toLowerCase()
|
|
25
|
-
.
|
|
26
|
-
.
|
|
25
|
+
.split(/[^a-z0-9]+/)
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.join('-');
|
|
27
28
|
|
|
28
29
|
if (slug.length === 0) {
|
|
29
30
|
throw new Error(`slugify: input produced empty slug (input: ${JSON.stringify(input)})`);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* finishDryRun — captures a dry-run branch as an on-disk snapshot under
|
|
3
|
+
* `.snapshots/<runId>/` on the parent config branch, then deletes the
|
|
4
|
+
* dry-run branch. Implements §11.3 of the config-branch design.
|
|
5
|
+
*
|
|
6
|
+
* Recovery: If snapshot write fails (line 73), the function returns early
|
|
7
|
+
* with {ok: false, ...} whilst still on the dry-run branch. The dry-run
|
|
8
|
+
* branch and partial `.snapshots/<runId>/` directory (if any) remain.
|
|
9
|
+
* Manual cleanup: delete the dry-run branch with `git branch -D <branch>`
|
|
10
|
+
* and remove the incomplete snapshot directory under `.snapshots/` if present.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { ulid } from '../ulid.js';
|
|
14
|
+
import { branchSlug } from '../tracing.js';
|
|
15
|
+
import { renderReadme } from './render.js';
|
|
16
|
+
|
|
17
|
+
const WORK_FILES = ['WORK.md', 'WORK.history.yaml', 'WORK.feedback.yaml'];
|
|
18
|
+
|
|
19
|
+
async function checkCleanTree(execFile) {
|
|
20
|
+
const status = await execFile(['status', '--porcelain', '--untracked-files=no']);
|
|
21
|
+
const trimmed = status.trim();
|
|
22
|
+
if (trimmed.length > 0) {
|
|
23
|
+
const dirty = trimmed.split('\n').map(l => l.slice(3).trim()).filter(Boolean);
|
|
24
|
+
return { ok: false, error: 'dirty worktree: cannot finish dry-run with uncommitted tracked changes', dirty };
|
|
25
|
+
}
|
|
26
|
+
return { ok: true };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function deriveParent(branch) {
|
|
30
|
+
const m = branch.match(/^dry-run\/([^/]+)\/[^/]+$/);
|
|
31
|
+
if (!m) return { ok: false, error: `cannot derive parent config branch from '${branch}'` };
|
|
32
|
+
return { ok: true, parent: `config/${m[1]}` };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function captureWorkFiles(io) {
|
|
36
|
+
const workCapture = {};
|
|
37
|
+
for (const f of WORK_FILES) {
|
|
38
|
+
if (await io.exists(f)) {
|
|
39
|
+
workCapture[f] = await io.readFile(f);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return workCapture;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function captureTrace(io, branch) {
|
|
46
|
+
const traceFile = `.foundry/trace/${branchSlug(branch)}.jsonl`;
|
|
47
|
+
if (await io.exists(traceFile)) return { traceFile, traceText: await io.readFile(traceFile) };
|
|
48
|
+
return { traceFile, traceText: '' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function writeSnapshot({ io, snapDir, readme, workCapture, diffPatch, traceText }) {
|
|
52
|
+
await io.mkdirp(`${snapDir}/work`);
|
|
53
|
+
await io.writeFile(`${snapDir}/README.md`, readme);
|
|
54
|
+
for (const [name, body] of Object.entries(workCapture)) {
|
|
55
|
+
await io.writeFile(`${snapDir}/work/${name}`, body);
|
|
56
|
+
}
|
|
57
|
+
await io.writeFile(`${snapDir}/diff.patch`, diffPatch);
|
|
58
|
+
await io.writeFile(`${snapDir}/trace.jsonl`, traceText);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function truncateTraceIfExists(io, traceFile) {
|
|
62
|
+
if (await io.exists(traceFile)) await io.writeFile(traceFile, '');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function cleanupDryRunBranch(execFile, parent, branch) {
|
|
66
|
+
await execFile(['checkout', parent]);
|
|
67
|
+
await execFile(['branch', '-D', branch]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function finishDryRun({ message, branch, io, execFile }) {
|
|
71
|
+
// 1. Verify clean tracked tree.
|
|
72
|
+
const cleanCheck = await checkCleanTree(execFile);
|
|
73
|
+
if (!cleanCheck.ok) return cleanCheck;
|
|
74
|
+
|
|
75
|
+
// 2. Compute parent.
|
|
76
|
+
const parentResult = deriveParent(branch);
|
|
77
|
+
if (!parentResult.ok) return parentResult;
|
|
78
|
+
const parent = parentResult.parent;
|
|
79
|
+
|
|
80
|
+
// 3-5. Capture diff, WORK files, and trace.
|
|
81
|
+
const diffPatch = await execFile(['diff', `${parent}...HEAD`]);
|
|
82
|
+
const workCapture = await captureWorkFiles(io);
|
|
83
|
+
const { traceFile, traceText } = await captureTrace(io, branch);
|
|
84
|
+
|
|
85
|
+
// 6-7. Build runId, snapshot dir, and render README.
|
|
86
|
+
const runId = `${branchSlug(branch)}-${ulid()}`;
|
|
87
|
+
const snapDir = `.snapshots/${runId}`;
|
|
88
|
+
const readme = renderReadme({ branch, parent, message, workfile: workCapture['WORK.md'] ?? '', traceText });
|
|
89
|
+
|
|
90
|
+
// 8. Materialise snapshot directory. If any write fails, preserve dry-run branch.
|
|
91
|
+
try {
|
|
92
|
+
await writeSnapshot({ io, snapDir, readme, workCapture, diffPatch, traceText });
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return { ok: false, error: `snapshot write failed: ${err.message}` };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 9-11. Checkout parent, delete dry-run branch, truncate trace.
|
|
98
|
+
await cleanupDryRunBranch(execFile, parent, branch);
|
|
99
|
+
await truncateTraceIfExists(io, traceFile);
|
|
100
|
+
|
|
101
|
+
// 12.
|
|
102
|
+
return { ok: true, runId, snapshotPath: snapDir, branch: parent };
|
|
103
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot inspection library: list, show, delete, prune.
|
|
3
|
+
*
|
|
4
|
+
* Operates on `.snapshots/<runId>/` directories produced by finishDryRun.
|
|
5
|
+
* IO contract: an injected `io` object exposing async `exists`, `readFile`,
|
|
6
|
+
* `readdir`, and `rm`. Pure logic; no direct fs imports.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { parseFrontmatter } from '../workfile.js';
|
|
10
|
+
import { decodeUlidTime } from '../ulid.js';
|
|
11
|
+
|
|
12
|
+
const REQUIRED = ['README.md', 'work/WORK.md', 'diff.patch', 'trace.jsonl'];
|
|
13
|
+
|
|
14
|
+
const META_FIELDS = ['branch', 'parent', 'flow', 'goal', 'startedAt', 'finishedAt', 'exitReason'];
|
|
15
|
+
|
|
16
|
+
// Fields that should be normalised to ISO strings if YAML parsed them as Date.
|
|
17
|
+
const DATE_FIELDS = new Set(['startedAt', 'finishedAt']);
|
|
18
|
+
|
|
19
|
+
function normaliseFieldValue(k, raw) {
|
|
20
|
+
if (raw === undefined) return null;
|
|
21
|
+
let v = raw;
|
|
22
|
+
if (v instanceof Date) v = v.toISOString();
|
|
23
|
+
if (DATE_FIELDS.has(k) && v === 'null') v = null;
|
|
24
|
+
return v;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normaliseMeta(fm) {
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const k of META_FIELDS) {
|
|
30
|
+
out[k] = normaliseFieldValue(k, fm[k]);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compute the list of REQUIRED files missing from a snapshot directory.
|
|
37
|
+
*/
|
|
38
|
+
async function missingFiles(io, dir) {
|
|
39
|
+
const missing = [];
|
|
40
|
+
for (const rel of REQUIRED) {
|
|
41
|
+
if (!(await io.exists(`${dir}/${rel}`))) missing.push(rel);
|
|
42
|
+
}
|
|
43
|
+
return missing;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readSnapshotMeta(io, runId) {
|
|
47
|
+
const dir = `.snapshots/${runId}`;
|
|
48
|
+
const missing = await missingFiles(io, dir);
|
|
49
|
+
|
|
50
|
+
if (missing.includes('README.md')) {
|
|
51
|
+
return { runId, error: 'incomplete', missing };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const readmeText = await io.readFile(`${dir}/README.md`);
|
|
55
|
+
const fm = parseFrontmatter(readmeText) || {};
|
|
56
|
+
|
|
57
|
+
const entry = { runId, ...normaliseMeta(fm) };
|
|
58
|
+
|
|
59
|
+
if (missing.length > 0) {
|
|
60
|
+
entry.error = 'incomplete';
|
|
61
|
+
entry.missing = missing;
|
|
62
|
+
}
|
|
63
|
+
return entry;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function compareStartedAtDesc(a, b) {
|
|
67
|
+
const av = a.startedAt;
|
|
68
|
+
const bv = b.startedAt;
|
|
69
|
+
if (!av && !bv) return 0;
|
|
70
|
+
if (!av) return 1;
|
|
71
|
+
if (!bv) return -1;
|
|
72
|
+
return compareTimestamps(av, bv);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function compareTimestamps(av, bv) {
|
|
76
|
+
if (av < bv) return 1;
|
|
77
|
+
if (av > bv) return -1;
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* List all snapshots under `.snapshots/`, sorted by startedAt desc.
|
|
83
|
+
* Returns [] if `.snapshots/` does not exist.
|
|
84
|
+
*/
|
|
85
|
+
export async function listSnapshots({ io }) {
|
|
86
|
+
if (!(await io.exists('.snapshots'))) return [];
|
|
87
|
+
const entries = await io.readdir('.snapshots');
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const runId of entries) {
|
|
90
|
+
out.push(await readSnapshotMeta(io, runId));
|
|
91
|
+
}
|
|
92
|
+
// Sort by startedAt desc; missing/null sort last.
|
|
93
|
+
out.sort(compareStartedAtDesc);
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function classifyPlusLine(line) {
|
|
98
|
+
return line[1] === '+' ? null : 'insertion';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function classifyMinusLine(line) {
|
|
102
|
+
return line[1] === '-' ? null : 'deletion';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function classifyDiffLine(line) {
|
|
106
|
+
if (line.startsWith('diff --git ')) return 'file';
|
|
107
|
+
if (line.startsWith('+')) return classifyPlusLine(line);
|
|
108
|
+
if (line.startsWith('-')) return classifyMinusLine(line);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseDiffStats(text) {
|
|
113
|
+
let files = 0;
|
|
114
|
+
let insertions = 0;
|
|
115
|
+
let deletions = 0;
|
|
116
|
+
for (const line of text.split('\n')) {
|
|
117
|
+
const kind = classifyDiffLine(line);
|
|
118
|
+
if (kind === 'file') files++;
|
|
119
|
+
else if (kind === 'insertion') insertions++;
|
|
120
|
+
else if (kind === 'deletion') deletions++;
|
|
121
|
+
}
|
|
122
|
+
return { files, insertions, deletions };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseTraceStats(text) {
|
|
126
|
+
const lines = text.split('\n').filter(l => l.length > 0);
|
|
127
|
+
if (lines.length === 0) return { lineCount: 0, firstTs: null, lastTs: null };
|
|
128
|
+
const parseTs = (line) => {
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(line)?.ts ?? null;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
return {
|
|
136
|
+
lineCount: lines.length,
|
|
137
|
+
firstTs: parseTs(lines[0]),
|
|
138
|
+
lastTs: parseTs(lines[lines.length - 1]),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function readReadmeRaw(io, dir) {
|
|
143
|
+
return await io.readFile(`${dir}/README.md`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function readDiffStats(io, dir) {
|
|
147
|
+
return parseDiffStats(await io.readFile(`${dir}/diff.patch`));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function readTraceStats(io, dir) {
|
|
151
|
+
return parseTraceStats(await io.readFile(`${dir}/trace.jsonl`));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function readMetadata(io, dir, missing) {
|
|
155
|
+
if (missing.includes('README.md')) return {};
|
|
156
|
+
const readmeText = await readReadmeRaw(io, dir);
|
|
157
|
+
return parseFrontmatter(readmeText) || {};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function buildSnapshotParts(io, dir, missing) {
|
|
161
|
+
const fm = await readMetadata(io, dir, missing);
|
|
162
|
+
const metadata = normaliseMeta(fm);
|
|
163
|
+
|
|
164
|
+
const diff = missing.includes('diff.patch')
|
|
165
|
+
? { files: 0, insertions: 0, deletions: 0 }
|
|
166
|
+
: await readDiffStats(io, dir);
|
|
167
|
+
|
|
168
|
+
const trace = missing.includes('trace.jsonl')
|
|
169
|
+
? { lineCount: 0, firstTs: null, lastTs: null }
|
|
170
|
+
: await readTraceStats(io, dir);
|
|
171
|
+
|
|
172
|
+
const readmeText = missing.includes('README.md') ? null : await readReadmeRaw(io, dir);
|
|
173
|
+
return { readmeText, metadata, diff, trace };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Return a structured summary of a single snapshot.
|
|
178
|
+
*/
|
|
179
|
+
export async function showSnapshot({ runId, io }) {
|
|
180
|
+
const dir = `.snapshots/${runId}`;
|
|
181
|
+
if (!(await io.exists(dir))) {
|
|
182
|
+
return { runId, error: 'unknown_runId', missing: [...REQUIRED] };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const missing = await missingFiles(io, dir);
|
|
186
|
+
const parts = await buildSnapshotParts(io, dir, missing);
|
|
187
|
+
return { runId, readme: parts.readmeText, metadata: parts.metadata, diff: parts.diff, trace: parts.trace, missing };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Delete a snapshot directory. Requires {confirm: true}.
|
|
192
|
+
*/
|
|
193
|
+
export async function deleteSnapshot({ runId, io, confirm }) {
|
|
194
|
+
const dir = `.snapshots/${runId}`;
|
|
195
|
+
if (!(await io.exists(dir))) {
|
|
196
|
+
return { ok: false, error: `unknown runId '${runId}'` };
|
|
197
|
+
}
|
|
198
|
+
if (confirm !== true) {
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
error: 'foundry_snapshot_delete requires {confirm: true}',
|
|
202
|
+
planned: { runId, path: dir },
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
await io.rm(dir, { recursive: true });
|
|
206
|
+
return { ok: true, runId, removed: dir };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isExpiredCandidate(runId, cutoff) {
|
|
210
|
+
if (runId.length < 26) return false;
|
|
211
|
+
const ulidPart = runId.slice(-26);
|
|
212
|
+
try {
|
|
213
|
+
return decodeUlidTime(ulidPart) < cutoff;
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function findExpiredCandidates(io, cutoff) {
|
|
220
|
+
if (!(await io.exists('.snapshots'))) return [];
|
|
221
|
+
const entries = await io.readdir('.snapshots');
|
|
222
|
+
return entries.filter(id => isExpiredCandidate(id, cutoff));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function deleteSnapshots(io, candidates) {
|
|
226
|
+
for (const runId of candidates) {
|
|
227
|
+
await io.rm(`.snapshots/${runId}`, { recursive: true });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Prune snapshots older than `olderThanDays`. Time is decoded from the
|
|
233
|
+
* trailing 26-char ULID of each runId. Entries with malformed ULIDs are
|
|
234
|
+
* skipped. Requires {confirm: true} to actually delete.
|
|
235
|
+
*/
|
|
236
|
+
export async function pruneSnapshots({ olderThanDays, io, confirm, now = Date.now() }) {
|
|
237
|
+
const cutoff = now - olderThanDays * 86400000;
|
|
238
|
+
const cutoffIso = new Date(cutoff).toISOString();
|
|
239
|
+
|
|
240
|
+
const candidates = await findExpiredCandidates(io, cutoff);
|
|
241
|
+
|
|
242
|
+
if (confirm !== true) {
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
error: 'foundry_snapshot_prune requires {confirm: true}',
|
|
246
|
+
candidates,
|
|
247
|
+
cutoff: cutoffIso,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
await deleteSnapshots(io, candidates);
|
|
252
|
+
return { ok: true, removed: candidates };
|
|
253
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a dry-run snapshot README.md from branch metadata, workfile, and trace.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { parseFrontmatter } from '../workfile.js';
|
|
6
|
+
|
|
7
|
+
function extractTimestamps(traceText) {
|
|
8
|
+
const lines = traceText.split('\n').filter(l => l.length > 0);
|
|
9
|
+
if (lines.length === 0) return { startedAt: null, finishedAt: null };
|
|
10
|
+
|
|
11
|
+
const parseTs = (line) => {
|
|
12
|
+
try {
|
|
13
|
+
const obj = JSON.parse(line);
|
|
14
|
+
return obj?.ts ?? null;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
startedAt: parseTs(lines[0]),
|
|
22
|
+
finishedAt: parseTs(lines[lines.length - 1]),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatValue(v) {
|
|
27
|
+
if (v === null || v === undefined) return 'null';
|
|
28
|
+
return String(v);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function renderReadme({ branch, parent, message, workfile, traceText }) {
|
|
32
|
+
const fm = parseFrontmatter(workfile) || {};
|
|
33
|
+
const { startedAt, finishedAt } = extractTimestamps(traceText);
|
|
34
|
+
|
|
35
|
+
// JSON.stringify the goal string to produce YAML-safe quoted output.
|
|
36
|
+
// Goals often contain colons, which would break YAML parsing without quotes.
|
|
37
|
+
const goalRaw = fm.goal;
|
|
38
|
+
const goalRendered = typeof goalRaw === 'string'
|
|
39
|
+
? JSON.stringify(goalRaw)
|
|
40
|
+
: 'null';
|
|
41
|
+
|
|
42
|
+
const lines = [
|
|
43
|
+
'---',
|
|
44
|
+
`branch: ${branch}`,
|
|
45
|
+
`parent: ${parent}`,
|
|
46
|
+
`flow: ${formatValue(fm.flow)}`,
|
|
47
|
+
`goal: ${goalRendered}`,
|
|
48
|
+
`startedAt: ${formatValue(startedAt)}`,
|
|
49
|
+
`finishedAt: ${formatValue(finishedAt)}`,
|
|
50
|
+
`exitReason: ${fm.status ?? 'unknown'}`,
|
|
51
|
+
'---',
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
return `${lines.join('\n')}\n\n# Dry-run snapshot\n\n${message}\n`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sort filesystem-check helpers — git-backed routines that verify which
|
|
3
|
+
* files were modified during a stage and that prior stage commits are clean.
|
|
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
|
+
import { readFileSync, writeFileSync, existsSync, renameSync } from 'fs';
|
|
10
|
+
import { execFileSync } from 'child_process';
|
|
11
|
+
|
|
12
|
+
import { minimatch } from 'minimatch';
|
|
13
|
+
import { parseFrontmatter } from './workfile.js';
|
|
14
|
+
|
|
15
|
+
export const defaultIO = {
|
|
16
|
+
readFile: (p) => readFileSync(p, 'utf-8'),
|
|
17
|
+
writeFile: (p, c) => writeFileSync(p, c),
|
|
18
|
+
rename: (from, to) => renameSync(from, to),
|
|
19
|
+
exists: (p) => existsSync(p),
|
|
20
|
+
exec: (argv) => execFileSync(argv[0], argv.slice(1), { encoding: 'utf8' }),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function findSortCommitSha(log, cycle) {
|
|
24
|
+
const sortPattern = `[${cycle}] sort:`;
|
|
25
|
+
for (const line of log.trim().split('\n')) {
|
|
26
|
+
if (line.includes(sortPattern)) {
|
|
27
|
+
return line.split(' ', 1)[0];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getModifiedFiles(cycle, io = defaultIO) {
|
|
34
|
+
try {
|
|
35
|
+
const log = io.exec(['git', 'log', '--oneline', '-20']);
|
|
36
|
+
const sortSha = findSortCommitSha(log, cycle);
|
|
37
|
+
if (!sortSha) return [];
|
|
38
|
+
const output = io.exec(['git', 'diff', '--name-only', '--no-renames', '-z', sortSha, 'HEAD']);
|
|
39
|
+
return output.split('\0').filter(Boolean);
|
|
40
|
+
} catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function globMatch(filePath, pattern) {
|
|
46
|
+
return minimatch(filePath, pattern);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveForgePatterns(foundryDir, cycleDef, io) {
|
|
50
|
+
const cycleText = io.readFile(cycleDef);
|
|
51
|
+
const cycleFm = parseFrontmatter(cycleText);
|
|
52
|
+
const outputType = cycleFm['output-type'];
|
|
53
|
+
if (!outputType) return null;
|
|
54
|
+
|
|
55
|
+
const artDefPath = `${foundryDir}/artefacts/${outputType}/definition.md`;
|
|
56
|
+
if (!io.exists(artDefPath)) return null;
|
|
57
|
+
|
|
58
|
+
const artText = io.readFile(artDefPath);
|
|
59
|
+
const artFm = parseFrontmatter(artText);
|
|
60
|
+
return artFm['file-patterns'] || [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function tryResolveForgePatterns(foundryDir, cycleDef, io) {
|
|
64
|
+
try {
|
|
65
|
+
return resolveForgePatterns(foundryDir, cycleDef, io);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getAllowedPatterns(lastBase, foundryDir, cycleDef, io = defaultIO) {
|
|
72
|
+
const always = ['WORK.md', 'WORK.feedback.yaml', 'WORK.history.yaml'];
|
|
73
|
+
if (lastBase === 'assay') return [...always, '.foundry/**', 'foundry-memory/**'];
|
|
74
|
+
if (lastBase !== 'forge') return always;
|
|
75
|
+
const filePatterns = tryResolveForgePatterns(foundryDir, cycleDef, io);
|
|
76
|
+
return filePatterns ? [...always, ...filePatterns] : always;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function checkModifiedFiles(lastBase, foundryDir, cycleDef, cycle, io = defaultIO) {
|
|
80
|
+
const allowedPatterns = getAllowedPatterns(lastBase, foundryDir, cycleDef, io);
|
|
81
|
+
const modified = getModifiedFiles(cycle, io);
|
|
82
|
+
|
|
83
|
+
if (modified.length === 0) {
|
|
84
|
+
return { ok: true, violations: [] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const violations = modified.filter(f =>
|
|
88
|
+
!allowedPatterns.some(pattern => globMatch(f, pattern))
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return { ok: violations.length === 0, violations };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Return a list of tool-managed files that have uncommitted changes
|
|
96
|
+
* (modified, staged, or untracked) in the working tree.
|
|
97
|
+
*
|
|
98
|
+
* Tool-managed files are WORK.md, WORK.feedback.yaml, WORK.history.yaml,
|
|
99
|
+
* and anything under .foundry/. `foundry_orchestrate` is the sole writer
|
|
100
|
+
* of these between stages, and every stage commit is performed internally
|
|
101
|
+
* by the orchestrator's git bridge (the previously-public
|
|
102
|
+
* `foundry_git_commit` tool was deregistered in v2.3.0). If this function
|
|
103
|
+
* returns a non-empty list at the start of a sort invocation, a prior
|
|
104
|
+
* stage's commit was skipped or aborted.
|
|
105
|
+
*/
|
|
106
|
+
export function getDirtyToolManagedFiles(io = defaultIO) {
|
|
107
|
+
try {
|
|
108
|
+
const output = io.exec([
|
|
109
|
+
'git', 'status', '--porcelain', '-z', '--',
|
|
110
|
+
'WORK.md', 'WORK.feedback.yaml', 'WORK.history.yaml', '.foundry',
|
|
111
|
+
]);
|
|
112
|
+
return output
|
|
113
|
+
.split('\0')
|
|
114
|
+
.map(line => line.trim())
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.map(line => line.replace(/^[\sMADRCU?!]+/, '').trim())
|
|
117
|
+
.filter(Boolean);
|
|
118
|
+
} catch {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
}
|