@erclx/canon 5.42.0 → 5.43.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/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/SKILL.md +3 -3
- package/claude/skills/ci-workflow/REQUIREMENT.md +2 -1
- package/claude/skills/ci-workflow/SKILL.md +12 -2
- package/claude/skills/ci-workflow/references/workflows.md +12 -9
- package/claude/skills/create-rule/SKILL.md +1 -0
- package/claude/skills/decision-escalate/SKILL.md +1 -2
- package/claude/skills/document-health/references/axes.md +2 -1
- package/claude/skills/draft-doc/references/wiki-kind.md +1 -1
- package/claude/skills/draft-doc/references/wiki.md +1 -1
- package/claude/skills/git-commit/references/commit.md +1 -5
- package/claude/skills/git-pr/references/pr.md +1 -0
- package/claude/skills/plan-feature/SKILL.md +1 -1
- package/claude/skills/plan-feature/references/plan.md +1 -1
- package/claude/skills/pr-evidence/references/evidence.md +1 -1
- package/claude/skills/role-orchestrator/SKILL.md +2 -0
- package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -0
- package/claude/skills/role-planner/REQUIREMENT.md +1 -1
- package/claude/skills/role-planner/SKILL.md +3 -3
- package/claude/skills/role-worker/REQUIREMENT.md +2 -0
- package/claude/skills/role-worker/SKILL.md +26 -2
- package/claude/skills/search-craft/REQUIREMENT.md +1 -1
- package/claude/skills/search-craft/SKILL.md +2 -2
- package/docs/agents/audits.md +1 -0
- package/docs/agents/migrate.md +11 -8
- package/docs/target/migrations.md +11 -0
- package/governance/detect.toml +4 -0
- package/governance/rules/canon/context.md +2 -1
- package/governance/rules/canon/plan.md +3 -5
- package/governance/rules/canon/scratch.md +3 -1
- package/governance/rules/canon/task-scope.md +2 -1
- package/governance/rules/canon/tasks.md +2 -1
- package/governance/rules/canon/worktrees.md +5 -2
- package/governance/rules/claude/output.md +7 -4
- package/governance/rules/claude/skill.md +6 -4
- package/governance/rules/tooling/ci-workflow.md +6 -39
- package/governance/rules/ui/design-reference.md +37 -0
- package/governance/rules/writing/markdown.md +2 -1
- package/package.json +6 -6
- package/src/audits/catalog.ts +42 -0
- package/src/claude/settings.ts +8 -4
- package/src/commands/claude.ts +11 -1
- package/src/commands/gov.ts +198 -0
- package/src/commands/migrate.ts +121 -2
- package/src/commands/pr.ts +4 -77
- package/src/commands/tooling.ts +3 -3
- package/src/commands/worktrees.ts +49 -0
- package/src/gate/stages.ts +15 -0
- package/src/gov/audit.ts +440 -0
- package/src/listeners/belongs.ts +21 -0
- package/src/listeners/machine.ts +93 -0
- package/src/listeners/parse.ts +41 -0
- package/src/migrate/session-defaults.ts +138 -0
- package/src/pr/local.ts +4 -69
- package/src/sync/check.ts +15 -0
- package/src/tooling/stamp.ts +56 -12
- package/src/worktrees/stop.ts +92 -0
- package/tooling/base/configs/.github/workflows/phase-label-gate.yml +6 -2
- package/tooling/base/configs/commitlint.config.js +0 -20
- package/tooling/base/reference.md +1 -1
- package/tooling/base/seeds/.github/workflows/verify.yml +4 -1
- package/tooling/claude/reference.md +13 -7
- package/tooling/claude/seeds/.claude/settings.json +4 -0
- package/tooling/claude/user/settings.template.json +0 -4
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +5 -5
- package/tooling/public/configs/.github/dependabot.yml +12 -0
- package/tooling/public/configs/.github/workflows/release-please.yml +30 -0
- package/tooling/public/manifest.toml +8 -0
- package/tooling/public/reference.md +53 -0
- package/tooling/public/seeds/.github/ISSUE_TEMPLATE/bug.yml +48 -0
- package/tooling/public/seeds/.github/ISSUE_TEMPLATE/config.yml +5 -0
- package/tooling/public/seeds/.github/ISSUE_TEMPLATE/feature.yml +16 -0
- package/tooling/public/seeds/.release-please-manifest.json +3 -0
- package/tooling/public/seeds/CONTRIBUTING.md +50 -0
- package/tooling/public/seeds/LICENSE +21 -0
- package/tooling/public/seeds/SECURITY.md +13 -0
- package/tooling/public/seeds/release-please-config.json +12 -0
- package/tooling/shell/configs/.github/workflows/shell.yml +4 -1
- package/tooling/web/configs/.github/workflows/readme-screenshot.yml +6 -3
- package/tooling/web/configs/.github/workflows/verify.yml +15 -12
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adds the session defaults the seeded `.claude/settings.json` carries to a
|
|
3
|
+
* target that was seeded before them.
|
|
4
|
+
*
|
|
5
|
+
* `canon claude init` copies a seed only when the file is absent, so a target
|
|
6
|
+
* holding its own settings never sees a later seed change. This verb adds each
|
|
7
|
+
* default only where the key is absent: a deny entry is never removed, and a
|
|
8
|
+
* person's `autoMemoryEnabled: true` is never overwritten.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import {
|
|
14
|
+
readSettings,
|
|
15
|
+
type SettingsKey,
|
|
16
|
+
type SettingsRecord,
|
|
17
|
+
serializeSettings,
|
|
18
|
+
writeSettings,
|
|
19
|
+
} from '@/claude/settings'
|
|
20
|
+
|
|
21
|
+
/** Both names, since `Task` is the tool's name before Claude Code renamed it. */
|
|
22
|
+
export const DENIED_TOOLS: readonly string[] = ['Agent', 'Task']
|
|
23
|
+
|
|
24
|
+
export interface SessionDefaultsPlan {
|
|
25
|
+
readonly keys: readonly SettingsKey[]
|
|
26
|
+
readonly next: SettingsRecord
|
|
27
|
+
readonly changed: boolean
|
|
28
|
+
/**
|
|
29
|
+
* True when the file holds no default at all. A file holding either one has
|
|
30
|
+
* been through the seed or this verb, so a missing deny there was removed
|
|
31
|
+
* on purpose, which is how an operator opts back into subagents.
|
|
32
|
+
*/
|
|
33
|
+
readonly predates: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type SessionDefaultsRead =
|
|
37
|
+
| { readonly kind: 'refused'; readonly reason: string }
|
|
38
|
+
| { readonly kind: 'absent'; readonly path: string }
|
|
39
|
+
| {
|
|
40
|
+
readonly kind: 'planned'
|
|
41
|
+
readonly path: string
|
|
42
|
+
readonly indent: string
|
|
43
|
+
readonly plan: SessionDefaultsPlan
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function settingsPath(root: string): string {
|
|
47
|
+
return join(root, '.claude', 'settings.json')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isRecord(value: unknown): value is SettingsRecord {
|
|
51
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Appends the denied tools a list lacks, keeping the list's own order, so the
|
|
56
|
+
* diff a person reads is the added entries alone.
|
|
57
|
+
*/
|
|
58
|
+
export function planSessionDefaults(
|
|
59
|
+
current: SettingsRecord,
|
|
60
|
+
): SessionDefaultsPlan {
|
|
61
|
+
const next: SettingsRecord = { ...current }
|
|
62
|
+
const permissions = isRecord(current.permissions) ? current.permissions : {}
|
|
63
|
+
const deny = Array.isArray(permissions.deny) ? permissions.deny : []
|
|
64
|
+
|
|
65
|
+
const missing = DENIED_TOOLS.filter((tool) => !deny.includes(tool))
|
|
66
|
+
if (missing.length > 0) {
|
|
67
|
+
next.permissions = { ...permissions, deny: [...deny, ...missing] }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const memoryAbsent = !('autoMemoryEnabled' in current)
|
|
71
|
+
if (memoryAbsent) next.autoMemoryEnabled = false
|
|
72
|
+
|
|
73
|
+
const keys: SettingsKey[] = [
|
|
74
|
+
{ label: 'permissions.deny', changed: missing.length > 0 },
|
|
75
|
+
{ label: 'autoMemoryEnabled', changed: memoryAbsent },
|
|
76
|
+
]
|
|
77
|
+
return {
|
|
78
|
+
keys,
|
|
79
|
+
next,
|
|
80
|
+
changed: keys.some((key) => key.changed),
|
|
81
|
+
predates: memoryAbsent && missing.length === DENIED_TOOLS.length,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Refuses rather than plans whenever a write could replace something the
|
|
87
|
+
* person owns: the toolkit's own settings, a file `JSON.parse` cannot read,
|
|
88
|
+
* or a `permissions` or `deny` value of an unexpected shape.
|
|
89
|
+
*/
|
|
90
|
+
export async function readSessionDefaults(
|
|
91
|
+
root: string,
|
|
92
|
+
protectedRoot: string,
|
|
93
|
+
): Promise<SessionDefaultsRead> {
|
|
94
|
+
if (root === protectedRoot) {
|
|
95
|
+
return {
|
|
96
|
+
kind: 'refused',
|
|
97
|
+
reason:
|
|
98
|
+
'Cannot run against toolkit root. Files here are the source of truth.',
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const path = settingsPath(root)
|
|
103
|
+
if (!existsSync(path)) return { kind: 'absent', path }
|
|
104
|
+
|
|
105
|
+
let read: Awaited<ReturnType<typeof readSettings>>
|
|
106
|
+
try {
|
|
107
|
+
read = await readSettings(path)
|
|
108
|
+
} catch (error) {
|
|
109
|
+
return {
|
|
110
|
+
kind: 'refused',
|
|
111
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { permissions } = read.value
|
|
116
|
+
if (permissions !== undefined && !isRecord(permissions)) {
|
|
117
|
+
return { kind: 'refused', reason: `${path}: permissions is not an object` }
|
|
118
|
+
}
|
|
119
|
+
if (permissions?.deny !== undefined && !Array.isArray(permissions.deny)) {
|
|
120
|
+
return {
|
|
121
|
+
kind: 'refused',
|
|
122
|
+
reason: `${path}: permissions.deny is not a list`,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
kind: 'planned',
|
|
128
|
+
path,
|
|
129
|
+
indent: read.indent,
|
|
130
|
+
plan: planSessionDefaults(read.value),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function applySessionDefaults(
|
|
135
|
+
read: Extract<SessionDefaultsRead, { kind: 'planned' }>,
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
await writeSettings(read.path, serializeSettings(read.plan.next, read.indent))
|
|
138
|
+
}
|
package/src/pr/local.ts
CHANGED
|
@@ -1,26 +1,11 @@
|
|
|
1
|
+
import { belongsTo } from '@/listeners/belongs'
|
|
2
|
+
import type { ListenerReader } from '@/listeners/machine'
|
|
1
3
|
import { readAddresses } from '@/pr/evidence'
|
|
2
4
|
|
|
3
|
-
/**
|
|
4
|
-
* The folder linked worktrees sit under. A listener there belongs to another
|
|
5
|
-
* branch even though its cwd sits inside the main checkout's toplevel.
|
|
6
|
-
*/
|
|
7
|
-
const LINKED_WORKTREES = '.claude/worktrees'
|
|
8
|
-
|
|
9
|
-
/** The state `/proc/net/tcp` writes for a socket in LISTEN. */
|
|
10
|
-
const PROC_LISTEN_STATE = '0A'
|
|
11
|
-
|
|
12
5
|
export type LocalRefusal = 'no-server' | 'no-listener-reader'
|
|
13
6
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
readonly pid: number
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** The machine reads a detection makes, injected so it can be driven without binding a socket. */
|
|
20
|
-
export interface LocalRunner {
|
|
21
|
-
/** Every TCP socket in LISTEN with its owning pid, or undefined when this machine offers no way to read them. */
|
|
22
|
-
listListeners(): Promise<readonly Listener[] | undefined>
|
|
23
|
-
cwdOf(pid: number): Promise<string | undefined>
|
|
7
|
+
/** The listener reads a detection makes plus an HTML probe, injected so it can be driven without binding a socket. */
|
|
8
|
+
export interface LocalRunner extends ListenerReader {
|
|
24
9
|
/** Whether the port serves an HTML page at its root inside the runner's timeout. */
|
|
25
10
|
probe(port: number): Promise<boolean>
|
|
26
11
|
}
|
|
@@ -29,22 +14,6 @@ export type LocalResult =
|
|
|
29
14
|
| { readonly kind: 'found'; readonly port: number; readonly url: string }
|
|
30
15
|
| { readonly kind: 'refused'; readonly reason: LocalRefusal }
|
|
31
16
|
|
|
32
|
-
function isInside(path: string, root: string): boolean {
|
|
33
|
-
return path === root || path.startsWith(`${root}/`)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Whether a process running in `cwd` serves this worktree. A tool that
|
|
38
|
-
* changes into a subfolder before listening still counts, while a sibling
|
|
39
|
-
* worktree nested under the main checkout's `.claude/worktrees/` does not.
|
|
40
|
-
*/
|
|
41
|
-
function belongsTo(cwd: string, worktreeRoot: string): boolean {
|
|
42
|
-
return (
|
|
43
|
-
isInside(cwd, worktreeRoot) &&
|
|
44
|
-
!isInside(cwd, `${worktreeRoot}/${LINKED_WORKTREES}`)
|
|
45
|
-
)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
17
|
/**
|
|
49
18
|
* Finds the server this worktree is running: the lowest port whose listening
|
|
50
19
|
* process runs inside the worktree and answers an HTTP probe. A port rather
|
|
@@ -76,40 +45,6 @@ export async function findLocalServer(
|
|
|
76
45
|
return { kind: 'refused', reason: 'no-server' }
|
|
77
46
|
}
|
|
78
47
|
|
|
79
|
-
/** Reads `lsof -nP -iTCP -sTCP:LISTEN -Fpn` output, where a `p` line opens a process and each `n` line names one of its sockets. */
|
|
80
|
-
export function parseLsofListeners(text: string): Listener[] {
|
|
81
|
-
const listeners: Listener[] = []
|
|
82
|
-
let pid: number | undefined
|
|
83
|
-
for (const line of text.split('\n')) {
|
|
84
|
-
if (line.startsWith('p')) pid = Number(line.slice(1))
|
|
85
|
-
if (!line.startsWith('n') || pid === undefined) continue
|
|
86
|
-
const port = Number(line.slice(line.lastIndexOf(':') + 1))
|
|
87
|
-
if (Number.isInteger(port) && port > 0) listeners.push({ pid, port })
|
|
88
|
-
}
|
|
89
|
-
return listeners
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export interface ProcSocket {
|
|
93
|
-
readonly port: number
|
|
94
|
-
readonly inode: string
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Reads `/proc/net/tcp` or `tcp6`, keeping each listening socket's port and inode for matching against `/proc/<pid>/fd`. */
|
|
98
|
-
export function parseProcNetTcp(text: string): ProcSocket[] {
|
|
99
|
-
const sockets: ProcSocket[] = []
|
|
100
|
-
for (const line of text.split('\n').slice(1)) {
|
|
101
|
-
const fields = line.trim().split(/\s+/)
|
|
102
|
-
const local = fields[1]
|
|
103
|
-
const state = fields[3]
|
|
104
|
-
const inode = fields[9]
|
|
105
|
-
if (local === undefined || inode === undefined) continue
|
|
106
|
-
if (state !== PROC_LISTEN_STATE) continue
|
|
107
|
-
const port = Number.parseInt(local.slice(local.lastIndexOf(':') + 1), 16)
|
|
108
|
-
if (Number.isInteger(port) && port > 0) sockets.push({ port, inode })
|
|
109
|
-
}
|
|
110
|
-
return sockets
|
|
111
|
-
}
|
|
112
|
-
|
|
113
48
|
/**
|
|
114
49
|
* Drops the address line of a marked body, hosted and local segments alike,
|
|
115
50
|
* since the close that runs this also deletes the branch's preview
|
package/src/sync/check.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
resolveChain,
|
|
39
39
|
} from '@/tooling/manifest'
|
|
40
40
|
import { scan } from '@/tooling/scan'
|
|
41
|
+
import { isAddOn } from '@/tooling/stamp'
|
|
41
42
|
import { readSkew, type SkewReport } from '@/version/skew'
|
|
42
43
|
|
|
43
44
|
/**
|
|
@@ -302,6 +303,20 @@ export function buildToolingReport(
|
|
|
302
303
|
function resolveRecorded(
|
|
303
304
|
toolkitRoot: string,
|
|
304
305
|
recorded: readonly string[],
|
|
306
|
+
): Manifest[] {
|
|
307
|
+
const addOns = recorded
|
|
308
|
+
.map((name) => loadManifest(toolkitRoot, name))
|
|
309
|
+
.filter((manifest) => manifest !== undefined)
|
|
310
|
+
.filter(isAddOn)
|
|
311
|
+
const addOnNames = new Set(addOns.map((manifest) => manifest.name))
|
|
312
|
+
const core = recorded.filter((name) => !addOnNames.has(name))
|
|
313
|
+
|
|
314
|
+
return [...resolveCore(toolkitRoot, core), ...addOns]
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function resolveCore(
|
|
318
|
+
toolkitRoot: string,
|
|
319
|
+
recorded: readonly string[],
|
|
305
320
|
): Manifest[] {
|
|
306
321
|
const loaded = recorded
|
|
307
322
|
.map((name) => loadManifest(toolkitRoot, name))
|
package/src/tooling/stamp.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import { writeChainStamp } from '@/sync/stamp'
|
|
4
|
-
import { isWholeStack, type Manifest } from '@/tooling/manifest'
|
|
3
|
+
import { readStamp, stampedChain, writeChainStamp } from '@/sync/stamp'
|
|
4
|
+
import { isWholeStack, loadManifest, type Manifest } from '@/tooling/manifest'
|
|
5
5
|
import { readPackage } from '@/tooling/package'
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -17,7 +17,9 @@ export function isWorkspaceRoot(target: string): boolean {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* Records the chain an install resolved, and returns
|
|
20
|
+
* Records the chain an install resolved, and returns the names it wrote, or
|
|
21
|
+
* nothing when it wrote none. The names are the merged list, which can carry
|
|
22
|
+
* stacks an earlier sync recorded. A
|
|
21
23
|
* workspace root is the single refusal: one chain recorded there would be a
|
|
22
24
|
* guess at what its packages hold, and the report reading tooling as unmeasured
|
|
23
25
|
* is the true answer rather than a clean one.
|
|
@@ -27,16 +29,58 @@ export async function recordToolingChain(
|
|
|
27
29
|
target: string,
|
|
28
30
|
chain: readonly Manifest[],
|
|
29
31
|
now: Date,
|
|
30
|
-
): Promise<
|
|
31
|
-
if (isWorkspaceRoot(target)) return
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
now,
|
|
32
|
+
): Promise<string[] | undefined> {
|
|
33
|
+
if (isWorkspaceRoot(target)) return undefined
|
|
34
|
+
|
|
35
|
+
const merged = mergeAddOns(
|
|
36
|
+
toolkitRoot,
|
|
37
|
+
stampedChain(readStamp(target), 'tooling'),
|
|
38
|
+
chain,
|
|
38
39
|
)
|
|
39
|
-
|
|
40
|
+
await writeChainStamp(target, { domain: 'tooling', toolkitRoot }, merged, now)
|
|
41
|
+
return merged
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* An add-on stands alone beside a core chain, so syncing one must not displace
|
|
46
|
+
* the chain it layers onto. Base extends nothing and still heads a core chain.
|
|
47
|
+
*/
|
|
48
|
+
export function isAddOn(manifest: Manifest | undefined): boolean {
|
|
49
|
+
return (
|
|
50
|
+
manifest !== undefined &&
|
|
51
|
+
manifest.parent === undefined &&
|
|
52
|
+
manifest.name !== 'base'
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Merges what this sync resolved into what an earlier one recorded. The core
|
|
58
|
+
* chain is the one just synced, or the recorded one when only add-ons synced.
|
|
59
|
+
* Add-ons accumulate after it, so a later stackless report measures them all.
|
|
60
|
+
*/
|
|
61
|
+
function mergeAddOns(
|
|
62
|
+
toolkitRoot: string,
|
|
63
|
+
recorded: readonly string[],
|
|
64
|
+
chain: readonly Manifest[],
|
|
65
|
+
): string[] {
|
|
66
|
+
const synced = chain.filter(isWholeStack)
|
|
67
|
+
const syncedCore = synced.filter((m) => !isAddOn(m)).map((m) => m.name)
|
|
68
|
+
const syncedAddOns = synced.filter(isAddOn).map((m) => m.name)
|
|
69
|
+
|
|
70
|
+
const recordedManifests = recorded.map((name) => ({
|
|
71
|
+
name,
|
|
72
|
+
manifest: loadManifest(toolkitRoot, name),
|
|
73
|
+
}))
|
|
74
|
+
const recordedCore = recordedManifests
|
|
75
|
+
.filter(({ manifest }) => !isAddOn(manifest))
|
|
76
|
+
.map(({ name }) => name)
|
|
77
|
+
const recordedAddOns = recordedManifests
|
|
78
|
+
.filter(({ manifest }) => isAddOn(manifest))
|
|
79
|
+
.map(({ name }) => name)
|
|
80
|
+
|
|
81
|
+
const core = syncedCore.length > 0 ? syncedCore : recordedCore
|
|
82
|
+
const addOns = [...new Set([...recordedAddOns, ...syncedAddOns])]
|
|
83
|
+
return [...core, ...addOns]
|
|
40
84
|
}
|
|
41
85
|
|
|
42
86
|
/**
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises'
|
|
2
|
+
import { belongsTo } from '@/listeners/belongs'
|
|
3
|
+
import type { ListenerReader } from '@/listeners/machine'
|
|
4
|
+
|
|
5
|
+
/** What a stop took, or would take on a dry run, from one worktree. */
|
|
6
|
+
export interface StoppedServer {
|
|
7
|
+
readonly path: string
|
|
8
|
+
readonly pids: readonly number[]
|
|
9
|
+
readonly ports: readonly number[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `unread` is a machine with no way to list sockets, which a reader must tell
|
|
14
|
+
* apart from `read` with nothing stopped, since the first says nothing about
|
|
15
|
+
* what is still listening.
|
|
16
|
+
*/
|
|
17
|
+
export type StopReport =
|
|
18
|
+
| { readonly kind: 'read'; readonly stopped: readonly StoppedServer[] }
|
|
19
|
+
| { readonly kind: 'unread' }
|
|
20
|
+
|
|
21
|
+
export interface StopOptions {
|
|
22
|
+
readonly reader: ListenerReader
|
|
23
|
+
/** Sends SIGTERM and says whether it landed, so a pid that already exited is not counted. */
|
|
24
|
+
readonly kill?: (pid: number) => boolean
|
|
25
|
+
readonly dryRun: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sigterm(pid: number): boolean {
|
|
29
|
+
try {
|
|
30
|
+
process.kill(pid, 'SIGTERM')
|
|
31
|
+
return true
|
|
32
|
+
} catch {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A process cwd reads back resolved, so a path held through a symlink has to be too. */
|
|
38
|
+
async function resolved(path: string): Promise<string> {
|
|
39
|
+
return realpath(path).catch(() => path)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Stops every server listening from inside one of the worktrees about to be
|
|
44
|
+
* removed, so none outlives the directory it serves. Each path is tested by the
|
|
45
|
+
* nesting rule `canon pr local` uses, so a listener in the main checkout or in
|
|
46
|
+
* a sibling under `.claude/worktrees/` is never signaled. The cwd is read
|
|
47
|
+
* against the given paths and never against `process.cwd()`, which a run from
|
|
48
|
+
* inside another worktree would otherwise make the only match.
|
|
49
|
+
*/
|
|
50
|
+
export async function stopListeners(
|
|
51
|
+
paths: readonly string[],
|
|
52
|
+
opts: StopOptions,
|
|
53
|
+
): Promise<StopReport> {
|
|
54
|
+
const listeners = await opts.reader.listListeners()
|
|
55
|
+
if (listeners === undefined) return { kind: 'unread' }
|
|
56
|
+
|
|
57
|
+
const kill = opts.kill ?? sigterm
|
|
58
|
+
const roots = await Promise.all(paths.map(resolved))
|
|
59
|
+
const cwdByPid = new Map<number, string | undefined>()
|
|
60
|
+
const stopped: StoppedServer[] = []
|
|
61
|
+
|
|
62
|
+
for (const [index, root] of roots.entries()) {
|
|
63
|
+
const pids = new Set<number>()
|
|
64
|
+
const ports = new Set<number>()
|
|
65
|
+
for (const { pid, port } of listeners) {
|
|
66
|
+
if (!cwdByPid.has(pid)) cwdByPid.set(pid, await opts.reader.cwdOf(pid))
|
|
67
|
+
const cwd = cwdByPid.get(pid)
|
|
68
|
+
if (cwd === undefined || !belongsTo(cwd, root)) continue
|
|
69
|
+
pids.add(pid)
|
|
70
|
+
ports.add(port)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const landed = [...pids]
|
|
74
|
+
.sort((a, b) => a - b)
|
|
75
|
+
.filter((pid) => opts.dryRun || kill(pid))
|
|
76
|
+
if (landed.length === 0) continue
|
|
77
|
+
const landedSet = new Set(landed)
|
|
78
|
+
stopped.push({
|
|
79
|
+
path: paths[index] ?? root,
|
|
80
|
+
pids: landed,
|
|
81
|
+
ports: [
|
|
82
|
+
...new Set(
|
|
83
|
+
listeners
|
|
84
|
+
.filter(({ pid, port }) => landedSet.has(pid) && ports.has(port))
|
|
85
|
+
.map(({ port }) => port),
|
|
86
|
+
),
|
|
87
|
+
].sort((a, b) => a - b),
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { kind: 'read', stopped }
|
|
92
|
+
}
|
|
@@ -11,14 +11,18 @@ on:
|
|
|
11
11
|
types: [submitted, edited]
|
|
12
12
|
workflow_dispatch:
|
|
13
13
|
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
|
|
14
17
|
jobs:
|
|
15
18
|
phase-label-gate:
|
|
16
19
|
name: 🏷️ Phase Label Gate
|
|
17
20
|
runs-on: ubuntu-latest
|
|
18
|
-
|
|
21
|
+
# Dependabot bodies carry upstream release notes that read as phase labels
|
|
22
|
+
if: github.event.pull_request.user.login != 'dependabot[bot]' && (github.event_name == 'workflow_dispatch' || github.event.pull_request.base.ref == 'main')
|
|
19
23
|
steps:
|
|
20
24
|
- name: Checkout
|
|
21
|
-
uses: actions/checkout@
|
|
25
|
+
uses: actions/checkout@v7
|
|
22
26
|
|
|
23
27
|
- name: Setup Bun
|
|
24
28
|
uses: oven-sh/setup-bun@v2
|
|
@@ -22,38 +22,18 @@ const subjectFirstWordCase = (parsed) => {
|
|
|
22
22
|
]
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
const CO_AUTHOR_TRAILER = /^co-authored-by:/i
|
|
26
|
-
const CLAUDE_OR_ANTHROPIC = /\b(claude|anthropic)\b/i
|
|
27
|
-
|
|
28
|
-
// Reads raw rather than footer, since the parser files a lone trailer under
|
|
29
|
-
// body and leaves footer null.
|
|
30
|
-
const noClaudeCoAuthor = (parsed) => {
|
|
31
|
-
const offending = (parsed.raw ?? '')
|
|
32
|
-
.split('\n')
|
|
33
|
-
.find(
|
|
34
|
-
(line) => CO_AUTHOR_TRAILER.test(line) && CLAUDE_OR_ANTHROPIC.test(line),
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
return [
|
|
38
|
-
offending === undefined,
|
|
39
|
-
`remove the co-author trailer crediting Claude or Anthropic: ${offending}`,
|
|
40
|
-
]
|
|
41
|
-
}
|
|
42
|
-
|
|
43
25
|
const config = {
|
|
44
26
|
extends: ['@commitlint/config-conventional'],
|
|
45
27
|
plugins: [
|
|
46
28
|
{
|
|
47
29
|
rules: {
|
|
48
30
|
'subject-first-word-case': subjectFirstWordCase,
|
|
49
|
-
'no-claude-co-author': noClaudeCoAuthor,
|
|
50
31
|
},
|
|
51
32
|
},
|
|
52
33
|
],
|
|
53
34
|
rules: {
|
|
54
35
|
'subject-case': [0],
|
|
55
36
|
'subject-first-word-case': [2, 'always'],
|
|
56
|
-
'no-claude-co-author': [2, 'always'],
|
|
57
37
|
'header-max-length': [2, 'always', 72],
|
|
58
38
|
'scope-case': [2, 'always', 'lower-case'],
|
|
59
39
|
'subject-full-stop': [2, 'never', '.'],
|
|
@@ -17,7 +17,7 @@ Two configs fix how output looks and how commits read.
|
|
|
17
17
|
- `.oxfmtrc.json` sets `semi: false`, `singleQuote: true`, `printWidth: 80`, and `sortPackageJson: false`, keeping the output Prettier wrote.
|
|
18
18
|
- The `astro` stack repeats those options in its own copy, so copy a change here into it.
|
|
19
19
|
- `commitlint.config.js` is an ESM default export extending `@commitlint/config-conventional`. It sets `header-max-length: 72`, `scope-case: lower-case`, and `subject-full-stop: never`.
|
|
20
|
-
- Its `subject-case` rule is off
|
|
20
|
+
- Its `subject-case` rule is off.
|
|
21
21
|
- `.husky/` holds `pre-commit`, `commit-msg`, `pre-push`, `post-merge`, and `post-rewrite`.
|
|
22
22
|
|
|
23
23
|
### Verify runner
|
|
@@ -6,13 +6,16 @@ on:
|
|
|
6
6
|
- main
|
|
7
7
|
workflow_dispatch:
|
|
8
8
|
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
9
12
|
jobs:
|
|
10
13
|
static-checks:
|
|
11
14
|
name: 🛡️ Static Checks
|
|
12
15
|
runs-on: ubuntu-latest
|
|
13
16
|
steps:
|
|
14
17
|
- name: Checkout
|
|
15
|
-
uses: actions/checkout@
|
|
18
|
+
uses: actions/checkout@v7
|
|
16
19
|
|
|
17
20
|
- name: Setup Bun
|
|
18
21
|
uses: oven-sh/setup-bun@v2
|
|
@@ -57,11 +57,11 @@ The `# Claude` group holds three entries, being `.canon/`, `.claude/worktrees/`,
|
|
|
57
57
|
|
|
58
58
|
## CLI
|
|
59
59
|
|
|
60
|
-
| Command | What it does
|
|
61
|
-
| -------------------- |
|
|
62
|
-
| `canon claude init` | Seeds `.claude/` workflow docs, updates `.gitignore`
|
|
63
|
-
| `canon claude sync` | Reconciles `.gitignore` against the manifest. Reports seeded file status.
|
|
64
|
-
| `canon claude setup` | Installs user-level config to `~/.claude/` once per machine: statusline
|
|
60
|
+
| Command | What it does |
|
|
61
|
+
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
62
|
+
| `canon claude init` | Seeds `.claude/` workflow docs, updates `.gitignore` |
|
|
63
|
+
| `canon claude sync` | Reconciles `.gitignore` against the manifest. Reports seeded file status. |
|
|
64
|
+
| `canon claude setup` | Installs user-level config to `~/.claude/` once per machine: statusline and the permissions allow and deny lists, and removes the empty attribution pair an earlier setup wrote. |
|
|
65
65
|
|
|
66
66
|
## CLAUDE.md
|
|
67
67
|
|
|
@@ -85,11 +85,17 @@ The seed at `tooling/claude/seeds/.claude/settings.json` ships hooks that pair w
|
|
|
85
85
|
- A PostToolUse hook pairs with `standards-audit.sh`.
|
|
86
86
|
- Project settings layer over user settings, so a project file carries only what is project-specific.
|
|
87
87
|
|
|
88
|
+
### Session defaults
|
|
89
|
+
|
|
90
|
+
The seed also sets two defaults every session in the target runs under.
|
|
91
|
+
|
|
92
|
+
- `permissions.deny` holds `Agent` and `Task`, so no session spawns a subagent. Parallel work runs as separate background sessions instead.
|
|
93
|
+
- `autoMemoryEnabled` is `false`. A person turns it back on for themselves in `settings.local.json`.
|
|
94
|
+
- `canon migrate session-defaults` adds both to a target seeded before them, since sync never rewrites an existing settings file.
|
|
95
|
+
|
|
88
96
|
### User template
|
|
89
97
|
|
|
90
98
|
The template at `tooling/claude/user/settings.template.json` is installed once per machine by `canon claude setup`.
|
|
91
99
|
|
|
92
|
-
- It sets `attribution.commit` and `attribution.pr` to empty strings to suppress co-author tags.
|
|
93
100
|
- It allows `Bash(bun run *)` under `permissions.allow`.
|
|
94
101
|
- Its `permissions.deny` holds `Read(**/.env)` and `Read(**/.env.*)`, blocking accidental secret exposure across any project.
|
|
95
|
-
- The deprecated `includeCoAuthoredBy` key is not used.
|
|
@@ -36,7 +36,7 @@ jobs:
|
|
|
36
36
|
runs-on: ubuntu-latest
|
|
37
37
|
steps:
|
|
38
38
|
- name: Checkout
|
|
39
|
-
uses: actions/checkout@
|
|
39
|
+
uses: actions/checkout@v7
|
|
40
40
|
|
|
41
41
|
- name: Setup Bun
|
|
42
42
|
uses: oven-sh/setup-bun@v2
|
|
@@ -50,7 +50,7 @@ jobs:
|
|
|
50
50
|
run: bun run build
|
|
51
51
|
|
|
52
52
|
- name: Upload artifact
|
|
53
|
-
uses: actions/upload-artifact@
|
|
53
|
+
uses: actions/upload-artifact@v7
|
|
54
54
|
with:
|
|
55
55
|
name: dist
|
|
56
56
|
path: dist
|
|
@@ -84,7 +84,7 @@ jobs:
|
|
|
84
84
|
# as dead weight breaks the deploy on every push.
|
|
85
85
|
- name: Checkout
|
|
86
86
|
if: steps.secrets-check.outputs.skip == 'false'
|
|
87
|
-
uses: actions/checkout@
|
|
87
|
+
uses: actions/checkout@v7
|
|
88
88
|
|
|
89
89
|
- name: Setup Bun
|
|
90
90
|
if: steps.secrets-check.outputs.skip == 'false'
|
|
@@ -94,7 +94,7 @@ jobs:
|
|
|
94
94
|
|
|
95
95
|
- name: Download artifact
|
|
96
96
|
if: steps.secrets-check.outputs.skip == 'false'
|
|
97
|
-
uses: actions/download-artifact@
|
|
97
|
+
uses: actions/download-artifact@v8
|
|
98
98
|
with:
|
|
99
99
|
name: dist
|
|
100
100
|
path: dist
|
|
@@ -104,7 +104,7 @@ jobs:
|
|
|
104
104
|
- name: Publish
|
|
105
105
|
id: publish
|
|
106
106
|
if: steps.secrets-check.outputs.skip == 'false'
|
|
107
|
-
uses: cloudflare/wrangler-action@
|
|
107
|
+
uses: cloudflare/wrangler-action@v4
|
|
108
108
|
with:
|
|
109
109
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
110
110
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# canon-no-seed: an add-on stack's config with no root counterpart by design.
|
|
2
|
+
version: 2
|
|
3
|
+
updates:
|
|
4
|
+
- package-ecosystem: github-actions
|
|
5
|
+
directory: /
|
|
6
|
+
schedule:
|
|
7
|
+
interval: weekly
|
|
8
|
+
commit-message:
|
|
9
|
+
prefix: ci
|
|
10
|
+
groups:
|
|
11
|
+
actions:
|
|
12
|
+
update-types: [minor, patch]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
# canon-no-seed: an add-on stack's workflow with no root counterpart by design, since the toolkit cuts its own releases through its own workflow.
|
|
3
|
+
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
branches:
|
|
7
|
+
- main
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
release-please:
|
|
14
|
+
name: 🏷️ Release Please
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
permissions:
|
|
17
|
+
contents: write
|
|
18
|
+
pull-requests: write
|
|
19
|
+
# Queues rather than cancels, since a cancel can land after the release exists and before its outputs do
|
|
20
|
+
concurrency:
|
|
21
|
+
group: release-please
|
|
22
|
+
cancel-in-progress: false
|
|
23
|
+
steps:
|
|
24
|
+
- name: Release Please
|
|
25
|
+
uses: googleapis/release-please-action@v5
|
|
26
|
+
with:
|
|
27
|
+
# A personal token lets CI run on the release pull request, which the default token's bot author does not
|
|
28
|
+
token: ${{ secrets.RELEASE_PLEASE_TOKEN || github.token }}
|
|
29
|
+
config-file: release-please-config.json
|
|
30
|
+
manifest-file: .release-please-manifest.json
|