@cohortapp/agent-sdk 2.12.0 → 2.13.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/bin/maestro.mjs +6 -2
- package/docs/guides/front-door-session.md +49 -0
- package/lib/cli/design.mjs +185 -0
- package/lib/cli/design.test.mjs +270 -0
- package/lib/cli/global-setup-extras.mjs +44 -0
- package/lib/cli/global-setup-extras.test.mjs +95 -0
- package/lib/cli/session.mjs +11 -1
- package/lib/cli/session.test.mjs +17 -6
- package/lib/collective/global-config.mjs +5 -0
- package/lib/collective/global-config.test.mjs +5 -0
- package/lib/collective/vendor-skills.mjs +305 -0
- package/lib/collective/vendor-skills.test.mjs +306 -0
- package/lib/design/design-md.mjs +793 -0
- package/lib/design/design-md.test.mjs +318 -0
- package/lib/design/fixtures/DESIGN.golden.md +238 -0
- package/lib/design/fixtures/PRODUCT.golden.md +67 -0
- package/lib/design/fixtures/foundation.json +133 -0
- package/lib/design/refresh-gate.mjs +154 -0
- package/lib/design/refresh-gate.test.mjs +144 -0
- package/lib/design/write.mjs +275 -0
- package/lib/design/write.test.mjs +241 -0
- package/lib/prompts/parallelism.mjs +79 -0
- package/lib/prompts/parallelism.test.mjs +177 -0
- package/package.json +1 -1
- package/plugins/maestro-skills/plugin.json +4 -0
- package/plugins/maestro-skills/skills/cohort-design.md +153 -0
- package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
- package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
- package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
- package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
- package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
- package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
- package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
- package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
- package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
- package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
- package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
- package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
- package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
- package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
- package/scripts/ci/check-skill-packs.mjs +388 -0
- package/scripts/ci/check-skill-packs.test.mjs +495 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
- package/scripts/daemon/agent-daemon.mjs +108 -0
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +61 -2
- package/scripts/daemon/cadence-consumer.mjs +46 -22
- package/scripts/daemon/prompt-builder.mjs +19 -3
- package/scripts/local-triggers/autoupdate.test.mjs +33 -3
- package/scripts/vendor/skill-packs.mjs +354 -0
- package/scripts/vendor/sync-skill-packs.mjs +242 -0
- package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/design/write.mjs — the effect edge of the design sync (WP-M7 mechanic 4).
|
|
3
|
+
*
|
|
4
|
+
* `design-md.mjs` decides WHAT the files say and `refresh-gate.mjs` decides
|
|
5
|
+
* WHEN; this module is the only place that touches the disk, so the CLI
|
|
6
|
+
* (`maestro design sync`) and the daemon's poller write the same bytes with the
|
|
7
|
+
* same modes and the same idempotence rule.
|
|
8
|
+
*
|
|
9
|
+
* IDEMPOTENCE IS BY BYTES, not by timestamp. A re-sync of an unchanged
|
|
10
|
+
* foundation must not touch DESIGN.md — the file is read by editors and skills,
|
|
11
|
+
* and a pointless mtime bump is how a watcher storms and how a git tree looks
|
|
12
|
+
* dirty for no reason. So each file is read back and compared before writing.
|
|
13
|
+
* That also means `generatedAt` must NOT be part of the rendered bytes when
|
|
14
|
+
* idempotence matters: the caller passes the SAME `generatedAt` it recorded on
|
|
15
|
+
* the previous sync, or none. (`sync.mjs` callers pass the stored value.)
|
|
16
|
+
*
|
|
17
|
+
* EVERY write here goes through `lib/fs-atomic.mjs` — the JSON snapshot and
|
|
18
|
+
* both markdown files. There are TWO writers of the same two absolute paths (a
|
|
19
|
+
* hand-run `maestro design sync` and the daemon's 15-minute poll), and a design
|
|
20
|
+
* skill reads `$AGENT_ROOT/DESIGN.md` at arbitrary moments; an in-place
|
|
21
|
+
* `writeFileSync` would let it read a half-written frontmatter block. The mode
|
|
22
|
+
* is applied with an explicit `chmod` after the rename as well as on the temp
|
|
23
|
+
* file, because Node honours a `mode` option only when it CREATES a file — a
|
|
24
|
+
* DESIGN.md some other tool left at 0600 would otherwise stay 0600 forever.
|
|
25
|
+
*
|
|
26
|
+
* Node builtins only. ESM.
|
|
27
|
+
*
|
|
28
|
+
* @module lib/design/write
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
"use strict";
|
|
32
|
+
|
|
33
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
34
|
+
import { dirname, join, resolve } from "node:path";
|
|
35
|
+
import { writeFileAtomic, writeJsonAtomic } from "../fs-atomic.mjs";
|
|
36
|
+
import { renderDesignBundle } from "./design-md.mjs";
|
|
37
|
+
import { DESIGN_STATE_REL, foundationVersion, foundationVersionLabel } from "./refresh-gate.mjs";
|
|
38
|
+
|
|
39
|
+
/** The mode the generated docs are written with (readable by any tool on the seat). */
|
|
40
|
+
export const DESIGN_FILE_MODE = 0o644;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The workspace/brand name for the rendered heading, from the loaded org config.
|
|
44
|
+
*
|
|
45
|
+
* SHARED ON PURPOSE. `maestro design sync` and the daemon's poller both write
|
|
46
|
+
* DESIGN.md, and the heading is part of the rendered bytes — two definitions of
|
|
47
|
+
* "what is this workspace called" would make a hand-run sync and an automatic
|
|
48
|
+
* one disagree, and the idempotence rule above would then rewrite the file on
|
|
49
|
+
* every alternation. One function, both callers.
|
|
50
|
+
*
|
|
51
|
+
* The foundation's own `positioning` is prose, not a name, so it is never used
|
|
52
|
+
* here. Nothing is invented: an org config without a name reads "Workspace".
|
|
53
|
+
*
|
|
54
|
+
* @param {object} cfg the loaded org config (`lib/org/client.mjs#loadOrgConfig`)
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function workspaceName(cfg) {
|
|
58
|
+
const org = cfg && typeof cfg === "object" ? cfg.org : null;
|
|
59
|
+
const cohort = org && typeof org === "object" ? org.cohort : null;
|
|
60
|
+
const name = cohort && typeof cohort === "object" ? cohort.orgName || cohort.workspace : null;
|
|
61
|
+
return typeof name === "string" && name.trim() ? name.trim() : "Workspace";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Write the rendered bundle, skipping any file whose bytes are already correct.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} o
|
|
68
|
+
* @param {Array<{rel:string, content:string}>} o.files rendered by `renderDesignBundle`
|
|
69
|
+
* @param {string} o.outDir where the two markdown files go
|
|
70
|
+
* @param {object} [io] injectable I/O for tests: {readFileImpl, writeFileImpl, existsImpl, mkdirImpl}
|
|
71
|
+
* @returns {{written:string[], unchanged:string[]}}
|
|
72
|
+
*/
|
|
73
|
+
export function writeDesignFiles({ files, outDir }, io = {}) {
|
|
74
|
+
const readImpl = io.readFileImpl || ((p) => readFileSync(p, "utf8"));
|
|
75
|
+
const writeImpl = io.writeFileImpl || ((p, data, mode) => {
|
|
76
|
+
writeFileAtomic(p, data, { mode });
|
|
77
|
+
// The rename published a file created with `mode`, but only because it was
|
|
78
|
+
// new. chmod the published name too, so a pre-existing file that some other
|
|
79
|
+
// tool left at a tighter mode is corrected rather than inherited.
|
|
80
|
+
try { chmodSync(p, mode); } catch { /* best effort: the bytes matter more than the bits */ }
|
|
81
|
+
});
|
|
82
|
+
const existsImpl = io.existsImpl || existsSync;
|
|
83
|
+
const mkdirImpl = io.mkdirImpl || ((p) => mkdirSync(p, { recursive: true }));
|
|
84
|
+
const written = [];
|
|
85
|
+
const unchanged = [];
|
|
86
|
+
if (!existsImpl(outDir)) mkdirImpl(outDir);
|
|
87
|
+
for (const file of Array.isArray(files) ? files : []) {
|
|
88
|
+
if (!file || typeof file.rel !== "string" || typeof file.content !== "string") continue;
|
|
89
|
+
const target = join(outDir, file.rel);
|
|
90
|
+
let current = null;
|
|
91
|
+
try {
|
|
92
|
+
current = existsImpl(target) ? readImpl(target) : null;
|
|
93
|
+
} catch {
|
|
94
|
+
current = null; // unreadable is the same as absent: rewrite it
|
|
95
|
+
}
|
|
96
|
+
if (current === file.content) {
|
|
97
|
+
unchanged.push(file.rel);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
writeImpl(target, file.content, DESIGN_FILE_MODE);
|
|
101
|
+
written.push(file.rel);
|
|
102
|
+
}
|
|
103
|
+
return { written, unchanged };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Persist the foundation snapshot the gate reads back next tick.
|
|
108
|
+
* `{fetchedAt, version, versionLabel, foundation}` — durable JSON, atomic.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} agentRoot
|
|
111
|
+
* @param {{fetchedAt:string, version:string|null, versionLabel?:string, foundation:object}} doc
|
|
112
|
+
* @param {object} [io] {writeJsonImpl, mkdirImpl}
|
|
113
|
+
* @returns {string} the path written
|
|
114
|
+
*/
|
|
115
|
+
export function writeFoundationState(agentRoot, doc, io = {}) {
|
|
116
|
+
const path = join(String(agentRoot || ""), DESIGN_STATE_REL);
|
|
117
|
+
const mkdirImpl = io.mkdirImpl || ((p) => mkdirSync(p, { recursive: true }));
|
|
118
|
+
mkdirImpl(dirname(path));
|
|
119
|
+
(io.writeJsonImpl || writeJsonAtomic)(path, doc);
|
|
120
|
+
return path;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Read back the last synced snapshot. Returns null when absent or corrupt — a
|
|
125
|
+
* corrupt snapshot is treated as "never synced", which re-syncs rather than
|
|
126
|
+
* wedging.
|
|
127
|
+
* @param {string} agentRoot
|
|
128
|
+
* @param {object} [io] {readFileImpl}
|
|
129
|
+
* @returns {{fetchedAt?:string, version?:string|null, versionLabel?:string, foundation?:object}|null}
|
|
130
|
+
*/
|
|
131
|
+
export function readFoundationState(agentRoot, io = {}) {
|
|
132
|
+
const path = join(String(agentRoot || ""), DESIGN_STATE_REL);
|
|
133
|
+
try {
|
|
134
|
+
const raw = (io.readFileImpl || ((p) => readFileSync(p, "utf8")))(path);
|
|
135
|
+
const doc = JSON.parse(raw);
|
|
136
|
+
return doc && typeof doc === "object" && !Array.isArray(doc) ? doc : null;
|
|
137
|
+
} catch {
|
|
138
|
+
return null; // absent or corrupt → re-sync
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The per-output-directory render stamps from a state snapshot, as a mutable
|
|
144
|
+
* map of resolved path → ISO string.
|
|
145
|
+
*
|
|
146
|
+
* MIGRATION. Snapshots written before the map existed carry one bare
|
|
147
|
+
* `renderedAt` string, which described the agent dir (the only directory the
|
|
148
|
+
* daemon ever writes). Reading it as that entry means an upgraded seat finds
|
|
149
|
+
* its DESIGN.md already matching and rewrites nothing.
|
|
150
|
+
*
|
|
151
|
+
* @param {object|null} prior
|
|
152
|
+
* @param {string} agentRoot
|
|
153
|
+
* @returns {Record<string,string>}
|
|
154
|
+
*/
|
|
155
|
+
export function priorRenderStamps(prior, agentRoot) {
|
|
156
|
+
const out = {};
|
|
157
|
+
const p = prior && typeof prior === "object" ? prior : {};
|
|
158
|
+
if (p.rendered && typeof p.rendered === "object" && !Array.isArray(p.rendered)) {
|
|
159
|
+
for (const [k, v] of Object.entries(p.rendered)) {
|
|
160
|
+
if (typeof k === "string" && typeof v === "string" && v) out[k] = v;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const legacy = typeof p.renderedAt === "string" && p.renderedAt ? p.renderedAt : "";
|
|
164
|
+
const rootKey = resolve(String(agentRoot || ""));
|
|
165
|
+
if (legacy && !out[rootKey]) out[rootKey] = legacy;
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Do all rendered files already match what is on disk? The idempotence probe.
|
|
171
|
+
* @param {Array<{rel:string, content:string}>} files
|
|
172
|
+
* @param {string} outDir
|
|
173
|
+
* @param {object} [io]
|
|
174
|
+
* @returns {boolean}
|
|
175
|
+
*/
|
|
176
|
+
export function filesMatch(files, outDir, io = {}) {
|
|
177
|
+
const readImpl = io.readFileImpl || ((p) => readFileSync(p, "utf8"));
|
|
178
|
+
const existsImpl = io.existsImpl || existsSync;
|
|
179
|
+
for (const file of Array.isArray(files) ? files : []) {
|
|
180
|
+
const target = join(outDir, file.rel);
|
|
181
|
+
let current = null;
|
|
182
|
+
try {
|
|
183
|
+
current = existsImpl(target) ? readImpl(target) : null;
|
|
184
|
+
} catch {
|
|
185
|
+
return false; // unreadable → rewrite
|
|
186
|
+
}
|
|
187
|
+
if (current !== file.content) return false;
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Render a fetched foundation and land it on the seat: DESIGN.md + PRODUCT.md in
|
|
194
|
+
* `outDir`, the snapshot in `state/design/foundation.json`. The ONE code path
|
|
195
|
+
* both `maestro design sync` and the daemon's poller use, so a hand-run sync and
|
|
196
|
+
* an automatic one produce identical bytes.
|
|
197
|
+
*
|
|
198
|
+
* THE `generatedAt` DANCE. A timestamp inside the rendered bytes would make
|
|
199
|
+
* every sync a rewrite, defeating the idempotence the whole design turns on. So
|
|
200
|
+
* the files are first rendered carrying the PREVIOUS sync's stamp: if they then
|
|
201
|
+
* match the disk byte-for-byte, nothing changed and nothing is written. Only
|
|
202
|
+
* when something genuinely differs are they re-rendered with `now` — so the
|
|
203
|
+
* stamp means "when this content was minted", which is the honest reading, and
|
|
204
|
+
* an unchanged foundation touches no file.
|
|
205
|
+
*
|
|
206
|
+
* THE STAMP IS PER OUTPUT DIRECTORY (`rendered`, keyed by resolved path). One
|
|
207
|
+
* shared stamp is a permanent rewrite loop the moment a second directory
|
|
208
|
+
* exists: `maestro design sync --out docs/brand` stamps the state with its own
|
|
209
|
+
* `now`, the daemon's next poll of the agent dir renders with that stamp, does
|
|
210
|
+
* not match ITS files, rewrites them and re-stamps — and each lane then
|
|
211
|
+
* invalidates the other's files forever, with a foundation that never changed.
|
|
212
|
+
* A legacy state file carrying a bare `renderedAt` string is read as the agent
|
|
213
|
+
* dir's entry, so an existing seat migrates without one spurious rewrite.
|
|
214
|
+
*
|
|
215
|
+
* @param {object} o
|
|
216
|
+
* @param {string} o.agentRoot the agent dir (where state/ lives)
|
|
217
|
+
* @param {string} [o.outDir] where the docs go (default: agentRoot)
|
|
218
|
+
* @param {object} o.result the `branding.getFoundation` result frame's `result`
|
|
219
|
+
* @param {number} o.now epoch ms (injected)
|
|
220
|
+
* @param {string} [o.name] workspace/brand name for the heading
|
|
221
|
+
* @param {string} [o.source] provenance line
|
|
222
|
+
* @param {object} [io] injectable I/O (see writeDesignFiles / writeFoundationState)
|
|
223
|
+
* @returns {{written:string[], unchanged:string[], version:string|null, statePath:string, outDir:string}}
|
|
224
|
+
*/
|
|
225
|
+
export function syncDesign(o, io = {}) {
|
|
226
|
+
const agentRoot = String(o.agentRoot || "");
|
|
227
|
+
const outDir = String(o.outDir || agentRoot);
|
|
228
|
+
const now = Number.isFinite(o.now) ? o.now : 0;
|
|
229
|
+
const nowIso = new Date(now).toISOString();
|
|
230
|
+
const result = o.result && typeof o.result === "object" ? o.result : {};
|
|
231
|
+
const foundation = result.foundation && typeof result.foundation === "object" ? result.foundation : {};
|
|
232
|
+
const version = foundationVersion(result);
|
|
233
|
+
const versionLabel = foundationVersionLabel(result);
|
|
234
|
+
const prior = readFoundationState(agentRoot, io);
|
|
235
|
+
const rendered = priorRenderStamps(prior, agentRoot);
|
|
236
|
+
const key = resolve(outDir);
|
|
237
|
+
const priorStamp = rendered[key] || nowIso;
|
|
238
|
+
|
|
239
|
+
const base = { name: o.name, source: o.source, versionId: version || "", versionLabel };
|
|
240
|
+
let renderedAt = priorStamp;
|
|
241
|
+
let files = renderDesignBundle(foundation, { ...base, generatedAt: renderedAt });
|
|
242
|
+
let out = { written: [], unchanged: files.map((f) => f.rel) };
|
|
243
|
+
if (!filesMatch(files, outDir, io)) {
|
|
244
|
+
renderedAt = nowIso;
|
|
245
|
+
files = renderDesignBundle(foundation, { ...base, generatedAt: renderedAt });
|
|
246
|
+
out = writeDesignFiles({ files, outDir }, io);
|
|
247
|
+
}
|
|
248
|
+
rendered[key] = renderedAt;
|
|
249
|
+
const statePath = writeFoundationState(
|
|
250
|
+
agentRoot,
|
|
251
|
+
{
|
|
252
|
+
fetchedAt: nowIso,
|
|
253
|
+
// The agent dir's own stamp stays at the top level: it is what a reader
|
|
254
|
+
// (and an older SDK) means by "when was DESIGN.md minted".
|
|
255
|
+
renderedAt: rendered[resolve(agentRoot)] || renderedAt,
|
|
256
|
+
rendered,
|
|
257
|
+
version: version || null,
|
|
258
|
+
versionLabel,
|
|
259
|
+
foundation,
|
|
260
|
+
},
|
|
261
|
+
io,
|
|
262
|
+
);
|
|
263
|
+
return { ...out, version: version || null, statePath, outDir };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export default {
|
|
267
|
+
workspaceName,
|
|
268
|
+
priorRenderStamps,
|
|
269
|
+
writeDesignFiles,
|
|
270
|
+
writeFoundationState,
|
|
271
|
+
readFoundationState,
|
|
272
|
+
filesMatch,
|
|
273
|
+
syncDesign,
|
|
274
|
+
DESIGN_FILE_MODE,
|
|
275
|
+
};
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* write.test.mjs — the effect edge of the design sync (WP-M7 mechanic 4).
|
|
3
|
+
*
|
|
4
|
+
* Real files in a temp dir, not stubs: the claims worth holding here are about
|
|
5
|
+
* the FILESYSTEM — that an unchanged foundation touches no mtime, and that the
|
|
6
|
+
* two markdown files land at 0644 so any tool on the seat can read them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, writeFileSync, statSync, utimesSync, existsSync, rmSync } from "node:fs";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
|
|
15
|
+
import { syncDesign, writeDesignFiles, writeFoundationState, readFoundationState, filesMatch, workspaceName, DESIGN_FILE_MODE } from "./write.mjs";
|
|
16
|
+
import { renderDesignBundle } from "./design-md.mjs";
|
|
17
|
+
import { DESIGN_STATE_REL } from "./refresh-gate.mjs";
|
|
18
|
+
|
|
19
|
+
const NOW = Date.parse("2026-09-08T12:00:00Z");
|
|
20
|
+
const FIXTURE = JSON.parse(readFileSync(new URL("./fixtures/foundation.json", import.meta.url), "utf8"));
|
|
21
|
+
|
|
22
|
+
/** A `branding.getFoundation` result frame's `result`. */
|
|
23
|
+
const result = (over = {}) => ({
|
|
24
|
+
foundation: FIXTURE,
|
|
25
|
+
version: { id: "bv_01HQZ", seq: 7, label: "V2.7" },
|
|
26
|
+
headVersionId: "bv_01HQZ",
|
|
27
|
+
isHead: true,
|
|
28
|
+
unversioned: false,
|
|
29
|
+
history: [],
|
|
30
|
+
...over,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function tmpRoot(t) {
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), "maestro-design-write-"));
|
|
35
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("a first sync writes both files plus the state snapshot", (t) => {
|
|
40
|
+
const root = tmpRoot(t);
|
|
41
|
+
const out = syncDesign({ agentRoot: root, result: result(), now: NOW, name: "Northwind" });
|
|
42
|
+
|
|
43
|
+
assert.deepEqual(out.written, ["DESIGN.md", "PRODUCT.md"]);
|
|
44
|
+
assert.deepEqual(out.unchanged, []);
|
|
45
|
+
assert.equal(out.version, "bv_01HQZ");
|
|
46
|
+
assert.equal(out.outDir, root);
|
|
47
|
+
|
|
48
|
+
const design = readFileSync(join(root, "DESIGN.md"), "utf8");
|
|
49
|
+
assert.match(design, /^---\nname: "Northwind"\n/);
|
|
50
|
+
assert.match(design, /\{colors\.primary\}/);
|
|
51
|
+
assert.match(readFileSync(join(root, "PRODUCT.md"), "utf8"), /# Northwind — product voice/);
|
|
52
|
+
|
|
53
|
+
const state = JSON.parse(readFileSync(join(root, DESIGN_STATE_REL), "utf8"));
|
|
54
|
+
assert.equal(state.version, "bv_01HQZ");
|
|
55
|
+
assert.equal(state.versionLabel, "V2.7");
|
|
56
|
+
assert.equal(state.fetchedAt, "2026-09-08T12:00:00.000Z");
|
|
57
|
+
assert.deepEqual(state.foundation, FIXTURE, "the raw foundation is kept verbatim for the next gate read");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("both markdown files are 0644", (t) => {
|
|
61
|
+
const root = tmpRoot(t);
|
|
62
|
+
syncDesign({ agentRoot: root, result: result(), now: NOW, name: "Northwind" });
|
|
63
|
+
for (const rel of ["DESIGN.md", "PRODUCT.md"]) {
|
|
64
|
+
assert.equal(statSync(join(root, rel)).mode & 0o777, DESIGN_FILE_MODE, rel);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("re-syncing an unchanged foundation rewrites NOTHING — mtimes are untouched", (t) => {
|
|
69
|
+
const root = tmpRoot(t);
|
|
70
|
+
syncDesign({ agentRoot: root, result: result(), now: NOW, name: "Northwind" });
|
|
71
|
+
|
|
72
|
+
// Backdate both files so any rewrite is unmistakable.
|
|
73
|
+
const old = new Date(NOW - 86_400_000);
|
|
74
|
+
for (const rel of ["DESIGN.md", "PRODUCT.md"]) utimesSync(join(root, rel), old, old);
|
|
75
|
+
const before = ["DESIGN.md", "PRODUCT.md"].map((rel) => statSync(join(root, rel)).mtimeMs);
|
|
76
|
+
|
|
77
|
+
// A LATER clock, deliberately: the timestamp inside the file must not be what
|
|
78
|
+
// makes a sync a rewrite, or the interval poll would churn the tree forever.
|
|
79
|
+
const again = syncDesign({ agentRoot: root, result: result(), now: NOW + 20 * 60_000, name: "Northwind" });
|
|
80
|
+
assert.deepEqual(again.written, [], "nothing written");
|
|
81
|
+
assert.deepEqual(again.unchanged, ["DESIGN.md", "PRODUCT.md"]);
|
|
82
|
+
|
|
83
|
+
const after = ["DESIGN.md", "PRODUCT.md"].map((rel) => statSync(join(root, rel)).mtimeMs);
|
|
84
|
+
assert.deepEqual(after, before, "the files were not touched at all");
|
|
85
|
+
|
|
86
|
+
const design = readFileSync(join(root, "DESIGN.md"), "utf8");
|
|
87
|
+
assert.match(design, /\*\*Synced:\*\* 2026-09-08T12:00:00\.000Z/, "the stamp still says when the CONTENT was minted");
|
|
88
|
+
|
|
89
|
+
// The snapshot is still refreshed, so the gate knows the seat did check.
|
|
90
|
+
const state = JSON.parse(readFileSync(join(root, DESIGN_STATE_REL), "utf8"));
|
|
91
|
+
assert.equal(state.fetchedAt, "2026-09-08T12:20:00.000Z");
|
|
92
|
+
assert.equal(state.renderedAt, "2026-09-08T12:00:00.000Z");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a changed foundation rewrites the affected file and re-stamps it", (t) => {
|
|
96
|
+
const root = tmpRoot(t);
|
|
97
|
+
syncDesign({ agentRoot: root, result: result(), now: NOW, name: "Northwind" });
|
|
98
|
+
|
|
99
|
+
const moved = JSON.parse(JSON.stringify(FIXTURE));
|
|
100
|
+
moved.colors[2].value = "#111111";
|
|
101
|
+
const out = syncDesign({
|
|
102
|
+
agentRoot: root,
|
|
103
|
+
result: result({ foundation: moved, headVersionId: "bv_02", version: { id: "bv_02", seq: 8, label: "V2.8" } }),
|
|
104
|
+
now: NOW + 20 * 60_000,
|
|
105
|
+
name: "Northwind",
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
assert.ok(out.written.includes("DESIGN.md"));
|
|
109
|
+
assert.equal(out.version, "bv_02");
|
|
110
|
+
const design = readFileSync(join(root, "DESIGN.md"), "utf8");
|
|
111
|
+
assert.match(design, /primary-moss: "#111111"/);
|
|
112
|
+
assert.match(design, /\*\*Synced:\*\* 2026-09-08T12:20:00\.000Z/);
|
|
113
|
+
assert.match(design, /\*\*Version:\*\* V2\.8/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("--out sends the docs elsewhere; the snapshot always stays under the agent root", (t) => {
|
|
117
|
+
const root = tmpRoot(t);
|
|
118
|
+
const outDir = join(root, "docs", "brand");
|
|
119
|
+
const out = syncDesign({ agentRoot: root, outDir, result: result(), now: NOW, name: "Northwind" });
|
|
120
|
+
|
|
121
|
+
assert.equal(out.outDir, outDir);
|
|
122
|
+
assert.ok(existsSync(join(outDir, "DESIGN.md")));
|
|
123
|
+
assert.ok(!existsSync(join(root, "DESIGN.md")));
|
|
124
|
+
assert.ok(existsSync(join(root, DESIGN_STATE_REL)));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("writeDesignFiles skips a file whose bytes already match and rewrites an unreadable one", (t) => {
|
|
128
|
+
const root = tmpRoot(t);
|
|
129
|
+
const files = renderDesignBundle(FIXTURE, { name: "Northwind" });
|
|
130
|
+
assert.deepEqual(writeDesignFiles({ files, outDir: root }).written, ["DESIGN.md", "PRODUCT.md"]);
|
|
131
|
+
assert.deepEqual(writeDesignFiles({ files, outDir: root }), { written: [], unchanged: ["DESIGN.md", "PRODUCT.md"] });
|
|
132
|
+
|
|
133
|
+
writeFileSync(join(root, "DESIGN.md"), "someone edited this by hand\n");
|
|
134
|
+
assert.deepEqual(writeDesignFiles({ files, outDir: root }).written, ["DESIGN.md"], "a hand edit is overwritten — the file is generated");
|
|
135
|
+
|
|
136
|
+
const boom = writeDesignFiles({ files, outDir: root }, { readFileImpl: () => { throw new Error("EIO"); } });
|
|
137
|
+
assert.deepEqual(boom.written, ["DESIGN.md", "PRODUCT.md"], "unreadable is treated as absent");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("writeDesignFiles ignores malformed entries rather than throwing", (t) => {
|
|
141
|
+
const root = tmpRoot(t);
|
|
142
|
+
const out = writeDesignFiles({ files: [null, { rel: "A.md" }, { content: "x" }, { rel: "ok.md", content: "y" }], outDir: root });
|
|
143
|
+
assert.deepEqual(out.written, ["ok.md"]);
|
|
144
|
+
assert.deepEqual(writeDesignFiles({ files: "not a list", outDir: root }), { written: [], unchanged: [] });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("readFoundationState reads back what writeFoundationState wrote; corrupt reads as never-synced", (t) => {
|
|
148
|
+
const root = tmpRoot(t);
|
|
149
|
+
assert.equal(readFoundationState(root), null, "absent → null");
|
|
150
|
+
|
|
151
|
+
const path = writeFoundationState(root, { fetchedAt: "2026-09-08T12:00:00.000Z", version: "bv_1", foundation: { colors: [] } });
|
|
152
|
+
assert.equal(path, join(root, DESIGN_STATE_REL));
|
|
153
|
+
assert.equal(readFoundationState(root).version, "bv_1");
|
|
154
|
+
|
|
155
|
+
writeFileSync(path, "{ not json");
|
|
156
|
+
assert.equal(readFoundationState(root), null, "corrupt → null → re-sync, never a wedge");
|
|
157
|
+
|
|
158
|
+
writeFileSync(path, JSON.stringify([1, 2]));
|
|
159
|
+
assert.equal(readFoundationState(root), null, "an array is not a snapshot");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("filesMatch is the idempotence probe the CLI's claim rests on", (t) => {
|
|
163
|
+
const root = tmpRoot(t);
|
|
164
|
+
const files = renderDesignBundle(FIXTURE, { name: "Northwind" });
|
|
165
|
+
assert.equal(filesMatch(files, root), false, "nothing on disk yet");
|
|
166
|
+
writeDesignFiles({ files, outDir: root });
|
|
167
|
+
assert.equal(filesMatch(files, root), true);
|
|
168
|
+
writeFileSync(join(root, "PRODUCT.md"), "drift\n");
|
|
169
|
+
assert.equal(filesMatch(files, root), false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("workspaceName reads the org config and invents nothing", () => {
|
|
173
|
+
assert.equal(workspaceName({ org: { cohort: { orgName: "Northwind" } } }), "Northwind");
|
|
174
|
+
assert.equal(workspaceName({ org: { cohort: { workspace: "adaptic-hq" } } }), "adaptic-hq");
|
|
175
|
+
assert.equal(workspaceName({ org: { cohort: { orgName: " " } } }), "Workspace");
|
|
176
|
+
assert.equal(workspaceName({}), "Workspace");
|
|
177
|
+
assert.equal(workspaceName(null), "Workspace");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("a state directory that does not exist yet is created", (t) => {
|
|
181
|
+
const root = join(tmpRoot(t), "not", "made", "yet");
|
|
182
|
+
mkdirSync(root, { recursive: true });
|
|
183
|
+
const out = syncDesign({ agentRoot: root, result: result(), now: NOW, name: "Northwind" });
|
|
184
|
+
assert.ok(existsSync(out.statePath));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ── two output directories, one state file ──────────────────────────────────
|
|
188
|
+
|
|
189
|
+
test("the daemon's agent dir and a --out directory do not invalidate each other, ever", (t) => {
|
|
190
|
+
// The scenario: an operator runs `maestro design sync --out docs/brand`
|
|
191
|
+
// (advertised in the guide) on a seat whose daemon polls the agent dir every
|
|
192
|
+
// 15 minutes. With one shared render stamp, each lane re-stamped the state
|
|
193
|
+
// and the other lane then found its own files "stale" — both files rewritten
|
|
194
|
+
// on every tick, forever, with a foundation that never changed.
|
|
195
|
+
const root = tmpRoot(t);
|
|
196
|
+
const brand = join(root, "docs", "brand");
|
|
197
|
+
let now = NOW;
|
|
198
|
+
const daemon = () => syncDesign({ agentRoot: root, result: result(), now: (now += 900_000), name: "Northwind" });
|
|
199
|
+
const cli = () => syncDesign({ agentRoot: root, outDir: brand, result: result(), now: (now += 60_000), name: "Northwind" });
|
|
200
|
+
|
|
201
|
+
assert.deepEqual(daemon().written, ["DESIGN.md", "PRODUCT.md"], "first write of the agent dir");
|
|
202
|
+
assert.deepEqual(cli().written, ["DESIGN.md", "PRODUCT.md"], "first write of docs/brand");
|
|
203
|
+
|
|
204
|
+
for (let i = 0; i < 6; i++) {
|
|
205
|
+
assert.deepEqual(daemon().written, [], `daemon poll ${i + 1} rewrote nothing`);
|
|
206
|
+
assert.deepEqual(cli().written, [], `--out run ${i + 1} rewrote nothing`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const state = readFoundationState(root);
|
|
210
|
+
assert.equal(typeof state.rendered, "object");
|
|
211
|
+
assert.equal(Object.keys(state.rendered).length, 2, "one stamp per output directory");
|
|
212
|
+
assert.equal(state.renderedAt, state.rendered[resolve(root)], "the top-level stamp still describes the agent dir");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("a legacy state file with a bare renderedAt migrates without a spurious rewrite", (t) => {
|
|
216
|
+
const root = tmpRoot(t);
|
|
217
|
+
syncDesign({ agentRoot: root, result: result(), now: NOW, name: "Northwind" });
|
|
218
|
+
// Rewrite the snapshot in the pre-map shape, as an older SDK left it.
|
|
219
|
+
const doc = readFoundationState(root);
|
|
220
|
+
const legacy = { ...doc };
|
|
221
|
+
delete legacy.rendered;
|
|
222
|
+
writeFoundationState(root, legacy);
|
|
223
|
+
assert.equal(readFoundationState(root).rendered, undefined);
|
|
224
|
+
|
|
225
|
+
const out = syncDesign({ agentRoot: root, result: result(), now: NOW + 3_600_000, name: "Northwind" });
|
|
226
|
+
assert.deepEqual(out.written, [], "the old stamp is read as the agent dir's, so the bytes still match");
|
|
227
|
+
assert.equal(readFoundationState(root).rendered[resolve(root)], legacy.renderedAt);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("both markdown files are published atomically and forced to 0644 even over a tighter file", (t) => {
|
|
231
|
+
const root = tmpRoot(t);
|
|
232
|
+
syncDesign({ agentRoot: root, result: result(), now: NOW, name: "Northwind" });
|
|
233
|
+
// Some other tool tightens the mode and truncates the content.
|
|
234
|
+
chmodSync(join(root, "DESIGN.md"), 0o600);
|
|
235
|
+
writeFileSync(join(root, "DESIGN.md"), "half a file");
|
|
236
|
+
syncDesign({ agentRoot: root, result: result(), now: NOW + 1000, name: "Northwind" });
|
|
237
|
+
assert.equal(statSync(join(root, "DESIGN.md")).mode & 0o777, DESIGN_FILE_MODE, "chmod runs on a rewrite, not only on create");
|
|
238
|
+
|
|
239
|
+
// No temp file is left behind by the atomic publish.
|
|
240
|
+
assert.deepEqual(readdirSync(root).filter((f) => f.includes(".tmp.")), []);
|
|
241
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/prompts/parallelism.mjs — the fleet-wide parallelism directive (WP-M7).
|
|
3
|
+
*
|
|
4
|
+
* THE GAP this closes (owner directive 2026-09-09): a session handed a plan with
|
|
5
|
+
* six independent tasks ran them one at a time, because nothing in its prompt
|
|
6
|
+
* ever said it was allowed not to. The directive below is the standing
|
|
7
|
+
* permission — plus the ONE safety rule that makes parallel dispatch safe on a
|
|
8
|
+
* shared checkout (no two agents editing the same file).
|
|
9
|
+
*
|
|
10
|
+
* It is a CONSTANT, not a template: the same bytes reach `maestro session spawn`
|
|
11
|
+
* (peer sessions) and `scripts/daemon/prompt-builder.mjs` (escalate/guarded
|
|
12
|
+
* sub-sessions), so the wording can be audited in one place and a test can pin
|
|
13
|
+
* it byte-for-byte.
|
|
14
|
+
*
|
|
15
|
+
* {@link withParallelism} is IDEMPOTENT. Two prompt seams compose (a daemon
|
|
16
|
+
* prompt handed to `session spawn`), and a directive repeated twice reads as
|
|
17
|
+
* emphasis to a model, which is exactly how "maximum safe parallelism" turns
|
|
18
|
+
* into "ignore the file-scope rule" — so a prompt that already carries the text
|
|
19
|
+
* is returned unchanged.
|
|
20
|
+
*
|
|
21
|
+
* Pure: no clock, no env, no I/O. Node builtins only. ESM.
|
|
22
|
+
*
|
|
23
|
+
* @module lib/prompts/parallelism
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
"use strict";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The directive, verbatim (design spec 2026-09-08 §WP-M7 mechanic 5). Do not
|
|
30
|
+
* reword without changing the spec — `parallelism.test.mjs` pins these bytes.
|
|
31
|
+
* @type {string}
|
|
32
|
+
*/
|
|
33
|
+
export const PARALLELISM_DIRECTIVE = `You are allowed to run multiple sub-agents concurrently.
|
|
34
|
+
|
|
35
|
+
When the plan contains independent tasks, dispatch them together in a single parallel batch rather than waiting for each task to finish before starting the next.
|
|
36
|
+
|
|
37
|
+
Only run tasks sequentially when one task genuinely depends on the output of another.
|
|
38
|
+
|
|
39
|
+
Before dispatching any batch, check the planned file scope for each task and ensure that no two agents are assigned to edit the same file. If tasks would overlap on a file, sequence those tasks or redefine their scope to eliminate the conflict.
|
|
40
|
+
|
|
41
|
+
Default to maximum safe parallelism.`;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Prepend {@link PARALLELISM_DIRECTIVE} to a prompt, exactly once.
|
|
45
|
+
*
|
|
46
|
+
* A prompt that already contains the directive anywhere in its body is returned
|
|
47
|
+
* unchanged (idempotent — see the module docstring). A non-string or empty
|
|
48
|
+
* prompt yields the directive alone, so a caller that lost its prompt still
|
|
49
|
+
* sends something coherent rather than `undefined`.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} prompt the prompt to lead
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
export function withParallelism(prompt) {
|
|
55
|
+
const body = typeof prompt === "string" ? prompt : "";
|
|
56
|
+
if (body.includes(PARALLELISM_DIRECTIVE)) return body;
|
|
57
|
+
if (!body.trim()) return PARALLELISM_DIRECTIVE;
|
|
58
|
+
return `${PARALLELISM_DIRECTIVE}\n\n${body}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* How many times the directive appears in a string. The seam a test (or a
|
|
63
|
+
* caller composing two prompts) uses to assert "exactly once".
|
|
64
|
+
* @param {string} text
|
|
65
|
+
* @returns {number}
|
|
66
|
+
*/
|
|
67
|
+
export function countParallelism(text) {
|
|
68
|
+
const body = typeof text === "string" ? text : "";
|
|
69
|
+
if (!body) return 0;
|
|
70
|
+
let n = 0;
|
|
71
|
+
let i = body.indexOf(PARALLELISM_DIRECTIVE);
|
|
72
|
+
while (i !== -1) {
|
|
73
|
+
n += 1;
|
|
74
|
+
i = body.indexOf(PARALLELISM_DIRECTIVE, i + PARALLELISM_DIRECTIVE.length);
|
|
75
|
+
}
|
|
76
|
+
return n;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export default { PARALLELISM_DIRECTIVE, withParallelism, countParallelism };
|