@bongos/core 1.19.510 → 1.19.512
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/.bongos-core.json +48 -38
- package/config/branding.neutral.json +3 -2
- package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +16 -2
- package/docs/adr/0238-the-join-door-is-a-provisioning-settings-policy-key.md +57 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +124 -118
- package/docs/copy-registry.json +223 -158
- package/docs/module-api-changelog.md +4 -0
- package/modules/copy-desk/proposals.js +13 -0
- package/modules/copy-desk/routes/copy-desk.js +9 -2
- package/modules/copy-desk/tests/copy_proposals.mjs +41 -0
- package/modules/lifecycle/conflict-resolve.js +59 -3
- package/modules/provisioning/provisioning.js +27 -1
- package/modules/provisioning/tests/provisioning.mjs +17 -14
- package/modules/public-landing/public/assets/cosmos.css +3 -1
- package/modules/public-landing/public/projects.html +125 -0
- package/modules/ui-design/kit/serve.js +4 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/copy-apply.js +54 -4
- package/scripts/gds/git-merge-regen.js +18 -0
- package/src/branding.js +9 -0
- package/src/module-api.js +1 -1
- package/tests/conflict_resolve.mjs +118 -4
- package/tests/currency_label.mjs +5 -4
- package/tests/git_merge_regen.mjs +128 -2
- package/tests/project_door_ui.mjs +267 -0
- package/tests/projects_hub.mjs +86 -1
- package/tests/projects_hub_pre_uat.mjs +4 -1
- package/tests/provision_settings_apply.mjs +8 -7
- package/tests/provisioning_settings.mjs +65 -6
- package/tests/provisioning_settings_apply.mjs +3 -3
- package/tests/provisioning_settings_env.mjs +10 -8
|
@@ -1457,5 +1457,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1457
1457
|
landed since 1.19.508 with no explicit bump. run 33790542026. (task 1002620)
|
|
1458
1458
|
1.19.510 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1459
1459
|
landed since 1.19.509 with no explicit bump. run 33791237400. (task 1002620)
|
|
1460
|
+
1.19.511 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1461
|
+
landed since 1.19.510 with no explicit bump. run 33792868990. (task 1002620)
|
|
1462
|
+
1.19.512 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1463
|
+
landed since 1.19.511 with no explicit bump. run 33793208383. (task 1002620)
|
|
1460
1464
|
---------------------------------------------------------------------------
|
|
1461
1465
|
```
|
|
@@ -260,8 +260,21 @@ function proposalSourceRef(patch) {
|
|
|
260
260
|
return `copy-proposal-${patch.string_id}-${hash.toString(36)}`;
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
// Which version a proposal's task lands in — the version currently BEING BUILT,
|
|
264
|
+
// and nothing else (task 1003493). There is deliberately no fallback: the route
|
|
265
|
+
// used to take `versions[0]` when nothing was building, which is whatever row
|
|
266
|
+
// the query ordered first and may be a shipped version — sealed history that
|
|
267
|
+
// cannot take new tasks. A proposal that lands on a closed version is worse than
|
|
268
|
+
// one that was not accepted, so the caller's answer here is a refusal, not a
|
|
269
|
+
// guess. Pure, so the rule is pinned in copy_proposals.mjs rather than only
|
|
270
|
+
// reachable through a live route.
|
|
271
|
+
function resolveTargetVersion(versions) {
|
|
272
|
+
return (versions || []).find((v) => v && v.status === 'building') || null;
|
|
273
|
+
}
|
|
274
|
+
|
|
263
275
|
module.exports = {
|
|
264
276
|
PROPOSAL_SCHEMA,
|
|
277
|
+
resolveTargetVersion,
|
|
265
278
|
FENCE,
|
|
266
279
|
PLACEHOLDER,
|
|
267
280
|
MIN_WHY_CHARS,
|
|
@@ -391,9 +391,16 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
391
391
|
// how every other unattributed create-vector picks. `createTask` then
|
|
392
392
|
// defaults the goal to that version's catch-all, so a copy change is
|
|
393
393
|
// ordinary work on the board rather than a special case.
|
|
394
|
+
// No `|| versions[0]` fallback (task 1003493) — see resolveTargetVersion:
|
|
395
|
+
// with nothing building, the first row back is whatever the query ordered
|
|
396
|
+
// first, possibly a shipped version, which is sealed history.
|
|
394
397
|
const versions = await lifecycle.listVersions();
|
|
395
|
-
const building =
|
|
396
|
-
if (!building)
|
|
398
|
+
const building = proposals.resolveTargetVersion(versions);
|
|
399
|
+
if (!building) {
|
|
400
|
+
return res.fail('no_open_version', 503, {
|
|
401
|
+
what_this_means: 'No version is currently being built, so there is nowhere for a copy change to land. Nothing was recorded.',
|
|
402
|
+
});
|
|
403
|
+
}
|
|
397
404
|
|
|
398
405
|
const sourceRef = proposals.proposalSourceRef(patch);
|
|
399
406
|
const existing = lifecycle.findTaskBySourceRef
|
|
@@ -275,5 +275,46 @@ await test('the applier re-checks the placeholder arithmetic against the real so
|
|
|
275
275
|
assert.equal(r.code, 'placeholder_mismatch');
|
|
276
276
|
});
|
|
277
277
|
|
|
278
|
+
// --- task 1003493: the two rules the grader found stated but not enforced ----
|
|
279
|
+
|
|
280
|
+
await test('--task proves the claim it names instead of standing in for one', () => {
|
|
281
|
+
const held = [{ task_id: '1003114' }, { task_id: 1003493 }];
|
|
282
|
+
|
|
283
|
+
const wrong = applier.claimGateFor(1003112, held);
|
|
284
|
+
assert.equal(wrong.ok, false);
|
|
285
|
+
assert.equal(wrong.code, 'claim_not_held');
|
|
286
|
+
assert.deepEqual(wrong.detail.held, ['1003114', '1003493'], 'the refusal names the claims you DO hold');
|
|
287
|
+
assert.ok(/claim.js 1003112/.test(wrong.detail.what_this_means), 'and the line that fixes it');
|
|
288
|
+
|
|
289
|
+
// A held claim passes, and an id given as a number matches a claim stored as
|
|
290
|
+
// a string — the ids arrive as both, and a type mismatch here would refuse
|
|
291
|
+
// the legitimate call, which is the failure mode that gets a check removed.
|
|
292
|
+
assert.equal(applier.claimGateFor(1003493, held).ok, true);
|
|
293
|
+
assert.equal(applier.claimGateFor('1003114', held).ok, true);
|
|
294
|
+
|
|
295
|
+
// Holding nothing at all cannot pass, whatever id is named.
|
|
296
|
+
assert.equal(applier.claimGateFor(1003114, []).ok, false);
|
|
297
|
+
assert.equal(applier.claimGateFor(1003114, undefined).ok, false);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await test('a proposal refuses rather than landing on an arbitrary version', () => {
|
|
301
|
+
const shipped = { id: 'BONGOS-V0', status: 'shipped' };
|
|
302
|
+
const building = { id: 'BONGOS-V1', status: 'building' };
|
|
303
|
+
const planning = { id: 'BONGOS-V2', status: 'planning' };
|
|
304
|
+
|
|
305
|
+
// The version being built wins wherever it sits in the list.
|
|
306
|
+
assert.equal(proposals.resolveTargetVersion([shipped, building, planning]).id, 'BONGOS-V1');
|
|
307
|
+
assert.equal(proposals.resolveTargetVersion([building]).id, 'BONGOS-V1');
|
|
308
|
+
|
|
309
|
+
// With nothing building there is NO fallback — not the first row, not a
|
|
310
|
+
// shipped version, not a planning one. null is what makes the route's
|
|
311
|
+
// no_open_version refusal reachable.
|
|
312
|
+
assert.equal(proposals.resolveTargetVersion([shipped, planning]), null);
|
|
313
|
+
assert.equal(proposals.resolveTargetVersion([shipped]), null);
|
|
314
|
+
assert.equal(proposals.resolveTargetVersion([]), null);
|
|
315
|
+
assert.equal(proposals.resolveTargetVersion(null), null);
|
|
316
|
+
assert.equal(proposals.resolveTargetVersion([null, undefined]), null);
|
|
317
|
+
});
|
|
318
|
+
|
|
278
319
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
279
320
|
process.exit(failed ? 1 : 0);
|
|
@@ -138,11 +138,13 @@ async function hasConflictMarkers(gitIn) {
|
|
|
138
138
|
// resolveBranchConflicts — attempt to auto-resolve a generated-file / append-index
|
|
139
139
|
// merge conflict on `branch` against origin/main, server-side. Returns a structured
|
|
140
140
|
// result (never throws for the expected outcomes; throws only on misconfiguration):
|
|
141
|
-
// { resolved: true, head_sha, branch }
|
|
141
|
+
// { resolved: true, head_sha, branch, regenerated } — merged + pushed
|
|
142
142
|
// { resolved: false, skipped: '<why>' [, conflicted: [...] ] } — left as-is
|
|
143
143
|
// skipped reasons: disabled | unconfigured | already_up_to_date | clone_failed |
|
|
144
144
|
// fetch_failed | checkout_failed | markers_remain | push_failed |
|
|
145
|
-
// unresolvable_conflict (with conflicted[]).
|
|
145
|
+
// unresolvable_conflict (with conflicted[]) | generator_failed | commit_failed.
|
|
146
|
+
// `regenerated` says whether step 7b had to commit post-merge generator drift on top
|
|
147
|
+
// of the merge (task 1003546) — head_sha is that commit when it did.
|
|
146
148
|
async function resolveBranchConflicts({ branch } = {}, deps = {}) {
|
|
147
149
|
// Kill-switch: deps.disabled wins for tests; otherwise the env flag (matches the
|
|
148
150
|
// publish-reconciler's PUBLISH_RECONCILER_DISABLED idiom).
|
|
@@ -163,6 +165,7 @@ async function resolveBranchConflicts({ branch } = {}, deps = {}) {
|
|
|
163
165
|
const exec = deps.exec || defaultExec;
|
|
164
166
|
const repoRoot = deps.repoRoot || REPO_ROOT;
|
|
165
167
|
const register = deps.registerMergeDriver || registerMergeDriver;
|
|
168
|
+
const runGenerators = deps.runGenerators || defaultRunGenerators;
|
|
166
169
|
const tmp = (deps.mkdtemp || (() => fs.mkdtempSync(path.join(os.tmpdir(), 'otb-resolve-'))))();
|
|
167
170
|
const work = path.join(tmp, 'work');
|
|
168
171
|
const git = (args, opts) => exec(['-C', work, ...args], opts);
|
|
@@ -225,6 +228,49 @@ async function resolveBranchConflicts({ branch } = {}, deps = {}) {
|
|
|
225
228
|
// not our case (mergeIfGreen reported a conflict), so report a no-op.
|
|
226
229
|
const after = (await git(['rev-parse', 'HEAD'])).stdout.trim();
|
|
227
230
|
if (after === before) return { resolved: false, skipped: 'already_up_to_date' };
|
|
231
|
+
let head = after;
|
|
232
|
+
|
|
233
|
+
// 7b. REGENERATE ON THE MERGED TREE — the step that makes step 6 trustworthy
|
|
234
|
+
// (task 1003546). The `otb-regen` driver is invoked BY git, DURING the merge,
|
|
235
|
+
// and every artifact it rebuilds is produced by SCANNING THE WORKING TREE
|
|
236
|
+
// (copy-inventory.js, gen-repo-map.js, gen-file-map.js). At driver time that
|
|
237
|
+
// tree is still the PRE-merge (ours) tree, so the driver writes an artifact
|
|
238
|
+
// describing the branch, git then completes the merge underneath it, and the
|
|
239
|
+
// result is a clean merge — exit 0, no unmerged paths, no markers — carrying a
|
|
240
|
+
// generated file that matches NEITHER side nor the merged tree. Nothing
|
|
241
|
+
// downstream can catch it: it is only detectable by regenerating again.
|
|
242
|
+
//
|
|
243
|
+
// Measured on the real repo (task 1003546): two branches each shifting lines
|
|
244
|
+
// in modules/hall-ui/public/task-detail.js merge clean, and the merge commit's
|
|
245
|
+
// docs/copy-registry.json is byte-identical to ours — the other side's line
|
|
246
|
+
// numbers are silently DISCARDED (`"line": 464` where the merged tree says
|
|
247
|
+
// 466). That is the PR #536 / task 1003544 strand exactly. Task 1003543 had
|
|
248
|
+
// given these files the driver, which converted the loud CONFLICT they used to
|
|
249
|
+
// throw into this silent staleness — strictly harder to see.
|
|
250
|
+
//
|
|
251
|
+
// A line-numbered or aggregate-carrying artifact cannot be resolved from the
|
|
252
|
+
// three merge sides by construction; the only correct value is a fresh scan of
|
|
253
|
+
// the FINAL tree. So we rebuild here, after the merge commit exists, and
|
|
254
|
+
// commit any drift on top. Ordinary case is a no-op (the driver's guess was
|
|
255
|
+
// right, or nothing generated moved) and we push the merge alone.
|
|
256
|
+
const gen = await runGenerators({ work });
|
|
257
|
+
if (gen && gen.code !== 0) {
|
|
258
|
+
// A generator hard-error / a missing hand-written note (gen-file-map exit 2).
|
|
259
|
+
// We cannot certify the merged tree, and pushing an uncertifiable one is what
|
|
260
|
+
// stranded #536 — leave it, exactly the module's "any failure is a no-op for
|
|
261
|
+
// the strand" posture. The reconciler's attempt cap still escalates to a human.
|
|
262
|
+
return { resolved: false, skipped: 'generator_failed' };
|
|
263
|
+
}
|
|
264
|
+
const drift = (await git(['status', '--porcelain'])).stdout.trim();
|
|
265
|
+
if (drift) {
|
|
266
|
+
await git(['add', '-A']);
|
|
267
|
+
const c = await git([
|
|
268
|
+
'commit', '-m',
|
|
269
|
+
'chore(gds): regenerate generated artifacts from the merged tree (conflict-resolve self-heal, task 1003546)',
|
|
270
|
+
]);
|
|
271
|
+
if (c.code !== 0) return { resolved: false, skipped: 'commit_failed' };
|
|
272
|
+
head = (await git(['rev-parse', 'HEAD'])).stdout.trim();
|
|
273
|
+
}
|
|
228
274
|
|
|
229
275
|
// 8. Belt-and-braces: a clean `git merge` already implies no unmerged paths and
|
|
230
276
|
// no markers, but assert both before we push anything.
|
|
@@ -238,7 +284,7 @@ async function resolveBranchConflicts({ branch } = {}, deps = {}) {
|
|
|
238
284
|
const p = await git(['push', authUrl, `HEAD:refs/heads/${branch}`]);
|
|
239
285
|
if (p.code !== 0) return { resolved: false, skipped: 'push_failed' };
|
|
240
286
|
|
|
241
|
-
return { resolved: true, head_sha:
|
|
287
|
+
return { resolved: true, head_sha: head, branch, regenerated: Boolean(drift) };
|
|
242
288
|
} finally {
|
|
243
289
|
try {
|
|
244
290
|
(deps.rmtmp || ((d) => fs.rmSync(d, { recursive: true, force: true })))(tmp);
|
|
@@ -255,6 +301,16 @@ const GENERATOR_SCRIPTS = [
|
|
|
255
301
|
'scripts/gds/gen-repo-map.js',
|
|
256
302
|
'scripts/gds/gen-file-map.js',
|
|
257
303
|
'scripts/gds/gen-session-index.js',
|
|
304
|
+
// task 1003546: copy-inventory.js writes docs/copy-registry.json +
|
|
305
|
+
// docs/copy-inventory.md, and its rows carry a `line` per copy string — so ANY
|
|
306
|
+
// merge that shifts lines in a scanned UI file re-stales it and `unit`
|
|
307
|
+
// (tests/copy_inventory.mjs) goes red on the merge ref. Its absence here did not
|
|
308
|
+
// merely fail to heal that: with the other three producing no diff, the caller
|
|
309
|
+
// returns `no_regen_diff`, whose documented meaning is "a REAL check failure, not
|
|
310
|
+
// staleness" — so copy-registry staleness was MISDIAGNOSED as a genuine failure,
|
|
311
|
+
// burned the attempt cap, and announceStrand flagged the task needs_rebase
|
|
312
|
+
// (PR #536 / task 1003544).
|
|
313
|
+
'scripts/gds/copy-inventory.js',
|
|
258
314
|
];
|
|
259
315
|
|
|
260
316
|
// Run the CLONE's generator scripts in WRITE mode against the merged working tree.
|
|
@@ -118,11 +118,37 @@ const RESTART_SHAPES = new Set(['co-tenant', 'standalone']);
|
|
|
118
118
|
// (migration provisioning_009) is a dumb store with room for future keys.
|
|
119
119
|
// DEFAULT_SETTINGS is TODAY's behavior (public surfaces, apply-to-join),
|
|
120
120
|
// applied at read time so pre-existing rows need no data backfill.
|
|
121
|
+
//
|
|
122
|
+
// `visibility` is ADR 0182's STORED JOIN DOOR (BV1.R05, task 1002324) and it is a
|
|
123
|
+
// third axis, not a synonym of its neighbours — the three answer three different
|
|
124
|
+
// questions and an owner may set them independently:
|
|
125
|
+
//
|
|
126
|
+
// platform_visibility who may READ this project's own pages (its sign-in door)
|
|
127
|
+
// joinability HOW someone becomes a member (self-join / apply / invite)
|
|
128
|
+
// visibility how the project stands on the PLATFORM's map: named and
|
|
129
|
+
// open (public), named and closed (private), or present but
|
|
130
|
+
// anonymous (stealth)
|
|
131
|
+
//
|
|
132
|
+
// The name is ADR 0182 D1's deliberate choice — `goals.visibility` established the
|
|
133
|
+
// word and one word across both planes beat an accurate name used in one place —
|
|
134
|
+
// and D1 also records that it is a mild lie: this key does NOT decide whether the
|
|
135
|
+
// project is on the map. PUBLISHABILITY does (a name + a description, ADR 0182 D4),
|
|
136
|
+
// it is derived and never stored, and no value here can put a husk on the map.
|
|
137
|
+
//
|
|
138
|
+
// It is a POLICY, not a platform-drawn key: ADR 0182 D6 makes the door the CEILING
|
|
139
|
+
// that `joinability` may only narrow, and D1/D5 put that authority on the instance —
|
|
140
|
+
// so it rides web.env like its two neighbours and a restart applies it. The instance
|
|
141
|
+
// reads it back as `project.visibility` (src/branding.js), which is what lets a later
|
|
142
|
+
// task enforce the door there and report the value to the hub catalog (R08).
|
|
121
143
|
const SETTINGS_VOCAB = Object.freeze({
|
|
122
144
|
platform_visibility: ['public', 'gated'],
|
|
123
145
|
joinability: ['open', 'apply', 'invite_only'],
|
|
146
|
+
visibility: ['public', 'private', 'stealth'],
|
|
124
147
|
});
|
|
125
|
-
|
|
148
|
+
// Default = today's behavior on every axis, including the join door: ADR 0182's
|
|
149
|
+
// migration gives the hub column the same `public` default, "existing rows keep
|
|
150
|
+
// today's behaviour".
|
|
151
|
+
const DEFAULT_SETTINGS = Object.freeze({ platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
|
|
126
152
|
|
|
127
153
|
// The project's PLANET (task 1003347, direction record §16): how the platform
|
|
128
154
|
// DRAWS it on the sky, in the owner's list and on the card — its template
|
|
@@ -263,21 +263,24 @@ await ta('recordDnsNote / clearErrorNote are NARROW single-column updates — no
|
|
|
263
263
|
console.log('\nPer-project settings (task 1003040):');
|
|
264
264
|
|
|
265
265
|
t('effectiveSettings: defaults for a bare/empty row = today\'s behavior; stored keys overlay; junk falls back', () => {
|
|
266
|
-
assert.deepEqual(P.effectiveSettings(null), { platform_visibility: 'public', joinability: 'apply' });
|
|
267
|
-
assert.deepEqual(P.effectiveSettings({}), { platform_visibility: 'public', joinability: 'apply' });
|
|
268
|
-
assert.deepEqual(P.effectiveSettings({ settings: {} }), { platform_visibility: 'public', joinability: 'apply' });
|
|
266
|
+
assert.deepEqual(P.effectiveSettings(null), { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
|
|
267
|
+
assert.deepEqual(P.effectiveSettings({}), { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
|
|
268
|
+
assert.deepEqual(P.effectiveSettings({ settings: {} }), { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
|
|
269
269
|
assert.deepEqual(
|
|
270
270
|
P.effectiveSettings({ settings: { platform_visibility: 'gated' } }),
|
|
271
|
-
{ platform_visibility: 'gated', joinability: 'apply' },
|
|
271
|
+
{ platform_visibility: 'gated', joinability: 'apply', visibility: 'public' },
|
|
272
272
|
'a stored key overlays only itself'
|
|
273
273
|
);
|
|
274
274
|
assert.deepEqual(
|
|
275
|
-
P.effectiveSettings({ settings: { platform_visibility: 'bogus', joinability: 'invite_only', future_key: 'x' } }),
|
|
276
|
-
{ platform_visibility: 'public', joinability: 'invite_only' },
|
|
275
|
+
P.effectiveSettings({ settings: { platform_visibility: 'bogus', joinability: 'invite_only', future_key: 'x', visibility: 'unlisted' } }),
|
|
276
|
+
{ platform_visibility: 'public', joinability: 'invite_only', visibility: 'public' },
|
|
277
277
|
'off-vocabulary values fall back to the default; unknown keys never leak out'
|
|
278
278
|
);
|
|
279
279
|
assert.deepEqual(P.effectiveSettings({ settings: ['not', 'an', 'object'] }),
|
|
280
|
-
{ platform_visibility: 'public', joinability: 'apply' }, 'a non-object settings value reads as defaults');
|
|
280
|
+
{ platform_visibility: 'public', joinability: 'apply', visibility: 'public' }, 'a non-object settings value reads as defaults');
|
|
281
|
+
// the join door reads back like any other policy key (task 1002324)
|
|
282
|
+
assert.deepEqual(P.effectiveSettings({ settings: { visibility: 'stealth' } }),
|
|
283
|
+
{ platform_visibility: 'public', joinability: 'apply', visibility: 'stealth' }, 'a stored door overlays only itself');
|
|
281
284
|
});
|
|
282
285
|
|
|
283
286
|
t('SETTINGS_VOCAB / DEFAULT_SETTINGS: every default is on its own vocabulary (lockstep guard)', () => {
|
|
@@ -336,7 +339,7 @@ t('the planet (task 1003347): PLANET_TEMPLATES mirrors platform-identity\'s PROJ
|
|
|
336
339
|
assert.equal(P.PLANET_TEMPLATES.length, 36);
|
|
337
340
|
assert.deepEqual([...P.PLANET_ACCENTS], ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent']);
|
|
338
341
|
for (const a of P.PLANET_ACCENTS) assert.doesNotMatch(a, /^#|rgb|hsl/, 'a name, never a colour (the Fifteen Rule)');
|
|
339
|
-
assert.deepEqual(Object.keys(P.PLANET_VOCAB), ['planet_template', 'planet_accent']);
|
|
342
|
+
assert.deepEqual(Object.keys(P.PLANET_VOCAB), ['planet_template', 'planet_accent', 'card_backdrop']);
|
|
340
343
|
// the deliberate asymmetry: the planet is NOT a setting — no default, never in SETTINGS_VOCAB
|
|
341
344
|
for (const k of Object.keys(P.PLANET_VOCAB)) {
|
|
342
345
|
assert.ok(!(k in P.SETTINGS_VOCAB), `${k} is not a policy the instance obeys`);
|
|
@@ -345,12 +348,12 @@ t('the planet (task 1003347): PLANET_TEMPLATES mirrors platform-identity\'s PROJ
|
|
|
345
348
|
});
|
|
346
349
|
|
|
347
350
|
t('planetChoice: null for a bare row or an unset key; a stored on-vocabulary value reads back; junk reads null, never leaks', () => {
|
|
348
|
-
assert.deepEqual(P.planetChoice(null), { planet_template: null, planet_accent: null });
|
|
349
|
-
assert.deepEqual(P.planetChoice({}), { planet_template: null, planet_accent: null });
|
|
350
|
-
assert.deepEqual(P.planetChoice({ settings: { platform_visibility: 'gated' } }), { planet_template: null, planet_accent: null });
|
|
351
|
-
assert.deepEqual(P.planetChoice({ settings: { planet_template: 'pair-frost-tint', planet_accent: 'sun-lit' } }), { planet_template: 'pair-frost-tint', planet_accent: 'sun-lit' });
|
|
352
|
-
assert.deepEqual(P.planetChoice({ settings: { planet_template: 'cube', planet_accent: '#fff' } }), { planet_template: null, planet_accent: null });
|
|
353
|
-
assert.deepEqual(P.planetChoice({ settings: ['nope'] }), { planet_template: null, planet_accent: null });
|
|
351
|
+
assert.deepEqual(P.planetChoice(null), { planet_template: null, planet_accent: null, card_backdrop: null });
|
|
352
|
+
assert.deepEqual(P.planetChoice({}), { planet_template: null, planet_accent: null, card_backdrop: null });
|
|
353
|
+
assert.deepEqual(P.planetChoice({ settings: { platform_visibility: 'gated' } }), { planet_template: null, planet_accent: null, card_backdrop: null });
|
|
354
|
+
assert.deepEqual(P.planetChoice({ settings: { planet_template: 'pair-frost-tint', planet_accent: 'sun-lit' } }), { planet_template: 'pair-frost-tint', planet_accent: 'sun-lit', card_backdrop: null });
|
|
355
|
+
assert.deepEqual(P.planetChoice({ settings: { planet_template: 'cube', planet_accent: '#fff' } }), { planet_template: null, planet_accent: null, card_backdrop: null });
|
|
356
|
+
assert.deepEqual(P.planetChoice({ settings: ['nope'] }), { planet_template: null, planet_accent: null, card_backdrop: null });
|
|
354
357
|
});
|
|
355
358
|
|
|
356
359
|
t('publicInstance carries the planet — both keys, null when unset — so the list and the manage page read it off the row', () => {
|
|
@@ -1501,7 +1501,9 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
1501
1501
|
#view-new #wizSignin:hover{background:color-mix(in srgb,var(--ink) 88%,var(--bg));}
|
|
1502
1502
|
/* the error alert: the error ink between two hairlines, no plate */
|
|
1503
1503
|
#view-new .alert{background:none;border:0;border-top:1px solid var(--err);border-bottom:1px solid var(--err);border-radius:0;padding:12px 0;font-weight:400;}
|
|
1504
|
-
|
|
1504
|
+
/* one fold for the whole wizard: 840 is where the page's bands and ledger stack
|
|
1505
|
+
(cosmos's createSplit collapse), so the rail folds with them, not before them */
|
|
1506
|
+
@media (max-width:840px){
|
|
1505
1507
|
#view-new .steps{grid-template-columns:repeat(3,1fr);gap:18px 10px;margin-bottom:30px;}
|
|
1506
1508
|
#view-new .steps .snum{font-size:18px;}
|
|
1507
1509
|
}
|
|
@@ -792,6 +792,25 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
792
792
|
</details>
|
|
793
793
|
</div>
|
|
794
794
|
|
|
795
|
+
<!-- live since task 1002324: the join door (ADR 0182 D1) — how the project
|
|
796
|
+
stands on the platform's map. The third axis, deliberately its own
|
|
797
|
+
section: the two above are about the project's OWN pages and its OWN
|
|
798
|
+
membership; this one is about the map everyone else reads. Same two
|
|
799
|
+
reads, key for key, as its siblings: the choice shown is the platform's
|
|
800
|
+
REQUEST (its settings row) and the save's reply is a receipt, never a
|
|
801
|
+
claim the project already runs it. Plain labels only — Public, Private,
|
|
802
|
+
Stealth — because the space words are the drawn orb's, not the copy's
|
|
803
|
+
(the goal's interview decision). The fold is a SIBLING of the volatile
|
|
804
|
+
slot so no re-render can drop it. -->
|
|
805
|
+
<div class="card mSec" id="mDoorState">
|
|
806
|
+
<h2>On the map</h2>
|
|
807
|
+
<div id="mDoor"><p class="finePrint">Reading the project’s settings…</p></div>
|
|
808
|
+
<details class="finePrint" id="mDoorWhat">
|
|
809
|
+
<summary>What each choice changes</summary>
|
|
810
|
+
<p><b>Public</b> — the project is on the map under its name, and anyone who finds it can join. <b>Private</b> — it is on the map under its name, but nobody joins on their own: people ask, and you decide. <b>Stealth</b> — the map shows only that something is there; its name, and everything else about it, stay yours, and nobody can ask to join. Whichever you pick, a project reaches the map only once it has a name and a description. This is separate from “Who can see it” above — that one is the door on the project’s own pages. Changing it restarts the project to pick it up.</p>
|
|
811
|
+
</details>
|
|
812
|
+
</div>
|
|
813
|
+
|
|
795
814
|
<!-- the planet picker (task 1003347; direction record §16, v1 the bands): the
|
|
796
815
|
owner's own choice of how the platform draws this project — the sky,
|
|
797
816
|
the list, this page's head and the card read the same row. The pick is
|
|
@@ -5168,6 +5187,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5168
5187
|
the GitHub read: entry + tab return, never the in-flight tick */
|
|
5169
5188
|
if (!keepRepo) loadVisibility(inst);
|
|
5170
5189
|
if (!keepRepo) loadJoinability(inst);
|
|
5190
|
+
if (!keepRepo) loadDoor();
|
|
5171
5191
|
/* the publish gate (task 1002335) needs no second read — the verdict
|
|
5172
5192
|
rode in on the instance itself. It sits behind the same keepRepo
|
|
5173
5193
|
guard because its card holds a textarea the in-flight tick must
|
|
@@ -5953,6 +5973,111 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5953
5973
|
if (t && t.hasAttribute('data-join-save')) doJoinSave();
|
|
5954
5974
|
});
|
|
5955
5975
|
|
|
5976
|
+
/* ── on the map (task 1002324): the join door, ADR 0182 D1 — the third axis,
|
|
5977
|
+
beside "who can see it" (the project's own sign-in door) and "how people
|
|
5978
|
+
join" (its membership door). The two reads above, key for key: the CHOICE
|
|
5979
|
+
comes from the platform's settings row and the save's reply is a RECEIPT —
|
|
5980
|
+
its push.message is shown verbatim and never painted as the live state,
|
|
5981
|
+
because the value reaches a running project only by the restart the runner
|
|
5982
|
+
performs. Unlike its siblings this section asks the project's manifest
|
|
5983
|
+
nothing: an already-deployed project reports no door yet, and a line that
|
|
5984
|
+
could only ever say "your project doesn't report this" is worse than no
|
|
5985
|
+
line. The read-back arrives with the enforcement (BV1.R11/R12).
|
|
5986
|
+
Labels are PLAIN — Public / Private / Stealth — per the goal's interview:
|
|
5987
|
+
the space metaphor is the drawn orb's, never the copy's. ── */
|
|
5988
|
+
|
|
5989
|
+
var DOOR_OPTIONS = {
|
|
5990
|
+
public: { label: 'Public', blurb: 'The project is on the map under its name, and anyone who finds it can join.' },
|
|
5991
|
+
private: { label: 'Private', blurb: 'On the map under its name, but nobody joins on their own — people ask, and you decide.' },
|
|
5992
|
+
stealth: { label: 'Stealth', blurb: 'The map shows only that something is there. Its name, and everything else about it, stay yours — and nobody can ask to join.' }
|
|
5993
|
+
};
|
|
5994
|
+
var mDoorSettings = null; /* the platform row's last answer: { value, options } */
|
|
5995
|
+
|
|
5996
|
+
function doorMsg(html) { $('mDoor').innerHTML = html; }
|
|
5997
|
+
|
|
5998
|
+
function renderDoor() {
|
|
5999
|
+
var s = mDoorSettings;
|
|
6000
|
+
if (!s) return;
|
|
6001
|
+
var opts = (s.options || []).filter(function (v) { return !!DOOR_OPTIONS[v]; });
|
|
6002
|
+
doorMsg('<div role="radiogroup" aria-label="On the map">' + opts.map(function (v) {
|
|
6003
|
+
var o = DOOR_OPTIONS[v];
|
|
6004
|
+
return '<label class="visOpt"><input type="radio" name="mDoorPick" value="' + esc(v) + '"' +
|
|
6005
|
+
(v === s.value ? ' checked' : '') + '><span><b>' + esc(o.label) + '</b>' + esc(o.blurb) + '</span></label>';
|
|
6006
|
+
}).join('') + '</div>' +
|
|
6007
|
+
'<div class="actions"><button type="button" class="btn small" data-door-save>Save</button></div>' +
|
|
6008
|
+
'<div class="projMsg" id="mDoorMsg" role="status" aria-live="polite"></div>');
|
|
6009
|
+
}
|
|
6010
|
+
|
|
6011
|
+
function loadDoor() {
|
|
6012
|
+
mDoorSettings = null;
|
|
6013
|
+
doorMsg('<p class="finePrint">Reading the project’s settings…</p>');
|
|
6014
|
+
var asked = manageId; /* a stale answer must never describe a newer project */
|
|
6015
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', { credentials: 'same-origin' })
|
|
6016
|
+
.then(function (res) {
|
|
6017
|
+
if (asked !== manageId) throw new Error('stale');
|
|
6018
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
6019
|
+
if (res.ok) {
|
|
6020
|
+
mDoorSettings = {
|
|
6021
|
+
value: (data.settings || {}).visibility,
|
|
6022
|
+
options: (data.options || {}).visibility || Object.keys(DOOR_OPTIONS)
|
|
6023
|
+
};
|
|
6024
|
+
renderDoor();
|
|
6025
|
+
return;
|
|
6026
|
+
}
|
|
6027
|
+
doorMsg('<p class="finePrint">' + (res.status === 401 ? signInAgain() : 'Couldn’t read this project’s settings right now — refresh in a moment.') + '</p>');
|
|
6028
|
+
});
|
|
6029
|
+
})
|
|
6030
|
+
.catch(function (err) {
|
|
6031
|
+
if (String(err && err.message) === 'stale') return;
|
|
6032
|
+
doorMsg('<p class="finePrint">Couldn’t read this project’s settings right now — refresh in a moment.</p>');
|
|
6033
|
+
});
|
|
6034
|
+
}
|
|
6035
|
+
|
|
6036
|
+
/* the save sends the CHOICE and shows the platform's receipt verbatim */
|
|
6037
|
+
function doDoorSave() {
|
|
6038
|
+
if (!manageInst || !mDoorSettings) return;
|
|
6039
|
+
var picked = document.querySelector('#mDoor input[name="mDoorPick"]:checked');
|
|
6040
|
+
if (!picked) return;
|
|
6041
|
+
var value = picked.value;
|
|
6042
|
+
var box = $('mDoorMsg');
|
|
6043
|
+
var btn = document.querySelector('#mDoor [data-door-save]');
|
|
6044
|
+
if (btn) btn.disabled = true;
|
|
6045
|
+
box.className = 'projMsg'; box.textContent = 'Saving…';
|
|
6046
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', {
|
|
6047
|
+
method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
|
6048
|
+
body: JSON.stringify({ visibility: value })
|
|
6049
|
+
}).then(function (res) {
|
|
6050
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
6051
|
+
if (res.ok) {
|
|
6052
|
+
/* the row now holds the choice — the radio follows it, and nothing
|
|
6053
|
+
here claims the project is already running it */
|
|
6054
|
+
mDoorSettings.value = (data.saved || {}).visibility || value;
|
|
6055
|
+
box.className = 'projMsg ok';
|
|
6056
|
+
box.textContent = (data.push && data.push.message) || 'Saved.';
|
|
6057
|
+
return;
|
|
6058
|
+
}
|
|
6059
|
+
var e = apiError(data, res.status);
|
|
6060
|
+
if (res.status === 401) { box.className = 'projMsg err'; box.innerHTML = signInAgain(); return; }
|
|
6061
|
+
/* 409 intent_conflict: the change IS saved (recorded) — the server's
|
|
6062
|
+
sentence says so and names the run holding the slot; show it as is */
|
|
6063
|
+
box.className = e.code === 'intent_conflict' ? 'projMsg' : 'projMsg err';
|
|
6064
|
+
box.textContent = e.message || 'The change could not be saved — try again in a moment.';
|
|
6065
|
+
});
|
|
6066
|
+
}).catch(function () {
|
|
6067
|
+
box.className = 'projMsg err';
|
|
6068
|
+
box.textContent = 'Couldn’t reach the platform — check your connection and try again.';
|
|
6069
|
+
}).then(function () {
|
|
6070
|
+
if (btn) btn.disabled = false;
|
|
6071
|
+
/* the strip + the sibling sections read open_intent — re-read it */
|
|
6072
|
+
loadManage(true, true);
|
|
6073
|
+
});
|
|
6074
|
+
}
|
|
6075
|
+
|
|
6076
|
+
$('mDoor').addEventListener('click', function (ev) {
|
|
6077
|
+
var t = ev.target.closest && ev.target.closest('button');
|
|
6078
|
+
if (t && t.hasAttribute('data-door-save')) doDoorSave();
|
|
6079
|
+
});
|
|
6080
|
+
|
|
5956
6081
|
/* ── delete the project (task 1003042): the manage page's teardown control.
|
|
5957
6082
|
It renders from status + open_intent only — no second request — so the
|
|
5958
6083
|
minute tick can repaint it and a queued teardown appears without a reload.
|
|
@@ -134,7 +134,7 @@ const FLOW_RECEIPTS = {
|
|
|
134
134
|
domain: { recorded: true, queued: true, message: 'Address recorded.' },
|
|
135
135
|
// the wizard's filing (task 1003346): the receipt names the instance the page then polls — Harbor, the fixture's standing-up row — and the address the page promises is the one the server assigned
|
|
136
136
|
provision: { queued: true, message: 'Queued. The platform picks it up from here.', instance: { id: 102, domain: 'mercury.cloudbongos.com' } },
|
|
137
|
-
settings: { settings: { platform_visibility: 'public', joinability: 'open' }, push: { queued: true, message: 'Saved. The project restarts to apply it.' } },
|
|
137
|
+
settings: { settings: { platform_visibility: 'public', joinability: 'open', visibility: 'public' }, push: { queued: true, message: 'Saved. The project restarts to apply it.' } },
|
|
138
138
|
};
|
|
139
139
|
// a teardown or restart ask is REMEMBERED for the run: the page re-reads the project right
|
|
140
140
|
// after a receipt (doDelete → loadManage), and what it must then see is the queued intent —
|
|
@@ -160,6 +160,7 @@ function readBody(req, cb) {
|
|
|
160
160
|
const STUB_VOCAB = {
|
|
161
161
|
platform_visibility: ['public', 'gated'],
|
|
162
162
|
joinability: ['open', 'apply', 'invite_only'],
|
|
163
|
+
visibility: ['public', 'private', 'stealth'],
|
|
163
164
|
planet_template: null, // the template set is long; shape is checked, not membership
|
|
164
165
|
planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
|
|
165
166
|
card_backdrop: ['nebula', 'dust', 'deepfield', 'chrome'],
|
|
@@ -178,7 +179,7 @@ function settingsPatch(res, id, body) {
|
|
|
178
179
|
const bad = settingsRejection(body);
|
|
179
180
|
if (bad) return json(res, 400, { error: { code: 'validation_failed', message: bad } });
|
|
180
181
|
if (PLANET_KEYS.some((k) => k in body)) PLANETS.set(String(id), Object.fromEntries(PLANET_KEYS.map((k) => [k, body[k] || null])));
|
|
181
|
-
const policy = ['platform_visibility', 'joinability'].some((k) => k in body);
|
|
182
|
+
const policy = ['platform_visibility', 'joinability', 'visibility'].some((k) => k in body);
|
|
182
183
|
const push = policy ? FLOW_RECEIPTS.settings.push
|
|
183
184
|
: { queued: false, restart: false, message: 'Saved. The sky, your list and the card draw it this way from here; nothing on your project restarts for it.' };
|
|
184
185
|
return json(res, 200, { ok: true, saved: FLOW_RECEIPTS.settings.settings, planet: PLANETS.get(String(id)) || NO_PLANET, push });
|
|
@@ -209,7 +210,7 @@ function instances(res, id, settings) {
|
|
|
209
210
|
if (id === null) return json(res, 200, { instances: list });
|
|
210
211
|
const inst = list.find((i) => String(i.id) === String(id));
|
|
211
212
|
if (!inst) return json(res, 404, { error: { code: 'not_found', message: 'no such instance' } });
|
|
212
|
-
if (settings) return json(res, 200, { settings: { platform_visibility: 'public', joinability: 'open' }, planet: PLANETS.get(String(id)) || NO_PLANET, options: { platform_visibility: ['public', 'gated'], joinability: ['open', 'apply', 'invite_only'] } });
|
|
213
|
+
if (settings) return json(res, 200, { settings: { platform_visibility: 'public', joinability: 'open', visibility: 'public' }, planet: PLANETS.get(String(id)) || NO_PLANET, options: { platform_visibility: ['public', 'gated'], joinability: ['open', 'apply', 'invite_only'], visibility: ['public', 'private', 'stealth'] } });
|
|
213
214
|
return json(res, 200, { instance: inst, open_intent: inst.open_intent || null });
|
|
214
215
|
}
|
|
215
216
|
// A project's art in the featured fixture may be root-relative (the surface's
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.512",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.512",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.512",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
//
|
|
33
33
|
// Run: node scripts/gds/copy-apply.js (the task you are HOLDING — the normal call)
|
|
34
34
|
// node scripts/gds/copy-apply.js --dry-run (say what would change, write nothing)
|
|
35
|
-
// node scripts/gds/copy-apply.js --task 1234 (a
|
|
35
|
+
// node scripts/gds/copy-apply.js --task 1234 (a claim you hold, when you hold more than one)
|
|
36
36
|
// node scripts/gds/copy-apply.js --patch-file p.json (a local patch — used by the tests)
|
|
37
37
|
//
|
|
38
38
|
// WHY THE BARE FORM DEFAULTS TO YOUR CLAIM rather than taking an id. The task a
|
|
@@ -43,6 +43,13 @@
|
|
|
43
43
|
// task. So the bare call reads the claim, and holding no claim is a refusal that
|
|
44
44
|
// says to go and claim the task — the methodology's own rule, enforced by the
|
|
45
45
|
// command instead of restated in its output.
|
|
46
|
+
//
|
|
47
|
+
// --task DOES NOT OPT OUT OF THAT (task 1003493). It selects which of your
|
|
48
|
+
// claims to act under when you hold several; it is checked against the same
|
|
49
|
+
// /me claim list the bare call reads, and refuses `claim_not_held` for a task
|
|
50
|
+
// you do not hold. The one path that never touches a task is --patch-file,
|
|
51
|
+
// which reads a local JSON patch and is the tests' entry point — it lands
|
|
52
|
+
// nothing a proposal task authorised, so there is no claim for it to prove.
|
|
46
53
|
|
|
47
54
|
const fs = require('node:fs');
|
|
48
55
|
const path = require('node:path');
|
|
@@ -208,13 +215,50 @@ function refuse(result) {
|
|
|
208
215
|
return 2;
|
|
209
216
|
}
|
|
210
217
|
|
|
218
|
+
// Every claim this session holds. One reader, so the bare call and --task are
|
|
219
|
+
// answered from the same source of truth — the shape of bug where a rule is
|
|
220
|
+
// enforced on one path and merely stated on the other (task 1003493).
|
|
221
|
+
async function activeClaims() {
|
|
222
|
+
const me = await apiCall('GET', '/api/gds/me');
|
|
223
|
+
if (!me.ok) return { ok: false, code: 'no_session', detail: { status: me.status, run: 'node scripts/gds/setup.js' } };
|
|
224
|
+
return { ok: true, claims: me.data.active_claims || (me.data.active_claim ? [me.data.active_claim] : []) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --task <id> names a task; this proves the caller actually HOLDS it. Without
|
|
228
|
+
// this the header's "may only be run under a claim" was true of the bare call
|
|
229
|
+
// and false of the named one — a rule stated three times in prose (here, ADR
|
|
230
|
+
// 0233, modules/copy-desk/CLAUDE.md) and enforced on one of two paths. The
|
|
231
|
+
// refusal names the claims you do hold, because the usual cause is a typo'd id.
|
|
232
|
+
// Pure so the gate is testable without a session: given the id asked for and
|
|
233
|
+
// the claims held, either accept or produce the named refusal.
|
|
234
|
+
function claimGateFor(taskId, claims) {
|
|
235
|
+
const wanted = String(taskId);
|
|
236
|
+
const held = (claims || []).map((c) => String(c.task_id));
|
|
237
|
+
if (held.includes(wanted)) return { ok: true, taskId: wanted };
|
|
238
|
+
return {
|
|
239
|
+
ok: false,
|
|
240
|
+
code: 'claim_not_held',
|
|
241
|
+
detail: {
|
|
242
|
+
task: wanted,
|
|
243
|
+
held,
|
|
244
|
+
what_this_means: `A copy change lands under a claim like every other change, and you do not hold one on #${wanted}. Claim it first (node scripts/gds/claim.js ${wanted}), then run this again.`,
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function claimOnTask(taskId) {
|
|
250
|
+
const held = await activeClaims();
|
|
251
|
+
if (!held.ok) return held;
|
|
252
|
+
return claimGateFor(taskId, held.claims);
|
|
253
|
+
}
|
|
254
|
+
|
|
211
255
|
// The task this session is holding. Returns a named refusal rather than an id
|
|
212
256
|
// when there is nothing to act under — "claim it first" is the correct answer,
|
|
213
257
|
// not an inconvenience to route around.
|
|
214
258
|
async function claimedTaskId() {
|
|
215
|
-
const
|
|
216
|
-
if (!
|
|
217
|
-
const claims =
|
|
259
|
+
const held = await activeClaims();
|
|
260
|
+
if (!held.ok) return held;
|
|
261
|
+
const claims = held.claims;
|
|
218
262
|
if (!claims.length) {
|
|
219
263
|
return {
|
|
220
264
|
ok: false,
|
|
@@ -271,6 +315,11 @@ async function main() {
|
|
|
271
315
|
if (!held.ok) return cliExit(refuse(held));
|
|
272
316
|
taskId = held.taskId;
|
|
273
317
|
console.log(`copy-apply: the task you are holding — #${taskId}`);
|
|
318
|
+
} else if (taskId) {
|
|
319
|
+
// --task picks WHICH claim to act under; it never substitutes for holding one.
|
|
320
|
+
const held = await claimOnTask(taskId);
|
|
321
|
+
if (!held.ok) return cliExit(refuse(held));
|
|
322
|
+
taskId = held.taskId;
|
|
274
323
|
}
|
|
275
324
|
|
|
276
325
|
const loaded = await loadPatch({ taskId, patchFile });
|
|
@@ -335,4 +384,5 @@ module.exports = {
|
|
|
335
384
|
locateOccurrence,
|
|
336
385
|
reinjectInterpolations,
|
|
337
386
|
applyPatchToSource,
|
|
387
|
+
claimGateFor,
|
|
338
388
|
};
|