@pmelab/gtd 18.0.0 → 19.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "18.0.0",
3
+ "version": "19.0.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -47,6 +47,7 @@
47
47
  "watch": "tsdown --watch",
48
48
  "bench:replay": "node scripts/bench-replay.mjs",
49
49
  "test": "turbo run format:check typecheck lint lint:sh lint:boundaries test:unit test:e2e:inmem test:e2e:live test:web analyze",
50
+ "test:fast": "turbo run format:check typecheck lint lint:sh lint:boundaries test:unit test:web analyze",
50
51
  "test:unit": "vitest run --project unit",
51
52
  "test:watch": "vitest",
52
53
  "test:changed": "vitest run --project unit --project e2e-inmem --changed HEAD",
package/schema.json CHANGED
@@ -105,6 +105,28 @@
105
105
  }
106
106
  }
107
107
  },
108
+ "access": {
109
+ "type": "object",
110
+ "description": "Flat scope full-name -> { read?, write? } map of glob arrays. Each entry REPLACES the named scope's declared access wholesale (never merges into it) and reaches every nested scope that sets none of its own; a missing side is unrestricted, [] allows nothing, and {} lifts every restriction. read and write are independent: write grants no read. A key that is not a scope running a turn is a load error listing the known scopes — the schema itself cannot validate a key, only a value's shape.",
111
+ "additionalProperties": {
112
+ "type": "object",
113
+ "additionalProperties": false,
114
+ "properties": {
115
+ "read": {
116
+ "type": "array",
117
+ "items": {
118
+ "type": "string"
119
+ }
120
+ },
121
+ "write": {
122
+ "type": "array",
123
+ "items": {
124
+ "type": "string"
125
+ }
126
+ }
127
+ }
128
+ }
129
+ },
108
130
  "$schema": {
109
131
  "type": "string",
110
132
  "description": "Editor-only pointer at this schema. Stripped by gtd before validation."
@@ -63,9 +63,9 @@ current directory is the whole workflow. If one already exists, read it and edit
63
63
  it in place.
64
64
 
65
65
  Prefer the bundled workflow's own parts over re-implementing them — `healthy`,
66
- `escalation`, `gate`, `design`, `architecturePass`, `packages`, `specReview`,
67
- `qualityLap`, `review`, `buildTail`, and single steps like `triage` or `fix`.
68
- Their full step names are versioned API.
66
+ `escalation`, `gate`, `design`, `architecture`, `packages`, `qualityLap`,
67
+ `review`, `buildTail`, and single steps like `triage` or `fix`. Their full step
68
+ names are versioned API.
69
69
 
70
70
  Make one small change, **verify it loads** (see "Verify"), then make the next. A
71
71
  workflow that fails to load breaks every gtd command in the repository.
@@ -212,7 +212,7 @@ sign-off between the two:
212
212
  const planAndBuild = async (): Promise<void> => {
213
213
  for (;;) {
214
214
  await design()
215
- await architecturePass()
215
+ await architecture()
216
216
  await human("approve-plan", {
217
217
  message:
218
218
  "The packages under .gtd/packages/ are ready. Edit them to adjust the plan, or change nothing — then run `gtd land` to start building.",
@@ -229,9 +229,8 @@ const planAndBuild = async (): Promise<void> => {
229
229
  `acceptClean: true` is what makes an untouched landing approve; without it the
230
230
  gate would wait for an edit. `approve-plan` sits in the `root` scope and is a
231
231
  new, unique name. Verify: `gtd next` loads without errors, and in a scratch
232
- repository a landing at `architecture.decompose` (or `architecture-promote`) now
233
- leads to `approve-plan`, and an untouched landing there to
234
- `packages.item.building`.
232
+ repository a landing at `architecture.decompose` now leads to `approve-plan`,
233
+ and an untouched landing there to `packages.item.building`.
235
234
 
236
235
  ## No migration
237
236
 
@@ -36,6 +36,8 @@ export interface CheckOptions {
36
36
  readonly sweep?: readonly string[] | undefined
37
37
  /** Paths removed once it passes. */
38
38
  readonly sweepOnGreen?: readonly string[] | undefined
39
+ /** Shell lines run before the command, outside its output capture; an `exit 0` skips it and leaves `report` untouched. */
40
+ readonly preamble?: readonly string[] | undefined
39
41
  }
40
42
 
41
43
  /**
@@ -48,10 +50,10 @@ export const check = async (
48
50
  command: string,
49
51
  options: CheckOptions,
50
52
  ): Promise<boolean> => {
51
- const { report, label, sweep, sweepOnGreen } = options
53
+ const { report, label, sweep, sweepOnGreen, preamble } = options
52
54
  await run(
53
55
  name,
54
- checkScript(command, { report, stamp: head().slice(0, 7), sweep, sweepOnGreen }),
56
+ checkScript(command, { report, stamp: head().slice(0, 7), sweep, sweepOnGreen, preamble }),
55
57
  { label },
56
58
  )
57
59
  return !wrote(report)
@@ -83,6 +83,18 @@ export interface Changes extends ReadonlyArray<Change> {
83
83
  readonly get: (path: string) => Change | undefined
84
84
  }
85
85
 
86
+ /** Glob arrays in the `glob()`/`changes()` dialect. A missing side is unrestricted, `[]` allows nothing; `write` grants no read. */
87
+ export interface ScopeAccess {
88
+ readonly read?: readonly string[] | undefined
89
+ readonly write?: readonly string[] | undefined
90
+ }
91
+
92
+ /** A resolved access: `null` is an unrestricted side. */
93
+ export interface AccessDef {
94
+ readonly read: readonly string[] | null
95
+ readonly write: readonly string[] | null
96
+ }
97
+
86
98
  export interface ScopeOptions {
87
99
  /** Prefixes every step name inside, and so names their memory scope. */
88
100
  readonly name?: string | undefined
@@ -92,6 +104,8 @@ export interface ScopeOptions {
92
104
  readonly system?: string | undefined
93
105
  /** The skill names every agent step inside declares — `gtd next --json`'s `skills`. A nested scope inherits it unless it sets its own. */
94
106
  readonly skills?: readonly string[] | undefined
107
+ /** The file access every agent step inside runs with — `gtd next --json`'s `access`. A nested scope inherits it; its own replaces it wholesale. */
108
+ readonly access?: ScopeAccess | undefined
95
109
  }
96
110
 
97
111
  export type StepRequest =
@@ -141,6 +155,8 @@ export interface FlowContext {
141
155
  readonly start: () => string
142
156
  /** The skill list the memory scope `localName` lands in resolves to — the same resolver as the wire's `skills`. */
143
157
  readonly skillsFor: (localName: string) => readonly string[]
158
+ /** The folded access the memory scope `localName` lands in resolves to; `file` is the step's own steering file. */
159
+ readonly accessFor: (localName: string, file?: string) => AccessDef
144
160
  }
145
161
 
146
162
  const CONTEXT_KEY = Symbol.for("@pmelab/gtd/flow-context")
@@ -249,6 +265,10 @@ export const start = (): string => ctx().start()
249
265
  /** The skill list `localName` (scoped from here, same as `agent()`) resolves to — for a prompt preamble. */
250
266
  export const skillsFor = (localName: string): readonly string[] => ctx().skillsFor(localName)
251
267
 
268
+ /** The folded access `localName` (scoped from here, same as `agent()`) resolves to — for a prompt preamble. */
269
+ export const accessFor = (localName: string, file?: string): AccessDef =>
270
+ ctx().accessFor(localName, file)
271
+
252
272
  const settingsProxy = (
253
273
  read: () => Readonly<Record<string, string>>,
254
274
  ): Readonly<Record<string, string>> =>
@@ -1,5 +1,9 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
1
5
  import { describe, expect, it } from "vitest"
2
- import { restoreScript } from "./scripts.js"
6
+ import { checkScript, restoreScript } from "./scripts.js"
3
7
 
4
8
  describe("restoreScript", () => {
5
9
  const paths = { restore: ["a.ts"], remove: [] }
@@ -14,3 +18,26 @@ describe("restoreScript", () => {
14
18
  expect(script).not.toContain("abc~1")
15
19
  })
16
20
  })
21
+
22
+ describe("checkScript preamble", () => {
23
+ const options = { report: ".gtd/FEEDBACK.md", stamp: "abc" }
24
+ const runIn = (script: string, dir: string): void => {
25
+ writeFileSync(join(dir, "s.sh"), script)
26
+ spawnSync("sh", ["s.sh"], { cwd: dir })
27
+ }
28
+
29
+ it("renders before the command subshell", () => {
30
+ const script = checkScript("npm test", { ...options, preamble: ["echo pre"] })
31
+ expect(script.indexOf("echo pre")).toBeGreaterThan(-1)
32
+ expect(script.indexOf("echo pre")).toBeLessThan(script.indexOf("npm test"))
33
+ })
34
+
35
+ it("an exit 0 in the preamble skips the command and leaves report alone", () => {
36
+ const dir = mkdtempSync(join(tmpdir(), "gtd-pre-"))
37
+ mkdirSync(join(dir, ".gtd"))
38
+ writeFileSync(join(dir, ".gtd/FEEDBACK.md"), "old")
39
+ runIn(checkScript("touch ran", { ...options, preamble: ["exit 0"] }), dir)
40
+ expect(existsSync(join(dir, "ran"))).toBe(false)
41
+ expect(readFileSync(join(dir, ".gtd/FEEDBACK.md"), "utf8")).toBe("old")
42
+ })
43
+ })
@@ -14,6 +14,8 @@ export interface CheckScriptOptions {
14
14
  readonly sweep?: readonly string[] | undefined
15
15
  /** Paths removed once it passes. */
16
16
  readonly sweepOnGreen?: readonly string[] | undefined
17
+ /** Shell lines run before the command, outside its output capture; an `exit 0` skips it and leaves `report` untouched. */
18
+ readonly preamble?: readonly string[] | undefined
17
19
  }
18
20
 
19
21
  const removal = (paths: readonly string[]): string[] =>
@@ -29,6 +31,7 @@ export const checkScript = (command: string, options: CheckScriptOptions): strin
29
31
  return [
30
32
  "#!/usr/bin/env sh",
31
33
  "set +e",
34
+ ...(options.preamble ?? []),
32
35
  ...removal(options.sweep ?? []),
33
36
  `mkdir -p "$(dirname ${report})"`,
34
37
  // A subshell, so an `exit` inside the command ends only the command.
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { access } from "./access.js"
3
+ import { unified } from "./index.js"
4
+
5
+ const { defaults } = unified
6
+
7
+ const bundled = access(defaults)
8
+
9
+ describe("the bundled workflow's access export", () => {
10
+ it("restricts writes on planning, review and lens scopes only", () => {
11
+ expect(bundled).toEqual({
12
+ design: { write: [] },
13
+ architecture: { write: [".gtd/REQUIREMENTS.md"] },
14
+ "architecture.decompose": { write: [".gtd/packages/**", ".gtd/ARCHITECTURE.md"] },
15
+ "build.review": { write: [".gtd/REVIEW.md"] },
16
+ "build.review.fix.nits": {},
17
+ "build.review.fix.risks": {},
18
+ "build.quality.correctness": { write: [] },
19
+ "build.quality.owasp-security": { write: [] },
20
+ "build.quality.ponytail-review": { write: [] },
21
+ "build.quality.test-audit": { write: [] },
22
+ "build.quality.conventions": { write: [] },
23
+ "build.quality.spec-challenge": { write: [] },
24
+ })
25
+ })
26
+
27
+ it("keys one lens scope per qualityReviews entry", () => {
28
+ const custom = access({ ...defaults, qualityReviews: " my-lens ,, correctness" })
29
+ expect(
30
+ Object.keys(custom)
31
+ .filter((k) => k.startsWith("build.quality."))
32
+ .sort(),
33
+ ).toEqual(["build.quality.correctness", "build.quality.my-lens"])
34
+ })
35
+
36
+ it("keys only scopes that run a turn (every key is a skills key)", () => {
37
+ const known = Object.keys(unified.skills(defaults))
38
+ for (const key of Object.keys(bundled)) expect(known).toContain(key)
39
+ })
40
+ })
@@ -0,0 +1,22 @@
1
+ import type { ScopeAccess } from "../flows/index.js"
2
+ import { lensesOf } from "./review.js"
3
+
4
+ // The bundled workflow's default file access, keyed by scope full name — the
5
+ // name a `.gtdrc` `access:` entry addresses. Only planning and review scopes
6
+ // are restricted, and only on writes; no scope restricts reads. A step's own
7
+ // steering file is folded in per step, so it is not listed here. Build and fix
8
+ // scopes carry no entry: no project layout is assumed.
9
+ export const access = (
10
+ vars: Readonly<Record<string, string>>,
11
+ ): Readonly<Record<string, ScopeAccess>> => ({
12
+ design: { write: [] },
13
+ architecture: { write: [".gtd/REQUIREMENTS.md"] },
14
+ "architecture.decompose": { write: [".gtd/packages/**", ".gtd/ARCHITECTURE.md"] },
15
+ "build.review": { write: [".gtd/REVIEW.md"] },
16
+ // `{}` reopens what `build.review` restricted: the fixes edit code.
17
+ "build.review.fix.nits": {},
18
+ "build.review.fix.risks": {},
19
+ ...Object.fromEntries(
20
+ lensesOf(vars.qualityReviews ?? "").map((lens) => [`build.quality.${lens}`, { write: [] }]),
21
+ ),
22
+ })
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import { toRequest } from "../judges/index.js"
3
- import { retryQuestion } from "./health.js"
3
+ import { fastSuiteGuard, retryQuestion } from "./health.js"
4
+ import { renderText } from "./text.fixture.js"
4
5
 
5
6
  describe("retryQuestion", () => {
6
7
  it.each([true, false])("is sendable to jev (comparable=%s)", (comparable) => {
@@ -11,3 +12,22 @@ describe("retryQuestion", () => {
11
12
  ).toBeGreaterThanOrEqual(2)
12
13
  })
13
14
  })
15
+
16
+ describe("fastSuiteGuard", () => {
17
+ it("unset: names the setting and exits 0", () => {
18
+ const lines = renderText(fastSuiteGuard).join("\n")
19
+ expect(lines).toContain("fastTestCommand")
20
+ expect(lines).toContain("GTD_FASTTESTCOMMAND")
21
+ expect(lines).toContain("exit 0")
22
+ })
23
+
24
+ it("blank after trim counts as unset", () => {
25
+ expect(renderText(fastSuiteGuard, { env: { fastTestCommand: " \t " } })).toContain("exit 0")
26
+ })
27
+
28
+ it("set: only removes SETUP", () => {
29
+ const lines = renderText(fastSuiteGuard, { env: { fastTestCommand: "true" } })
30
+ expect(lines.join("\n")).not.toContain("exit 0")
31
+ expect(lines.join("\n")).toContain("rm -f")
32
+ })
33
+ })
@@ -1,3 +1,4 @@
1
+ import { dirname } from "node:path"
1
2
  import {
2
3
  answered,
3
4
  check,
@@ -5,6 +6,7 @@ import {
5
6
  human,
6
7
  judge,
7
8
  numeric,
9
+ quote,
8
10
  read,
9
11
  scope,
10
12
  vars,
@@ -13,12 +15,37 @@ import {
13
15
  import { describeEscalation, escalate, escalationExhausted, ESCALATION, FEEDBACK } from "./steps.js"
14
16
  import * as t from "./text.js"
15
17
 
18
+ /** Written while `fastTestCommand` is unset; its presence rests the process at the check. */
19
+ export const SETUP = ".gtd/SETUP.md"
20
+
21
+ const SETUP_TEXT =
22
+ "The bundled workflow needs the `fastTestCommand` setting: the fast suite (everything but e2e).\nSet it under `env:` in `.gtdrc` or as GTD_FASTTESTCOMMAND."
23
+
24
+ /** Shell lines for a check's preamble: unset (blank after trim) setting writes `SETUP` and skips the suite. */
25
+ export const fastSuiteGuard = (): string[] =>
26
+ (env.fastTestCommand ?? "").trim() === ""
27
+ ? [
28
+ `mkdir -p ${quote(dirname(SETUP))}`,
29
+ `printf '%s\\n' ${quote(SETUP_TEXT)} > ${quote(SETUP)}`,
30
+ `printf '%s\\n' ${quote(SETUP_TEXT)} >&2`,
31
+ "exit 0",
32
+ ]
33
+ : [`rm -f ${quote(SETUP)}`]
34
+
16
35
  /** Fix turns a run of red checks gets before it escalates. */
17
36
  export const FIX_CAP = 3
18
37
 
19
38
  /** Run the suite as step `name`; resolves `true` when it passed. A failure is in `.gtd/FEEDBACK.md`. */
20
- export const baseline = (name: string, label = "Checking the baseline"): Promise<boolean> =>
21
- check(name, env.testCommand ?? "", { report: FEEDBACK, label })
39
+ export const baseline = async (name: string, label = "Checking the baseline"): Promise<boolean> => {
40
+ for (;;) {
41
+ const green = await check(name, env.testCommand ?? "", {
42
+ report: FEEDBACK,
43
+ label,
44
+ preamble: fastSuiteGuard(),
45
+ })
46
+ if (read(SETUP) === undefined) return green
47
+ }
48
+ }
22
49
 
23
50
  /** How many escalation rounds a run of red checks has spent — reset once the suite goes green. */
24
51
  export interface EscalationCount {
@@ -81,6 +108,21 @@ export interface HealthOptions {
81
108
  readonly fixesSoFar?: number
82
109
  /** Escalation rounds shared with other callers in the same run of red checks. */
83
110
  readonly escalations?: EscalationCount
111
+ /** `fast` runs `fastTestCommand` (default `full`: `testCommand`). */
112
+ readonly suite?: "full" | "fast"
113
+ /** Paths removed on a green run. */
114
+ readonly sweepOnGreen?: readonly string[]
115
+ }
116
+
117
+ const runSuite = (options: HealthOptions): Promise<boolean> => {
118
+ const fast = options.suite === "fast"
119
+ return check("health.check", (fast ? env.fastTestCommand : env.testCommand) ?? "", {
120
+ report: FEEDBACK,
121
+ label: "Running checks",
122
+ // Swept only on green: an unresolved analysis survives every retry.
123
+ sweepOnGreen: [ESCALATION, ...(options.sweepOnGreen ?? [])],
124
+ ...(fast ? { preamble: fastSuiteGuard() } : {}),
125
+ })
84
126
  }
85
127
 
86
128
  /**
@@ -96,12 +138,9 @@ export const healthy = async (
96
138
  let fixes = options.fixesSoFar ?? 0
97
139
  let previous: string | undefined
98
140
  for (;;) {
99
- const green = await check("health.check", env.testCommand ?? "", {
100
- report: FEEDBACK,
101
- label: "Running checks",
102
- // Swept only on green: an unresolved analysis survives every retry.
103
- sweepOnGreen: [ESCALATION],
104
- })
141
+ const green = await runSuite(options)
142
+ // Unconfigured: straight back to the check, no fix turn or count.
143
+ if (options.suite === "fast" && read(SETUP) !== undefined) continue
105
144
  if (green) {
106
145
  escalations.rounds = 0
107
146
  return
@@ -0,0 +1,91 @@
1
+ import { afterEach, describe, expect, it } from "vitest"
2
+ import { installContext, type Change, type StepRequest } from "../flows/index.js"
3
+ import { declaredTests, packages, SCENARIO_PACKAGE } from "./packages.js"
4
+ import { fixtureContext } from "./text.fixture.js"
5
+
6
+ afterEach(() => installContext(undefined))
7
+
8
+ describe("declaredTests", () => {
9
+ it("parses unit and e2e lines of the Tests section", () => {
10
+ const text = [
11
+ "# Package",
12
+ "",
13
+ "## Tests",
14
+ "",
15
+ "- unit: `lib/a.test.ts`",
16
+ "- e2e: `spec/a.feature`",
17
+ ].join("\n")
18
+ expect(declaredTests(text)).toEqual([
19
+ { level: "unit", path: "lib/a.test.ts" },
20
+ { level: "e2e", path: "spec/a.feature" },
21
+ ])
22
+ })
23
+
24
+ it("ignores prose, malformed lines and other sections", () => {
25
+ const text = [
26
+ "## Tasks",
27
+ "- unit: `lib/not-here.test.ts`",
28
+ "## Tests",
29
+ "Some prose.",
30
+ "- unit: lib/unquoted.test.ts",
31
+ "- integration: `lib/x.test.ts`",
32
+ "- unit: `lib/ok.test.ts` extra",
33
+ "## Notes",
34
+ "- e2e: `spec/other.feature`",
35
+ ].join("\n")
36
+ expect(declaredTests(text)).toEqual([{ level: "unit", path: "lib/ok.test.ts" }])
37
+ })
38
+
39
+ it("returns nothing without a Tests section", () => {
40
+ expect(declaredTests("# Chore\n\n## Tasks\n- do it\n")).toEqual([])
41
+ })
42
+ })
43
+
44
+ describe("packages scenarios", () => {
45
+ it("holds only package 0's own paths, not what later packages add", async () => {
46
+ const queue = [SCENARIO_PACKAGE, ".gtd/packages/01-impl.md"]
47
+ const touches: { at: number; change: Change }[] = []
48
+ let steps = 0
49
+ const touch = (path: string, status: Change["status"]): void => {
50
+ touches.push({ at: steps, change: { path, status, before: undefined, after: "x" } })
51
+ }
52
+ const effects: Record<string, () => void> = {
53
+ building: () => {
54
+ if (queue[0] === SCENARIO_PACKAGE) {
55
+ touch("tests/e2e/a.feature", "added")
56
+ touch(".gtd/packages/00-e2e-scenarios.md", "modified")
57
+ } else {
58
+ touch("src/impl.ts", "added")
59
+ touch("tests/e2e/b.feature", "added")
60
+ touch("tests/e2e/a.feature", "modified")
61
+ }
62
+ },
63
+ closing: () => void queue.shift(),
64
+ }
65
+ installContext(
66
+ fixtureContext(
67
+ {},
68
+ {
69
+ step: (request: StepRequest) => {
70
+ steps++
71
+ if (request.kind !== "restart") effects[request.name]?.()
72
+ return Promise.resolve()
73
+ },
74
+ pushScope: () => undefined,
75
+ popScope: () => undefined,
76
+ refuse: (message: string): never => {
77
+ throw new Error(message)
78
+ },
79
+ glob: () => [...queue],
80
+ changesSince: (hash: string): readonly Change[] =>
81
+ touches.filter((t) => t.at >= Number(hash.slice(1))).map((t) => t.change),
82
+ head: () => `c${steps}`,
83
+ },
84
+ ),
85
+ )
86
+ const plan = await packages()
87
+ expect(plan.ranges.map((r) => r.pkg)).toEqual([SCENARIO_PACKAGE, ".gtd/packages/01-impl.md"])
88
+ expect(plan.scenarios.added).toEqual(["tests/e2e/a.feature"])
89
+ expect(plan.scenarios.changed).toEqual([])
90
+ })
91
+ })