@markjaquith/agency 2.62.0 → 2.63.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 +4 -0
- package/package.json +1 -1
- package/src/commands/context.test.ts +118 -1
- package/src/commands/context.ts +3 -1
- package/src/services/ContextService.ts +125 -39
package/README.md
CHANGED
|
@@ -535,6 +535,10 @@ a discovery catalog of all epics, tasks, and phases, including frontmatter,
|
|
|
535
535
|
paths, and document revisions. Elsewhere it returns context for an epic, task,
|
|
536
536
|
or phase. The target defaults to the current directory; entity directories,
|
|
537
537
|
document paths, checkout descendants, and bare task IDs are accepted.
|
|
538
|
+
Archived entity paths and selectors are also accepted. Archived context is
|
|
539
|
+
explicitly marked with `target.archived: true` and never grants writable
|
|
540
|
+
document, repository checkout, or reference authority; restore the item before
|
|
541
|
+
attempting mutation or execution.
|
|
538
542
|
|
|
539
543
|
Root discovery is compact by default and includes a hint to run `agency context
|
|
540
544
|
. --full --json` when document prose is needed. Entity context remains complete
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
-
import { mkdir, rm } from "node:fs/promises"
|
|
2
|
+
import { mkdir, rename, rm } from "node:fs/promises"
|
|
3
3
|
import { dirname, join } from "node:path"
|
|
4
4
|
import {
|
|
5
5
|
captureLogs,
|
|
@@ -342,6 +342,123 @@ status: dropped
|
|
|
342
342
|
expect(result.authority.writable.branch).toBe("foundations")
|
|
343
343
|
})
|
|
344
344
|
|
|
345
|
+
test("resolves archived tasks without granting mutation or execution authority", async () => {
|
|
346
|
+
await mkdir(join(root, "archive/tasks"), { recursive: true })
|
|
347
|
+
await rename(
|
|
348
|
+
join(root, "tasks/foundations"),
|
|
349
|
+
join(root, "archive/tasks/foundations"),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
const result = await readContext(root, "foundations")
|
|
353
|
+
|
|
354
|
+
expect(result.target).toMatchObject({
|
|
355
|
+
kind: "task",
|
|
356
|
+
taskId: "foundations",
|
|
357
|
+
archived: true,
|
|
358
|
+
path: join(root, "archive/tasks/foundations/TASK.md"),
|
|
359
|
+
})
|
|
360
|
+
expect(result.documents.task.body).toContain("# Foundations")
|
|
361
|
+
expect(result.authority).toMatchObject({
|
|
362
|
+
mode: "orchestration",
|
|
363
|
+
writable: null,
|
|
364
|
+
references: [],
|
|
365
|
+
documents: { writable: [] },
|
|
366
|
+
})
|
|
367
|
+
expect(result.workspace).toMatchObject({
|
|
368
|
+
materialization: "absent",
|
|
369
|
+
writable: null,
|
|
370
|
+
references: [],
|
|
371
|
+
})
|
|
372
|
+
expect(result.graph.readiness).toMatchObject({
|
|
373
|
+
ready: false,
|
|
374
|
+
blocked: true,
|
|
375
|
+
})
|
|
376
|
+
expect(result.graph.readiness.blockers).toContainEqual({
|
|
377
|
+
kind: "status",
|
|
378
|
+
id: "foundations",
|
|
379
|
+
reason: "Target is archived; restore it before mutation or execution",
|
|
380
|
+
})
|
|
381
|
+
expect(result.pr).toMatchObject({
|
|
382
|
+
url: "https://github.com/example/agency/pull/1",
|
|
383
|
+
state: "open",
|
|
384
|
+
})
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
test("resolves archived phases with their active parent context", async () => {
|
|
388
|
+
await mkdir(join(root, "archive/tasks/agent-contract/phases"), {
|
|
389
|
+
recursive: true,
|
|
390
|
+
})
|
|
391
|
+
await rename(
|
|
392
|
+
join(root, "tasks/agent-contract/phases/context-command"),
|
|
393
|
+
join(root, "archive/tasks/agent-contract/phases/context-command"),
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
const result = await readContext(
|
|
397
|
+
root,
|
|
398
|
+
"archive/tasks/agent-contract/phases/context-command",
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
expect(result.target).toMatchObject({
|
|
402
|
+
kind: "phase",
|
|
403
|
+
taskId: "agent-contract",
|
|
404
|
+
phaseId: "context-command",
|
|
405
|
+
archived: true,
|
|
406
|
+
path: join(
|
|
407
|
+
root,
|
|
408
|
+
"archive/tasks/agent-contract/phases/context-command/PHASE.md",
|
|
409
|
+
),
|
|
410
|
+
})
|
|
411
|
+
expect(result.documents.task.body).toContain("Task prose.")
|
|
412
|
+
expect(result.documents.phase.body).toContain("Phase prose.")
|
|
413
|
+
expect(result.graph.parent).toEqual({ kind: "task", id: "agent-contract" })
|
|
414
|
+
expect(result.graph.readiness).toMatchObject({
|
|
415
|
+
ready: false,
|
|
416
|
+
blocked: true,
|
|
417
|
+
})
|
|
418
|
+
expect(result.authority).toMatchObject({
|
|
419
|
+
mode: "orchestration",
|
|
420
|
+
writable: null,
|
|
421
|
+
references: [],
|
|
422
|
+
documents: { writable: [] },
|
|
423
|
+
})
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
test("resolves archived epics with archived descendant graph context", async () => {
|
|
427
|
+
await mkdir(join(root, "archive/epics"), { recursive: true })
|
|
428
|
+
await mkdir(join(root, "archive/tasks"), { recursive: true })
|
|
429
|
+
await rename(
|
|
430
|
+
join(root, "epics/contract"),
|
|
431
|
+
join(root, "archive/epics/contract"),
|
|
432
|
+
)
|
|
433
|
+
for (const taskId of ["agent-contract", "foundations"]) {
|
|
434
|
+
await rename(
|
|
435
|
+
join(root, "tasks", taskId),
|
|
436
|
+
join(root, "archive/tasks", taskId),
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const result = await readContext(root, "epics/contract")
|
|
441
|
+
|
|
442
|
+
expect(result.target).toMatchObject({
|
|
443
|
+
kind: "epic",
|
|
444
|
+
epicId: "contract",
|
|
445
|
+
archived: true,
|
|
446
|
+
path: join(root, "archive/epics/contract/EPIC.md"),
|
|
447
|
+
})
|
|
448
|
+
expect(result.documents.epic.body).toContain("# Contract")
|
|
449
|
+
expect(result.graph.aggregate).toMatchObject({ total: 3, done: 2, open: 1 })
|
|
450
|
+
expect(result.graph.readiness).toMatchObject({
|
|
451
|
+
ready: false,
|
|
452
|
+
blocked: true,
|
|
453
|
+
})
|
|
454
|
+
expect(result.authority).toMatchObject({
|
|
455
|
+
mode: "orchestration",
|
|
456
|
+
writable: null,
|
|
457
|
+
references: [],
|
|
458
|
+
documents: { writable: [] },
|
|
459
|
+
})
|
|
460
|
+
})
|
|
461
|
+
|
|
345
462
|
test("computes orchestration readiness from runnable descendants", async () => {
|
|
346
463
|
await write(
|
|
347
464
|
root,
|
package/src/commands/context.ts
CHANGED
|
@@ -23,7 +23,9 @@ Usage: agency context [target] [options]
|
|
|
23
23
|
|
|
24
24
|
Return complete, read-only context for a workbase, epic, task, or phase. The
|
|
25
25
|
target defaults to the current directory and may be the workbase root, an entity
|
|
26
|
-
path, or a task ID.
|
|
26
|
+
path, or a task ID. Archived entities are inspectable but never receive mutation
|
|
27
|
+
or execution authority. Workbase context catalogs all active epics, tasks, and
|
|
28
|
+
phases.
|
|
27
29
|
|
|
28
30
|
Options:
|
|
29
31
|
--json Output a versioned machine result
|
|
@@ -20,6 +20,11 @@ import {
|
|
|
20
20
|
type TaskFrontmatter as TaskData,
|
|
21
21
|
type WorkStatus,
|
|
22
22
|
} from "../workbase/schemas"
|
|
23
|
+
import {
|
|
24
|
+
archivedEpicDirectory,
|
|
25
|
+
archivedPhaseDirectory,
|
|
26
|
+
archivedTaskDirectory,
|
|
27
|
+
} from "../workbase/archive"
|
|
23
28
|
|
|
24
29
|
class ContextError extends Data.TaggedError("ContextError")<{
|
|
25
30
|
readonly message: string
|
|
@@ -36,6 +41,7 @@ interface Document<T> {
|
|
|
36
41
|
|
|
37
42
|
interface Target {
|
|
38
43
|
readonly kind: "epic" | "task" | "phase"
|
|
44
|
+
readonly archived: boolean
|
|
39
45
|
readonly epicId?: string
|
|
40
46
|
readonly taskId?: string
|
|
41
47
|
readonly phaseId?: string
|
|
@@ -240,15 +246,45 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
240
246
|
if (!candidateExists && !suppliedTarget.includes(sep)) {
|
|
241
247
|
return {
|
|
242
248
|
kind: "task",
|
|
249
|
+
archived: false,
|
|
243
250
|
taskId: suppliedTarget,
|
|
244
251
|
path: join(root, "tasks", suppliedTarget, "TASK.md"),
|
|
245
252
|
}
|
|
246
253
|
}
|
|
247
254
|
if (!isWithin(root, candidate)) return null
|
|
248
255
|
const parts = relative(root, candidate).split(sep)
|
|
256
|
+
if (parts[0] === "archive" && parts[1] === "epics" && parts[2]) {
|
|
257
|
+
return {
|
|
258
|
+
kind: "epic",
|
|
259
|
+
archived: true,
|
|
260
|
+
epicId: parts[2],
|
|
261
|
+
path: join(archivedEpicDirectory(root, parts[2]), "EPIC.md"),
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (parts[0] === "archive" && parts[1] === "tasks" && parts[2]) {
|
|
265
|
+
if (parts[3] === "phases" && parts[4]) {
|
|
266
|
+
return {
|
|
267
|
+
kind: "phase",
|
|
268
|
+
archived: true,
|
|
269
|
+
taskId: parts[2],
|
|
270
|
+
phaseId: parts[4],
|
|
271
|
+
path: join(
|
|
272
|
+
archivedPhaseDirectory(root, parts[2], parts[4]),
|
|
273
|
+
"PHASE.md",
|
|
274
|
+
),
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
kind: "task",
|
|
279
|
+
archived: true,
|
|
280
|
+
taskId: parts[2],
|
|
281
|
+
path: join(archivedTaskDirectory(root, parts[2]), "TASK.md"),
|
|
282
|
+
}
|
|
283
|
+
}
|
|
249
284
|
if (parts[0] === "epics" && parts[1]) {
|
|
250
285
|
return {
|
|
251
286
|
kind: "epic",
|
|
287
|
+
archived: false,
|
|
252
288
|
epicId: parts[1],
|
|
253
289
|
path: join(root, "epics", parts[1], "EPIC.md"),
|
|
254
290
|
}
|
|
@@ -257,6 +293,7 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
257
293
|
if (parts[2] === "phases" && parts[3]) {
|
|
258
294
|
return {
|
|
259
295
|
kind: "phase",
|
|
296
|
+
archived: false,
|
|
260
297
|
taskId: parts[1],
|
|
261
298
|
phaseId: parts[3],
|
|
262
299
|
path: join(
|
|
@@ -271,6 +308,7 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
271
308
|
}
|
|
272
309
|
return {
|
|
273
310
|
kind: "task",
|
|
311
|
+
archived: false,
|
|
274
312
|
taskId: parts[1],
|
|
275
313
|
path: join(root, "tasks", parts[1], "TASK.md"),
|
|
276
314
|
}
|
|
@@ -278,13 +316,32 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
278
316
|
return null
|
|
279
317
|
}
|
|
280
318
|
|
|
281
|
-
const
|
|
282
|
-
if (!
|
|
319
|
+
const inferredTarget = inferTarget()
|
|
320
|
+
if (!inferredTarget) {
|
|
283
321
|
return yield* new ContextError({
|
|
284
322
|
target: suppliedTarget,
|
|
285
323
|
message: `Cannot infer an Agency target from ${candidate}`,
|
|
286
324
|
})
|
|
287
325
|
}
|
|
326
|
+
let target = inferredTarget
|
|
327
|
+
if (!target.archived && !(yield* fs.exists(target.path))) {
|
|
328
|
+
const archivedPath =
|
|
329
|
+
target.kind === "epic"
|
|
330
|
+
? join(archivedEpicDirectory(root, target.epicId!), "EPIC.md")
|
|
331
|
+
: target.kind === "phase"
|
|
332
|
+
? join(
|
|
333
|
+
archivedPhaseDirectory(
|
|
334
|
+
root,
|
|
335
|
+
target.taskId!,
|
|
336
|
+
target.phaseId!,
|
|
337
|
+
),
|
|
338
|
+
"PHASE.md",
|
|
339
|
+
)
|
|
340
|
+
: join(archivedTaskDirectory(root, target.taskId!), "TASK.md")
|
|
341
|
+
if (yield* fs.exists(archivedPath)) {
|
|
342
|
+
target = { ...target, archived: true, path: archivedPath }
|
|
343
|
+
}
|
|
344
|
+
}
|
|
288
345
|
|
|
289
346
|
const readDocument = <S extends Schema.Schema.AnyNoContext>(
|
|
290
347
|
id: string,
|
|
@@ -334,26 +391,22 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
334
391
|
return yield* readDocument(id, path, schema)
|
|
335
392
|
})
|
|
336
393
|
|
|
337
|
-
const
|
|
338
|
-
?
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
394
|
+
const activeTaskPath = target.taskId
|
|
395
|
+
? join(root, "tasks", target.taskId, "TASK.md")
|
|
396
|
+
: null
|
|
397
|
+
const taskPath = target.taskId
|
|
398
|
+
? target.kind === "task"
|
|
399
|
+
? target.path
|
|
400
|
+
: activeTaskPath && (yield* fs.exists(activeTaskPath))
|
|
401
|
+
? activeTaskPath
|
|
402
|
+
: join(archivedTaskDirectory(root, target.taskId), "TASK.md")
|
|
343
403
|
: null
|
|
404
|
+
const task =
|
|
405
|
+
target.taskId && taskPath
|
|
406
|
+
? yield* readDocument(target.taskId, taskPath, TaskFrontmatter)
|
|
407
|
+
: null
|
|
344
408
|
const phase = target.phaseId
|
|
345
|
-
? yield* readDocument(
|
|
346
|
-
target.phaseId,
|
|
347
|
-
join(
|
|
348
|
-
root,
|
|
349
|
-
"tasks",
|
|
350
|
-
target.taskId!,
|
|
351
|
-
"phases",
|
|
352
|
-
target.phaseId,
|
|
353
|
-
"PHASE.md",
|
|
354
|
-
),
|
|
355
|
-
PhaseFrontmatter,
|
|
356
|
-
)
|
|
409
|
+
? yield* readDocument(target.phaseId, target.path, PhaseFrontmatter)
|
|
357
410
|
: null
|
|
358
411
|
const epicId =
|
|
359
412
|
target.epicId ??
|
|
@@ -361,7 +414,11 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
361
414
|
const epic = epicId
|
|
362
415
|
? yield* readOptionalDocument(
|
|
363
416
|
epicId,
|
|
364
|
-
|
|
417
|
+
target.kind === "epic"
|
|
418
|
+
? target.path
|
|
419
|
+
: (yield* fs.exists(join(root, "epics", epicId, "EPIC.md")))
|
|
420
|
+
? join(root, "epics", epicId, "EPIC.md")
|
|
421
|
+
: join(archivedEpicDirectory(root, epicId), "EPIC.md"),
|
|
365
422
|
EpicFrontmatter,
|
|
366
423
|
)
|
|
367
424
|
: null
|
|
@@ -374,8 +431,11 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
374
431
|
|
|
375
432
|
const taskDocuments = new Map<string, Document<TaskData>>()
|
|
376
433
|
const phaseDocuments = new Map<string, Document<PhaseData>>()
|
|
377
|
-
const taskRoot
|
|
378
|
-
|
|
434
|
+
for (const taskRoot of [
|
|
435
|
+
join(root, "tasks"),
|
|
436
|
+
join(root, "archive", "tasks"),
|
|
437
|
+
]) {
|
|
438
|
+
if (!(yield* fs.isDirectory(taskRoot))) continue
|
|
379
439
|
const entries = (yield* fs.readDirectory(taskRoot))
|
|
380
440
|
.filter((entry) => entry.isDirectory)
|
|
381
441
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
@@ -455,14 +515,18 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
455
515
|
{ concurrency: "unbounded" },
|
|
456
516
|
)
|
|
457
517
|
for (const document of documents) {
|
|
458
|
-
if (
|
|
518
|
+
if (
|
|
519
|
+
document.taskDocument &&
|
|
520
|
+
!taskDocuments.has(document.taskDocument.id)
|
|
521
|
+
) {
|
|
459
522
|
taskDocuments.set(
|
|
460
523
|
document.taskDocument.id,
|
|
461
524
|
document.taskDocument,
|
|
462
525
|
)
|
|
463
526
|
}
|
|
464
527
|
for (const [key, phaseDocument] of document.phaseDocuments) {
|
|
465
|
-
phaseDocuments.
|
|
528
|
+
if (!phaseDocuments.has(key))
|
|
529
|
+
phaseDocuments.set(key, phaseDocument)
|
|
466
530
|
}
|
|
467
531
|
}
|
|
468
532
|
}
|
|
@@ -751,6 +815,18 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
751
815
|
const orchestrationTarget =
|
|
752
816
|
target.kind === "epic" ||
|
|
753
817
|
(target.kind === "task" && task !== null && "phases" in task.data)
|
|
818
|
+
if (target.archived) {
|
|
819
|
+
blockers.push({
|
|
820
|
+
kind: "status",
|
|
821
|
+
id:
|
|
822
|
+
target.phaseId ??
|
|
823
|
+
target.taskId ??
|
|
824
|
+
target.epicId ??
|
|
825
|
+
suppliedTarget,
|
|
826
|
+
reason:
|
|
827
|
+
"Target is archived; restore it before mutation or execution",
|
|
828
|
+
})
|
|
829
|
+
}
|
|
754
830
|
if (!orchestrationTarget && targetStatus !== "open") {
|
|
755
831
|
blockers.push({
|
|
756
832
|
kind: "status",
|
|
@@ -773,17 +849,22 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
773
849
|
})
|
|
774
850
|
}
|
|
775
851
|
const ready = orchestrationTarget
|
|
776
|
-
?
|
|
852
|
+
? !target.archived &&
|
|
853
|
+
descendantsReady &&
|
|
777
854
|
!blockers.some((blocker) => blocker.kind === "validation")
|
|
778
855
|
: targetStatus === "open" && blockers.length === 0
|
|
779
856
|
|
|
780
|
-
const executionData: ExecutionData | null =
|
|
781
|
-
?
|
|
782
|
-
:
|
|
783
|
-
?
|
|
784
|
-
:
|
|
857
|
+
const executionData: ExecutionData | null = target.archived
|
|
858
|
+
? null
|
|
859
|
+
: phase?.data
|
|
860
|
+
? phase.data
|
|
861
|
+
: task?.data && "repo" in task.data
|
|
862
|
+
? (task.data as ExecutionData)
|
|
863
|
+
: null
|
|
785
864
|
const reviewData =
|
|
786
|
-
task?.data && "review" in task.data
|
|
865
|
+
!target.archived && task?.data && "review" in task.data
|
|
866
|
+
? task.data.review
|
|
867
|
+
: null
|
|
787
868
|
const writableDocuments = reviewData
|
|
788
869
|
? task
|
|
789
870
|
? [task.path]
|
|
@@ -793,11 +874,13 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
793
874
|
? [task.path, phase.path]
|
|
794
875
|
: [task.path]
|
|
795
876
|
: []
|
|
796
|
-
const references: readonly RepositoryReference[] =
|
|
797
|
-
? [
|
|
798
|
-
:
|
|
799
|
-
?
|
|
800
|
-
:
|
|
877
|
+
const references: readonly RepositoryReference[] = target.archived
|
|
878
|
+
? []
|
|
879
|
+
: reviewData
|
|
880
|
+
? [{ repo: reviewData.repo, ref: reviewData.commit }]
|
|
881
|
+
: executionData
|
|
882
|
+
? (executionData.repos ?? [])
|
|
883
|
+
: (epic?.data.repos ?? [])
|
|
801
884
|
const entityDirectory = target.path.replace(
|
|
802
885
|
/\/(?:EPIC|TASK|PHASE)\.md$/,
|
|
803
886
|
"",
|
|
@@ -1035,6 +1118,9 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
1035
1118
|
}
|
|
1036
1119
|
: document
|
|
1037
1120
|
: null
|
|
1121
|
+
const pullRequestData =
|
|
1122
|
+
phase?.data ??
|
|
1123
|
+
(task?.data && "repo" in task.data ? task.data : null)
|
|
1038
1124
|
|
|
1039
1125
|
return {
|
|
1040
1126
|
projection: options.compact ? "compact" : "complete",
|
|
@@ -1124,8 +1210,8 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
1124
1210
|
checkout: referenceCheckouts[0] ?? null,
|
|
1125
1211
|
}
|
|
1126
1212
|
: null,
|
|
1127
|
-
pr:
|
|
1128
|
-
? normalizePullRequestRecord(
|
|
1213
|
+
pr: pullRequestData?.pr
|
|
1214
|
+
? normalizePullRequestRecord(pullRequestData.pr)
|
|
1129
1215
|
: { url: null, state: "none" },
|
|
1130
1216
|
validation: {
|
|
1131
1217
|
valid: validation.valid,
|