@pmelab/gtd 18.0.0 → 18.1.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/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -0
- package/claude/hooks/access.ts +58 -0
- package/claude/hooks/drive.ts +42 -4
- package/claude/hooks/register.tsx +12 -0
- package/dist/gtd.bundle.mjs +442 -209
- package/package.json +1 -1
- package/schema.json +22 -0
- package/src/flows/runtime.ts +20 -0
- package/src/workflows/access.test.ts +41 -0
- package/src/workflows/access.ts +23 -0
- package/src/workflows/prose.ts +17 -0
- package/src/workflows/text.fixture.ts +26 -0
- package/src/workflows/text.test.ts +41 -0
- package/src/workflows/text.ts +8 -2
- package/src/workflows/unified.ts +1 -0
package/package.json
CHANGED
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."
|
package/src/flows/runtime.ts
CHANGED
|
@@ -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>> =>
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
"packages.item.spec": { write: [".gtd/SPEC_FEEDBACK.md"] },
|
|
16
|
+
"build.review": { write: [".gtd/REVIEW.md"] },
|
|
17
|
+
"build.review.fix.nits": {},
|
|
18
|
+
"build.review.fix.risks": {},
|
|
19
|
+
"build.quality.correctness": { write: [] },
|
|
20
|
+
"build.quality.owasp-security": { write: [] },
|
|
21
|
+
"build.quality.ponytail-review": { write: [] },
|
|
22
|
+
"build.quality.test-audit": { write: [] },
|
|
23
|
+
"build.quality.conventions": { write: [] },
|
|
24
|
+
"build.quality.spec-challenge": { write: [] },
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("keys one lens scope per qualityReviews entry", () => {
|
|
29
|
+
const custom = access({ ...defaults, qualityReviews: " my-lens ,, correctness" })
|
|
30
|
+
expect(
|
|
31
|
+
Object.keys(custom)
|
|
32
|
+
.filter((k) => k.startsWith("build.quality."))
|
|
33
|
+
.sort(),
|
|
34
|
+
).toEqual(["build.quality.correctness", "build.quality.my-lens"])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("keys only scopes that run a turn (every key is a skills key)", () => {
|
|
38
|
+
const known = Object.keys(unified.skills(defaults))
|
|
39
|
+
for (const key of Object.keys(bundled)) expect(known).toContain(key)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
"packages.item.spec": { write: [".gtd/SPEC_FEEDBACK.md"] },
|
|
16
|
+
"build.review": { write: [".gtd/REVIEW.md"] },
|
|
17
|
+
// `{}` reopens what `build.review` restricted: the fixes edit code.
|
|
18
|
+
"build.review.fix.nits": {},
|
|
19
|
+
"build.review.fix.risks": {},
|
|
20
|
+
...Object.fromEntries(
|
|
21
|
+
lensesOf(vars.qualityReviews ?? "").map((lens) => [`build.quality.${lens}`, { write: [] }]),
|
|
22
|
+
),
|
|
23
|
+
})
|
package/src/workflows/prose.ts
CHANGED
|
@@ -12,6 +12,23 @@ export const skillsPreamble = `- Load whatever's listed here that your harness a
|
|
|
12
12
|
- Never let a loaded skill turn this turn interactive — answer nothing,
|
|
13
13
|
ask nothing; this runs unattended, with no one at a keyboard`
|
|
14
14
|
|
|
15
|
+
/** One bullet per restricted side; empty when neither is. An empty glob list reads "nothing". */
|
|
16
|
+
export const accessPreamble = (access: {
|
|
17
|
+
readonly read: readonly string[] | null
|
|
18
|
+
readonly write: readonly string[] | null
|
|
19
|
+
}): string => {
|
|
20
|
+
const globs = (list: readonly string[]): string =>
|
|
21
|
+
list.length === 0 ? "nothing" : list.join(", ")
|
|
22
|
+
return [
|
|
23
|
+
...(access.read === null ? [] : [`- This turn may read only: ${globs(access.read)}`]),
|
|
24
|
+
...(access.write === null
|
|
25
|
+
? []
|
|
26
|
+
: [
|
|
27
|
+
`- This turn may write only: ${globs(access.write)} — anything else is refused when the turn lands`,
|
|
28
|
+
]),
|
|
29
|
+
].join("\n")
|
|
30
|
+
}
|
|
31
|
+
|
|
15
32
|
export const styleBlock = `- A deliverable, not a chat reply — size follows the work; cut padding
|
|
16
33
|
- Lead with the answer; never circle back to restate it
|
|
17
34
|
- Flat, commanding sentences — commit to the claim, never hedge
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
installContext,
|
|
3
3
|
type CodeThreadInfo,
|
|
4
|
+
type AccessDef,
|
|
4
5
|
type FlowContext,
|
|
6
|
+
type ScopeAccess,
|
|
5
7
|
type StepRequest,
|
|
6
8
|
} from "../flows/index.js"
|
|
9
|
+
import { foldAccess } from "../replay/index.js"
|
|
10
|
+
import { access as bundledAccess } from "./access.js"
|
|
7
11
|
import { skills as bundledSkills } from "./skills.js"
|
|
8
12
|
import { defaults, envDefaults } from "./vars.js"
|
|
9
13
|
|
|
@@ -18,6 +22,8 @@ export interface TextContext {
|
|
|
18
22
|
readonly scope?: string
|
|
19
23
|
/** Stands in for `.gtdrc` `skills:`, keyed by scope full name. */
|
|
20
24
|
readonly skills?: Readonly<Record<string, readonly string[]>>
|
|
25
|
+
/** Stands in for `.gtdrc` `access:`, keyed by scope full name. */
|
|
26
|
+
readonly access?: Readonly<Record<string, ScopeAccess>>
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
const unavailable = (): never => {
|
|
@@ -39,6 +45,25 @@ const skillsForOf =
|
|
|
39
45
|
return []
|
|
40
46
|
}
|
|
41
47
|
|
|
48
|
+
/** The same walk for access, folded the way `Replay.ts` folds it. */
|
|
49
|
+
const accessForOf =
|
|
50
|
+
(context: TextContext) =>
|
|
51
|
+
(localName: string, file?: string): AccessDef => {
|
|
52
|
+
const full = [context.scope, localName].filter((part) => part !== undefined && part !== "")
|
|
53
|
+
const parts = full.join(".").split(".").slice(0, -1)
|
|
54
|
+
const bundled = bundledAccess({ ...defaults, ...context.vars })
|
|
55
|
+
let hit: ScopeAccess | undefined
|
|
56
|
+
for (let n = parts.length; n >= 0 && hit === undefined; n--) {
|
|
57
|
+
const prefix = parts.slice(0, n).join(".")
|
|
58
|
+
hit = context.access?.[prefix] ?? bundled[prefix]
|
|
59
|
+
}
|
|
60
|
+
return foldAccess(
|
|
61
|
+
hit,
|
|
62
|
+
file,
|
|
63
|
+
(context.codeThreads ?? []).filter((t) => t.waitingOn === "agent").map((t) => t.path),
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
42
67
|
/** The fixed replay context texts render against; steps, refusals and scopes are unavailable unless overridden. */
|
|
43
68
|
export const fixtureContext = (
|
|
44
69
|
context: TextContext = {},
|
|
@@ -63,6 +88,7 @@ export const fixtureContext = (
|
|
|
63
88
|
head: () => context.head ?? "",
|
|
64
89
|
start: () => context.start ?? "",
|
|
65
90
|
skillsFor: skillsForOf(context),
|
|
91
|
+
accessFor: accessForOf(context),
|
|
66
92
|
...overrides,
|
|
67
93
|
})
|
|
68
94
|
|
|
@@ -56,6 +56,47 @@ describe("agentWithSkills", () => {
|
|
|
56
56
|
})
|
|
57
57
|
})
|
|
58
58
|
|
|
59
|
+
describe("agentWithSkills access preamble", () => {
|
|
60
|
+
const capture = (
|
|
61
|
+
context: Parameters<typeof captureStep>[1],
|
|
62
|
+
options: Parameters<typeof agentWithSkills>[2] = {},
|
|
63
|
+
) => captureStep(() => agentWithSkills("name", "do-the-work", options), context)
|
|
64
|
+
|
|
65
|
+
it("names each restricted side, after the skills preamble and before the prompt", async () => {
|
|
66
|
+
const request = await capture({
|
|
67
|
+
scope: "a",
|
|
68
|
+
skills: { a: ["testing"] },
|
|
69
|
+
access: { a: { read: ["docs/**"], write: ["out/**"] } },
|
|
70
|
+
})
|
|
71
|
+
if (request.kind !== "agent") throw new Error("unreachable")
|
|
72
|
+
const prompt = request.prompt
|
|
73
|
+
expect(prompt).toContain("- This turn may read only: docs/**")
|
|
74
|
+
expect(prompt).toContain(
|
|
75
|
+
"- This turn may write only: out/** — anything else is refused when the turn lands",
|
|
76
|
+
)
|
|
77
|
+
expect(prompt.indexOf("missing one: testing")).toBeLessThan(
|
|
78
|
+
prompt.indexOf("This turn may read only"),
|
|
79
|
+
)
|
|
80
|
+
expect(prompt.endsWith("\n\ndo-the-work")).toBe(true)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("folds the steering file into the named globs", async () => {
|
|
84
|
+
const request = await capture(
|
|
85
|
+
{ scope: "a", access: { a: { write: [] } } },
|
|
86
|
+
{ file: ".gtd/NOTES.md" },
|
|
87
|
+
)
|
|
88
|
+
if (request.kind !== "agent") throw new Error("unreachable")
|
|
89
|
+
expect(request.prompt).toContain("may write only: .gtd/NOTES.md —")
|
|
90
|
+
expect(request.prompt).not.toContain("may read only")
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("leaves the prompt byte-identical when both sides are unrestricted", async () => {
|
|
94
|
+
const request = await capture({ scope: "a", skills: { a: [] }, access: { a: {} } })
|
|
95
|
+
if (request.kind !== "agent") throw new Error("unreachable")
|
|
96
|
+
expect(request.prompt).toBe("do-the-work")
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
59
100
|
describe("the shared open-question instruction", () => {
|
|
60
101
|
it("leaves the option count to the agent, floored at two real options", () => {
|
|
61
102
|
const prompt = renderText(() => designTriagePrompt("base"))
|
package/src/workflows/text.ts
CHANGED
|
@@ -3,12 +3,14 @@ import {
|
|
|
3
3
|
codeThreads,
|
|
4
4
|
env,
|
|
5
5
|
head,
|
|
6
|
+
accessFor,
|
|
6
7
|
skillsFor,
|
|
7
8
|
start,
|
|
8
9
|
type AgentOptions,
|
|
9
10
|
type SummaryContext,
|
|
10
11
|
} from "../flows/index.js"
|
|
11
12
|
import {
|
|
13
|
+
accessPreamble,
|
|
12
14
|
skillsPreamble,
|
|
13
15
|
styleBlock,
|
|
14
16
|
styleFormatContract,
|
|
@@ -37,12 +39,16 @@ export const withSkills = (skills: string | undefined, prompt: string): string =
|
|
|
37
39
|
return `${skillsPreamble.replaceAll("{skills}", skills)}\n\n${prompt}`
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
/** An `agent()` step whose prompt opens with the preamble naming its scope's skills. */
|
|
42
|
+
/** An `agent()` step whose prompt opens with the preamble naming its scope's skills, then its restricted file access. */
|
|
41
43
|
export const agentWithSkills = (
|
|
42
44
|
name: string,
|
|
43
45
|
prompt: string,
|
|
44
46
|
options: AgentOptions = {},
|
|
45
|
-
): Promise<void> =>
|
|
47
|
+
): Promise<void> => {
|
|
48
|
+
const access = accessPreamble(accessFor(name, options.file))
|
|
49
|
+
const body = access === "" ? prompt : `${access}\n\n${prompt}`
|
|
50
|
+
return agent(name, withSkills(skillsFor(name).join(", "), body), options)
|
|
51
|
+
}
|
|
46
52
|
|
|
47
53
|
export const unwindFailure = (commit: string): string =>
|
|
48
54
|
`gtd could not unwind ${commit} out of your working tree.`
|
package/src/workflows/unified.ts
CHANGED
|
@@ -33,6 +33,7 @@ import * as t from "./text.js"
|
|
|
33
33
|
export { threads, type ThreadInfo } from "../flows/index.js"
|
|
34
34
|
export { defaults, envDefaults } from "./vars.js"
|
|
35
35
|
export { skills } from "./skills.js"
|
|
36
|
+
export { access } from "./access.js"
|
|
36
37
|
export { agentWithSkills } from "./text.js"
|
|
37
38
|
export * from "./steps.js"
|
|
38
39
|
export * from "./health.js"
|