@markjaquith/agency 2.58.2 → 2.60.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/README.md +55 -4
- package/cli-main.ts +5 -0
- package/index.ts +1 -0
- package/package.json +4 -1
- package/schemas/agency-kickoff-v1.schema.json +124 -0
- package/src/cli-parser.test.ts +1 -1
- package/src/cli-parser.ts +23 -6
- package/src/commands/task.test.ts +77 -1
- package/src/commands/task.ts +76 -6
- package/src/commands/work.test.ts +62 -3
- package/src/commands/work.ts +106 -3
- package/src/services/ArchiveService.ts +9 -0
- package/src/services/IntegrationService.test.ts +37 -0
- package/src/services/ReviewService.ts +2 -0
- package/src/services/WorktreeService.test.ts +279 -1
- package/src/services/WorktreeService.ts +418 -30
- package/src/workbase/AGENTS.md +55 -4
- package/src/workbase/kickoff-contract.test.ts +145 -0
- package/src/workbase/kickoff-contract.ts +358 -0
package/src/workbase/AGENTS.md
CHANGED
|
@@ -20,10 +20,26 @@ At the workbase root, use `agency next --json` or `agency graph --json` to choos
|
|
|
20
20
|
work, then inspect the returned document path or explicit entity selectors. Use
|
|
21
21
|
`agency --help` and `agency <command> --help` for exact command syntax.
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
## Adding a Repository
|
|
24
|
+
|
|
25
|
+
Add and materialize a new repository alias with:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
agency repo add <alias> <remote> --json
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`agency repo add` mutates immediately and does not accept `--apply`. Do not edit
|
|
32
|
+
`agency.json` or `repos/` manually. `agency repo setup --dry-run` and
|
|
33
|
+
`agency repo setup --apply` are only for repositories that are already declared
|
|
34
|
+
but locally missing; obtain explicit approval before applying setup.
|
|
35
|
+
|
|
36
|
+
After adding a repository, run only these checks, in order, unless
|
|
37
|
+
`agency context` reports a relevant problem:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
agency repo verify <alias> --json
|
|
41
|
+
agency validate --json
|
|
42
|
+
```
|
|
27
43
|
|
|
28
44
|
## Authority
|
|
29
45
|
|
|
@@ -64,6 +80,41 @@ to override readiness.
|
|
|
64
80
|
|
|
65
81
|
## Execution
|
|
66
82
|
|
|
83
|
+
### Canonical create and kickoff
|
|
84
|
+
|
|
85
|
+
This Agency recipe takes precedence over generic Herdr defaults whenever the
|
|
86
|
+
request creates, opens, works, launches, starts, or kicks off an Agency item.
|
|
87
|
+
Do not rediscover commands that this recipe or a known-current
|
|
88
|
+
`agency-kickoff-v1` plan supplies.
|
|
89
|
+
|
|
90
|
+
1. Create noninteractively with explicit recalled context when available:
|
|
91
|
+
`agency task create <slug> --context-repo <alias> --context-base <base> --context-slug <slug> --authoritative-source <absolute-path-or-url> --json`.
|
|
92
|
+
Repeat `--authoritative-source` as needed. Supplied context must agree with
|
|
93
|
+
explicit task flags; Agency rejects stale or conflicting values.
|
|
94
|
+
2. For create-only intent, return the creation result and stop. For open intent,
|
|
95
|
+
prepare the task with
|
|
96
|
+
`agency work prepare <slug> --evidence <creation-json> --dry-run --json`, then
|
|
97
|
+
execute the returned plan through `task-document-split` to prepare the
|
|
98
|
+
checkout and open or reuse the background tab. Stop before `runner-start`.
|
|
99
|
+
3. For work/launch/start/kickoff intent, run that same preflight and execute its
|
|
100
|
+
ordered kickoff steps. The plan owns worktree dry-run/preparation, a
|
|
101
|
+
retry-safe background Herdr tab, the side-by-side task document,
|
|
102
|
+
`agency work . --auto`, and exactly one final
|
|
103
|
+
`agency context <document-path> --json` verification.
|
|
104
|
+
4. When the orchestrator has known-current support for the plan's
|
|
105
|
+
`agency-kickoff-v1` capability, execute the supplied actions directly. Do not
|
|
106
|
+
call Herdr help, skill, or CLI discovery. If capability/version evidence is
|
|
107
|
+
absent or stale, discovery is the compatibility path; then resume the same
|
|
108
|
+
idempotency key rather than creating another tab, checkout, or runner.
|
|
109
|
+
5. After the one final context verification succeeds, leave the runner in the
|
|
110
|
+
background and stop. Do not inspect, poll, or babysit it unless the user asks.
|
|
111
|
+
|
|
112
|
+
Validation evidence is a local, auditable optimization, not authority. Preflight
|
|
113
|
+
refreshes it after workbase, target document, configuration, repository mapping,
|
|
114
|
+
payload digest, or kickoff-contract changes. Readiness, claims, repository
|
|
115
|
+
materialization, branch ownership, reference drift, and dirty-workspace checks
|
|
116
|
+
still run on every preparation.
|
|
117
|
+
|
|
67
118
|
For implementation work, read the task and phase prose returned by context,
|
|
68
119
|
change only the writable checkout, keep durable decisions current, and run the
|
|
69
120
|
repository's formatting, type checks, build, dead-code checks, and focused tests.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { mkdir } from "node:fs/promises"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
5
|
+
import { documentRevision } from "./document-revision"
|
|
6
|
+
import {
|
|
7
|
+
assessValidationEvidence,
|
|
8
|
+
buildKickoffPlan,
|
|
9
|
+
buildValidationEvidence,
|
|
10
|
+
KICKOFF_SOURCE_LOCATIONS,
|
|
11
|
+
normalizeRecalledContext,
|
|
12
|
+
parseValidationEvidence,
|
|
13
|
+
readValidationEvidence,
|
|
14
|
+
} from "./kickoff-contract"
|
|
15
|
+
|
|
16
|
+
describe("kickoff contract", () => {
|
|
17
|
+
let root: string
|
|
18
|
+
let taskPath: string
|
|
19
|
+
let taskContent: string
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
root = await createTempDir()
|
|
23
|
+
taskPath = join(root, "tasks/example/TASK.md")
|
|
24
|
+
taskContent =
|
|
25
|
+
"---\nticketUrl: null\nrepo: agency\nbranch: task/example\nbase: main\npr: null\nstatus: open\n---\n\n# Example\n"
|
|
26
|
+
await mkdir(join(root, "repos/agency"), { recursive: true })
|
|
27
|
+
await mkdir(join(root, "tasks/example"), { recursive: true })
|
|
28
|
+
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
29
|
+
await Bun.write(taskPath, taskContent)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
afterEach(async () => cleanupTempDir(root))
|
|
33
|
+
|
|
34
|
+
const createEvidence = () =>
|
|
35
|
+
runTestEffect(
|
|
36
|
+
buildValidationEvidence({
|
|
37
|
+
startPath: root,
|
|
38
|
+
target: "execution-unit:task/example",
|
|
39
|
+
documentPath: taskPath,
|
|
40
|
+
documentRevision: documentRevision(taskContent),
|
|
41
|
+
recalledContext: normalizeRecalledContext({
|
|
42
|
+
id: "example",
|
|
43
|
+
repo: "agency",
|
|
44
|
+
base: "main",
|
|
45
|
+
}),
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
test("reuses evidence only for the same workbase and revision", async () => {
|
|
50
|
+
const evidence = await createEvidence()
|
|
51
|
+
expect(parseValidationEvidence(evidence)).toEqual(evidence)
|
|
52
|
+
const assessment = await runTestEffect(
|
|
53
|
+
assessValidationEvidence({
|
|
54
|
+
evidence,
|
|
55
|
+
startPath: root,
|
|
56
|
+
target: evidence.target,
|
|
57
|
+
documentPath: taskPath,
|
|
58
|
+
documentRevision: evidence.documentRevision,
|
|
59
|
+
}),
|
|
60
|
+
)
|
|
61
|
+
expect(assessment.disposition).toEqual({ status: "reused", reasons: [] })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test("treats legacy creation output as a validation refresh", async () => {
|
|
65
|
+
expect(
|
|
66
|
+
await runTestEffect(
|
|
67
|
+
readValidationEvidence(
|
|
68
|
+
JSON.stringify({ version: 1, ok: true, result: { id: "example" } }),
|
|
69
|
+
root,
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
).toBeUndefined()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("refreshes evidence after document, config, mapping, or payload changes", async () => {
|
|
76
|
+
const evidence = await createEvidence()
|
|
77
|
+
const changedContent = `${taskContent}\nChanged\n`
|
|
78
|
+
await Bun.write(taskPath, changedContent)
|
|
79
|
+
await mkdir(join(root, "repos/other"), { recursive: true })
|
|
80
|
+
await Bun.write(
|
|
81
|
+
join(root, "agency.json"),
|
|
82
|
+
'{"version":2,"repositories":{"other":{"remote":"https://example.com/other.git"}}}\n',
|
|
83
|
+
)
|
|
84
|
+
const assessment = await runTestEffect(
|
|
85
|
+
assessValidationEvidence({
|
|
86
|
+
evidence: { ...evidence, digest: "0".repeat(64) },
|
|
87
|
+
startPath: root,
|
|
88
|
+
target: evidence.target,
|
|
89
|
+
documentPath: taskPath,
|
|
90
|
+
documentRevision: documentRevision(changedContent),
|
|
91
|
+
}),
|
|
92
|
+
)
|
|
93
|
+
expect(assessment.disposition.status).toBe("refreshed")
|
|
94
|
+
expect(assessment.disposition.reasons).toEqual(
|
|
95
|
+
expect.arrayContaining([
|
|
96
|
+
"digest-mismatch",
|
|
97
|
+
"document-revision-changed",
|
|
98
|
+
"workbase-revision-changed",
|
|
99
|
+
"configuration-changed",
|
|
100
|
+
"repository-mapping-changed",
|
|
101
|
+
]),
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("plans retry-safe single-phase and phased launches with one verification", () => {
|
|
106
|
+
const single = buildKickoffPlan({
|
|
107
|
+
workbaseRoot: root,
|
|
108
|
+
target: "execution-unit:task/example",
|
|
109
|
+
taskId: "example",
|
|
110
|
+
taskPath,
|
|
111
|
+
checkoutPath: join(root, "tasks/example/code/agency"),
|
|
112
|
+
documentRevision: "a".repeat(64),
|
|
113
|
+
})
|
|
114
|
+
const phased = buildKickoffPlan({
|
|
115
|
+
workbaseRoot: root,
|
|
116
|
+
target: "execution-unit:phase/example/implementation",
|
|
117
|
+
taskId: "example",
|
|
118
|
+
phaseId: "implementation",
|
|
119
|
+
taskPath,
|
|
120
|
+
phasePath: join(root, "tasks/example/phases/implementation/PHASE.md"),
|
|
121
|
+
documentRevision: "b".repeat(64),
|
|
122
|
+
})
|
|
123
|
+
expect(single.steps[0]?.argv).toContain("example")
|
|
124
|
+
expect(phased.steps[0]?.argv).toEqual(
|
|
125
|
+
expect.arrayContaining(["example", "implementation"]),
|
|
126
|
+
)
|
|
127
|
+
expect(
|
|
128
|
+
single.steps.filter(({ id }) => id === "final-context-verification"),
|
|
129
|
+
).toHaveLength(1)
|
|
130
|
+
expect(single.orchestrator.knownCurrentCommandsBypassDiscovery).toBe(true)
|
|
131
|
+
expect(single.sourceLocations).toEqual(KICKOFF_SOURCE_LOCATIONS)
|
|
132
|
+
expect(
|
|
133
|
+
single.steps.find(({ id }) => id === "herdr-tab")?.recovery,
|
|
134
|
+
).toContain("never create a duplicate")
|
|
135
|
+
expect(single.idempotencyKey).toBe(
|
|
136
|
+
buildKickoffPlan({
|
|
137
|
+
workbaseRoot: root,
|
|
138
|
+
target: "execution-unit:task/example",
|
|
139
|
+
taskId: "example",
|
|
140
|
+
taskPath,
|
|
141
|
+
documentRevision: "a".repeat(64),
|
|
142
|
+
}).idempotencyKey,
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { Schema, TreeFormatter } from "@effect/schema"
|
|
2
|
+
import { Effect, Either } from "effect"
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path"
|
|
4
|
+
import { FileSystemService } from "../services/FileSystemService"
|
|
5
|
+
import { WorkbaseService } from "../services/WorkbaseService"
|
|
6
|
+
import { documentRevision } from "./document-revision"
|
|
7
|
+
|
|
8
|
+
export const KICKOFF_CONTRACT_VERSION = 1 as const
|
|
9
|
+
|
|
10
|
+
export const KICKOFF_SOURCE_LOCATIONS = [
|
|
11
|
+
"src/commands/task.ts",
|
|
12
|
+
"src/workbase/kickoff-contract.ts",
|
|
13
|
+
"src/commands/work.ts",
|
|
14
|
+
"src/services/WorktreeService.ts",
|
|
15
|
+
"src/workbase/AGENTS.md",
|
|
16
|
+
] as const
|
|
17
|
+
|
|
18
|
+
export const RecalledTaskContext = Schema.Struct({
|
|
19
|
+
repo: Schema.optional(Schema.String),
|
|
20
|
+
base: Schema.optional(Schema.String),
|
|
21
|
+
preferredSlug: Schema.String,
|
|
22
|
+
authoritativeSources: Schema.Array(Schema.String),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export type RecalledTaskContext = Schema.Schema.Type<typeof RecalledTaskContext>
|
|
26
|
+
|
|
27
|
+
const ValidationEvidencePayload = Schema.Struct({
|
|
28
|
+
version: Schema.Literal(KICKOFF_CONTRACT_VERSION),
|
|
29
|
+
workbaseRoot: Schema.String,
|
|
30
|
+
target: Schema.String,
|
|
31
|
+
documentPath: Schema.String,
|
|
32
|
+
documentRevision: Schema.String,
|
|
33
|
+
workbaseRevision: Schema.String,
|
|
34
|
+
configRevision: Schema.String,
|
|
35
|
+
repositoryMappingRevision: Schema.String,
|
|
36
|
+
valid: Schema.Literal(true),
|
|
37
|
+
recalledContext: RecalledTaskContext,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
export const ValidationEvidence = Schema.Struct({
|
|
41
|
+
...ValidationEvidencePayload.fields,
|
|
42
|
+
digest: Schema.String,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
export type ValidationEvidence = Schema.Schema.Type<typeof ValidationEvidence>
|
|
46
|
+
|
|
47
|
+
export type EvidenceDisposition =
|
|
48
|
+
| { readonly status: "reused"; readonly reasons: readonly [] }
|
|
49
|
+
| {
|
|
50
|
+
readonly status: "refreshed"
|
|
51
|
+
readonly reasons: readonly string[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const digest = (value: unknown) => documentRevision(JSON.stringify(value))
|
|
55
|
+
|
|
56
|
+
const validateSource = (source: string) => {
|
|
57
|
+
if (!source.trim()) throw new Error("Authoritative source cannot be empty")
|
|
58
|
+
if (isAbsolute(source)) return source
|
|
59
|
+
try {
|
|
60
|
+
const url = new URL(source)
|
|
61
|
+
if (url.protocol === "https:" || url.protocol === "http:") return source
|
|
62
|
+
} catch {}
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Authoritative source '${source}' must be an absolute path or HTTP(S) URL`,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const normalizeRecalledContext = (input: {
|
|
69
|
+
readonly id: string
|
|
70
|
+
readonly repo?: string
|
|
71
|
+
readonly base?: string
|
|
72
|
+
readonly preferredSlug?: string
|
|
73
|
+
readonly authoritativeSources?: readonly string[]
|
|
74
|
+
}): RecalledTaskContext => {
|
|
75
|
+
if (input.repo !== undefined && !input.repo.trim()) {
|
|
76
|
+
throw new Error("Recalled repository cannot be empty")
|
|
77
|
+
}
|
|
78
|
+
if (input.base !== undefined && !input.base.trim()) {
|
|
79
|
+
throw new Error("Recalled base cannot be empty")
|
|
80
|
+
}
|
|
81
|
+
if (input.preferredSlug !== undefined && !input.preferredSlug.trim()) {
|
|
82
|
+
throw new Error("Recalled task slug cannot be empty")
|
|
83
|
+
}
|
|
84
|
+
if (input.preferredSlug && input.preferredSlug !== input.id) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Recalled task slug '${input.preferredSlug}' conflicts with task ID '${input.id}'`,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
...(input.repo ? { repo: input.repo } : {}),
|
|
91
|
+
...(input.base ? { base: input.base } : {}),
|
|
92
|
+
preferredSlug: input.id,
|
|
93
|
+
authoritativeSources: [...(input.authoritativeSources ?? [])]
|
|
94
|
+
.map(validateSource)
|
|
95
|
+
.sort(),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const workbaseIdentity = (startPath: string) =>
|
|
100
|
+
Effect.gen(function* () {
|
|
101
|
+
const fs = yield* FileSystemService
|
|
102
|
+
const workbase = yield* WorkbaseService
|
|
103
|
+
const root = yield* workbase.discover(startPath)
|
|
104
|
+
const configPath = join(root, "agency.json")
|
|
105
|
+
const configContent = yield* fs.readFile(configPath)
|
|
106
|
+
const { config } = yield* workbase.loadConfig(root)
|
|
107
|
+
const documents: Array<readonly [string, string]> = []
|
|
108
|
+
|
|
109
|
+
const collect = (directory: string, filename: string) =>
|
|
110
|
+
Effect.gen(function* () {
|
|
111
|
+
if (!(yield* fs.isDirectory(directory))) return
|
|
112
|
+
for (const entry of (yield* fs.readDirectory(directory)).sort((a, b) =>
|
|
113
|
+
a.name.localeCompare(b.name),
|
|
114
|
+
)) {
|
|
115
|
+
if (!entry.isDirectory) continue
|
|
116
|
+
const path = join(directory, entry.name, filename)
|
|
117
|
+
if (yield* fs.exists(path))
|
|
118
|
+
documents.push([path, yield* fs.readFile(path)])
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
yield* collect(join(root, "epics"), "EPIC.md")
|
|
123
|
+
yield* collect(join(root, "tasks"), "TASK.md")
|
|
124
|
+
const tasksDirectory = join(root, "tasks")
|
|
125
|
+
if (yield* fs.isDirectory(tasksDirectory)) {
|
|
126
|
+
for (const task of yield* fs.readDirectory(tasksDirectory)) {
|
|
127
|
+
if (task.isDirectory) {
|
|
128
|
+
yield* collect(join(tasksDirectory, task.name, "phases"), "PHASE.md")
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
documents.sort(([left], [right]) => left.localeCompare(right))
|
|
133
|
+
const aliases = yield* workbase.repositoryAliases(root)
|
|
134
|
+
const materializedMappings: Array<readonly [string, string | null]> = []
|
|
135
|
+
for (const alias of aliases) {
|
|
136
|
+
const path = join(root, "repos", alias)
|
|
137
|
+
materializedMappings.push([
|
|
138
|
+
alias,
|
|
139
|
+
(yield* fs.exists(path)) ? yield* fs.realPath(path) : null,
|
|
140
|
+
])
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
root,
|
|
144
|
+
configRevision: documentRevision(configContent),
|
|
145
|
+
repositoryMappingRevision: digest({
|
|
146
|
+
repositories: config.repositories ?? {},
|
|
147
|
+
aliases,
|
|
148
|
+
materializedMappings,
|
|
149
|
+
}),
|
|
150
|
+
workbaseRevision: digest(
|
|
151
|
+
documents.map(([path, content]) => [
|
|
152
|
+
path.slice(root.length),
|
|
153
|
+
digest(content),
|
|
154
|
+
]),
|
|
155
|
+
),
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
export const buildValidationEvidence = (input: {
|
|
160
|
+
readonly startPath: string
|
|
161
|
+
readonly target: string
|
|
162
|
+
readonly documentPath: string
|
|
163
|
+
readonly documentRevision: string
|
|
164
|
+
readonly recalledContext: RecalledTaskContext
|
|
165
|
+
}) =>
|
|
166
|
+
Effect.gen(function* () {
|
|
167
|
+
const identity = yield* workbaseIdentity(input.startPath)
|
|
168
|
+
const payload = {
|
|
169
|
+
version: KICKOFF_CONTRACT_VERSION,
|
|
170
|
+
workbaseRoot: identity.root,
|
|
171
|
+
target: input.target,
|
|
172
|
+
documentPath: resolve(input.documentPath),
|
|
173
|
+
documentRevision: input.documentRevision,
|
|
174
|
+
workbaseRevision: identity.workbaseRevision,
|
|
175
|
+
configRevision: identity.configRevision,
|
|
176
|
+
repositoryMappingRevision: identity.repositoryMappingRevision,
|
|
177
|
+
valid: true as const,
|
|
178
|
+
recalledContext: input.recalledContext,
|
|
179
|
+
}
|
|
180
|
+
return { ...payload, digest: digest(payload) } satisfies ValidationEvidence
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
export const parseValidationEvidence = (value: unknown): ValidationEvidence => {
|
|
184
|
+
const decoded = Schema.decodeUnknownEither(ValidationEvidence, {
|
|
185
|
+
errors: "all",
|
|
186
|
+
onExcessProperty: "error",
|
|
187
|
+
})(value)
|
|
188
|
+
if (Either.isLeft(decoded)) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`Invalid validation evidence: ${TreeFormatter.formatErrorSync(decoded.left)}`,
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
return decoded.right
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export const readValidationEvidence = (input: string, cwd: string) =>
|
|
197
|
+
Effect.gen(function* () {
|
|
198
|
+
const fs = yield* FileSystemService
|
|
199
|
+
const trimmed = input.trim()
|
|
200
|
+
const content = trimmed.startsWith("{")
|
|
201
|
+
? trimmed
|
|
202
|
+
: yield* fs.readFile(resolve(cwd, trimmed))
|
|
203
|
+
try {
|
|
204
|
+
const parsed = JSON.parse(content)
|
|
205
|
+
const candidate =
|
|
206
|
+
parsed?.result?.validationEvidence?.evidence ??
|
|
207
|
+
parsed?.result?.evidence ??
|
|
208
|
+
parsed?.validationEvidence?.evidence ??
|
|
209
|
+
parsed?.evidence ??
|
|
210
|
+
(parsed?.target && parsed?.documentRevision ? parsed : undefined)
|
|
211
|
+
if (!candidate || candidate.version !== KICKOFF_CONTRACT_VERSION) {
|
|
212
|
+
return undefined
|
|
213
|
+
}
|
|
214
|
+
return parseValidationEvidence(candidate)
|
|
215
|
+
} catch (cause) {
|
|
216
|
+
return yield* Effect.fail(
|
|
217
|
+
cause instanceof Error ? cause : new Error(String(cause)),
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
export const assessValidationEvidence = (input: {
|
|
223
|
+
readonly evidence?: ValidationEvidence
|
|
224
|
+
readonly startPath: string
|
|
225
|
+
readonly target: string
|
|
226
|
+
readonly documentPath: string
|
|
227
|
+
readonly documentRevision: string
|
|
228
|
+
}) =>
|
|
229
|
+
Effect.gen(function* () {
|
|
230
|
+
const identity = yield* workbaseIdentity(input.startPath)
|
|
231
|
+
const evidence = input.evidence
|
|
232
|
+
const reasons: string[] = []
|
|
233
|
+
if (!evidence) reasons.push("not-supplied")
|
|
234
|
+
else {
|
|
235
|
+
const { digest: evidenceDigest, ...payload } = evidence
|
|
236
|
+
if (digest(payload) !== evidenceDigest) reasons.push("digest-mismatch")
|
|
237
|
+
if (evidence.workbaseRoot !== identity.root)
|
|
238
|
+
reasons.push("workbase-mismatch")
|
|
239
|
+
if (evidence.target !== input.target) reasons.push("target-mismatch")
|
|
240
|
+
if (evidence.documentPath !== resolve(input.documentPath))
|
|
241
|
+
reasons.push("document-path-mismatch")
|
|
242
|
+
if (evidence.documentRevision !== input.documentRevision)
|
|
243
|
+
reasons.push("document-revision-changed")
|
|
244
|
+
if (evidence.workbaseRevision !== identity.workbaseRevision)
|
|
245
|
+
reasons.push("workbase-revision-changed")
|
|
246
|
+
if (evidence.configRevision !== identity.configRevision)
|
|
247
|
+
reasons.push("configuration-changed")
|
|
248
|
+
if (
|
|
249
|
+
evidence.repositoryMappingRevision !==
|
|
250
|
+
identity.repositoryMappingRevision
|
|
251
|
+
)
|
|
252
|
+
reasons.push("repository-mapping-changed")
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
identity,
|
|
256
|
+
disposition: reasons.length
|
|
257
|
+
? ({ status: "refreshed", reasons } as const)
|
|
258
|
+
: ({ status: "reused", reasons: [] } as const),
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
export const buildKickoffPlan = (input: {
|
|
263
|
+
readonly workbaseRoot: string
|
|
264
|
+
readonly target: string
|
|
265
|
+
readonly taskId: string
|
|
266
|
+
readonly phaseId?: string
|
|
267
|
+
readonly taskPath: string
|
|
268
|
+
readonly phasePath?: string | null
|
|
269
|
+
readonly checkoutPath?: string | null
|
|
270
|
+
readonly documentRevision: string
|
|
271
|
+
}) => {
|
|
272
|
+
const selector = [input.taskId, ...(input.phaseId ? [input.phaseId] : [])]
|
|
273
|
+
const taskDirectory = dirname(input.phasePath ?? input.taskPath)
|
|
274
|
+
const idempotencyKey = digest({
|
|
275
|
+
version: KICKOFF_CONTRACT_VERSION,
|
|
276
|
+
workbaseRoot: input.workbaseRoot,
|
|
277
|
+
target: input.target,
|
|
278
|
+
})
|
|
279
|
+
return {
|
|
280
|
+
version: KICKOFF_CONTRACT_VERSION,
|
|
281
|
+
idempotencyKey,
|
|
282
|
+
workbaseRoot: input.workbaseRoot,
|
|
283
|
+
target: input.target,
|
|
284
|
+
documentRevision: input.documentRevision,
|
|
285
|
+
sourceLocations: KICKOFF_SOURCE_LOCATIONS,
|
|
286
|
+
taskDirectory,
|
|
287
|
+
taskDocument: input.taskPath,
|
|
288
|
+
phaseDocument: input.phasePath ?? null,
|
|
289
|
+
preparedCheckout: input.checkoutPath ?? null,
|
|
290
|
+
orchestrator: {
|
|
291
|
+
capability: "agency-kickoff-v1",
|
|
292
|
+
knownCurrentCommandsBypassDiscovery: true,
|
|
293
|
+
fallback:
|
|
294
|
+
"Discover Herdr capabilities only when capability/version evidence is absent or stale.",
|
|
295
|
+
},
|
|
296
|
+
steps: [
|
|
297
|
+
{
|
|
298
|
+
id: "worktree-dry-run",
|
|
299
|
+
argv: [
|
|
300
|
+
"agency",
|
|
301
|
+
"worktree",
|
|
302
|
+
"prepare",
|
|
303
|
+
...selector,
|
|
304
|
+
"--dry-run",
|
|
305
|
+
"--json",
|
|
306
|
+
],
|
|
307
|
+
retry: "safe",
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
id: "worktree-prepare",
|
|
311
|
+
argv: ["agency", "worktree", "prepare", ...selector, "--json"],
|
|
312
|
+
retry: "reuses matching clean workspaces",
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
id: "herdr-tab",
|
|
316
|
+
action: "create-or-reuse-background-tab",
|
|
317
|
+
idempotencyKey,
|
|
318
|
+
recovery:
|
|
319
|
+
"Reuse the tab recorded for this idempotency key; never create a duplicate.",
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
id: "task-document-split",
|
|
323
|
+
action: "open-side-by-side-document",
|
|
324
|
+
path: input.phasePath ?? input.taskPath,
|
|
325
|
+
recovery: "Reuse the existing split when present.",
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
id: "runner-start",
|
|
329
|
+
cwd: taskDirectory,
|
|
330
|
+
argv: ["agency", "work", ".", "--auto"],
|
|
331
|
+
recovery:
|
|
332
|
+
"Inspect the recorded tab before retrying; a working runner must not be duplicated.",
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
id: "final-context-verification",
|
|
336
|
+
argv: [
|
|
337
|
+
"agency",
|
|
338
|
+
"context",
|
|
339
|
+
input.phasePath ?? input.taskPath,
|
|
340
|
+
"--json",
|
|
341
|
+
],
|
|
342
|
+
exactlyOnce: true,
|
|
343
|
+
recovery:
|
|
344
|
+
"If verification fails, inspect the existing tab; do not launch another runner.",
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
successFields: [
|
|
348
|
+
"target",
|
|
349
|
+
"taskDirectory",
|
|
350
|
+
"taskDocument",
|
|
351
|
+
"preparedCheckout",
|
|
352
|
+
"herdrWorkspace",
|
|
353
|
+
"herdrTab",
|
|
354
|
+
"runnerStart",
|
|
355
|
+
"contextVerification",
|
|
356
|
+
],
|
|
357
|
+
}
|
|
358
|
+
}
|