weir-harness 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/cordis.patch.yml +0 -2
  2. package/lib/client.blackboard-model.js +172 -56
  3. package/lib/client.capability-manager.js +0 -1
  4. package/lib/client.capability-model.js +378 -31
  5. package/lib/client.chain-model.js +80 -0
  6. package/lib/client.disabled-categories-editor.js +22 -0
  7. package/lib/client.edit-lock-maintenance.js +239 -246
  8. package/lib/client.edit-lock-panel.js +110 -0
  9. package/lib/client.hash-edit-model.js +48 -0
  10. package/lib/client.js +267 -1549
  11. package/lib/client.lsp-model.js +92 -0
  12. package/lib/client.lsp-panel.js +11 -16
  13. package/lib/client.lsp-toggle.js +18 -0
  14. package/lib/client.notify-permissions.js +112 -20
  15. package/lib/client.notify-web.js +14 -11
  16. package/lib/client.robash-model.js +26 -0
  17. package/lib/client.settings-page.js +287 -17
  18. package/lib/client.worktree-model.js +225 -0
  19. package/package.json +5 -2
  20. package/src/blackboard/durable.js +12 -0
  21. package/src/blackboard/index.js +2 -2
  22. package/src/blackboard/kernel.js +23 -6
  23. package/src/blackboard/notify.js +10 -3
  24. package/src/blackboard/remote.js +31 -13
  25. package/src/blackboard/storage.js +13 -5
  26. package/src/capabilities/apply-engine.js +189 -101
  27. package/src/capabilities/defaults-transaction.js +136 -1
  28. package/src/capabilities/lifecycle-snapshot.js +83 -204
  29. package/src/capabilities/mcp-admission.js +1 -23
  30. package/src/capabilities/mcp-drain.js +30 -19
  31. package/src/capabilities/mcp-facade.js +23 -3
  32. package/src/capabilities/preset-library.js +247 -0
  33. package/src/capabilities/preset-package.js +212 -1
  34. package/src/capabilities/same-name-policy.js +3 -41
  35. package/src/capabilities/selection-notify.js +324 -21
  36. package/src/capabilities/skill-office-adapter.js +7 -1
  37. package/src/capabilities/skill-selection-plugin.js +315 -729
  38. package/src/capabilities/store/checked-contract.d.ts +5 -0
  39. package/src/capabilities/store/fs-adapter.js +52 -0
  40. package/src/capabilities/store/lock.js +86 -0
  41. package/src/capabilities/store/paths.js +5 -0
  42. package/src/capabilities/store/store.js +105 -5
  43. package/src/creative-guide/guide.js +1 -1
  44. package/src/delegate/child-composition.js +258 -0
  45. package/src/delegate/group-coordinator.js +25 -27
  46. package/src/delegate/index.js +21 -2
  47. package/src/delegate/rehydrate.js +24 -4
  48. package/src/delegate/robash-guard-core.js +2 -2
  49. package/src/delegate/spawn-adapter.js +214 -100
  50. package/src/delegate/status-tool.js +9 -6
  51. package/src/delegate/supervision-mount.js +35 -6
  52. package/src/delegate/tool.js +29 -4
  53. package/src/{shared → delegate}/whitelist-defaults.js +2 -2
  54. package/src/edit-lock/admin-ledger.js +8 -11
  55. package/src/edit-lock/admin-recovery.js +2 -1
  56. package/src/edit-lock/admission.js +12 -2
  57. package/src/edit-lock/binding-guidance.js +28 -10
  58. package/src/edit-lock/binding-machine.js +312 -0
  59. package/src/edit-lock/derived-facts.js +67 -0
  60. package/src/edit-lock/index.js +44 -244
  61. package/src/edit-lock/inspect.js +113 -1
  62. package/src/edit-lock/lifecycle.js +8 -5
  63. package/src/edit-lock/limits.js +61 -0
  64. package/src/edit-lock/maintenance.js +5 -2
  65. package/src/edit-lock/manager.js +6 -2
  66. package/src/edit-lock/operation-history.js +1 -1
  67. package/src/edit-lock/peer-client.js +9 -2
  68. package/src/edit-lock/peer-transport.js +7 -1
  69. package/src/edit-lock/remote.js +11 -4
  70. package/src/edit-lock/state.js +2 -4
  71. package/src/edit-lock/view.js +10 -11
  72. package/src/lsp/admin.js +2 -24
  73. package/src/lsp/index.js +1 -1
  74. package/src/lsp/manager.js +1 -1
  75. package/src/notify/permissions-admin.js +1 -1
  76. package/src/{lsp/child-process.js → process/index.js} +23 -6
  77. package/src/settings/env-admin.js +1 -1
  78. package/src/settings/index.js +1 -1
  79. package/src/settings/sections.js +0 -54
  80. package/src/shared/audit.js +12 -0
  81. package/src/shared/child-scope.js +3 -1
  82. package/src/shared/http-json.js +28 -0
  83. package/src/{delegate/audit-readers.js → shared/terminal-evidence.js} +34 -4
  84. package/src/worktree/errors.js +3 -0
  85. package/src/worktree/index.js +1 -2
  86. package/src/worktree/lane-core.js +641 -0
  87. package/src/worktree/lane-views.js +147 -0
  88. package/src/worktree/lane-workflows.js +853 -0
  89. package/src/worktree/lanes.js +24 -1513
  90. package/src/worktree/runner.js +5 -4
  91. package/src/capabilities/content-pin.js +0 -88
  92. package/src/capabilities/content-refresh.js +0 -331
  93. package/src/capabilities/distribution-manifest.js +0 -78
  94. package/src/capabilities/isolated-exec.js +0 -106
  95. package/src/capabilities/publisher.js +0 -129
  96. package/src/capabilities/skill-admission.js +0 -181
  97. package/src/capabilities/update-lifecycle.js +0 -106
  98. package/src/edit-lock/remote-service.js +0 -28
  99. package/src/worktree/contract-guidance.test.js +0 -100
