@thinkingsage/kanon 0.8.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/CHANGELOG.md +410 -0
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/bridge/mcp-server.cjs +14171 -0
- package/package.json +98 -0
- package/src/adapters/capabilities.ts +178 -0
- package/src/adapters/claude-code.ts +110 -0
- package/src/adapters/cline.ts +98 -0
- package/src/adapters/codex.ts +173 -0
- package/src/adapters/copilot.ts +106 -0
- package/src/adapters/cursor.ts +97 -0
- package/src/adapters/degradation.ts +95 -0
- package/src/adapters/index.ts +324 -0
- package/src/adapters/kiro-frontmatter.ts +139 -0
- package/src/adapters/kiro-inclusion.ts +86 -0
- package/src/adapters/kiro.ts +412 -0
- package/src/adapters/qdeveloper.ts +115 -0
- package/src/adapters/types.ts +81 -0
- package/src/adapters/windsurf.ts +96 -0
- package/src/admin.ts +283 -0
- package/src/asset-conventions.ts +118 -0
- package/src/attribution-backfill.ts +319 -0
- package/src/attribution-report.ts +95 -0
- package/src/attribution.ts +239 -0
- package/src/backends/github.ts +194 -0
- package/src/backends/http.ts +122 -0
- package/src/backends/index.ts +39 -0
- package/src/backends/local.ts +47 -0
- package/src/backends/s3.ts +157 -0
- package/src/backends/types.ts +59 -0
- package/src/base-cache.ts +270 -0
- package/src/browse-ui.ts +3754 -0
- package/src/browse.ts +1038 -0
- package/src/build.ts +1108 -0
- package/src/catalog.ts +204 -0
- package/src/cli-deprecated.ts +29 -0
- package/src/cli.ts +773 -0
- package/src/collection-admin.ts +287 -0
- package/src/collection-builder.ts +464 -0
- package/src/collections.ts +116 -0
- package/src/compatibility.ts +105 -0
- package/src/config.ts +743 -0
- package/src/eval/rubrics/kiro-progressive-steering.ts +841 -0
- package/src/eval.ts +1169 -0
- package/src/file-writer.ts +61 -0
- package/src/format-registry.ts +141 -0
- package/src/guild/auto-updater.ts +163 -0
- package/src/guild/backend-resolver.ts +49 -0
- package/src/guild/cli.ts +592 -0
- package/src/guild/collection-expander.ts +47 -0
- package/src/guild/global-cache.ts +247 -0
- package/src/guild/hook-generator.ts +100 -0
- package/src/guild/manifest.ts +154 -0
- package/src/guild/path-utils.ts +12 -0
- package/src/guild/sync.ts +622 -0
- package/src/guild/version-resolver.ts +42 -0
- package/src/help/metadata.ts +445 -0
- package/src/help/renderer.ts +265 -0
- package/src/help/typo-suggester.ts +25 -0
- package/src/hooks/expression.ts +493 -0
- package/src/hooks/pipeline.ts +141 -0
- package/src/import.ts +773 -0
- package/src/importers/claude-code.ts +134 -0
- package/src/importers/cline.ts +103 -0
- package/src/importers/codex.ts +140 -0
- package/src/importers/copilot.ts +103 -0
- package/src/importers/cursor.ts +105 -0
- package/src/importers/index.ts +390 -0
- package/src/importers/kiro.ts +110 -0
- package/src/importers/qdeveloper.ts +103 -0
- package/src/importers/types.ts +54 -0
- package/src/importers/windsurf.ts +104 -0
- package/src/install.ts +1005 -0
- package/src/manifest-admin.ts +306 -0
- package/src/mcp-bridge.ts +240 -0
- package/src/mutation/delta.ts +50 -0
- package/src/mutation/history.ts +66 -0
- package/src/mutation/operators.ts +524 -0
- package/src/mutation/runner.ts +332 -0
- package/src/new.ts +106 -0
- package/src/outcomes/collision.ts +127 -0
- package/src/outcomes/normalize.ts +208 -0
- package/src/outcomes/registry.ts +173 -0
- package/src/parser.ts +446 -0
- package/src/provenance-backfill-cli.ts +319 -0
- package/src/provenance-backfill.ts +520 -0
- package/src/publish.ts +354 -0
- package/src/reconcile-orchestrator.ts +502 -0
- package/src/reconcile-report-renderer.ts +176 -0
- package/src/resolve-body.ts +15 -0
- package/src/rosetta/builtins/compatibility-profiles.ts +297 -0
- package/src/rosetta/builtins/contracts.ts +1033 -0
- package/src/rosetta/builtins/pretty-printers/claude-code-native.ts +122 -0
- package/src/rosetta/builtins/pretty-printers/cline-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/codex-native.ts +127 -0
- package/src/rosetta/builtins/pretty-printers/copilot-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/cursor-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/index.ts +81 -0
- package/src/rosetta/builtins/pretty-printers/kiro-native.ts +166 -0
- package/src/rosetta/builtins/pretty-printers/kiro-power.ts +108 -0
- package/src/rosetta/builtins/pretty-printers/kiro-skill.ts +88 -0
- package/src/rosetta/builtins/pretty-printers/qdeveloper-native.ts +51 -0
- package/src/rosetta/builtins/pretty-printers/superpowers.ts +97 -0
- package/src/rosetta/builtins/pretty-printers/windsurf-native.ts +50 -0
- package/src/rosetta/builtins/sources/claude-code-native.ts +348 -0
- package/src/rosetta/builtins/sources/cline-native.ts +176 -0
- package/src/rosetta/builtins/sources/codex-native.ts +343 -0
- package/src/rosetta/builtins/sources/copilot-native.ts +178 -0
- package/src/rosetta/builtins/sources/cursor-native.ts +176 -0
- package/src/rosetta/builtins/sources/index.ts +95 -0
- package/src/rosetta/builtins/sources/kiro-native.ts +462 -0
- package/src/rosetta/builtins/sources/kiro-power.ts +285 -0
- package/src/rosetta/builtins/sources/kiro-skill.ts +230 -0
- package/src/rosetta/builtins/sources/qdeveloper-native.ts +181 -0
- package/src/rosetta/builtins/sources/superpowers.ts +240 -0
- package/src/rosetta/builtins/sources/windsurf-native.ts +176 -0
- package/src/rosetta/builtins/targets/claude-code.ts +181 -0
- package/src/rosetta/builtins/targets/cline.ts +87 -0
- package/src/rosetta/builtins/targets/codex.ts +226 -0
- package/src/rosetta/builtins/targets/copilot.ts +103 -0
- package/src/rosetta/builtins/targets/cursor.ts +87 -0
- package/src/rosetta/builtins/targets/index.ts +60 -0
- package/src/rosetta/builtins/targets/kiro.ts +278 -0
- package/src/rosetta/builtins/targets/qdeveloper.ts +103 -0
- package/src/rosetta/builtins/targets/windsurf.ts +87 -0
- package/src/rosetta/canonical.ts +729 -0
- package/src/rosetta/compatibility.ts +432 -0
- package/src/rosetta/contracts.ts +329 -0
- package/src/rosetta/detector.ts +724 -0
- package/src/rosetta/diagnostics.ts +630 -0
- package/src/rosetta/engine-bootstrap.ts +103 -0
- package/src/rosetta/engine.ts +744 -0
- package/src/rosetta/index.ts +381 -0
- package/src/rosetta/inspection.ts +530 -0
- package/src/rosetta/plan.ts +448 -0
- package/src/rosetta/provenance-digest.ts +369 -0
- package/src/rosetta/reconcile.ts +812 -0
- package/src/rosetta/redaction.ts +467 -0
- package/src/rosetta/registry.ts +712 -0
- package/src/rosetta/renderers.ts +571 -0
- package/src/rosetta/request-guard.ts +335 -0
- package/src/rosetta/resolution.ts +419 -0
- package/src/rosetta/source-accounting.ts +233 -0
- package/src/rosetta/templates.ts +129 -0
- package/src/rosetta-cli.ts +717 -0
- package/src/rosetta-docs-generator.ts +793 -0
- package/src/rosetta-profiles-cli.ts +367 -0
- package/src/schemas.ts +1712 -0
- package/src/spec-coordination.ts +1141 -0
- package/src/temper.ts +747 -0
- package/src/template-bundle-loader.ts +312 -0
- package/src/template-engine.ts +53 -0
- package/src/translation-application-policy.ts +496 -0
- package/src/translation-orchestrator.ts +1013 -0
- package/src/translation-plan-applier.ts +473 -0
- package/src/tutorial.ts +305 -0
- package/src/validate.ts +1093 -0
- package/src/versioning.ts +553 -0
- package/src/wizard.ts +660 -0
- package/src/workspace.ts +237 -0
- package/templates/eval-contexts/claude-code.md.njk +6 -0
- package/templates/eval-contexts/cline.md.njk +6 -0
- package/templates/eval-contexts/copilot.md.njk +6 -0
- package/templates/eval-contexts/cursor.md.njk +6 -0
- package/templates/eval-contexts/kiro.md.njk +10 -0
- package/templates/eval-contexts/qdeveloper.md.njk +6 -0
- package/templates/eval-contexts/windsurf.md.njk +6 -0
- package/templates/harness-adapters/_base/attribution-footer.md.njk +17 -0
- package/templates/harness-adapters/_base/base.md.njk +16 -0
- package/templates/harness-adapters/claude-code/claude.md.njk +1 -0
- package/templates/harness-adapters/claude-code/mcp.json.njk +1 -0
- package/templates/harness-adapters/claude-code/settings.json.njk +1 -0
- package/templates/harness-adapters/claude-code/skill-library-index.md.njk +13 -0
- package/templates/harness-adapters/claude-code/skill.md.njk +19 -0
- package/templates/harness-adapters/cline/hook.sh.njk +4 -0
- package/templates/harness-adapters/cline/mcp.json.njk +1 -0
- package/templates/harness-adapters/cline/rule.md.njk +1 -0
- package/templates/harness-adapters/codex/agents-md.md.njk +6 -0
- package/templates/harness-adapters/codex/agents-pointer.md.njk +16 -0
- package/templates/harness-adapters/codex/skill.md.njk +27 -0
- package/templates/harness-adapters/copilot/agents.md.njk +1 -0
- package/templates/harness-adapters/copilot/instructions.md.njk +1 -0
- package/templates/harness-adapters/copilot/scoped.md.njk +6 -0
- package/templates/harness-adapters/cursor/mcp.json.njk +1 -0
- package/templates/harness-adapters/cursor/rule.md.njk +6 -0
- package/templates/harness-adapters/kiro/hook.json.njk +1 -0
- package/templates/harness-adapters/kiro/mcp.json.njk +1 -0
- package/templates/harness-adapters/kiro/power-steering.md.njk +3 -0
- package/templates/harness-adapters/kiro/power.md.njk +12 -0
- package/templates/harness-adapters/kiro/steering.md.njk +16 -0
- package/templates/harness-adapters/qdeveloper/agent.md.njk +1 -0
- package/templates/harness-adapters/qdeveloper/mcp.json.njk +1 -0
- package/templates/harness-adapters/qdeveloper/rule.md.njk +1 -0
- package/templates/harness-adapters/windsurf/mcp.json.njk +1 -0
- package/templates/harness-adapters/windsurf/rule.md.njk +1 -0
- package/templates/harness-adapters/windsurf/workflow.md.njk +1 -0
- package/templates/knowledge/hooks.yaml.njk +4 -0
- package/templates/knowledge/knowledge.md.njk +53 -0
- package/templates/knowledge/mcp-servers.yaml.njk +2 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot attribution backfill (ADR-0064, Requirement 9).
|
|
3
|
+
*
|
|
4
|
+
* The PURE core here classifies an existing artifact and computes the
|
|
5
|
+
* attribution draft it would receive, WITHOUT touching disk. It is the logic
|
|
6
|
+
* validated by the corpus dry-run: 24 skip (in-house), 39 clean (verbatim), 3
|
|
7
|
+
* manual review. The CLI runner (registered in src/cli.ts) walks `knowledge/`,
|
|
8
|
+
* calls `planArtifactBackfill` per artifact, and writes drafts for the "clean"
|
|
9
|
+
* bucket while listing the "manual" bucket for a human relationship call.
|
|
10
|
+
*
|
|
11
|
+
* `author` is never modified. An artifact that already has an `attribution`
|
|
12
|
+
* block is skipped (idempotent).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { writeFile } from "node:fs/promises";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { deriveAttributionDraft } from "./attribution";
|
|
18
|
+
import { generateCatalog } from "./catalog";
|
|
19
|
+
import { isParseError, loadKnowledgeArtifact } from "./parser";
|
|
20
|
+
import { serializeCanonical } from "./rosetta/canonical";
|
|
21
|
+
import { codePointCompare } from "./rosetta/contracts";
|
|
22
|
+
import type {
|
|
23
|
+
AttributionRecord,
|
|
24
|
+
Frontmatter,
|
|
25
|
+
KnowledgeArtifact,
|
|
26
|
+
UpstreamWork,
|
|
27
|
+
} from "./schemas";
|
|
28
|
+
/** Where a scanned artifact lands after classification. */
|
|
29
|
+
export type BackfillClassification =
|
|
30
|
+
| "skip-in-house"
|
|
31
|
+
| "skip-has-attribution"
|
|
32
|
+
| "clean"
|
|
33
|
+
| "manual-review";
|
|
34
|
+
|
|
35
|
+
export interface BackfillPlan {
|
|
36
|
+
name: string;
|
|
37
|
+
classification: BackfillClassification;
|
|
38
|
+
/** The draft to write (present for `clean` and `manual-review`). */
|
|
39
|
+
draft?: AttributionRecord;
|
|
40
|
+
/** Why it was routed to manual review (present for `manual-review`). */
|
|
41
|
+
reason?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Author strings that mark in-house authorship (no upstream to credit) or a
|
|
46
|
+
* scaffolder default. Matched case-insensitively as a whole-string or a
|
|
47
|
+
* contains check for the org names.
|
|
48
|
+
*/
|
|
49
|
+
const IN_HOUSE_AUTHORS = [
|
|
50
|
+
"steven j. miklovic",
|
|
51
|
+
"steven",
|
|
52
|
+
"kanon",
|
|
53
|
+
"johns hopkins drcc",
|
|
54
|
+
"jhu sheridan libraries",
|
|
55
|
+
"context-bazaar",
|
|
56
|
+
// Scaffolder defaults — not a person or upstream.
|
|
57
|
+
"kiro power builder",
|
|
58
|
+
"demo",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Prose markers in an `author` string that imply the artifact was adapted or
|
|
63
|
+
* repackaged from an upstream — routes to manual review for a `relationship`
|
|
64
|
+
* decision rather than a blind `verbatim` backfill.
|
|
65
|
+
*/
|
|
66
|
+
const ADAPTATION_MARKERS =
|
|
67
|
+
/\b(adapted|packaged|repackaged|derived|based on|forked|modified)\b|\bby\b.*\bfor\b|\(.*\b(packaged|adapted)\b.*\)/i;
|
|
68
|
+
|
|
69
|
+
/** A source banner in the body, e.g. "> **Source and adaptation:** ...". */
|
|
70
|
+
const SOURCE_BANNER = /Source and adaptation:/i;
|
|
71
|
+
/** A github-style repo slug, optionally with @commit, inside a banner. */
|
|
72
|
+
const BANNER_REPO = /\b([\w.-]+\/[\w.-]+)\b(?:[^\n]*?@([0-9a-f]{7,40}))?/i;
|
|
73
|
+
|
|
74
|
+
function isInHouseAuthor(author: string): boolean {
|
|
75
|
+
const a = author.trim().toLowerCase();
|
|
76
|
+
if (a.length === 0) return true;
|
|
77
|
+
return IN_HOUSE_AUTHORS.some((h) => a === h || a.includes(h));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parse a body source banner into repo/commit and, when it names a distinct
|
|
82
|
+
* original work/author beyond the packaging repo, a second upstream work.
|
|
83
|
+
* Returns null when no banner is present.
|
|
84
|
+
*/
|
|
85
|
+
export function scanBodyBanner(body: string): {
|
|
86
|
+
sourceRepo?: string;
|
|
87
|
+
sourceCommit?: string;
|
|
88
|
+
url?: string;
|
|
89
|
+
/** A distinct original work named by the banner (→ second upstream + manual). */
|
|
90
|
+
secondWork?: UpstreamWork;
|
|
91
|
+
} | null {
|
|
92
|
+
const bannerLineIdx = body.search(SOURCE_BANNER);
|
|
93
|
+
if (bannerLineIdx === -1) return null;
|
|
94
|
+
// Take the banner plus a little following text (the blockquote body).
|
|
95
|
+
const snippet = body.slice(bannerLineIdx, bannerLineIdx + 600);
|
|
96
|
+
|
|
97
|
+
const repoMatch = snippet.match(BANNER_REPO);
|
|
98
|
+
const sourceRepo = repoMatch?.[1];
|
|
99
|
+
const sourceCommit = repoMatch?.[2];
|
|
100
|
+
const url = sourceRepo ? `https://github.com/${sourceRepo}` : undefined;
|
|
101
|
+
|
|
102
|
+
// Detect a distinct original work: a "public-domain"/year/author phrasing
|
|
103
|
+
// beyond the packaging repo (e.g. Strunk's 1918 text behind obra's repo).
|
|
104
|
+
let secondWork: UpstreamWork | undefined;
|
|
105
|
+
const originalMatch = snippet.match(
|
|
106
|
+
/\b(?:by|text by|written by)\s+([A-Z][A-Za-z.\-\s]+?(?:Jr\.|Sr\.|[A-Z][a-z]+))\b/,
|
|
107
|
+
);
|
|
108
|
+
if (originalMatch) {
|
|
109
|
+
const author = originalMatch[1].trim();
|
|
110
|
+
const yearMatch = snippet.match(/\b(1[6-9]\d{2}|20\d{2})\b/);
|
|
111
|
+
const pd = /public[-\s]?domain/i.test(snippet);
|
|
112
|
+
secondWork = {
|
|
113
|
+
work: yearMatch ? `Original work (${yearMatch[1]})` : "Original work",
|
|
114
|
+
authors: [author],
|
|
115
|
+
license: pd ? "public-domain" : undefined,
|
|
116
|
+
relationship: "verbatim",
|
|
117
|
+
} as UpstreamWork;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { sourceRepo, sourceCommit, url, secondWork };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Classify one artifact and compute its backfill draft (pure).
|
|
125
|
+
*
|
|
126
|
+
* @param frontmatter the artifact's parsed frontmatter
|
|
127
|
+
* @param body the artifact's markdown body (for the banner scan)
|
|
128
|
+
*/
|
|
129
|
+
export function planArtifactBackfill(
|
|
130
|
+
frontmatter: Frontmatter,
|
|
131
|
+
body: string,
|
|
132
|
+
): BackfillPlan {
|
|
133
|
+
const name = frontmatter.name;
|
|
134
|
+
|
|
135
|
+
// Idempotent: never overwrite an existing attribution block.
|
|
136
|
+
if (frontmatter.attribution?.upstream?.length) {
|
|
137
|
+
return { name, classification: "skip-has-attribution" };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const author = frontmatter.author ?? "";
|
|
141
|
+
|
|
142
|
+
// Adaptation/packaging prose implies a real upstream even when the string
|
|
143
|
+
// also names an in-house adapter ("adapted for Kanon by <curator>"), so this
|
|
144
|
+
// check MUST precede the in-house contains-check below — otherwise
|
|
145
|
+
// "robin (revfactory), adapted ... by Steven J. Miklovic" is misfiled as
|
|
146
|
+
// in-house because it contains a curator name.
|
|
147
|
+
const hasAdaptationMarker = ADAPTATION_MARKERS.test(author);
|
|
148
|
+
|
|
149
|
+
// In-house / scaffolder default → no upstream to credit (only when there is
|
|
150
|
+
// no adaptation marker pointing at a real upstream).
|
|
151
|
+
if (!hasAdaptationMarker && isInHouseAuthor(author)) {
|
|
152
|
+
return { name, classification: "skip-in-house" };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Derive a base draft from author/license/provenance.
|
|
156
|
+
const provenance = frontmatter.provenance;
|
|
157
|
+
const draft = deriveAttributionDraft({
|
|
158
|
+
upstreamFrontmatter: frontmatter as unknown as Record<string, unknown>,
|
|
159
|
+
sourceRepo: provenance?.upstream,
|
|
160
|
+
sourceCommit: provenance?.sourceRevision,
|
|
161
|
+
sourcePath: provenance?.sourcePath,
|
|
162
|
+
curatedBy: undefined,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Body-banner scan (task 11.2): enrich the derived work and detect a
|
|
166
|
+
// distinct second upstream.
|
|
167
|
+
const banner = scanBodyBanner(body);
|
|
168
|
+
if (banner) {
|
|
169
|
+
const [first, ...rest] = draft.upstream;
|
|
170
|
+
if (banner.sourceRepo) first["source-repo"] = banner.sourceRepo;
|
|
171
|
+
if (banner.sourceCommit) first["source-commit"] = banner.sourceCommit;
|
|
172
|
+
if (banner.url && !first.url) first.url = banner.url;
|
|
173
|
+
// When the banner names the packaging repo, the frontmatter author is the
|
|
174
|
+
// packager → relationship packaged.
|
|
175
|
+
if (banner.sourceRepo) first.relationship = "packaged";
|
|
176
|
+
draft.upstream = [first, ...rest];
|
|
177
|
+
if (banner.secondWork) {
|
|
178
|
+
draft.upstream.push(banner.secondWork);
|
|
179
|
+
return {
|
|
180
|
+
name,
|
|
181
|
+
classification: "manual-review",
|
|
182
|
+
draft,
|
|
183
|
+
reason: `body banner names a distinct original work (${banner.secondWork.authors.join(", ")}); confirm relationships`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Adaptation/packaging prose in the author string → manual relationship call.
|
|
189
|
+
if (hasAdaptationMarker) {
|
|
190
|
+
return {
|
|
191
|
+
name,
|
|
192
|
+
classification: "manual-review",
|
|
193
|
+
draft,
|
|
194
|
+
reason: `author string implies adaptation/packaging ("${author}"); set relationship by hand`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { name, classification: "clean", draft };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Summary counts for a backfill run/dry-run. */
|
|
202
|
+
export interface BackfillSummary {
|
|
203
|
+
skipInHouse: number;
|
|
204
|
+
skipHasAttribution: number;
|
|
205
|
+
clean: number;
|
|
206
|
+
manualReview: number;
|
|
207
|
+
plans: BackfillPlan[];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Fold a list of per-artifact plans into a summary (pure). Used by both the
|
|
212
|
+
* dry-run report and the applying runner.
|
|
213
|
+
*/
|
|
214
|
+
export function summarizeBackfill(plans: BackfillPlan[]): BackfillSummary {
|
|
215
|
+
const summary: BackfillSummary = {
|
|
216
|
+
skipInHouse: 0,
|
|
217
|
+
skipHasAttribution: 0,
|
|
218
|
+
clean: 0,
|
|
219
|
+
manualReview: 0,
|
|
220
|
+
plans,
|
|
221
|
+
};
|
|
222
|
+
for (const plan of plans) {
|
|
223
|
+
switch (plan.classification) {
|
|
224
|
+
case "skip-in-house":
|
|
225
|
+
summary.skipInHouse++;
|
|
226
|
+
break;
|
|
227
|
+
case "skip-has-attribution":
|
|
228
|
+
summary.skipHasAttribution++;
|
|
229
|
+
break;
|
|
230
|
+
case "clean":
|
|
231
|
+
summary.clean++;
|
|
232
|
+
break;
|
|
233
|
+
case "manual-review":
|
|
234
|
+
summary.manualReview++;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return summary;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
242
|
+
// IO runner (impure orchestration shell)
|
|
243
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
244
|
+
|
|
245
|
+
/** Options for a backfill run over one or more knowledge directories. */
|
|
246
|
+
export interface BackfillRunOptions {
|
|
247
|
+
/** Knowledge source directories to scan (e.g. ["knowledge", "packages"]). */
|
|
248
|
+
knowledgeDirs: string[];
|
|
249
|
+
/** When true, classify and report without writing any file. */
|
|
250
|
+
dryRun: boolean;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Re-serialize an artifact carrying its new `attribution` block and write ONLY
|
|
255
|
+
* `knowledge.md` back, mirroring the provenance backfill's write discipline
|
|
256
|
+
* (deterministic canonical bytes; auxiliary files/workflows left untouched).
|
|
257
|
+
*/
|
|
258
|
+
async function writeAttributionIntoKnowledgeMd(
|
|
259
|
+
artifactDir: string,
|
|
260
|
+
artifact: KnowledgeArtifact,
|
|
261
|
+
): Promise<boolean> {
|
|
262
|
+
const { plan } = serializeCanonical(artifact, {
|
|
263
|
+
emitEmptyAuxiliaryFiles: false,
|
|
264
|
+
emitBodyOverrides: false,
|
|
265
|
+
emitWorkflows: false,
|
|
266
|
+
});
|
|
267
|
+
if (!plan) return false;
|
|
268
|
+
const knowledgeFile = plan.outputFiles.find(
|
|
269
|
+
(f) => f.relativePath === "knowledge.md",
|
|
270
|
+
);
|
|
271
|
+
if (!knowledgeFile) return false;
|
|
272
|
+
const content =
|
|
273
|
+
typeof knowledgeFile.content === "string"
|
|
274
|
+
? knowledgeFile.content
|
|
275
|
+
: new TextDecoder().decode(knowledgeFile.content);
|
|
276
|
+
await writeFile(join(artifactDir, "knowledge.md"), content, "utf-8");
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Walk each knowledge directory, plan a backfill per artifact, and (unless
|
|
282
|
+
* dry-run) write the `attribution` block for the `clean` bucket. Artifacts in
|
|
283
|
+
* `manual-review` are reported with their draft but NOT written — a human must
|
|
284
|
+
* confirm the relationship first. Deterministic: directories are visited in
|
|
285
|
+
* code-point name order.
|
|
286
|
+
*/
|
|
287
|
+
export async function runAttributionBackfill(
|
|
288
|
+
options: BackfillRunOptions,
|
|
289
|
+
): Promise<BackfillSummary> {
|
|
290
|
+
const plans: BackfillPlan[] = [];
|
|
291
|
+
|
|
292
|
+
// Enumerate artifact directories via the catalog scan, which handles BOTH
|
|
293
|
+
// the flat (knowledge/<artifact>/) and namespaced
|
|
294
|
+
// (knowledge/<group>/<artifact>/) layouts — a plain readdir would miss the
|
|
295
|
+
// nested kiro-official/byron-powers artifacts (AGENTS.md: two-layout scan).
|
|
296
|
+
const entries = await generateCatalog(options.knowledgeDirs);
|
|
297
|
+
const artifactDirs = entries.map((e) => e.path).sort(codePointCompare);
|
|
298
|
+
|
|
299
|
+
for (const artifactDir of artifactDirs) {
|
|
300
|
+
const loaded = await loadKnowledgeArtifact(artifactDir);
|
|
301
|
+
if (isParseError(loaded)) continue;
|
|
302
|
+
|
|
303
|
+
const artifact = loaded.data;
|
|
304
|
+
const plan = planArtifactBackfill(artifact.frontmatter, artifact.body);
|
|
305
|
+
plans.push(plan);
|
|
306
|
+
|
|
307
|
+
// Only the clean bucket is written automatically; manual-review needs a
|
|
308
|
+
// human relationship call, and skips write nothing.
|
|
309
|
+
if (!options.dryRun && plan.classification === "clean" && plan.draft) {
|
|
310
|
+
const withAttribution: KnowledgeArtifact = {
|
|
311
|
+
...artifact,
|
|
312
|
+
frontmatter: { ...artifact.frontmatter, attribution: plan.draft },
|
|
313
|
+
};
|
|
314
|
+
await writeAttributionIntoKnowledgeMd(artifactDir, withAttribution);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return summarizeBackfill(plans);
|
|
319
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure NOTICES report generator (ADR-0064, Requirement 8).
|
|
3
|
+
*
|
|
4
|
+
* `renderAttributionReport` takes the list of catalog entries and emits a
|
|
5
|
+
* deterministic, byte-stable NOTICES string grouped by license. It performs no
|
|
6
|
+
* IO, so it is unit-tested and reproducible (mirrors the ADR-0049 reconciliation
|
|
7
|
+
* report). The `kanon attribute` command (src/cli.ts) loads the catalog and
|
|
8
|
+
* hands the entries here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { CatalogEntry, UpstreamWork } from "./schemas";
|
|
12
|
+
|
|
13
|
+
/** A single credited upstream work with the artifact it appears in. */
|
|
14
|
+
interface CreditLine {
|
|
15
|
+
artifact: string;
|
|
16
|
+
work: UpstreamWork;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Case-insensitive, then case-sensitive, code-point string compare. */
|
|
20
|
+
function compareStrings(a: string, b: string): number {
|
|
21
|
+
const la = a.toLowerCase();
|
|
22
|
+
const lb = b.toLowerCase();
|
|
23
|
+
if (la < lb) return -1;
|
|
24
|
+
if (la > lb) return 1;
|
|
25
|
+
if (a < b) return -1;
|
|
26
|
+
if (a > b) return 1;
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const NO_LICENSE_GROUP = "(no license declared)";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Render a deterministic NOTICES report grouped by license. Entries without an
|
|
34
|
+
* `attribution` block contribute nothing. Within each license group, works are
|
|
35
|
+
* sorted by title then artifact name so identical input yields identical output.
|
|
36
|
+
*/
|
|
37
|
+
export function renderAttributionReport(
|
|
38
|
+
entries: readonly CatalogEntry[],
|
|
39
|
+
): string {
|
|
40
|
+
// Collect every credited upstream work, keyed by license group.
|
|
41
|
+
const byLicense = new Map<string, CreditLine[]>();
|
|
42
|
+
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
const upstream = entry.attribution?.upstream;
|
|
45
|
+
if (!upstream || upstream.length === 0) continue;
|
|
46
|
+
for (const work of upstream) {
|
|
47
|
+
const group = work.license?.trim() || NO_LICENSE_GROUP;
|
|
48
|
+
const list = byLicense.get(group) ?? [];
|
|
49
|
+
list.push({ artifact: entry.name, work });
|
|
50
|
+
byLicense.set(group, list);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const licenseGroups = [...byLicense.keys()].sort(compareStrings);
|
|
55
|
+
|
|
56
|
+
const lines: string[] = [];
|
|
57
|
+
lines.push("NOTICES");
|
|
58
|
+
lines.push("=======");
|
|
59
|
+
lines.push("");
|
|
60
|
+
lines.push(
|
|
61
|
+
"Upstream works this library derives from, grouped by license. Generated",
|
|
62
|
+
);
|
|
63
|
+
lines.push("by `kanon attribute` — do not edit by hand.");
|
|
64
|
+
lines.push("");
|
|
65
|
+
|
|
66
|
+
if (licenseGroups.length === 0) {
|
|
67
|
+
lines.push("No upstream attribution recorded.");
|
|
68
|
+
lines.push("");
|
|
69
|
+
return lines.join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const license of licenseGroups) {
|
|
73
|
+
lines.push(`## ${license}`);
|
|
74
|
+
lines.push("");
|
|
75
|
+
const works = (byLicense.get(license) ?? [])
|
|
76
|
+
.slice()
|
|
77
|
+
.sort(
|
|
78
|
+
(a, b) =>
|
|
79
|
+
compareStrings(a.work.work, b.work.work) ||
|
|
80
|
+
compareStrings(a.artifact, b.artifact),
|
|
81
|
+
);
|
|
82
|
+
for (const { artifact, work } of works) {
|
|
83
|
+
const authors =
|
|
84
|
+
work.authors.length > 0 ? work.authors.join(", ") : "(author unknown)";
|
|
85
|
+
const urlSuffix = work.url ? ` <${work.url}>` : "";
|
|
86
|
+
lines.push(
|
|
87
|
+
`- ${work.work} — ${authors} [${work.relationship}]${urlSuffix}`,
|
|
88
|
+
);
|
|
89
|
+
lines.push(` used in: ${artifact}`);
|
|
90
|
+
}
|
|
91
|
+
lines.push("");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return lines.join("\n");
|
|
95
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure attribution-draft derivation (see ADR-0064, design §"Field derivation").
|
|
3
|
+
*
|
|
4
|
+
* `deriveAttributionDraft` builds an {@link AttributionRecord} from an upstream
|
|
5
|
+
* artifact's frontmatter plus the acquisition data the import path already
|
|
6
|
+
* knows (`source-repo`/`source-commit`/`url`). It is deliberately IO-free and
|
|
7
|
+
* side-effect-free so it can be unit-tested with fixtures and reused by both
|
|
8
|
+
* the interactive wizard (task 5) and the one-shot backfill (task 11).
|
|
9
|
+
*
|
|
10
|
+
* The draft always sets `relationship: "verbatim"` — the copyright-relevant
|
|
11
|
+
* relationship is NOT derivable and is the one required human input, elicited
|
|
12
|
+
* by the wizard or hand-set during backfill.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AttributionRecord, UpstreamWork } from "./schemas";
|
|
16
|
+
import { RelationshipSchema } from "./schemas";
|
|
17
|
+
|
|
18
|
+
/** Inputs available at import/acquisition time for deriving a draft. */
|
|
19
|
+
export interface DeriveAttributionInput {
|
|
20
|
+
/** The upstream artifact's own frontmatter (source of `work`/`authors`/`license`). */
|
|
21
|
+
upstreamFrontmatter: Record<string, unknown>;
|
|
22
|
+
/** Acquisition repo, e.g. `owner/repo` (same value written to provenance). */
|
|
23
|
+
sourceRepo?: string;
|
|
24
|
+
/** Acquisition revision/commit. */
|
|
25
|
+
sourceCommit?: string;
|
|
26
|
+
/** Relative source path within the upstream repo (used to build a `url`). */
|
|
27
|
+
sourcePath?: string;
|
|
28
|
+
/** An explicit upstream URL; when absent one is built from repo + path. */
|
|
29
|
+
url?: string;
|
|
30
|
+
/** Curator identity (git/config); recorded as `curated-by`. */
|
|
31
|
+
curatedBy?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read a string frontmatter field, trimming and treating blank as absent.
|
|
36
|
+
*/
|
|
37
|
+
function readString(
|
|
38
|
+
fm: Record<string, unknown>,
|
|
39
|
+
key: string,
|
|
40
|
+
): string | undefined {
|
|
41
|
+
const value = fm[key];
|
|
42
|
+
if (typeof value !== "string") return undefined;
|
|
43
|
+
const trimmed = value.trim();
|
|
44
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Split an upstream `author` string into a list of author names. Upstream
|
|
49
|
+
* `author` is free-text; we keep it as a single author entry rather than
|
|
50
|
+
* guessing at parenthetical packagers or "adapted by" prose — that nuance is a
|
|
51
|
+
* `relationship` decision the human makes, not something to infer here. When
|
|
52
|
+
* `author` is absent, `authors` is empty (the wizard/validator then flag it).
|
|
53
|
+
*/
|
|
54
|
+
function deriveAuthors(fm: Record<string, unknown>): string[] {
|
|
55
|
+
const author = readString(fm, "author");
|
|
56
|
+
return author ? [author] : [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build an upstream URL from `owner/repo` + a source path when no explicit URL
|
|
61
|
+
* was supplied. Best-effort: returns undefined if we cannot form a plausible
|
|
62
|
+
* https URL (the schema treats `url` as optional).
|
|
63
|
+
*/
|
|
64
|
+
function buildUrl(input: DeriveAttributionInput): string | undefined {
|
|
65
|
+
if (input.url && input.url.trim().length > 0) return input.url.trim();
|
|
66
|
+
const repo = input.sourceRepo?.trim();
|
|
67
|
+
if (!repo) return undefined;
|
|
68
|
+
// Only build a URL for a github-style `owner/repo` slug.
|
|
69
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(repo)) return undefined;
|
|
70
|
+
const base = `https://github.com/${repo}`;
|
|
71
|
+
const path = input.sourcePath?.trim();
|
|
72
|
+
if (!path) return base;
|
|
73
|
+
const commit = input.sourceCommit?.trim();
|
|
74
|
+
const ref = commit && commit.length > 0 ? commit : "HEAD";
|
|
75
|
+
const cleanPath = path.replace(/^\/+/, "");
|
|
76
|
+
return `${base}/blob/${ref}/${cleanPath}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Derive a draft {@link AttributionRecord} for a single upstream work.
|
|
81
|
+
*
|
|
82
|
+
* `work` falls back to the upstream `name` when no more specific title is
|
|
83
|
+
* present. `relationship` is always `verbatim` in the draft.
|
|
84
|
+
*/
|
|
85
|
+
export function deriveAttributionDraft(
|
|
86
|
+
input: DeriveAttributionInput,
|
|
87
|
+
): AttributionRecord {
|
|
88
|
+
const fm = input.upstreamFrontmatter ?? {};
|
|
89
|
+
|
|
90
|
+
const work =
|
|
91
|
+
readString(fm, "work") ??
|
|
92
|
+
readString(fm, "title") ??
|
|
93
|
+
readString(fm, "name") ??
|
|
94
|
+
input.sourceRepo?.trim() ??
|
|
95
|
+
"";
|
|
96
|
+
|
|
97
|
+
const upstream: UpstreamWork = {
|
|
98
|
+
work,
|
|
99
|
+
authors: deriveAuthors(fm),
|
|
100
|
+
relationship: "verbatim",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const url = buildUrl(input);
|
|
104
|
+
if (url) upstream.url = url;
|
|
105
|
+
|
|
106
|
+
const license = readString(fm, "license");
|
|
107
|
+
if (license) upstream.license = license;
|
|
108
|
+
|
|
109
|
+
const sourceRepo = input.sourceRepo?.trim();
|
|
110
|
+
if (sourceRepo) upstream["source-repo"] = sourceRepo;
|
|
111
|
+
|
|
112
|
+
const sourceCommit = input.sourceCommit?.trim();
|
|
113
|
+
if (sourceCommit) upstream["source-commit"] = sourceCommit;
|
|
114
|
+
|
|
115
|
+
const record: AttributionRecord = { upstream: [upstream] };
|
|
116
|
+
|
|
117
|
+
const curatedBy = input.curatedBy?.trim();
|
|
118
|
+
if (curatedBy) record["curated-by"] = curatedBy;
|
|
119
|
+
|
|
120
|
+
return record;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
124
|
+
// Attribution wizard (import-time capture shell)
|
|
125
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* How the attribution step behaves during import (Requirement 3, 4):
|
|
129
|
+
*
|
|
130
|
+
* - `interactive`: confirm the derived draft and require a `relationship` select.
|
|
131
|
+
* - `defaults`: accept the derived draft unchanged (`relationship: verbatim`),
|
|
132
|
+
* no prompts — used by `--attribution-defaults` and the non-TTY fallback.
|
|
133
|
+
* - `skip`: write no attribution block — used by `--no-attribution`.
|
|
134
|
+
*/
|
|
135
|
+
export type AttributionWizardMode = "interactive" | "defaults" | "skip";
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Prompt callbacks, injectable so the wizard is unit-testable without a live
|
|
139
|
+
* TTY. Mirrors the surface of `@clack/prompts` we use. In production the import
|
|
140
|
+
* path passes real `@clack/prompts` bindings; tests pass stubs.
|
|
141
|
+
*/
|
|
142
|
+
export interface AttributionPrompts {
|
|
143
|
+
/** Free-text prompt returning the entered string (or a cancel symbol). */
|
|
144
|
+
text(opts: {
|
|
145
|
+
message: string;
|
|
146
|
+
initialValue?: string;
|
|
147
|
+
placeholder?: string;
|
|
148
|
+
}): Promise<unknown>;
|
|
149
|
+
/** Single-select prompt returning the chosen value (or a cancel symbol). */
|
|
150
|
+
select<T>(opts: {
|
|
151
|
+
message: string;
|
|
152
|
+
options: { value: T; label: string; hint?: string }[];
|
|
153
|
+
initialValue?: T;
|
|
154
|
+
}): Promise<unknown>;
|
|
155
|
+
/** Cancellation guard — exits the flow if the value is a cancel symbol. */
|
|
156
|
+
handleCancel(value: unknown): void;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const RELATIONSHIP_HINTS: Record<UpstreamWork["relationship"], string> = {
|
|
160
|
+
verbatim: "vendored unchanged; the body is upstream's",
|
|
161
|
+
adapted: "materially edited from upstream",
|
|
162
|
+
"inspired-by": "original expression here; only the idea is upstream's",
|
|
163
|
+
packaged: "repackaged/reformatted; authorship unchanged",
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Confirm a derived attribution draft and elicit the one non-derivable field,
|
|
168
|
+
* `relationship`. Returns the finalized record, or `undefined` when skipped.
|
|
169
|
+
*
|
|
170
|
+
* In `interactive` mode the curator may correct the derived `work`, `authors`
|
|
171
|
+
* (comma-separated), and `curated-by`, then must pick a `relationship`. In
|
|
172
|
+
* `defaults` mode the draft is returned unchanged. Only the FIRST upstream work
|
|
173
|
+
* is prompted; multi-upstream cases (rare at import time) are hand-completed or
|
|
174
|
+
* come from the backfill's body-banner scan.
|
|
175
|
+
*/
|
|
176
|
+
export async function runAttributionWizard(
|
|
177
|
+
draft: AttributionRecord,
|
|
178
|
+
opts: { mode: AttributionWizardMode; prompts?: AttributionPrompts },
|
|
179
|
+
): Promise<AttributionRecord | undefined> {
|
|
180
|
+
if (opts.mode === "skip") return undefined;
|
|
181
|
+
if (opts.mode === "defaults") return draft;
|
|
182
|
+
|
|
183
|
+
const prompts = opts.prompts;
|
|
184
|
+
if (!prompts) {
|
|
185
|
+
// No prompt bindings available (non-TTY): behave as defaults.
|
|
186
|
+
return draft;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const [first, ...rest] = draft.upstream;
|
|
190
|
+
|
|
191
|
+
const workRaw = await prompts.text({
|
|
192
|
+
message: "Upstream work title",
|
|
193
|
+
initialValue: first.work,
|
|
194
|
+
});
|
|
195
|
+
prompts.handleCancel(workRaw);
|
|
196
|
+
|
|
197
|
+
const authorsRaw = await prompts.text({
|
|
198
|
+
message: "Upstream author(s), comma-separated",
|
|
199
|
+
initialValue: first.authors.join(", "),
|
|
200
|
+
});
|
|
201
|
+
prompts.handleCancel(authorsRaw);
|
|
202
|
+
|
|
203
|
+
const relationshipRaw = await prompts.select<UpstreamWork["relationship"]>({
|
|
204
|
+
message: "Relationship to the upstream work",
|
|
205
|
+
initialValue: first.relationship,
|
|
206
|
+
options: RelationshipSchema.options.map((r) => ({
|
|
207
|
+
value: r,
|
|
208
|
+
label: r,
|
|
209
|
+
hint: RELATIONSHIP_HINTS[r],
|
|
210
|
+
})),
|
|
211
|
+
});
|
|
212
|
+
prompts.handleCancel(relationshipRaw);
|
|
213
|
+
|
|
214
|
+
const curatedByRaw = await prompts.text({
|
|
215
|
+
message: "Curated by (leave blank to omit)",
|
|
216
|
+
initialValue: draft["curated-by"] ?? "",
|
|
217
|
+
});
|
|
218
|
+
prompts.handleCancel(curatedByRaw);
|
|
219
|
+
|
|
220
|
+
const authors = String(authorsRaw)
|
|
221
|
+
.split(",")
|
|
222
|
+
.map((s) => s.trim())
|
|
223
|
+
.filter((s) => s.length > 0);
|
|
224
|
+
|
|
225
|
+
const confirmedFirst: UpstreamWork = {
|
|
226
|
+
...first,
|
|
227
|
+
work: String(workRaw).trim() || first.work,
|
|
228
|
+
authors,
|
|
229
|
+
relationship: relationshipRaw as UpstreamWork["relationship"],
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const result: AttributionRecord = { upstream: [confirmedFirst, ...rest] };
|
|
233
|
+
const curatedBy = String(curatedByRaw).trim();
|
|
234
|
+
if (curatedBy) result["curated-by"] = curatedBy;
|
|
235
|
+
else if (draft["curated-by"]) result["curated-by"] = draft["curated-by"];
|
|
236
|
+
if (draft.notice) result.notice = draft.notice;
|
|
237
|
+
|
|
238
|
+
return result;
|
|
239
|
+
}
|