@uniqbit/mate-core 0.18.5 → 1.0.0-canary.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/hooks/companion-instructions.mjs +9 -0
- package/claude-plugin/hooks/hooks.json +12 -2
- package/package.json +10 -2
- package/src/brand/art.generated.ts +69 -0
- package/src/brand/index.ts +12 -0
- package/src/brand/palette.ts +19 -0
- package/src/brand/svg.generated.ts +8 -0
- package/src/brand/terminal.ts +83 -0
- package/src/brand/types.ts +33 -0
- package/src/cli/commands/artifact/artifact.ts +8 -0
- package/src/cli/commands/artifact/delivery/archive.ts +117 -0
- package/src/cli/commands/artifact/delivery/branch-flags.ts +55 -0
- package/src/cli/commands/artifact/delivery/environment.ts +104 -0
- package/src/cli/commands/artifact/delivery/link.ts +154 -0
- package/src/cli/commands/artifact/delivery/output.ts +58 -0
- package/src/cli/commands/artifact/finish/command.ts +33 -3
- package/src/cli/commands/artifact/finish/engine.ts +47 -3
- package/src/cli/commands/artifact/finish/finisher.ts +10 -0
- package/src/cli/commands/artifact/finish/openspec.ts +177 -138
- package/src/cli/commands/artifact/finish/secret-scan.ts +43 -0
- package/src/cli/commands/artifact/pending/command.ts +31 -3
- package/src/cli/commands/artifact/pending/discovery.ts +21 -57
- package/src/cli/commands/cap/graphify.ts +52 -3
- package/src/cli/commands/cap/index-cmd.ts +8 -1
- package/src/cli/commands/cap/openspec.ts +13 -25
- package/src/cli/commands/companion/open.ts +56 -39
- package/src/cli/commands/companion/update.ts +75 -42
- package/src/cli/commands/doctor.ts +176 -3
- package/src/cli/commands/plugin/install.ts +9 -4
- package/src/cli/commands/plugin/plugin.ts +3 -2
- package/src/cli/commands/report/adapter.ts +11 -2
- package/src/cli/commands/report/contract.ts +21 -0
- package/src/cli/commands/report/highlight.ts +34 -4
- package/src/cli/commands/report/markdown.ts +110 -0
- package/src/cli/commands/report/palette.ts +118 -0
- package/src/cli/commands/report/renderer.ts +456 -153
- package/src/cli/commands/report/types.ts +16 -0
- package/src/cli/commands/setup.ts +7 -15
- package/src/cli/commands/studio/client-asset.ts +16 -0
- package/src/cli/commands/studio/openspec-cli.ts +10 -58
- package/src/cli/commands/studio/routes.ts +2 -0
- package/src/cli/commands/studio/selection.ts +19 -1
- package/src/cli/commands/studio/server.ts +42 -1
- package/src/cli/commands/studio/views/client.ts +6 -616
- package/src/cli/commands/studio/views/dashboard/changes.tsx +5 -10
- package/src/cli/commands/studio/views/document.tsx +137 -69
- package/src/cli/commands/studio/views/markdown.tsx +250 -0
- package/src/cli/commands/studio/views/palette.ts +134 -0
- package/src/cli/commands/studio/views/reports/index.tsx +2 -7
- package/src/cli/commands/studio/views/skills/index.tsx +43 -62
- package/src/cli/commands/studio/views/specs/index.tsx +7 -15
- package/src/cli/commands/studio/views/studio-client/actions.ts +51 -0
- package/src/cli/commands/studio/views/studio-client/companion.ts +13 -0
- package/src/cli/commands/studio/views/studio-client/index.ts +32 -0
- package/src/cli/commands/studio/views/studio-client/keys.ts +3 -0
- package/src/cli/commands/studio/views/studio-client/main.ts +3 -0
- package/src/cli/commands/studio/views/studio-client/page.ts +146 -0
- package/src/cli/commands/studio/views/studio-client/panels.ts +57 -0
- package/src/cli/commands/studio/views/studio-client/reports.ts +122 -0
- package/src/cli/commands/studio/views/studio-client/theme.ts +63 -0
- package/src/cli/commands/studio/views/studio-client/vault-editor.ts +166 -0
- package/src/cli/commands/studio/views/studio-client/vault-pane.ts +202 -0
- package/src/cli/commands/studio/views/studio-client/vault-tree.ts +258 -0
- package/src/cli/commands/studio/views/studio-client.generated.ts +2 -0
- package/src/cli/commands/studio/views/styles.ts +217 -206
- package/src/cli/commands/studio/views/vault/state.ts +3 -5
- package/src/cli/commands/studio/views/workflow/delivery-path.tsx +370 -0
- package/src/cli/commands/studio/views/workflow/graph.ts +220 -0
- package/src/cli/commands/studio/views/workflow/index.tsx +2 -3
- package/src/cli/commands/studio/views/workflow/steps.ts +228 -287
- package/src/cli/commands/wrap.ts +5 -13
- package/src/cli/main.ts +4 -1
- package/src/cli/parse-flags.ts +25 -7
- package/src/cli/setup-selector.tsx +38 -162
- package/src/cli/usage.ts +4 -0
- package/src/hooks/companion-instructions.ts +190 -0
- package/src/hooks/hook-io.ts +63 -0
- package/src/hooks/session-banner.ts +21 -29
- package/src/hooks/session-guidance.ts +9 -17
- package/src/hooks/session-prepare.ts +115 -28
- package/src/hooks/validate-artifact-path.ts +82 -24
- package/src/index.ts +4 -0
- package/src/lib/change-layout.ts +154 -0
- package/src/lib/components/wizard-header.tsx +3 -1
- package/src/lib/context-mode-package.ts +24 -11
- package/src/lib/delivery/archive-change.ts +154 -0
- package/src/lib/delivery/branches.ts +108 -0
- package/src/lib/delivery/environment.ts +29 -0
- package/src/lib/delivery/forge.ts +279 -0
- package/src/lib/delivery/overlap.ts +202 -0
- package/src/lib/delivery/record-delivery.ts +196 -0
- package/src/lib/delivery/record.ts +343 -0
- package/src/lib/delivery/refresh.ts +174 -0
- package/src/lib/delivery/repository-access.ts +97 -0
- package/src/lib/delivery/stages.ts +111 -0
- package/src/lib/delivery/token.ts +58 -0
- package/src/lib/frontmatter.ts +18 -0
- package/src/lib/opencode-plugin-package.ts +5 -5
- package/src/lib/openspec-cli.ts +116 -0
- package/src/lib/openspec-scopes.ts +86 -0
- package/src/lib/orchestrator/adapters/claude-args.ts +50 -0
- package/src/lib/orchestrator/adapters/claude.ts +48 -40
- package/src/lib/orchestrator/adapters/opencode.ts +2 -0
- package/src/lib/orchestrator/editor.ts +9 -9
- package/src/lib/orchestrator/openspec-store.ts +14 -54
- package/src/lib/orchestrator/projection-claude-commands.ts +36 -0
- package/src/lib/orchestrator/projection-claude-links.ts +159 -0
- package/src/lib/orchestrator/projection-claude-skills.ts +19 -137
- package/src/lib/orchestrator/projection-entries.ts +45 -14
- package/src/lib/orchestrator/projection-runtime-documents.ts +15 -6
- package/src/lib/orchestrator/projection-types.ts +3 -0
- package/src/lib/orchestrator/refresh-working-claude-links.ts +87 -0
- package/src/lib/orchestrator/session-prepare.ts +45 -5
- package/src/lib/orchestrator/setup-compatibilities.ts +18 -75
- package/src/lib/orchestrator/types.ts +2 -2
- package/src/lib/orchestrator/wrap-pass.ts +22 -1
- package/src/lib/package-paths.ts +8 -0
- package/src/opencode/companion-hooks.ts +64 -5
- package/src/opencode/companion-policy.ts +37 -18
- package/src/opencode/projected-guidance.ts +4 -1
- package/src/opencode/session-prepare.ts +2 -1
- package/src/opencode/tui.tsx +4 -2
- package/src/runtime/artifact-refusal.ts +63 -0
- package/src/runtime/claude-delivery-plan.ts +89 -7
- package/src/runtime/commit-attribution-guard.ts +181 -0
- package/src/runtime/companion-guidance.ts +15 -3
- package/src/runtime/companion-instructions.ts +340 -0
- package/src/runtime/error-message.ts +4 -0
- package/src/runtime/index.ts +5 -0
- package/src/runtime/managed-block.ts +117 -0
- package/src/runtime/markdown-scan.ts +266 -0
- package/src/runtime/policy.ts +15 -1
- package/src/runtime/projected-flag.ts +7 -0
- package/src/runtime/projected-guidance.ts +1 -0
- package/src/runtime/runtime-document-manifest.ts +62 -11
- package/src/runtime/secret-guard.ts +148 -0
- package/src/runtime/secret-patterns.ts +164 -0
- package/src/runtime/secret-scan.ts +90 -0
- package/src/runtime/session-kind.ts +13 -0
- package/src/runtime/shell-heredoc.ts +81 -0
- package/src/runtime/wrap-state.ts +36 -2
- package/src/runtime/write-atomic.ts +64 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +8 -6
- package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +3 -5
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +7 -9
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
- package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +10 -12
- package/src/templates/mate-skills/THIRD-PARTY-NOTICES.md +40 -0
- package/src/templates/mate-skills/agents/mate-apply-change/SKILL.md +76 -0
- package/src/templates/mate-skills/agents/mate-apply-change/references/openspec-apply-change.md +198 -0
- package/src/templates/mate-skills/agents/mate-archive-change/SKILL.md +59 -0
- package/src/templates/mate-skills/{claude/mate-openspec-backfill → agents/mate-backfill-spec}/SKILL.md +4 -4
- package/src/templates/mate-skills/agents/mate-clarify-intent/SKILL.md +95 -0
- package/src/templates/mate-skills/agents/mate-explore-change/SKILL.md +50 -0
- package/src/templates/mate-skills/agents/mate-explore-change/references/openspec-explore.md +348 -0
- package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +3 -3
- package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +5 -5
- package/src/templates/mate-skills/agents/mate-propose-change/SKILL.md +103 -0
- package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-propose.md +167 -0
- package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-update-change.md +101 -0
- package/src/templates/mate-skills/{claude/mate-artifact-publish → agents/mate-publish-artifact}/SKILL.md +91 -23
- package/src/templates/mate-skills/agents/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
- package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +74 -16
- package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +74 -68
- package/src/templates/mate-skills/claude/mate-apply-change/SKILL.md +76 -0
- package/src/templates/mate-skills/claude/mate-apply-change/references/openspec-apply-change.md +198 -0
- package/src/templates/mate-skills/claude/mate-archive-change/SKILL.md +59 -0
- package/src/templates/mate-skills/{agents/mate-openspec-backfill → claude/mate-backfill-spec}/SKILL.md +4 -4
- package/src/templates/mate-skills/claude/mate-clarify-intent/SKILL.md +95 -0
- package/src/templates/mate-skills/claude/mate-explore-change/SKILL.md +50 -0
- package/src/templates/mate-skills/claude/mate-explore-change/references/openspec-explore.md +348 -0
- package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +3 -3
- package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +5 -5
- package/src/templates/mate-skills/claude/mate-propose-change/SKILL.md +103 -0
- package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-propose.md +167 -0
- package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-update-change.md +101 -0
- package/src/templates/mate-skills/{agents/mate-artifact-publish → claude/mate-publish-artifact}/SKILL.md +91 -23
- package/src/templates/mate-skills/claude/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
- package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +74 -16
- package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +74 -68
- package/src/templates/mate-skills/openspec-references.ts +140 -0
- package/src/templates/root/TEMPLATE_AGENTS.md +2 -2
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3727 -320
- package/src/tools/setup/capabilities/context-mode.ts +25 -5
- package/src/tools/setup/capabilities/graphify-gate.ts +31 -0
- package/src/tools/setup/capabilities/graphify-secrets.ts +255 -0
- package/src/tools/setup/capabilities/graphify.ts +12 -0
- package/src/tools/setup/capabilities/openspec.ts +151 -270
- package/src/tools/setup/capabilities/tokensave.ts +36 -3
- package/src/tools/setup/context-services.ts +9 -17
- package/src/tools/setup/dynamic-plugins/install.ts +24 -28
- package/src/tools/setup/engine.ts +97 -21
- package/src/tools/setup/managed-output.ts +51 -0
- package/src/tools/setup/mate.ts +86 -11
- package/src/tools/setup/plugin.ts +35 -0
- package/src/tools/setup/plugins/guidance.ts +7 -4
- package/src/tools/setup/providers/agent-file-sections.ts +4 -2
- package/src/tools/setup/providers/claude-attribution.ts +65 -0
- package/src/tools/setup/providers/claude-plugin-folders.ts +625 -0
- package/src/tools/setup/providers/claude-plugin-hooks.ts +39 -19
- package/src/tools/setup/providers/claude.ts +284 -8
- package/src/tools/setup/providers/opencode.ts +180 -1
- package/src/tools/setup/utils.ts +14 -4
- package/src/tools/setup.ts +126 -10
- package/wrappers/bin/graphify +52 -4
- package/wrappers/bin/openspec +33 -9
- package/wrappers/lib/graphify-gate.mjs +8 -0
- package/src/cli/commands/studio/views/workflow/transcript.tsx +0 -190
- package/src/tools/setup/runtime-documents.ts +0 -291
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one secret-file pattern set every guardrail layer reads: guidance,
|
|
3
|
+
* projected deny rules, hook and middleware enforcement, indexer exclusion and
|
|
4
|
+
* the publish scan. Keeping it in one module is what stops the layers from
|
|
5
|
+
* disagreeing about what a secret file is.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_SECRET_DENY_PATTERNS: readonly string[] = [
|
|
9
|
+
".env",
|
|
10
|
+
".env.*",
|
|
11
|
+
"*.pem",
|
|
12
|
+
"*.key",
|
|
13
|
+
"*.p12",
|
|
14
|
+
"*.pfx",
|
|
15
|
+
"id_rsa",
|
|
16
|
+
"id_ecdsa",
|
|
17
|
+
"id_ed25519",
|
|
18
|
+
".netrc",
|
|
19
|
+
".pgpass",
|
|
20
|
+
".npmrc",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** Example files that document which variables exist; they never hold values. */
|
|
24
|
+
export const DEFAULT_SECRET_ALLOW_PATTERNS: readonly string[] = [
|
|
25
|
+
".env.example",
|
|
26
|
+
".env.sample",
|
|
27
|
+
".env.template",
|
|
28
|
+
".env.dist",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export interface SecretPolicy {
|
|
32
|
+
/** False only when the companion explicitly opts out. */
|
|
33
|
+
guardrails: boolean;
|
|
34
|
+
/** Defaults plus the companion's extra `secrets.deny` patterns. */
|
|
35
|
+
deny: string[];
|
|
36
|
+
/** Defaults plus the companion's extra `secrets.allow` patterns. */
|
|
37
|
+
allow: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const defaultSecretPolicy = (): SecretPolicy => ({
|
|
41
|
+
guardrails: true,
|
|
42
|
+
deny: [...DEFAULT_SECRET_DENY_PATTERNS],
|
|
43
|
+
allow: [...DEFAULT_SECRET_ALLOW_PATTERNS],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function patternList(value: unknown): string[] {
|
|
47
|
+
if (!Array.isArray(value)) return [];
|
|
48
|
+
return value.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parses the `secrets` block of `framework.yaml`. Anything that is not an
|
|
53
|
+
* object yields the defaults with guardrails on, and only a literal `false`
|
|
54
|
+
* turns them off: a broken config never disables protection.
|
|
55
|
+
*/
|
|
56
|
+
export function parseSecretsConfig(value: unknown): SecretPolicy {
|
|
57
|
+
const policy = defaultSecretPolicy();
|
|
58
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return policy;
|
|
59
|
+
const block = value as Record<string, unknown>;
|
|
60
|
+
return {
|
|
61
|
+
guardrails: block.guardrails !== false,
|
|
62
|
+
deny: [...policy.deny, ...patternList(block.deny)],
|
|
63
|
+
allow: [...policy.allow, ...patternList(block.allow)],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const compiled = new Map<string, RegExp>();
|
|
68
|
+
|
|
69
|
+
/** Gitignore-style subset: `*` and `?` stay inside a segment, `**` crosses them. */
|
|
70
|
+
function patternRegExp(pattern: string): RegExp {
|
|
71
|
+
const cached = compiled.get(pattern);
|
|
72
|
+
if (cached) return cached;
|
|
73
|
+
let source = "";
|
|
74
|
+
const body = pattern.replace(/^\/+/, "");
|
|
75
|
+
for (let i = 0; i < body.length; i++) {
|
|
76
|
+
const char = body[i];
|
|
77
|
+
if (char === "*" && body[i + 1] === "*") {
|
|
78
|
+
// `**/` matches zero or more whole segments; a bare `**` matches anything.
|
|
79
|
+
if (body[i + 2] === "/") {
|
|
80
|
+
source += "(?:.*/)?";
|
|
81
|
+
i += 2;
|
|
82
|
+
} else {
|
|
83
|
+
source += ".*";
|
|
84
|
+
i += 1;
|
|
85
|
+
}
|
|
86
|
+
} else if (char === "*") {
|
|
87
|
+
source += "[^/]*";
|
|
88
|
+
} else if (char === "?") {
|
|
89
|
+
source += "[^/]";
|
|
90
|
+
} else {
|
|
91
|
+
source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// A slash-free pattern matches the basename; one with a slash matches the
|
|
95
|
+
// trailing segments of the path, since the repository root is not known here.
|
|
96
|
+
const regExp = body.includes("/") ? new RegExp(`(?:^|/)${source}$`) : new RegExp(`^${source}$`);
|
|
97
|
+
compiled.set(pattern, regExp);
|
|
98
|
+
return regExp;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizePath(filePath: string): string {
|
|
102
|
+
return filePath.replaceAll("\\", "/").replace(/\/+$/, "");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function matchesSecretPattern(filePath: string, patterns: readonly string[]): boolean {
|
|
106
|
+
const normalized = normalizePath(filePath);
|
|
107
|
+
if (!normalized) return false;
|
|
108
|
+
const basename = normalized.slice(normalized.lastIndexOf("/") + 1);
|
|
109
|
+
return patterns.some((pattern) => {
|
|
110
|
+
const regExp = patternRegExp(pattern);
|
|
111
|
+
return pattern.replace(/^\/+/, "").includes("/")
|
|
112
|
+
? regExp.test(normalized)
|
|
113
|
+
: regExp.test(basename);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A path is a secret file when it matches a deny pattern and no allow pattern. */
|
|
118
|
+
export function isSecretFile(filePath: string, policy: SecretPolicy): boolean {
|
|
119
|
+
return (
|
|
120
|
+
matchesSecretPattern(filePath, policy.deny) && !matchesSecretPattern(filePath, policy.allow)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Narrower stand-ins for a default deny pattern that also matches an allowed
|
|
126
|
+
* file. Runtime deny rules are evaluated before allow rules, so `.env.*` there
|
|
127
|
+
* would deny `.env.example` too; these cover the common environment files.
|
|
128
|
+
*/
|
|
129
|
+
const ALLOW_SAFE_EXPANSIONS: Readonly<Record<string, readonly string[]>> = {
|
|
130
|
+
".env.*": [".env.local", ".env.development", ".env.production", ".env.test", ".env.*.local"],
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The default deny patterns a runtime's own deny rules may carry: every
|
|
135
|
+
* default that can't match an allow-listed file, with an overlapping pattern
|
|
136
|
+
* replaced by its narrower stand-ins. Extra `secrets.deny` patterns are never
|
|
137
|
+
* projected: once removed from config, nothing would mark their rules as Mate's.
|
|
138
|
+
*/
|
|
139
|
+
export function projectableSecretDenyPatterns(): string[] {
|
|
140
|
+
const overlapsAllow = (pattern: string) =>
|
|
141
|
+
DEFAULT_SECRET_ALLOW_PATTERNS.some((allowed) => matchesSecretPattern(allowed, [pattern]));
|
|
142
|
+
return DEFAULT_SECRET_DENY_PATTERNS.flatMap((pattern) =>
|
|
143
|
+
overlapsAllow(pattern) ? (ALLOW_SAFE_EXPANSIONS[pattern] ?? []) : [pattern],
|
|
144
|
+
).filter((pattern) => !overlapsAllow(pattern));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Ordinary files a broad glob also matches; a glob matching one is not aimed at secrets. */
|
|
148
|
+
const ORDINARY_SAMPLES = ["index.ts", "README.md", "config.json"];
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether a search glob is aimed at secret files: it matches a sample name of
|
|
152
|
+
* some deny pattern that the policy still treats as secret, and no ordinary
|
|
153
|
+
* file. `.env*` and `*.key` are; `**\/*` and `*.ts` are not. A heuristic for
|
|
154
|
+
* search tools, which take a glob rather than a path.
|
|
155
|
+
*/
|
|
156
|
+
export function globTargetsSecretFiles(glob: string, policy: SecretPolicy): boolean {
|
|
157
|
+
const pattern = glob.trim();
|
|
158
|
+
if (!pattern) return false;
|
|
159
|
+
if (ORDINARY_SAMPLES.some((sample) => matchesSecretPattern(sample, [pattern]))) return false;
|
|
160
|
+
return policy.deny.some((deny) => {
|
|
161
|
+
const sample = deny.slice(deny.lastIndexOf("/") + 1).replace(/[*?]+/g, "x");
|
|
162
|
+
return isSecretFile(sample, policy) && matchesSecretPattern(sample, [pattern]);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { lintSource as LintSourceFn } from "@secretlint/core";
|
|
5
|
+
import type { SecretLintCoreConfig } from "@secretlint/types";
|
|
6
|
+
|
|
7
|
+
/** One secretlint finding. Deliberately carries no message: messages quote the value. */
|
|
8
|
+
export interface SecretFinding {
|
|
9
|
+
path: string;
|
|
10
|
+
line: number;
|
|
11
|
+
ruleId: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type LintSource = typeof LintSourceFn;
|
|
15
|
+
|
|
16
|
+
let engine: Promise<{ lintSource: LintSource; config: SecretLintCoreConfig }> | undefined;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Loaded on first use only, so hook calls that never scan don't pay for it.
|
|
20
|
+
* The config is fixed in memory: the recommended preset and nothing else. No
|
|
21
|
+
* `.secretlintrc` is ever read, so a repository can't weaken the scan.
|
|
22
|
+
*/
|
|
23
|
+
function loadEngine() {
|
|
24
|
+
engine ??= (async () => {
|
|
25
|
+
const [{ lintSource }, { creator }] = await Promise.all([
|
|
26
|
+
import("@secretlint/core"),
|
|
27
|
+
import("@secretlint/secretlint-rule-preset-recommend"),
|
|
28
|
+
]);
|
|
29
|
+
const config: SecretLintCoreConfig = {
|
|
30
|
+
rules: [{ id: "@secretlint/secretlint-rule-preset-recommend", rule: creator }],
|
|
31
|
+
};
|
|
32
|
+
return { lintSource, config };
|
|
33
|
+
})();
|
|
34
|
+
return engine;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Scans in-memory content. Throws when the engine fails, so callers can fail closed. */
|
|
38
|
+
export async function scanContent(filePath: string, content: string): Promise<SecretFinding[]> {
|
|
39
|
+
const { lintSource, config } = await loadEngine();
|
|
40
|
+
const result = await lintSource({
|
|
41
|
+
source: { content, filePath, contentType: "text" },
|
|
42
|
+
options: { config, maskSecrets: true, noPhysicFilePath: true },
|
|
43
|
+
});
|
|
44
|
+
return result.messages.map((message) => ({
|
|
45
|
+
path: filePath,
|
|
46
|
+
line: message.loc.start.line,
|
|
47
|
+
ruleId: message.ruleId,
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Every file under a path, in name order; a path that doesn't exist yields none. */
|
|
52
|
+
export async function listFiles(target: string): Promise<string[]> {
|
|
53
|
+
let stat;
|
|
54
|
+
try {
|
|
55
|
+
stat = await fs.stat(target);
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
if (stat.isFile()) return [target];
|
|
60
|
+
if (!stat.isDirectory()) return [];
|
|
61
|
+
const entries = await fs.readdir(target, { withFileTypes: true });
|
|
62
|
+
const nested = await Promise.all(
|
|
63
|
+
entries
|
|
64
|
+
.toSorted((a, b) => a.name.localeCompare(b.name))
|
|
65
|
+
.map((entry) => listFiles(path.join(target, entry.name))),
|
|
66
|
+
);
|
|
67
|
+
return nested.flat();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Scans files and directories on disk; paths that don't exist are skipped. */
|
|
71
|
+
export async function scanFiles(targets: readonly string[]): Promise<SecretFinding[]> {
|
|
72
|
+
const findings: SecretFinding[] = [];
|
|
73
|
+
for (const target of targets) {
|
|
74
|
+
// oxlint-disable-next-line no-await-in-loop -- findings keep the caller's order
|
|
75
|
+
for (const file of await listFiles(target)) {
|
|
76
|
+
// oxlint-disable-next-line no-await-in-loop -- one file at a time bounds memory
|
|
77
|
+
const content = await fs.readFile(file, "utf8");
|
|
78
|
+
// oxlint-disable-next-line no-await-in-loop -- see above
|
|
79
|
+
findings.push(...(await scanContent(file, content)));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return findings;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One line per finding, for refusal messages: never the value. Line 0 means the whole file. */
|
|
86
|
+
export function describeSecretFindings(findings: readonly SecretFinding[]): string[] {
|
|
87
|
+
return findings.map(
|
|
88
|
+
(finding) => `${finding.path}${finding.line > 0 ? `:${finding.line}` : ""} (${finding.ruleId})`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kind of session a prepare or a delivery plan answers for, shared by every
|
|
3
|
+
* Agent Runtime. `managed-repo`: a managed launch with a Working Repository; its
|
|
4
|
+
* prepare just reconciled the wrap, which the launcher asserts.
|
|
5
|
+
* `managed-companion`: a companion-scoped managed launch. `unmanaged`: a
|
|
6
|
+
* session with no launch environment.
|
|
7
|
+
*/
|
|
8
|
+
export type SessionKind = "managed-repo" | "managed-companion" | "unmanaged";
|
|
9
|
+
|
|
10
|
+
/** A managed launch's kind: with a Working Repository, or companion-scoped. */
|
|
11
|
+
export function managedSessionKind(withRepository: boolean): SessionKind {
|
|
12
|
+
return withRepository ? "managed-repo" : "managed-companion";
|
|
13
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a heredoc starts, matched at a `<<` outside quotes: an optional `-`,
|
|
3
|
+
* then the terminator word, optionally quoted. Shared by the secret guard's
|
|
4
|
+
* shell tokenizer and the artifact guard's redirect scan, so both agree on
|
|
5
|
+
* what is command text and what is a body.
|
|
6
|
+
*/
|
|
7
|
+
export const HEREDOC_MARKER = /^<<-?\s*(['"]?)([\w.-]+)\1/;
|
|
8
|
+
|
|
9
|
+
/** The terminators a command line opens, in order; quoted text is skipped. */
|
|
10
|
+
function heredocTerminators(line: string): string[] {
|
|
11
|
+
const terminators: string[] = [];
|
|
12
|
+
let quote: string | undefined;
|
|
13
|
+
for (let i = 0; i < line.length; i++) {
|
|
14
|
+
const char = line[i];
|
|
15
|
+
if (quote) {
|
|
16
|
+
if (char === quote) quote = undefined;
|
|
17
|
+
else if (char === "\\" && quote === '"') i++;
|
|
18
|
+
} else if (char === "'" || char === '"') {
|
|
19
|
+
quote = char;
|
|
20
|
+
} else if (char === "\\") {
|
|
21
|
+
i++;
|
|
22
|
+
} else if (char === "<" && line[i + 1] === "<") {
|
|
23
|
+
if (line[i + 2] === "<") {
|
|
24
|
+
i += 2;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const marker = HEREDOC_MARKER.exec(line.slice(i));
|
|
28
|
+
if (marker) {
|
|
29
|
+
terminators.push(marker[2]);
|
|
30
|
+
i += marker[0].length - 1;
|
|
31
|
+
} else {
|
|
32
|
+
i++;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return terminators;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A heredoc body and the line, in the stripped command, whose marker opened it. */
|
|
40
|
+
export interface HeredocBody {
|
|
41
|
+
line: number;
|
|
42
|
+
text: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The command's own lines and each heredoc body, split in one pass. */
|
|
46
|
+
function splitHeredocs(command: string): { kept: string[]; bodies: HeredocBody[] } {
|
|
47
|
+
const kept: string[] = [];
|
|
48
|
+
const bodies: HeredocBody[] = [];
|
|
49
|
+
const pending: string[] = [];
|
|
50
|
+
let body: string[] = [];
|
|
51
|
+
for (const line of command.split("\n")) {
|
|
52
|
+
if (pending.length > 0) {
|
|
53
|
+
if (line.trim() === pending[0]) {
|
|
54
|
+
pending.shift();
|
|
55
|
+
bodies.push({ line: kept.length - 1, text: body.join("\n") });
|
|
56
|
+
body = [];
|
|
57
|
+
} else {
|
|
58
|
+
body.push(line);
|
|
59
|
+
}
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
kept.push(line);
|
|
63
|
+
pending.push(...heredocTerminators(line));
|
|
64
|
+
}
|
|
65
|
+
if (pending.length > 0) bodies.push({ line: kept.length - 1, text: body.join("\n") });
|
|
66
|
+
return { kept, bodies };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The command with every heredoc body and terminator line removed. Marker
|
|
71
|
+
* lines stay: a redirect the command performs is spelled there, never in the
|
|
72
|
+
* body it writes. A heuristic, not a parser, like the tokenizer it mirrors.
|
|
73
|
+
*/
|
|
74
|
+
export function stripHeredocBodies(command: string): string {
|
|
75
|
+
return splitHeredocs(command).kept.join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Every heredoc body in the command, in order; an unterminated one runs to the end. */
|
|
79
|
+
export function heredocBodies(command: string): HeredocBody[] {
|
|
80
|
+
return splitHeredocs(command).bodies;
|
|
81
|
+
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
copies,
|
|
10
10
|
documents,
|
|
11
11
|
links,
|
|
12
|
+
recordedMapEntries,
|
|
12
13
|
manifestPath,
|
|
13
14
|
parseManifest,
|
|
14
15
|
parseManifestStrict,
|
|
@@ -21,13 +22,16 @@ import {
|
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Wrap State: the one read model over the runtime document manifest for "which
|
|
24
|
-
* runtimes are wrapped" and "which Claude hook groups
|
|
25
|
+
* runtimes are wrapped" and "which Claude hook groups and plugin folders does
|
|
26
|
+
* the wrap carry".
|
|
25
27
|
* Mate's own plugin groups are not folded in; `isMateGuardHookGroup` owns those.
|
|
26
28
|
*/
|
|
27
29
|
export interface WrapState {
|
|
28
30
|
readonly runtimes: ReadonlySet<WrapRuntime>;
|
|
29
31
|
readonly carriedClaudeHookGroups: readonly ClaudeHookGroup[];
|
|
30
32
|
carriesClaudeHookGroup(group: ClaudeHookGroup): boolean;
|
|
33
|
+
/** Capability plugin folders the wrap enables through a `directory` marketplace, as recorded. */
|
|
34
|
+
readonly carriedClaudePluginDirectories: readonly string[];
|
|
31
35
|
/** MCP server names the wrap declared in `.opencode/opencode.json`; user servers are not listed. */
|
|
32
36
|
readonly openCodeMcpServers: readonly string[];
|
|
33
37
|
}
|
|
@@ -43,10 +47,39 @@ function wrappedFor(manifest: Manifest, runtime: WrapRuntime): boolean {
|
|
|
43
47
|
);
|
|
44
48
|
}
|
|
45
49
|
|
|
46
|
-
|
|
50
|
+
/**
|
|
51
|
+
* The folders of the recorded `directory` marketplaces whose plugin the wrap
|
|
52
|
+
* also enables: one without the other loads nothing.
|
|
53
|
+
*/
|
|
54
|
+
function carriedPluginDirectories(manifest: Manifest): string[] {
|
|
55
|
+
const marketplaces = recordedMapEntries(manifest, CLAUDE_SETTINGS_DOCUMENT, [
|
|
56
|
+
"extraKnownMarketplaces",
|
|
57
|
+
]);
|
|
58
|
+
const enabled = recordedMapEntries(manifest, CLAUDE_SETTINGS_DOCUMENT, ["enabledPlugins"]);
|
|
59
|
+
const enabledMarketplaces = new Set(
|
|
60
|
+
Object.entries(enabled)
|
|
61
|
+
.filter(([, value]) => value === true)
|
|
62
|
+
.map(([id]) => id.slice(id.lastIndexOf("@") + 1)),
|
|
63
|
+
);
|
|
64
|
+
return Object.entries(marketplaces).flatMap(([name, entry]) => {
|
|
65
|
+
const source = isRecord(entry) ? entry.source : undefined;
|
|
66
|
+
return enabledMarketplaces.has(name) &&
|
|
67
|
+
isRecord(source) &&
|
|
68
|
+
source.source === "directory" &&
|
|
69
|
+
typeof source.path === "string"
|
|
70
|
+
? [source.path]
|
|
71
|
+
: [];
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
47
76
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
48
77
|
}
|
|
49
78
|
|
|
79
|
+
function isHookGroup(value: unknown): value is ClaudeHookGroup {
|
|
80
|
+
return isRecord(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
50
83
|
export function wrapStateOf(manifest: Manifest): WrapState {
|
|
51
84
|
const carried = recordedListValues(manifest, CLAUDE_SETTINGS_DOCUMENT, ["hooks"]).filter(
|
|
52
85
|
isHookGroup,
|
|
@@ -56,6 +89,7 @@ export function wrapStateOf(manifest: Manifest): WrapState {
|
|
|
56
89
|
runtimes: new Set(WRAP_RUNTIMES.filter((runtime) => wrappedFor(manifest, runtime))),
|
|
57
90
|
carriedClaudeHookGroups: carried,
|
|
58
91
|
carriesClaudeHookGroup: (group) => carriedKeys.has(hookGroupKey(group)),
|
|
92
|
+
carriedClaudePluginDirectories: carriedPluginDirectories(manifest),
|
|
59
93
|
openCodeMcpServers: recordedMapKeys(
|
|
60
94
|
manifest,
|
|
61
95
|
OPENCODE_CONFIG_DOCUMENT,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
// oxlint-disable no-await-in-loop
|
|
1
2
|
import crypto from "node:crypto";
|
|
2
3
|
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Writes via a sibling temp file + `fs.rename()` so an interrupted write cannot
|
|
@@ -11,17 +13,79 @@ export async function writeAtomic(
|
|
|
11
13
|
target: string,
|
|
12
14
|
contents: string | Buffer,
|
|
13
15
|
mode?: number,
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
await replaceViaTemp(target, contents, mode, false);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `exactMode`: chmod past the umask, so a replaced file keeps the mode it had. */
|
|
21
|
+
async function replaceViaTemp(
|
|
22
|
+
target: string,
|
|
23
|
+
contents: string | Buffer,
|
|
24
|
+
mode: number | undefined,
|
|
25
|
+
exactMode: boolean,
|
|
14
26
|
): Promise<void> {
|
|
15
27
|
const tempPath = `${target}.${crypto.randomUUID()}.tmp`;
|
|
16
28
|
try {
|
|
29
|
+
/** Created with the mode, so private contents are never readable at a wider one. */
|
|
17
30
|
await fs.writeFile(
|
|
18
31
|
tempPath,
|
|
19
32
|
contents,
|
|
20
33
|
mode === undefined ? "utf8" : { encoding: "utf8", mode },
|
|
21
34
|
);
|
|
35
|
+
if (exactMode && mode !== undefined) await fs.chmod(tempPath, mode);
|
|
22
36
|
await fs.rename(tempPath, target);
|
|
23
37
|
} catch (error) {
|
|
24
38
|
await fs.rm(tempPath, { force: true }).catch(() => {});
|
|
25
39
|
throw error;
|
|
26
40
|
}
|
|
27
41
|
}
|
|
42
|
+
|
|
43
|
+
const MAX_LINK_HOPS = 40;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Follows a symlink chain to its end, which may not exist yet: a dangling link names where to create.
|
|
47
|
+
* Link text is never normalized lexically: `..` after a directory symlink means the real parent.
|
|
48
|
+
*/
|
|
49
|
+
async function resolveLinkEnd(filePath: string): Promise<string> {
|
|
50
|
+
let current = filePath;
|
|
51
|
+
for (let hop = 0; hop < MAX_LINK_HOPS; hop++) {
|
|
52
|
+
const parent = await fs.realpath(path.dirname(current));
|
|
53
|
+
const entry = path.join(parent, path.basename(current));
|
|
54
|
+
const link = await fs.readlink(entry).catch((error: NodeJS.ErrnoException) => {
|
|
55
|
+
if (error.code === "ENOENT" || error.code === "EINVAL") return null;
|
|
56
|
+
throw error;
|
|
57
|
+
});
|
|
58
|
+
if (link === null) return entry;
|
|
59
|
+
current = path.isAbsolute(link) ? link : `${parent}${path.sep}${link}`;
|
|
60
|
+
}
|
|
61
|
+
throw Object.assign(new Error(`too many levels of symbolic links: ${filePath}`), {
|
|
62
|
+
code: "ELOOP",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* As `writeAtomic`, through a symlink: the link stays and its target is
|
|
68
|
+
* replaced, or created when the link dangles. An existing file keeps its mode.
|
|
69
|
+
* A read-only file is refused, as a plain write would refuse it. A hardlinked
|
|
70
|
+
* file is written in place: replacing it would split it from its other links.
|
|
71
|
+
*/
|
|
72
|
+
export async function writeAtomicThroughLink(
|
|
73
|
+
filePath: string,
|
|
74
|
+
contents: string | Buffer,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
const target = await resolveLinkEnd(filePath);
|
|
77
|
+
const existing = await fs.stat(target).catch(() => null);
|
|
78
|
+
if (existing && existing.nlink > 1) return fs.writeFile(target, contents, "utf8");
|
|
79
|
+
if (existing) await fs.access(target, fs.constants.W_OK);
|
|
80
|
+
await replaceViaTemp(target, contents, existing ? existing.mode & 0o7777 : undefined, true);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Deletes a file its writer left without content. A symlink stays: deleting it
|
|
85
|
+
* would leave the old text in its target, so the target is emptied instead.
|
|
86
|
+
*/
|
|
87
|
+
export async function removeEmptiedFile(filePath: string): Promise<void> {
|
|
88
|
+
const entry = await fs.lstat(filePath);
|
|
89
|
+
if (entry.isSymbolicLink()) await writeAtomicThroughLink(filePath, "");
|
|
90
|
+
else await fs.unlink(filePath);
|
|
91
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
name: mate-minimal
|
|
2
|
-
version:
|
|
2
|
+
version: 4
|
|
3
3
|
description: Mate's short OpenSpec workflow for one-Area, low-risk changes - specs → tasks
|
|
4
4
|
artifacts:
|
|
5
5
|
- id: specs
|
|
@@ -27,11 +27,13 @@ artifacts:
|
|
|
27
27
|
include **Reason** and **Migration**.
|
|
28
28
|
|
|
29
29
|
Every user story carries an **Area:** marker naming the Areas it binds, using values from
|
|
30
|
-
the
|
|
30
|
+
the `scopes` in the spec's `mate:delta` comment - always, even when there is only one Area.
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
`scopes` metadata.
|
|
32
|
+
Start each spec file with a `<!-- mate:delta` comment on its very first line, not YAML
|
|
33
|
+
frontmatter. The comment holds YAML with the actual change name as `change`, the
|
|
34
|
+
`capability`, and paired `scopes` metadata, and closes with `-->` on its own line. Between
|
|
35
|
+
the comment and the first `## ` section, put nothing but blank lines and at most one `# `
|
|
36
|
+
title line, or `openspec validate --strict` reports the text archive would drop.
|
|
35
37
|
requires: []
|
|
36
38
|
- id: tasks
|
|
37
39
|
generates: tasks.md
|
|
@@ -39,7 +41,7 @@ artifacts:
|
|
|
39
41
|
template: tasks.md
|
|
40
42
|
instruction: |
|
|
41
43
|
Create a flat implementation checklist that satisfies the delta specs. Keep it short: this
|
|
42
|
-
profile uses the shared `/
|
|
44
|
+
profile uses the shared `/mate-explore-change` and `/mate-propose-change` planning steps but
|
|
43
45
|
intentionally writes no explore, proposal, or design artifact.
|
|
44
46
|
|
|
45
47
|
Number tasks `N.M` and group them under `## N. <group>` headers so apply tracking can address them individually.
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
type: delta-spec
|
|
1
|
+
<!-- mate:delta
|
|
3
2
|
change: <change-name>
|
|
4
3
|
capability: <capability>
|
|
5
|
-
tags: [openspec/change, openspec/spec, openspec/delta]
|
|
6
4
|
scopes:
|
|
7
5
|
- repository: org/repository
|
|
8
6
|
area: .
|
|
9
|
-
|
|
7
|
+
-->
|
|
10
8
|
|
|
11
9
|
## ADDED Requirements
|
|
12
10
|
|
|
@@ -16,7 +14,7 @@ As a <!-- role -->, I want <!-- capability -->, so that <!-- benefit -->.
|
|
|
16
14
|
The system MUST support this behavior.
|
|
17
15
|
**Area:** `.` <!-- replace with the Area from scopes -->
|
|
18
16
|
|
|
19
|
-
<!-- Every requirement must bind an Area from the
|
|
17
|
+
<!-- Every requirement must bind an Area from the `mate:delta` scopes. -->
|
|
20
18
|
|
|
21
19
|
#### Acceptance Criteria
|
|
22
20
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
name: mate-v1
|
|
2
|
-
version:
|
|
2
|
+
version: 10
|
|
3
3
|
description: Mate's complete OpenSpec workflow - proposal → specs → design → tasks
|
|
4
4
|
artifacts:
|
|
5
5
|
- id: proposal
|
|
@@ -74,7 +74,7 @@ artifacts:
|
|
|
74
74
|
- **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently.
|
|
75
75
|
- Every requirement MUST have at least one scenario.
|
|
76
76
|
|
|
77
|
-
New capabilities only: after the
|
|
77
|
+
New capabilities only: after the `mate:delta` comment and any `#` title, start the delta spec body with a `## Purpose` section - one or two sentences (50+ characters, or `openspec validate --strict` reports it as too brief) describing what the capability is for. Archive copies it into the main spec it creates; without it the new main spec is left with a `TBD ... Update Purpose after archive.` placeholder to fill in by hand. Do NOT add `## Purpose` to a delta for an existing capability - that spec already has one and the delta's is ignored. To change an existing capability's Purpose - including a leftover `TBD` placeholder - edit `openspec/specs/<capability>/spec.md` directly.
|
|
78
78
|
|
|
79
79
|
MODIFIED requirements workflow:
|
|
80
80
|
1. Locate the existing requirement in `openspec/specs/<capability>/spec.md`.
|
|
@@ -84,7 +84,7 @@ artifacts:
|
|
|
84
84
|
|
|
85
85
|
Common pitfall: Using MODIFIED with partial content loses detail at archive time. If adding new concerns without changing existing behavior, use ADDED instead.
|
|
86
86
|
|
|
87
|
-
Example (
|
|
87
|
+
Example (scope metadata covers `acme` and `packages/api`; every requirement carries an `**Area:**` marker, as always — the first binds both, the second one):
|
|
88
88
|
|
|
89
89
|
```
|
|
90
90
|
## ADDED Requirements
|
|
@@ -107,21 +107,19 @@ artifacts:
|
|
|
107
107
|
|
|
108
108
|
Specs should be testable - each scenario is a potential test case.
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
**Obsidian**: Start each spec file with this `mate:delta` comment as its very first line, not YAML frontmatter. Replace `<change-name>`, `<repository>`, `<area>`, and `<capability>` with actual values. Each `scopes` entry MUST preserve its repository/Area pairing:
|
|
111
111
|
|
|
112
112
|
```
|
|
113
|
-
|
|
114
|
-
type: delta-spec
|
|
113
|
+
<!-- mate:delta
|
|
115
114
|
change: <change-name>
|
|
116
115
|
capability: <capability>
|
|
117
|
-
tags: [openspec/change, openspec/spec, openspec/delta]
|
|
118
116
|
scopes:
|
|
119
117
|
- repository: org/repository
|
|
120
118
|
area: .
|
|
121
|
-
|
|
119
|
+
-->
|
|
122
120
|
```
|
|
123
121
|
|
|
124
|
-
|
|
122
|
+
Between the comment and the first `## ` section, put nothing but blank lines and at most one `# ` title line. OpenSpec skips the comment and the title, but `openspec validate --strict` reports any other text there, because archive drops it, so add no backlink or cross-reference lines.
|
|
125
123
|
|
|
126
124
|
Canonical-spec shape, delta-to-canonical projection, and migration rules live in
|
|
127
125
|
`openspec/mate-conventions.yaml`. Follow that reference when creating or repairing
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
type: delta-spec
|
|
1
|
+
<!-- mate:delta
|
|
3
2
|
change: <change-name>
|
|
4
3
|
capability: <capability>
|
|
5
|
-
tags: [openspec/change, openspec/spec, openspec/delta]
|
|
6
4
|
scopes:
|
|
7
5
|
- repository: org/repository
|
|
8
6
|
area: .
|
|
9
|
-
|
|
7
|
+
-->
|
|
10
8
|
|
|
11
9
|
## Purpose
|
|
12
10
|
|
|
@@ -17,7 +15,7 @@ scopes:
|
|
|
17
15
|
### Requirement: <!-- requirement name -->
|
|
18
16
|
|
|
19
17
|
<!-- Write the normative requirement. EVERY requirement carries an **Area:** marker from the
|
|
20
|
-
|
|
18
|
+
`mate:delta` scopes, for example **Area:** `packages/ui`. No **Repository:** marker is ever used.
|
|
21
19
|
See openspec/mate-conventions.yaml for shared scope and canonical-spec rules. -->
|
|
22
20
|
|
|
23
21
|
#### Scenario: <!-- scenario name -->
|