@@ -1,129 +0,0 @@
1
- // Tasks 11.1/11.2 of the session-capability-manager change: pre-publish
2
- // target and reference validation, plus immutable-generation publishing with
3
- // the universal → DSH root bridge.
4
- //
5
- // 11.1 — after resolution a target path must stay INSIDE its selected scope
6
- // root; a symlink escaping the root is refused and named. Absolute paths,
7
- // traversal paths and credential-carrying references are refused. An
8
- // unrecognized install-lock schema stops everything — never migrated,
9
- // rewritten, downgraded or deleted.
10
- //
11
- // 11.2 — staging lands on the target filesystem first; publications are
12
- // serialized per scope/identity; the active manifest pointer, provenance and
13
- // operation receipt switch in ONE atomic publication (the group-2
14
- // distribution pointer). The active generation and local changes are
15
- // re-checked with CAS before switching. A platform without supported atomic
16
- // switch is reported unsupported — never delete-then-copy. Staging output
17
- // maps to the DSH-recognized global / workspace skill roots; bundled paths
18
- // are never allowed targets; the workspace root comes from the host session
19
- // workspace resolver.
20
- import { realpathSync } from 'node:fs'
21
- import { isAbsolute, join, resolve, sep } from 'node:path'
22
- import { createHash } from 'node:crypto'
23
-
24
- export const LOCK_SCHEMA_VERSION = 1
25
-
26
- const message = error => (error instanceof Error ? error.message : String(error))
27
-
28
- /**
29
- * The scope roots staging output may map to (11.2). `bundled` is present for
30
- * completeness of the table and is always a REFUSED target.
31
- * @param {{ agentsHome?: string, workspaceRoot?: string, bundledRoot?: string }} roots
32
- */
33
- export function resolveScopeRoots({ agentsHome, workspaceRoot, bundledRoot } = {}) {
34
- const roots = {}
35
- if (typeof agentsHome === 'string' && agentsHome.length > 0) roots.global = join(agentsHome, 'skills')
36
- if (typeof workspaceRoot === 'string' && workspaceRoot.length > 0) roots.workspace = join(workspaceRoot, '.agents', 'skills')
37
- return {
38
- roots,
39
- /** @param {string} scope */
40
- rootFor(scope) {
41
- if (scope === 'bundled') return { allowed: false, reason: 'bundled paths are not publish targets' }
42
- const root = roots[scope]
43
- if (!root) return { allowed: false, reason: `scope root for "${scope}" is not configured` }
44
- return { allowed: true, root }
45
- },
46
- }
47
- }
48
-
49
- /** @param {unknown} ref */
50
- export function referenceProblem(ref) {
51
- if (typeof ref !== 'string' || ref.length === 0) return 'reference must be a non-empty string'
52
- if (isAbsolute(ref)) return `absolute reference refused: ${ref}`
53
- if (ref.split(/[\\/]+/).includes('..')) return `traversal reference refused: ${ref}`
54
- if (/(token|secret|password|key|credential)[:=]/i.test(ref)) return `credential-carrying reference refused`
55
- return null
56
- }
57
-
58
- /**
59
- * The resolved target must stay inside the scope root; a symlink escaping
60
- * the root is refused and named (11.1).
61
- * @param {string} scopeRoot @param {string} targetPath
62
- * @param {{ realpath?: (path: string) => string }} [deps]
63
- */
64
- export function validateTargetInsideRoot(scopeRoot, targetPath, deps = {}) {
65
- const realpath = deps.realpath ?? realpathSync
66
- const root = realpath(scopeRoot)
67
- let resolved
68
- try {
69
- resolved = realpath(targetPath)
70
- } catch {
71
- resolved = resolve(targetPath)
72
- }
73
- const inside = resolved === root || resolved.startsWith(`${root}${sep}`)
74
- if (!inside) {
75
- return { ok: false, reason: `target escapes the scope root: ${resolved} is outside ${root}${resolved === targetPath ? '' : ` (symlink from ${targetPath})`}` }
76
- }
77
- return { ok: true, resolved }
78
- }
79
-
80
- /**
81
- * The install lock must be schema-recognized before any write (11.1):
82
- * anything else stops without migrating, rewriting, downgrading or deleting.
83
- * @param {unknown} lockPayload
84
- */
85
- export function lockVerdict(lockPayload) {
86
- if (lockPayload === null || typeof lockPayload !== 'object' || Array.isArray(lockPayload)) {
87
- return { recognized: false, reason: 'install lock payload is not an object' }
88
- }
89
- if (lockPayload.schemaVersion !== LOCK_SCHEMA_VERSION) {
90
- return { recognized: false, reason: `unrecognized install-lock schema version: ${JSON.stringify(lockPayload.schemaVersion)} — stopping without migrating, rewriting, downgrading or deleting` }
91
- }
92
- return { recognized: true }
93
- }
94
-
95
- /**
96
- * The pre-publish re-check (11.2): the active generation must still match
97
- * what the caller prepared against, and the local content must be untouched
98
- * since the managed digest was recorded.
99
- * @param {{ activeGeneration: string|null, expectedActive: string|null,
100
- * localDigest: string|null, managedDigest: string|null }} input
101
- */
102
- export function publishPrecondition({ activeGeneration, expectedActive, localDigest, managedDigest }) {
103
- if (activeGeneration !== expectedActive) {
104
- return { ok: false, reason: `active generation moved: expected ${expectedActive ?? 'none'}, found ${activeGeneration ?? 'none'}` }
105
- }
106
- if (typeof managedDigest === 'string' && managedDigest.length > 0 && localDigest !== managedDigest) {
107
- return { ok: false, reason: 'local content diverged from the last managed digest — showing the diff and pausing publication (overwrite / leave unchanged required)' }
108
- }
109
- return { ok: true }
110
- }
111
-
112
- /**
113
- * One immutable generation record (11.2): generation id, file manifest and
114
- * provenance — content-addressed by the manifest digest.
115
- * @param {{ identity: string, files: Record<string, string>, provenance: unknown, receipt: unknown }} input
116
- */
117
- export function generationRecordOf({ identity, files, provenance, receipt }) {
118
- const manifest = Object.fromEntries(Object.entries(files).sort(([a], [b]) => a.localeCompare(b)).map(([path, content]) => [path, createHash('sha256').update(String(content)).digest('hex')])
119
- )
120
- return {
121
- generationId: createHash('sha256').update(JSON.stringify([identity, manifest])).digest('hex').slice(0, 16),
122
- identity,
123
- manifest,
124
- provenance,
125
- receipt,
126
- }
127
- }
128
-
129
- export const __message = message
@@ -1,181 +0,0 @@
1
- // Skill-side session-level admission of the session capability manager
2
- // (design D2, tasks 4.6). ONE core check against the SERVER-SIDE current
3
- // selection — the accepted authority snapshot, never a client-filtered list —
4
- // backs three blocking paths:
5
- // 1. `skill` tool calls (admit/loadBody): an unselected skill gets an
6
- // explicit 'unavailable' result and its body is NEVER loaded.
7
- // 2. Slash submissions (validateSlash): the server-side revalidation mount
8
- // point for the slash pipeline — a client-side filtered list can never
9
- // smuggle an unselected skill through.
10
- // 3. Subagent prompt publication (admitPromptPublication): a skill prompt
11
- // may only be published into a child session while the skill is still
12
- // selected at publication time.
13
- // While the session's admission fence is held (an Apply is committing),
14
- // not-yet-handed-off calls are refused; handed-off reads may complete, but a
15
- // read whose authority changed before completion is revoked BEFORE any content
16
- // is returned: read is not authorization. Admission writes nothing and audits
17
- // nothing — durable policy events stay with the Apply and refresh engines.
18
- import { isSegment } from './store/paths.js'
19
- import { createSkillIdentity, skillIdentityKey } from './skill-identity.js'
20
-
21
- const message = error => error instanceof Error ? error.message : String(error)
22
-
23
- /** English user-facing denial messages (template layer). */
24
- const MESSAGES = {
25
- 'invalid-session': () => 'The session is unavailable.',
26
- 'invalid-identity': name => `Skill "${name}" has an invalid identity.`,
27
- 'admission-fence-held': name => `Skill "${name}" is temporarily unavailable: the session selection is being updated.`,
28
- 'selection-authority-unavailable': name => `Skill "${name}" is unavailable: the session selection is not loaded.`,
29
- 'skill-not-selected': name => `Skill "${name}" is not selected for this session.`,
30
- }
31
-
32
- /**
33
- * @typedef {{ status: 'admitted', revision: number, identityKey: string }
34
- * | { status: 'unavailable', reason: string, revision: number|null, message: string }} Admission
35
- * @typedef {{ status: 'loaded', revision: number, content: unknown }
36
- * | { status: 'revoked', reason: 'selection-revoked', revision: number|null, message: string }
37
- * | { status: 'unavailable', reason: string, revision: number|null, message: string }} LoadResult
38
- */
39
-
40
- /**
41
- * @param {{
42
- * authority: (sessionId: string) => unknown | Promise<unknown>,
43
- * fence: { admit(scope: string): boolean, isHeld?(scope: string): boolean },
44
- * trace?: (event: string, data?: unknown) => void,
45
- * }} options
46
- * `authority` returns the current in-memory authority snapshot
47
- * ({ revision, selection: { skills } }) or null when not loaded; the Apply
48
- * engine's authority() is the canonical source.
49
- */
50
- export function createSkillAdmission(options) {
51
- const { authority, fence, trace = () => {} } = options
52
- if (typeof authority !== 'function') throw new TypeError('An authority accessor is required')
53
- if (!fence || typeof fence.admit !== 'function') throw new TypeError('An admission fence is required')
54
-
55
- /** Selected identity keys of an authority snapshot. @param {unknown} snapshot @returns {Set<string>|null} */
56
- function selectedKeys(snapshot) {
57
- const skills = snapshot !== null && typeof snapshot === 'object'
58
- ? /** @type {{ selection?: { skills?: unknown } }} */ (snapshot).selection?.skills
59
- : null
60
- if (!Array.isArray(skills)) return null
61
- const keys = new Set()
62
- for (const raw of skills) {
63
- try { keys.add(skillIdentityKey(raw)) } catch { /* stray entries stay unauthorized */ }
64
- }
65
- return keys
66
- }
67
-
68
- /** @param {unknown} snapshot @returns {number|null} */
69
- function revisionOf(snapshot) {
70
- return snapshot !== null && typeof snapshot === 'object' && Number.isSafeInteger(/** @type {{ revision?: unknown }} */ (snapshot).revision)
71
- ? /** @type {number} */ (/** @type {{ revision: number }} */ (snapshot).revision)
72
- : null
73
- }
74
-
75
- /** @param {string} reason @param {number|null} revision @param {string} name */
76
- const denial = (reason, revision, name) => ({
77
- status: /** @type {const} */ ('unavailable'), reason, revision,
78
- message: (MESSAGES[reason] ?? MESSAGES['skill-not-selected'])(name),
79
- })
80
-
81
- /**
82
- * The single admission core: fence first (an Apply is committing), then the
83
- * current authority snapshot, then exact identity membership. Exact
84
- * identities only — the host still resolves skills by name; precision lives
85
- * in the Weir manifest, never in a host-side identity lookup.
86
- * @param {string} sessionId @param {unknown} identity
87
- * @returns {Promise<Admission>}
88
- */
89
- async function check(sessionId, identity) {
90
- if (!isSegment(sessionId)) return denial('invalid-session', null, 'unknown')
91
- /** @type {string} */ let key
92
- /** @type {string} */ let name
93
- try {
94
- const value = createSkillIdentity(identity)
95
- key = skillIdentityKey(value)
96
- name = value.name
97
- } catch (error) {
98
- trace('admission-denied', { sessionId, reason: 'invalid-identity', error: message(error) })
99
- return denial('invalid-identity', null, 'unknown')
100
- }
101
- if (!fence.admit(sessionId)) {
102
- // The fence is held: not-yet-handed-off calls are refused while the
103
- // session's Apply commits.
104
- trace('admission-denied', { sessionId, reason: 'admission-fence-held', name })
105
- return denial('admission-fence-held', null, name)
106
- }
107
- const snapshot = await authority(sessionId)
108
- const revision = revisionOf(snapshot)
109
- if (revision === null) {
110
- // Fail closed: no loaded authority means nothing is authorized.
111
- trace('admission-denied', { sessionId, reason: 'selection-authority-unavailable', name })
112
- return denial('selection-authority-unavailable', null, name)
113
- }
114
- const selected = selectedKeys(snapshot) ?? new Set()
115
- if (!selected.has(key)) {
116
- trace('admission-denied', { sessionId, reason: 'skill-not-selected', name })
117
- return denial('skill-not-selected', revision, name)
118
- }
119
- return { status: 'admitted', revision, identityKey: key }
120
- }
121
-
122
- return {
123
- /** Path 1: `skill` tool call admission. */
124
- admit: check,
125
-
126
- /**
127
- * Path 2: slash submission revalidation. The server-side mount point for
128
- * the slash pipeline: the submission is re-checked against the CURRENT
129
- * selection even when the client already filtered its list.
130
- * @param {string} sessionId @param {unknown} identity
131
- * @returns {Promise<Admission>}
132
- */
133
- async validateSlash(sessionId, identity) {
134
- const result = await check(sessionId, identity)
135
- trace('slash-revalidation', { sessionId, status: result.status, ...(result.status === 'unavailable' ? { reason: result.reason } : {}) })
136
- return result
137
- },
138
-
139
- /**
140
- * Path 3: subagent prompt publication. A skill prompt may only be
141
- * published into a child session under the current selection; a
142
- * publication attempted after the skill's removal is refused.
143
- * @param {string} sessionId @param {unknown} identity
144
- * @returns {Promise<Admission>}
145
- */
146
- async admitPromptPublication(sessionId, identity) {
147
- const result = await check(sessionId, identity)
148
- trace('prompt-publication', { sessionId, status: result.status, ...(result.status === 'unavailable' ? { reason: result.reason } : {}) })
149
- return result
150
- },
151
-
152
- /**
153
- * Load a skill body under admission. The loader is invoked ONLY after
154
- * admission (an unselected skill's body is never read); when the
155
- * handed-off read completes, the authority is re-checked BEFORE any
156
- * content is returned — a superseded revision or a removed identity
157
- * revokes the publication. Read is not authorization.
158
- * @param {string} sessionId @param {unknown} identity
159
- * @param {() => Promise<unknown>} loader
160
- * @returns {Promise<LoadResult>}
161
- */
162
- async loadBody(sessionId, identity, loader) {
163
- const admitted = await check(sessionId, identity)
164
- if (admitted.status !== 'admitted') return admitted
165
- // Handed-off read: it may finish even while a later Apply holds the
166
- // fence; the publication check below is the gate that matters.
167
- const content = await loader()
168
- const snapshot = await authority(sessionId)
169
- const revision = revisionOf(snapshot)
170
- const selected = revision === null ? null : selectedKeys(snapshot)
171
- if (revision !== admitted.revision || !selected?.has(admitted.identityKey)) {
172
- trace('publication-revoked', { sessionId, from: admitted.revision, to: revision })
173
- return {
174
- status: 'revoked', reason: /** @type {const} */ ('selection-revoked'), revision,
175
- message: 'The skill content was read under a superseded selection and will not be published.',
176
- }
177
- }
178
- return { status: 'loaded', revision: admitted.revision, content }
179
- },
180
- }
181
- }
@@ -1,106 +0,0 @@
1
- // Tasks 11.3/11.4/11.5 of the session-capability-manager change: the check
2
- // cadence, the verified manual update, and the opt-in automatic update.
3
- //
4
- // 11.3 — checks are read-only: a 24h cadence with a single make-up check for
5
- // an overdue startup, a manual "Check now" that either triggers immediately
6
- // or joins the one in-flight check (at most one at any time), and a failure
7
- // backoff of 48h / 96h capped at 7d. offline / unknown / private-source are
8
- // reported separately from up-to-date. A check NEVER runs check/update/
9
- // upgrade against a real install directory.
10
- //
11
- // 11.4 — a manual update runs only after explicit user confirmation (source
12
- // and ref named), then verifies BEFORE the atomic swap: requested
13
- // source/ref satisfied, expected identity at the expected location, content
14
- // identity recorded, lock schema recognized. A tool reporting success while
15
- // the post-conditions fail is recorded as a failure — nothing publishes and
16
- // last-good stays.
17
- //
18
- // 11.5 — automatic updates are OFF by default. opt-in is an explicit grant
19
- // per source/scope, never implied by a preset load, a check or one manual
20
- // update. Before running: tool baseline, target layout, lock schema,
21
- // provenance unchanged, no local edits — any failure pauses THAT entry with
22
- // a report and no retry loop. A running session never auto-adopts published
23
- // content.
24
-
25
- export const CHECK_CADENCE_MS = 86_400_000
26
- export const CHECK_BACKOFF_MS = Object.freeze([172_800_000, 345_600_000, 604_800_000])
27
-
28
- /**
29
- * The check scheduler (11.3): at most one check in flight; manual joins the
30
- * in-flight one; overdue startup makes up exactly once; failures back off.
31
- */
32
- export function createCheckScheduler({ now = Date.now } = {}) {
33
- let inFlight = false
34
- let lastCheckAt = null
35
- let failures = 0
36
- return {
37
- /** When the next scheduled check may run. */
38
- nextAt() {
39
- // No completed check yet: overdue since epoch, made up exactly once.
40
- if (lastCheckAt === null) return 0
41
- const interval = failures === 0 ? CHECK_CADENCE_MS : CHECK_BACKOFF_MS[Math.min(failures - 1, CHECK_BACKOFF_MS.length - 1)]
42
- return lastCheckAt + interval
43
- },
44
- /**
45
- * Whether a check should start; when one starts the caller must settle it.
46
- * @param {'scheduled'|'manual'} reason
47
- * @returns {{ start: boolean, joinedInFlight: boolean, reason: string|null }}
48
- */
49
- requestCheck(reason) {
50
- if (inFlight) return { start: false, joinedInFlight: true, reason: null }
51
- if (reason === 'scheduled' && now() < this.nextAt()) return { start: false, joinedInFlight: false, reason: 'not-due' }
52
- inFlight = true
53
- return { start: true, joinedInFlight: false, reason: null }
54
- },
55
- settle(ok) {
56
- inFlight = false
57
- failures = ok ? 0 : failures + 1
58
- lastCheckAt = now()
59
- },
60
- get inFlight() { return inFlight },
61
- }
62
- }
63
-
64
- /**
65
- * The classified check outcome (11.3): offline / unknown / private-source
66
- * are reported separately from up-to-date and from real changes.
67
- * @param {'up-to-date'|'changed'|'offline'|'unknown'|'private-source'} outcome
68
- */
69
- export function checkOutcomeOf(outcome) {
70
- const known = ['up-to-date', 'changed', 'offline', 'unknown', 'private-source']
71
- return known.includes(outcome) ? outcome : 'unknown'
72
- }
73
-
74
- /**
75
- * The manual-update post-conditions (11.4): every one must hold before the
76
- * atomic swap; any failure records a failure and publishes nothing.
77
- * @param {{ requestedSatisfied: boolean, identityAtLocation: boolean, contentIdentityRecorded: boolean, lockRecognized: boolean }} input
78
- */
79
- export function manualUpdatePostconditions(input) {
80
- const failures = []
81
- if (!input.requestedSatisfied) failures.push('requested source/ref not satisfied by staging')
82
- if (!input.identityAtLocation) failures.push('expected identity not at the expected location')
83
- if (!input.contentIdentityRecorded) failures.push('content identity was not recorded')
84
- if (!input.lockRecognized) failures.push('install-lock schema not recognized')
85
- return failures.length === 0 ? { ok: true } : { ok: false, failures }
86
- }
87
-
88
- /**
89
- * The automatic-update gates (11.5): opt-in is a per source/scope grant and
90
- * every precondition must hold; any failure pauses the entry (no retry loop).
91
- * @param {{ optedIn: boolean, toolBaselineOk: boolean, layoutOk: boolean, lockRecognized: boolean, provenanceUnchanged: boolean, noLocalEdits: boolean }} input
92
- */
93
- export function autoUpdateVerdict(input) {
94
- if (!input.optedIn) return { run: false, reason: 'automatic updates are off for this source/scope (no explicit opt-in)' }
95
- const gates = [
96
- ['toolBaselineOk', input.toolBaselineOk, 'executor baseline check failed'],
97
- ['layoutOk', input.layoutOk, 'target layout check failed'],
98
- ['lockRecognized', input.lockRecognized, 'install-lock schema not recognized'],
99
- ['provenanceUnchanged', input.provenanceUnchanged, 'provenance changed'],
100
- ['noLocalEdits', input.noLocalEdits, 'local edits present'],
101
- ]
102
- for (const [, ok, reason] of gates) {
103
- if (!ok) return { run: false, reason: `${reason} — this entry is paused and reported, no retry loop` }
104
- }
105
- return { run: true }
106
- }
@@ -1,28 +0,0 @@
1
- /** Tool service for exactly one authenticated child channel. No local fs
2
- * capability and no fallback. Parent must bind the other end before admission.
3
- * @param {ReturnType<typeof import('./peer-client.js').createEditLockPeerClient>} client
4
- * @param {object} agent
5
- * @param {(agent:object)=>boolean} isRegisteredAgent */
6
- export function createRemoteEditLockService(client, agent, isRegisteredAgent) {
7
- let closed = false
8
- /** @param {{agent:object,callId:string,signal:AbortSignal}} exec */
9
- function authorize(exec) {
10
- if (closed || exec.agent !== agent || !isRegisteredAgent(agent)) throw new Error('agent has no authenticated remote edit execution')
11
- }
12
- return Object.freeze({
13
- service:Object.freeze({
14
- /** @param {{agent:object,callId:string,signal:AbortSignal}} exec @param {unknown} request */
15
- async publish(exec,request) {
16
- authorize(exec)
17
- return client.request('publish',exec.callId,request,exec.signal)
18
- },
19
- /** @param {{agent:object,callId:string,signal:AbortSignal}} exec @param {unknown} request */
20
- async publishBatch(exec,request) {
21
- authorize(exec)
22
- return client.request('batch',exec.callId,request,exec.signal)
23
- },
24
- }),
25
- // This only closes local admission; durable stop is acknowledged by parent.
26
- close() {closed = true; client.close()},
27
- })
28
- }
@@ -1,100 +0,0 @@
1
- import { test } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { mkdtempSync, mkdirSync, realpathSync, rmSync } from 'node:fs'
4
- import { tmpdir } from 'node:os'
5
- import { join } from 'node:path'
6
- import { createLaneService } from './lanes.js'
7
- import { renderChildContract } from './prompts.js'
8
-
9
- // Exercise the real refusal/serialization path without spawning git processes.
10
- async function fixture(t, states, maxActive = 4) {
11
- const root = realpathSync(mkdtempSync(join(tmpdir(), 'worktree-guidance-')))
12
- t.after(() => rmSync(root, { recursive: true, force: true }))
13
- const commonDir = join(root, '.git')
14
- mkdirSync(join(commonDir, 'info'), { recursive: true })
15
- const lanes = states.map((state, i) => ({
16
- id: `lane-${i}`, state, path: join(root, '.weir/worktrees', `lane-${i}`),
17
- branch: `weir/lane-${i}`, base: { branch: 'main' }, scope: [],
18
- }))
19
- let adds = 0
20
- const service = createLaneService({
21
- git: {
22
- version: async () => [2, 40, 0], supported: () => true,
23
- repoOf: async () => ({ mainRoot: root, commonDir }),
24
- currentBranch: async () => 'main', revParse: async () => 'base',
25
- worktreeList: async () => [{ path: root }, ...lanes.map(({ path }) => ({ path }))],
26
- worktreeAdd: async () => { adds++ },
27
- },
28
- settings: () => ({ enabled: true, root: '.weir/worktrees', maxActive, autoSetup: false }),
29
- shellRun: null, ask: null, notify() {}, audit() {}, modeOf: () => false,
30
- })
31
- const repo = await service.repoFor(root)
32
- await repo.ledger.replace({ schemaVersion: 1, seq: lanes.length, lanes })
33
- return { service, repo, lanes, session: { id: 'owner', header: { cwd: root } }, adds: () => adds }
34
- }
35
-
36
- test('MAX_ACTIVE lists every active lane and prefers the landable lane', async (t) => {
37
- const h = await fixture(t, ['working', 'landable', 'ready', 'abandoned'], 3)
38
- const before = h.repo.ledger.read()
39
- await assert.rejects(h.service.open(h.session, { title: 'new' }), (error) => {
40
- const payload = error.toJSON()
41
- assert.equal(payload.code, 'MAX_ACTIVE')
42
- assert.deepEqual(payload.data.lanes.map(({ id, state }) => ({ id, state })),
43
- h.lanes.slice(0, 3).map(({ id, state }) => ({ id, state })))
44
- for (const lane of payload.data.lanes) assert.ok(payload.message.includes(`${lane.id} · ${lane.state}`))
45
- assert.match(payload.next.hint, /worktree_land.*lane-1/)
46
- assert.match(payload.data.lanes[0].hint, /wait.*lane-0.*worktree_abandon/)
47
- assert.doesNotMatch(payload.message, /lane-3/)
48
- return true
49
- })
50
- assert.deepEqual(h.repo.ledger.read(), before)
51
- assert.equal(h.adds(), 0)
52
- })
53
-
54
- test('MAX_ACTIVE without a landable lane recommends waiting or abandonment', async (t) => {
55
- const h = await fixture(t, ['working'], 1)
56
- await assert.rejects(h.service.open(h.session, { title: 'new' }), (error) => {
57
- assert.equal(error.code, 'MAX_ACTIVE')
58
- assert.match(error.next.hint, /wait.*lane-0.*worktree_abandon/)
59
- assert.doesNotMatch(error.next.hint, /worktree_land/)
60
- return true
61
- })
62
- })
63
-
64
- test('SCOPE_OVERLAP serializes only overlapping globs from both scopes and recovery', async (t) => {
65
- const h = await fixture(t, ['working'])
66
- await h.repo.ledger.update((ledger) => {
67
- ledger.lanes[0].scope = ['src/auth/**', 'docs/**', 'src/shared/**']
68
- return { ledger }
69
- })
70
- const before = h.repo.ledger.read()
71
- await assert.rejects(h.service.open(h.session, { title: 'new', scope: ['src/**', 'test/**'] }), (error) => {
72
- const payload = error.toJSON()
73
- assert.equal(payload.code, 'SCOPE_OVERLAP')
74
- assert.equal(payload.lane, 'lane-0')
75
- assert.deepEqual(payload.data.overlapping, { scope: ['src/**'], laneScope: ['src/auth/**', 'src/shared/**'] })
76
- assert.match(payload.message, /narrow the new scope/)
77
- assert.match(payload.message, /wait for lane lane-0 to land/)
78
- assert.match(payload.next.hint, /src\/auth\/\*\*/)
79
- return true
80
- })
81
- assert.deepEqual(h.repo.ledger.read(), before)
82
- assert.equal(h.adds(), 0)
83
- })
84
-
85
- test('worker guidance is present only in the write contract; read-only contract stays exact', () => {
86
- const lane = { id: 'lane-1', path: '/repo/lane-1', branch: 'weir/lane-1', base: { branch: 'main' }, scope: ['src/**'] }
87
- const write = renderChildContract(lane, { readOnly: false })
88
- const read = renderChildContract(lane, { readOnly: true })
89
- for (const line of [
90
- 'Report blockers and outcomes in your final report; do not send_message to the parent (its id is not available to you).',
91
- 'Run every command, tests included, with workdir at the lane root; the integration-test root resolves lane-locally by default.',
92
- ]) {
93
- assert.ok(write.includes(line))
94
- assert.ok(!read.includes(line))
95
- }
96
- assert.equal(read, `<lane id="lane-1">
97
- You are investigating lane lane-1 at /repo/lane-1 (branch weir/lane-1).
98
- - Pass workdir="/repo/lane-1" (or a directory inside it) on every shell call; use absolute paths under it for reads.
99
- </lane>`)
100
- })