@execuro-sw-ecosystem/sw-ecosystem-agentic-harness 0.1.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/.mcp.json +8 -0
- package/CHANGELOG.md +78 -0
- package/CONTRIBUTING.md +54 -0
- package/LICENSE +21 -0
- package/README.md +304 -0
- package/SECURITY.md +50 -0
- package/agents/sw-admin-frontend-developer.md +52 -0
- package/agents/sw-php-backend-developer.md +63 -0
- package/agents/sw-product-manager.md +62 -0
- package/agents/sw-qa-engineer.md +160 -0
- package/agents/sw-shopware-architect.md +79 -0
- package/agents/sw-storefront-developer.md +93 -0
- package/agents/sw-tender-editor.md +43 -0
- package/bin/cli.mjs +73 -0
- package/codex/agents/sw-admin-frontend-developer.toml +54 -0
- package/codex/agents/sw-php-backend-developer.toml +65 -0
- package/codex/agents/sw-product-manager.toml +66 -0
- package/codex/agents/sw-qa-engineer.toml +161 -0
- package/codex/agents/sw-shopware-architect.toml +81 -0
- package/codex/agents/sw-storefront-developer.toml +96 -0
- package/codex/agents/sw-tender-editor.toml +50 -0
- package/content/AGENTS.md +63 -0
- package/content/claude-settings.json +31 -0
- package/content/mcp-servers.json +18 -0
- package/copilot/agents/sw-admin-frontend-developer.agent.md +53 -0
- package/copilot/agents/sw-php-backend-developer.agent.md +64 -0
- package/copilot/agents/sw-product-manager.agent.md +65 -0
- package/copilot/agents/sw-qa-engineer.agent.md +160 -0
- package/copilot/agents/sw-shopware-architect.agent.md +80 -0
- package/copilot/agents/sw-storefront-developer.agent.md +94 -0
- package/copilot/agents/sw-tender-editor.agent.md +46 -0
- package/lib/actions.mjs +449 -0
- package/lib/commands.mjs +446 -0
- package/lib/companions.mjs +117 -0
- package/lib/content.mjs +101 -0
- package/lib/fsx.mjs +188 -0
- package/lib/guide.mjs +98 -0
- package/lib/hosts.mjs +283 -0
- package/lib/lock.mjs +158 -0
- package/lib/merge.mjs +338 -0
- package/lib/out.mjs +74 -0
- package/mcp.json +10 -0
- package/package.json +58 -0
- package/plugin.json +19 -0
- package/skills/sw-design-requirements/SKILL.md +102 -0
- package/skills/sw-design-requirements/reference/clarification-loop.md +20 -0
- package/skills/sw-design-requirements/reference/confidence-rubric.md +82 -0
- package/skills/sw-design-requirements/reference/consistency-probes.md +65 -0
- package/skills/sw-design-requirements/reference/editor-gate.md +15 -0
- package/skills/sw-design-requirements/reference/editor-mode.md +15 -0
- package/skills/sw-design-requirements/reference/prd-confidence-checklist.md +95 -0
- package/skills/sw-design-requirements/reference/prd-template.md +153 -0
- package/skills/sw-design-requirements/reference/readiness-and-report.md +21 -0
- package/skills/sw-design-requirements/reference/splitting-guidelines.md +69 -0
- package/skills/sw-design-solution/SKILL.md +48 -0
- package/skills/sw-design-solution/reference/adr-flow.md +13 -0
- package/skills/sw-design-solution/reference/adr-template.md +40 -0
- package/skills/sw-design-solution/reference/clarification-loop.md +13 -0
- package/skills/sw-design-solution/reference/draft-and-spawn.md +21 -0
- package/skills/sw-design-solution/reference/editor-mode.md +16 -0
- package/skills/sw-design-solution/reference/pre-check-and-target.md +19 -0
- package/skills/sw-design-solution/reference/readiness-and-report.md +15 -0
- package/skills/sw-design-solution/reference/research-and-evidence.md +25 -0
- package/skills/sw-design-solution/reference/tech-spec-template.md +112 -0
- package/skills/sw-design-solution/reference/toolchain-conformance.md +9 -0
- package/skills/sw-discover-tender/SKILL.md +68 -0
- package/skills/sw-discover-tender/reference/agent-briefs.md +135 -0
- package/skills/sw-discover-tender/reference/analysis-template.md +224 -0
- package/skills/sw-discover-tender/reference/assumption-catalogue.md +104 -0
- package/skills/sw-discover-tender/reference/client-question-rules.md +47 -0
- package/skills/sw-discover-tender/reference/confidence-rubric.md +80 -0
- package/skills/sw-discover-tender/reference/context-parameters.md +116 -0
- package/skills/sw-discover-tender/reference/coverage-mapping.md +62 -0
- package/skills/sw-discover-tender/reference/editor-mode.md +11 -0
- package/skills/sw-discover-tender/reference/editor-session.md +115 -0
- package/skills/sw-discover-tender/reference/estimation-model.md +106 -0
- package/skills/sw-discover-tender/reference/procedure.md +98 -0
- package/skills/sw-discover-tender/reference/response-rules.md +13 -0
- package/skills/sw-document-feature/SKILL.md +64 -0
- package/skills/sw-document-feature/reference/adr-promotion.md +64 -0
- package/skills/sw-document-feature/reference/built-check.md +71 -0
- package/skills/sw-document-feature/reference/domain-guidelines.md +59 -0
- package/skills/sw-document-feature/reference/page-rules.md +129 -0
- package/skills/sw-document-feature/reference/procedure.md +58 -0
- package/skills/sw-document-feature/reference/scaffold/README.md +78 -0
- package/skills/sw-document-feature/reference/scaffold/_config.yml +51 -0
- package/skills/sw-document-feature/reference/scaffold/adr/index.md +44 -0
- package/skills/sw-document-feature/reference/scaffold/baseline/glossary.md +55 -0
- package/skills/sw-document-feature/reference/scaffold/baseline/tech-stack.md +48 -0
- package/skills/sw-document-feature/reference/scaffold/domains/index.md +55 -0
- package/skills/sw-document-feature/reference/scaffold/domains/platform/configuration.md +54 -0
- package/skills/sw-document-feature/reference/scaffold/domains/platform/customization-guidelines.md +47 -0
- package/skills/sw-document-feature/reference/scaffold/domains/platform/debugging.md +55 -0
- package/skills/sw-document-feature/reference/scaffold/domains/platform/environments-and-deployment.md +49 -0
- package/skills/sw-document-feature/reference/scaffold/domains/platform/extensions-inventory.md +40 -0
- package/skills/sw-document-feature/reference/scaffold/domains/platform/index.md +39 -0
- package/skills/sw-document-feature/reference/scaffold/domains/platform/logging.md +46 -0
- package/skills/sw-document-feature/reference/scaffold/guidelines/documentation-guidelines.md +102 -0
- package/skills/sw-document-feature/reference/scaffold/guidelines/index.md +50 -0
- package/skills/sw-document-feature/reference/scaffold/index.md +68 -0
- package/skills/sw-document-feature/reference/scaffold/vanilla/README.md +24 -0
- package/skills/sw-document-feature/reference/scaffold/vanilla/adr/index.md +36 -0
- package/skills/sw-document-feature/reference/scaffold/vanilla/index.md +68 -0
- package/skills/sw-document-feature/reference/setup-wiki.md +142 -0
- package/skills/sw-document-feature/reference/source-mapping.md +71 -0
- package/skills/sw-document-feature/reference/templates/adr.md +26 -0
- package/skills/sw-document-feature/reference/templates/domain-index.md +36 -0
- package/skills/sw-document-feature/reference/templates/feature-page.md +64 -0
- package/skills/sw-document-feature/reference/templates/guideline.md +42 -0
- package/skills/sw-implement-feature/SKILL.md +52 -0
- package/skills/sw-implement-feature/reference/ac-loop.md +24 -0
- package/skills/sw-implement-feature/reference/build-order.md +17 -0
- package/skills/sw-implement-feature/reference/delegation.md +18 -0
- package/skills/sw-setup/SKILL.md +144 -0
- package/skills/sw-setup/reference/rows.md +223 -0
- package/skills/sw-verify-feature/SKILL.md +64 -0
- package/skills/sw-verify-feature/reference/reconcile-and-report.md +42 -0
- package/skills/sw-verify-feature/reference/verification-report-template.md +57 -0
- package/skills/sw-verify-feature-ac-tests/SKILL.md +76 -0
- package/skills/sw-verify-feature-ac-tests/reference/audit-execute-score.md +40 -0
- package/skills/sw-verify-feature-architecture/SKILL.md +66 -0
- package/skills/sw-verify-feature-architecture/reference/guidelines-and-twig.md +27 -0
- package/skills/sw-verify-feature-code-quality/SKILL.md +73 -0
- package/skills/sw-verify-feature-code-quality/reference/gates.md +22 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "sw-tender-editor"
|
|
3
|
+
description: "Mechanical file worker for the sw-discover-tender skill — extracts requirement tables from a tender's source export into cluster files, merges PM/architect/QA cluster reports into finished §4 row blocks, splices those blocks into the analysis sidecar, and assembles and checks the final response CSVs. Deterministic, no judgment: every value it writes comes from a named input file, never from memory or estimation. Not for research, feasibility checks, effort estimation or any judgment call — that is `sw-product-manager`, `sw-shopware-architect` and `sw-qa-engineer`'s work; this agent only reshapes what they already produced into the file the skill needs next."
|
|
4
|
+
tools: [read, edit, search]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<!-- Generated by scripts/gen-copilot-agents.mjs — do not edit. -->
|
|
8
|
+
<!-- source: agents/sw-tender-editor.md -->
|
|
9
|
+
<!-- source-sha256: 72f167887e7d97164c0c7bb2ed03e23dd041fb868746857744bcd255909a03d5 -->
|
|
10
|
+
# sw-tender-editor
|
|
11
|
+
|
|
12
|
+
## Identity
|
|
13
|
+
|
|
14
|
+
A mechanical editor spawned by `sw-discover-tender` for one bulk file job at a time: extract, rows, splice or assemble, per the brief in `reference/agent-briefs.md` of the `sw-discover-tender` skill (`§EXTRACT`, `§ROWS`, `§SPLICE`, `§ASSEMBLE`). It reshapes and joins files that other agents already wrote; it never researches, estimates or decides.
|
|
15
|
+
|
|
16
|
+
## Rules
|
|
17
|
+
|
|
18
|
+
1. **Write only the files named in the brief.** Nothing else, ever — not a scratch file, not a summary, not the client's source.
|
|
19
|
+
2. **Never open the client workbook, a hidden sheet, or any sheet, table or file whose name starts with `_`.** If a brief's file list would require it, skip that source and note it in the return line instead.
|
|
20
|
+
3. **Never invent a figure, a compliance token or a requirement row.** A missing input is `_TBD_` plus a one-line note in the return line — never a guess, never filled from memory.
|
|
21
|
+
4. **PD only.** Never a currency amount, a price, a rate or a budget figure, in any file this agent writes.
|
|
22
|
+
5. **Never edit the client's source file.** Source exports and the original workbook are read-only inputs.
|
|
23
|
+
6. **Every write leaves its target valid and complete for its format.** The analysis document is parsed live by a page watcher: after every write to it, all seven sections must still be present and parseable — a partial or malformed write is worse than no write.
|
|
24
|
+
7. **One line back, nothing pasted.** End the turn with exactly one line — path(s) plus a count or sum, per the brief's delivery rule. Never paste the file's content into the response.
|
|
25
|
+
|
|
26
|
+
## Flow
|
|
27
|
+
|
|
28
|
+
1. Read the brief section named in the spawn message (`§EXTRACT`, `§ROWS`, `§SPLICE` or `§ASSEMBLE`) in `agent-briefs.md` — it is the exact spec: which files to read, what to write, and the one-line return format.
|
|
29
|
+
2. Read only the files that brief names. Nothing else, no exploring the rest of `specs/.rfp/<slug>/`.
|
|
30
|
+
3. Produce the output exactly per the brief's grammar (analysis-template row/status grammar for `§ROWS`/`§SPLICE`; response-rules.md for `§ASSEMBLE`).
|
|
31
|
+
4. Write only the named target file(s), by Write or Edit as the brief specifies.
|
|
32
|
+
5. Return the one-line result. Stop.
|
|
33
|
+
|
|
34
|
+
## Never
|
|
35
|
+
|
|
36
|
+
- Never do the PM's, architect's or QA's job: no verdicts, no verification, no estimates, no mechanism judgment. If an input those agents should have supplied is missing, that row is `_TBD_`, not filled in.
|
|
37
|
+
- Never touch a row, section, frozen line, human `[x]`/`[-]` tick, or `partner`-marked line outside what the brief names.
|
|
38
|
+
- Never run a shell command, fetch a URL, or call another agent — this agent has no Bash, WebFetch or Agent tool.
|
|
39
|
+
|
|
40
|
+
## Report
|
|
41
|
+
|
|
42
|
+
Exactly one line, as specified by the brief that spawned this agent — typically a path (or paths) plus a row count or PD sum, or the single failing check when a check fails.
|
|
43
|
+
|
|
44
|
+
## Boundaries
|
|
45
|
+
|
|
46
|
+
Not for research, feasibility checks, effort estimation, drafting questions, or writing the analysis prose in §1/§2/§5/§6/§7 — those are `sw-product-manager`, `sw-shopware-architect`, `sw-qa-engineer` and the orchestrating skill's work. This agent only moves already-produced content between files.
|
package/lib/actions.mjs
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
// The Action engine. `plan` previews, `apply` previews then executes, and
|
|
2
|
+
// `uninstall` reverts from the lock — one code path, three verbs.
|
|
3
|
+
//
|
|
4
|
+
// `preview` is the only place `state`, `prior` and `hash` are decided, and it
|
|
5
|
+
// never writes. That is what makes `plan` provably read-only and what keeps
|
|
6
|
+
// `plan` and `apply` from ever disagreeing about what is about to happen.
|
|
7
|
+
//
|
|
8
|
+
// The safety rule throughout: refuse, report, continue. A target we do not own
|
|
9
|
+
// is never overwritten and never aborts the run — it becomes a `conflict` or
|
|
10
|
+
// `manual` action whose `remedy` reaches the user in `help`.
|
|
11
|
+
|
|
12
|
+
import { dirname } from 'node:path';
|
|
13
|
+
import {
|
|
14
|
+
abs, ensureDir, readMaybe, readTextMaybe, removeDirIfEmpty, removeFile, rel, sha256, writeAtomic,
|
|
15
|
+
} from './fsx.mjs';
|
|
16
|
+
import {
|
|
17
|
+
canonical, deletePointer, fenceInner, findFence, getPointer, mergeArrayUnique, parseJsonLoose,
|
|
18
|
+
removeArrayValues, removeFence, serializeJson, setPointer, upsertFence,
|
|
19
|
+
} from './merge.mjs';
|
|
20
|
+
import { PKG_ROOT } from './content.mjs';
|
|
21
|
+
import { isDeclined } from './lock.mjs';
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { COMPANIONS, probe } from './companions.mjs';
|
|
24
|
+
|
|
25
|
+
/** Preview one action against the filesystem. Reads only. */
|
|
26
|
+
export function preview(action, ctx) {
|
|
27
|
+
switch (action.kind) {
|
|
28
|
+
case 'file-copy': return previewFile(action, ctx);
|
|
29
|
+
case 'skill-copy': return previewSkill(action, ctx);
|
|
30
|
+
case 'json-merge': return previewJson(action, ctx);
|
|
31
|
+
case 'toml-fence':
|
|
32
|
+
case 'text-fence': return previewFence(action, ctx);
|
|
33
|
+
case 'manual': return { ...action, state: 'manual' };
|
|
34
|
+
default: throw new Error(`unknown action kind: ${action.kind}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ------------------------------------------------------------- file-copy
|
|
39
|
+
|
|
40
|
+
function previewFile(action, ctx) {
|
|
41
|
+
const shipped = readFileSync(`${PKG_ROOT}/${action.source}`);
|
|
42
|
+
return previewBytes(action, ctx, shipped);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The adopt/conflict/drift/update ladder, shared by `file-copy` and, with a
|
|
46
|
+
* probed body instead of a shipped one, `skill-copy`. */
|
|
47
|
+
function previewBytes(action, ctx, bytes) {
|
|
48
|
+
const hash = sha256(bytes);
|
|
49
|
+
const target = abs(ctx.root, action.target);
|
|
50
|
+
const current = readMaybe(target);
|
|
51
|
+
const locked = ctx.lock?.files?.[action.target] ?? null;
|
|
52
|
+
const base = { ...action, hash, bytes: bytes.length };
|
|
53
|
+
|
|
54
|
+
if (current === null) {
|
|
55
|
+
return { ...base, prior: null, state: 'create', created_file: true,
|
|
56
|
+
detail: locked ? 'reinstalling a file that was deleted' : 'new file' };
|
|
57
|
+
}
|
|
58
|
+
const priorHash = sha256(current);
|
|
59
|
+
if (priorHash === hash) {
|
|
60
|
+
// Byte-identical to what we would write. Materially ours; adopt it.
|
|
61
|
+
return { ...base, prior: priorHash, state: 'unchanged', created_file: locked?.created_file ?? false,
|
|
62
|
+
detail: locked ? 'up to date' : 'already identical to the shipped file — adopted' };
|
|
63
|
+
}
|
|
64
|
+
if (!locked) {
|
|
65
|
+
return { ...base, prior: priorHash, state: 'conflict',
|
|
66
|
+
reason: 'a different file already exists here and was not installed by this CLI',
|
|
67
|
+
remedy: `move or delete ${action.target}, then re-run apply` };
|
|
68
|
+
}
|
|
69
|
+
if (locked.hash !== priorHash) {
|
|
70
|
+
return { ...base, prior: priorHash, state: 'drift',
|
|
71
|
+
reason: 'edited after install', remedy: `delete ${action.target} to take the shipped version` };
|
|
72
|
+
}
|
|
73
|
+
return { ...base, prior: priorHash, state: 'update', created_file: locked.created_file,
|
|
74
|
+
detail: 'shipped content changed' };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ------------------------------------------------------------ skill-copy
|
|
78
|
+
|
|
79
|
+
/** Strip the probed body (a Buffer) before it lands anywhere near the
|
|
80
|
+
* action, which `commands.mjs` serialises straight to stdout. */
|
|
81
|
+
function stripProbeBody({ body, ...rest }) {
|
|
82
|
+
return rest;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function previewSkill(action, ctx) {
|
|
86
|
+
const companion = COMPANIONS.find((c) => c.id === action.companion);
|
|
87
|
+
const probed = probe(companion, { platform: ctx.platform });
|
|
88
|
+
const probeSummary = stripProbeBody(probed);
|
|
89
|
+
|
|
90
|
+
if (!probed.available) {
|
|
91
|
+
const remedy = probed.state === 'absent'
|
|
92
|
+
? `npm i -D ${probed.spec}`
|
|
93
|
+
: 'check that npx works — probing needs network-free npx --no-install';
|
|
94
|
+
return {
|
|
95
|
+
...action, state: 'skipped', available: false,
|
|
96
|
+
probe: probeSummary, reason: probed.reason, remedy,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
...previewBytes(action, ctx, probed.body),
|
|
102
|
+
available: true, probe: probeSummary, body: probed.body,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ------------------------------------------------------------- json-merge
|
|
107
|
+
|
|
108
|
+
function previewJson(action, ctx) {
|
|
109
|
+
const target = abs(ctx.root, action.target);
|
|
110
|
+
const found = readTextMaybe(target);
|
|
111
|
+
const createdFile = found === null;
|
|
112
|
+
const fmt = found ?? { indent: ' ', eol: '\n', bom: false, trailingNewline: true };
|
|
113
|
+
const base = { ...action, fmt, created_file: createdFile };
|
|
114
|
+
|
|
115
|
+
let doc = {};
|
|
116
|
+
if (found !== null) {
|
|
117
|
+
const parsed = parseJsonLoose(found.text);
|
|
118
|
+
// JSONC first: a file with comments is not malformed, it is a format
|
|
119
|
+
// `JSON.parse` does not read. Checking validity first would tell the user
|
|
120
|
+
// to "fix" a perfectly good `.vscode/mcp.json`.
|
|
121
|
+
if (parsed.hasComments) {
|
|
122
|
+
// Re-serialising would silently delete the user's comments. Not ours to do.
|
|
123
|
+
return { ...base, kind: 'manual', prior: null, state: 'manual',
|
|
124
|
+
reason: 'the existing file uses JSONC (comments or trailing commas), which a rewrite would destroy',
|
|
125
|
+
remedy: renderManualJson(action) };
|
|
126
|
+
}
|
|
127
|
+
if (!parsed.valid) {
|
|
128
|
+
return { ...base, prior: null, state: 'conflict',
|
|
129
|
+
reason: 'the existing file is not valid JSON',
|
|
130
|
+
remedy: `fix or move ${action.target}, then re-run apply` };
|
|
131
|
+
}
|
|
132
|
+
doc = parsed.value ?? {};
|
|
133
|
+
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) doc = {};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (action.op === 'array-union') return previewArray(base, doc, ctx);
|
|
137
|
+
return previewSet(base, doc, ctx);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function previewSet(action, doc, ctx) {
|
|
141
|
+
const hash = sha256(canonical(action.value));
|
|
142
|
+
const got = getPointer(doc, action.pointer);
|
|
143
|
+
const locked = ctx.lock?.json_pointers?.find(
|
|
144
|
+
(p) => p.file === action.target && p.pointer === action.pointer,
|
|
145
|
+
);
|
|
146
|
+
const { next, createdContainers } = setPointer(doc, action.pointer, action.value);
|
|
147
|
+
const base = { ...action, hash, doc: next, created_containers: createdContainers };
|
|
148
|
+
|
|
149
|
+
if (!got.found) return { ...base, prior: null, state: 'create', detail: `sets ${action.pointer}` };
|
|
150
|
+
const priorHash = sha256(canonical(got.value));
|
|
151
|
+
if (priorHash === hash) {
|
|
152
|
+
return { ...base, prior: priorHash, state: 'unchanged',
|
|
153
|
+
created_containers: locked?.created_containers ?? [],
|
|
154
|
+
detail: locked ? 'up to date' : 'already set to the shipped value — adopted' };
|
|
155
|
+
}
|
|
156
|
+
if (!locked) {
|
|
157
|
+
return { ...base, prior: priorHash, state: 'conflict',
|
|
158
|
+
reason: `${action.pointer} already has a different value and was not set by this CLI`,
|
|
159
|
+
remedy: `remove or rename the existing ${action.pointer} entry, then re-run apply` };
|
|
160
|
+
}
|
|
161
|
+
if (locked.hash !== priorHash) {
|
|
162
|
+
return { ...base, prior: priorHash, state: 'drift',
|
|
163
|
+
created_containers: locked.created_containers ?? [],
|
|
164
|
+
reason: 'changed after install',
|
|
165
|
+
remedy: `revert your change at ${action.pointer}, or leave it — apply will not touch it` };
|
|
166
|
+
}
|
|
167
|
+
return { ...base, prior: priorHash, state: 'update',
|
|
168
|
+
created_containers: locked.created_containers ?? [], detail: 'shipped value changed' };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function previewArray(action, doc, ctx) {
|
|
172
|
+
const got = getPointer(doc, action.pointer);
|
|
173
|
+
const existing = Array.isArray(got.value) ? got.value : [];
|
|
174
|
+
const lock = ctx.lock;
|
|
175
|
+
|
|
176
|
+
// A rule we recorded that the user has since removed is a decline. Never
|
|
177
|
+
// re-offer it — that is directive 4, and the reason this is trusted near a
|
|
178
|
+
// settings file.
|
|
179
|
+
const recorded = lock?.rules?.find(
|
|
180
|
+
(r) => r.file === action.target && r.pointer === action.pointer,
|
|
181
|
+
);
|
|
182
|
+
const skipped = [];
|
|
183
|
+
const wanted = [];
|
|
184
|
+
for (const value of action.values) {
|
|
185
|
+
if (lock && isDeclined(lock, action.target, action.pointer, value)) { skipped.push(value); continue; }
|
|
186
|
+
if (recorded?.values?.includes(value) && !existing.includes(value)) { skipped.push(value); continue; }
|
|
187
|
+
wanted.push(value);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const merged = mergeArrayUnique(existing, wanted);
|
|
191
|
+
const { next, createdContainers } = setPointer(doc, action.pointer, merged.next);
|
|
192
|
+
const values = [...new Set([...(recorded?.values ?? []), ...merged.added])].filter(
|
|
193
|
+
(v) => !skipped.includes(v),
|
|
194
|
+
);
|
|
195
|
+
const base = {
|
|
196
|
+
...action, doc: next, values,
|
|
197
|
+
created_containers: got.found ? (recorded?.created_containers ?? []) : createdContainers,
|
|
198
|
+
added: merged.added, kept: existing, skipped,
|
|
199
|
+
hash: sha256(canonical(merged.added)),
|
|
200
|
+
// Values the user deleted after we added them, for the caller to record.
|
|
201
|
+
newly_declined: (recorded?.values ?? []).filter(
|
|
202
|
+
(v) => !existing.includes(v) && !lock?.declined?.some((d) => d.value === v),
|
|
203
|
+
),
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
if (merged.added.length === 0) {
|
|
207
|
+
return { ...base, prior: null, state: 'unchanged',
|
|
208
|
+
detail: skipped.length ? `${skipped.length} rule(s) declined earlier, not re-offered` : 'all rules already present' };
|
|
209
|
+
}
|
|
210
|
+
return { ...base, prior: null, state: got.found ? 'update' : 'create',
|
|
211
|
+
detail: `adds ${merged.added.length} rule(s), keeps ${existing.length} existing` +
|
|
212
|
+
(skipped.length ? `, skips ${skipped.length} declined` : '') };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function renderManualJson(action) {
|
|
216
|
+
if (action.op === 'array-union') {
|
|
217
|
+
return `add these entries to "${action.pointer}" in ${action.target}: ${action.values.map((v) => JSON.stringify(v)).join(', ')}`;
|
|
218
|
+
}
|
|
219
|
+
return `set "${action.pointer}" in ${action.target} to ${JSON.stringify(action.value)}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ------------------------------------------------------------- fences
|
|
223
|
+
|
|
224
|
+
function previewFence(action, ctx) {
|
|
225
|
+
const target = abs(ctx.root, action.target);
|
|
226
|
+
const found = readTextMaybe(target);
|
|
227
|
+
const createdFile = found === null;
|
|
228
|
+
// Hash what will actually sit inside the fence, warning line included, so it
|
|
229
|
+
// can be compared with what `findFence` reads back.
|
|
230
|
+
const hash = sha256(fenceInner(action.body, action.syntax));
|
|
231
|
+
const locked = ctx.lock?.fences?.find(
|
|
232
|
+
(f) => f.file === action.target && f.block === action.block,
|
|
233
|
+
);
|
|
234
|
+
const base = { ...action, hash, fmt: found ?? { eol: '\n', bom: false }, created_file: createdFile };
|
|
235
|
+
|
|
236
|
+
if (action.conflict) {
|
|
237
|
+
return { ...base, kind: 'manual', state: 'manual',
|
|
238
|
+
reason: action.conflict.reason, remedy: action.conflict.remedy };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const existing = found ? findFence(found.text, action.block) : null;
|
|
242
|
+
if (!existing) {
|
|
243
|
+
return { ...base, prior: null, state: 'create',
|
|
244
|
+
detail: createdFile ? 'creates the file with a managed block' : 'appends a managed block' };
|
|
245
|
+
}
|
|
246
|
+
const priorHash = sha256(existing.inner);
|
|
247
|
+
if (priorHash === hash) return { ...base, prior: priorHash, state: 'unchanged', created_file: locked?.created_file ?? false, detail: 'up to date' };
|
|
248
|
+
if (locked && locked.hash !== priorHash) {
|
|
249
|
+
return { ...base, prior: priorHash, state: 'drift', created_file: locked.created_file,
|
|
250
|
+
reason: 'the managed block was hand-edited',
|
|
251
|
+
remedy: `revert your edits inside the ${action.block} block in ${action.target}, or leave them — apply will not touch it` };
|
|
252
|
+
}
|
|
253
|
+
return { ...base, prior: priorHash, state: 'update', created_file: locked?.created_file ?? false,
|
|
254
|
+
detail: 'shipped block changed' };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ------------------------------------------------------------- execute
|
|
258
|
+
|
|
259
|
+
const WRITABLE = new Set(['create', 'update']);
|
|
260
|
+
|
|
261
|
+
export function isWritableState(state) {
|
|
262
|
+
return WRITABLE.has(state);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Perform a previewed action. Returns the lock entry it produced. */
|
|
266
|
+
export function execute(action, ctx) {
|
|
267
|
+
const target = abs(ctx.root, action.target);
|
|
268
|
+
switch (action.kind) {
|
|
269
|
+
case 'file-copy': {
|
|
270
|
+
const shipped = readFileSync(`${PKG_ROOT}/${action.source}`);
|
|
271
|
+
ensureDir(dirname(target));
|
|
272
|
+
writeAtomic(target, shipped);
|
|
273
|
+
const dirs = ancestorDirs(ctx.root, action.target);
|
|
274
|
+
return { kind: 'file-copy', host: action.host, target: action.target, source: action.source,
|
|
275
|
+
hash: action.hash, bytes: action.bytes, created_file: action.created_file !== false, dirs };
|
|
276
|
+
}
|
|
277
|
+
case 'skill-copy': {
|
|
278
|
+
// Collapses to a `file-copy` lock entry on purpose: `lock.record`,
|
|
279
|
+
// `lock.entries`, `revert` and `rebuild` need no companion-specific
|
|
280
|
+
// case, and `uninstall` needs no probe. `source` is provenance only —
|
|
281
|
+
// `revert` never reads it.
|
|
282
|
+
ensureDir(dirname(target));
|
|
283
|
+
writeAtomic(target, action.body);
|
|
284
|
+
const dirs = ancestorDirs(ctx.root, action.target);
|
|
285
|
+
return { kind: 'file-copy', host: action.host, target: action.target,
|
|
286
|
+
source: `npm:${action.package}#install-skill --print`,
|
|
287
|
+
hash: action.hash, bytes: action.bytes, created_file: action.created_file !== false, dirs };
|
|
288
|
+
}
|
|
289
|
+
case 'json-merge': {
|
|
290
|
+
// Recompute from what is on disk right now rather than writing the
|
|
291
|
+
// snapshot `preview` took. Several actions can target one file — the two
|
|
292
|
+
// MCP servers in `.mcp.json` are the obvious pair — and each preview saw
|
|
293
|
+
// the file before any of them had run, so writing those snapshots would
|
|
294
|
+
// let the last action silently drop the earlier ones' work.
|
|
295
|
+
const found = readTextMaybe(target);
|
|
296
|
+
const fmt = found ?? action.fmt ?? { indent: ' ', eol: '\n', bom: false, trailingNewline: true };
|
|
297
|
+
let doc = {};
|
|
298
|
+
if (found !== null) {
|
|
299
|
+
const parsed = parseJsonLoose(found.text);
|
|
300
|
+
if (!parsed.valid) throw new Error(`${action.target} is not valid JSON`);
|
|
301
|
+
doc = parsed.value ?? {};
|
|
302
|
+
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) doc = {};
|
|
303
|
+
}
|
|
304
|
+
let next;
|
|
305
|
+
let createdContainers;
|
|
306
|
+
if (action.op === 'array-union') {
|
|
307
|
+
const got = getPointer(doc, action.pointer);
|
|
308
|
+
const merged = mergeArrayUnique(got.value, action.added ?? []);
|
|
309
|
+
({ next, createdContainers } = setPointer(doc, action.pointer, merged.next));
|
|
310
|
+
} else {
|
|
311
|
+
({ next, createdContainers } = setPointer(doc, action.pointer, action.value));
|
|
312
|
+
}
|
|
313
|
+
writeAtomic(target, serializeJson(next, fmt), { eol: fmt.eol ?? '\n', bom: fmt.bom });
|
|
314
|
+
return { kind: 'json-merge', host: action.host, target: action.target, pointer: action.pointer,
|
|
315
|
+
op: action.op, hash: action.hash, values: action.values,
|
|
316
|
+
created_containers: (action.created_containers ?? []).length
|
|
317
|
+
? action.created_containers : createdContainers,
|
|
318
|
+
created_file: action.created_file === true };
|
|
319
|
+
}
|
|
320
|
+
case 'toml-fence':
|
|
321
|
+
case 'text-fence': {
|
|
322
|
+
// Same hazard as JSON: `.codex/config.toml` carries two managed blocks,
|
|
323
|
+
// so re-read rather than build on the snapshot `preview` took.
|
|
324
|
+
const found = readTextMaybe(target);
|
|
325
|
+
const { next } = upsertFence(found?.text ?? '', action.block, action.body,
|
|
326
|
+
{ version: ctx.version }, action.syntax);
|
|
327
|
+
writeAtomic(target, next, { eol: found?.eol ?? '\n', bom: found?.bom });
|
|
328
|
+
return { kind: action.kind, host: action.host, target: action.target, block: action.block,
|
|
329
|
+
syntax: action.syntax, hash: action.hash, created_file: action.created_file === true };
|
|
330
|
+
}
|
|
331
|
+
case 'manual':
|
|
332
|
+
return { kind: 'manual', host: action.host, target: action.target,
|
|
333
|
+
block: action.block ?? null, remedy: action.remedy };
|
|
334
|
+
default:
|
|
335
|
+
throw new Error(`unknown action kind: ${action.kind}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function ancestorDirs(root, targetRel) {
|
|
340
|
+
const parts = targetRel.split('/');
|
|
341
|
+
const dirs = [];
|
|
342
|
+
for (let i = 1; i < parts.length; i += 1) dirs.push(parts.slice(0, i).join('/'));
|
|
343
|
+
return dirs;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ------------------------------------------------------------- revert
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Undo one lock entry. Total on the lock — uninstall never needs the original
|
|
350
|
+
* Action, which is what lets it work against an install from an older run.
|
|
351
|
+
*
|
|
352
|
+
* Anything hand-edited is kept and reported, never overwritten.
|
|
353
|
+
*/
|
|
354
|
+
export function revert(entry, ctx) {
|
|
355
|
+
const target = abs(ctx.root, entry.target);
|
|
356
|
+
switch (entry.kind) {
|
|
357
|
+
case 'file-copy': {
|
|
358
|
+
const current = readMaybe(target);
|
|
359
|
+
if (current === null) return { state: 'missing', target: entry.target, reason: 'already gone' };
|
|
360
|
+
if (sha256(current) !== entry.hash) {
|
|
361
|
+
return { state: 'kept', target: entry.target, reason: 'edited after install (hash mismatch)' };
|
|
362
|
+
}
|
|
363
|
+
if (!entry.created_file) return { state: 'kept', target: entry.target, reason: 'the file existed before install' };
|
|
364
|
+
removeFile(target);
|
|
365
|
+
return { state: 'removed', target: entry.target };
|
|
366
|
+
}
|
|
367
|
+
case 'json-merge': {
|
|
368
|
+
const found = readTextMaybe(target);
|
|
369
|
+
if (found === null) return { state: 'missing', target: entry.target, reason: 'already gone' };
|
|
370
|
+
const parsed = parseJsonLoose(found.text);
|
|
371
|
+
if (!parsed.valid) return { state: 'kept', target: entry.target, reason: 'the file is no longer valid JSON' };
|
|
372
|
+
if (parsed.hasComments) return { state: 'kept', target: entry.target, reason: 'the file now uses JSONC; a rewrite would destroy comments' };
|
|
373
|
+
const doc = parsed.value ?? {};
|
|
374
|
+
|
|
375
|
+
if (entry.op === 'array-union') {
|
|
376
|
+
const got = getPointer(doc, entry.pointer);
|
|
377
|
+
const { next: arr, removed } = removeArrayValues(got.value, entry.values ?? []);
|
|
378
|
+
if (removed.length === 0) return { state: 'missing', target: entry.target, reason: 'our rules are already gone' };
|
|
379
|
+
let doc2 = setPointer(doc, entry.pointer, arr).next;
|
|
380
|
+
if (arr.length === 0 && (entry.created_containers ?? []).length) {
|
|
381
|
+
doc2 = deletePointer(doc2, entry.pointer, entry.created_containers);
|
|
382
|
+
}
|
|
383
|
+
if (isEmptyDoc(doc2) && entry.created_file) { removeFile(target); return { state: 'removed', target: entry.target }; }
|
|
384
|
+
writeAtomic(target, serializeJson(doc2, found), { eol: found.eol, bom: found.bom });
|
|
385
|
+
return { state: 'reverted', target: entry.target, removed };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const got = getPointer(doc, entry.pointer);
|
|
389
|
+
if (!got.found) return { state: 'missing', target: entry.target, reason: 'our entry is already gone' };
|
|
390
|
+
if (sha256(canonical(got.value)) !== entry.hash) {
|
|
391
|
+
return { state: 'kept', target: entry.target, reason: 'changed after install' };
|
|
392
|
+
}
|
|
393
|
+
const doc2 = deletePointer(doc, entry.pointer, entry.created_containers ?? []);
|
|
394
|
+
if (isEmptyDoc(doc2) && entry.created_file) { removeFile(target); return { state: 'removed', target: entry.target }; }
|
|
395
|
+
writeAtomic(target, serializeJson(doc2, found), { eol: found.eol, bom: found.bom });
|
|
396
|
+
return { state: 'reverted', target: entry.target };
|
|
397
|
+
}
|
|
398
|
+
case 'toml-fence':
|
|
399
|
+
case 'text-fence': {
|
|
400
|
+
const found = readTextMaybe(target);
|
|
401
|
+
if (found === null) return { state: 'missing', target: entry.target, reason: 'already gone' };
|
|
402
|
+
const fence = findFence(found.text, entry.block);
|
|
403
|
+
if (!fence) return { state: 'missing', target: entry.target, reason: 'our block is already gone' };
|
|
404
|
+
if (sha256(fence.inner) !== entry.hash) {
|
|
405
|
+
return { state: 'kept', target: entry.target, reason: 'content inside the managed fence was hand-edited' };
|
|
406
|
+
}
|
|
407
|
+
const { next } = removeFence(found.text, entry.block);
|
|
408
|
+
if (next.trim() === '' && entry.created_file) { removeFile(target); return { state: 'removed', target: entry.target }; }
|
|
409
|
+
writeAtomic(target, next, { eol: found.eol, bom: found.bom });
|
|
410
|
+
return { state: 'reverted', target: entry.target };
|
|
411
|
+
}
|
|
412
|
+
default:
|
|
413
|
+
return { state: 'kept', target: entry.target, reason: `unknown lock entry kind ${entry.kind}` };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function isEmptyDoc(doc) {
|
|
418
|
+
return doc !== null && typeof doc === 'object' && Object.keys(doc).length === 0;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Prune directories we created, deepest first, stopping at any that is used. */
|
|
422
|
+
export function pruneDirs(dirs, ctx) {
|
|
423
|
+
const removed = [];
|
|
424
|
+
for (const dir of [...new Set(dirs)].sort((a, b) => b.length - a.length)) {
|
|
425
|
+
// A host's own marker directory (`.claude`, `.codex`, …) is the user's,
|
|
426
|
+
// not ours — leaving it empty is tidier than deleting something they made.
|
|
427
|
+
if (!dir.includes('/')) continue;
|
|
428
|
+
try {
|
|
429
|
+
if (removeDirIfEmpty(abs(ctx.root, dir))) removed.push(dir);
|
|
430
|
+
} catch {
|
|
431
|
+
// A directory we cannot tidy is cosmetic, never a reason to fail an
|
|
432
|
+
// uninstall that has already removed the files.
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return removed;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Shared targets (`.mcp.json`, `AGENTS.md`) are produced once by the plan
|
|
440
|
+
* builder and claimed by several hosts. Dedupe by id so `apply` cannot write
|
|
441
|
+
* the same file twice with different content.
|
|
442
|
+
*/
|
|
443
|
+
export function dedupe(actions) {
|
|
444
|
+
const byId = new Map();
|
|
445
|
+
for (const action of actions) byId.set(action.id, action);
|
|
446
|
+
return [...byId.values()];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export { rel };
|